Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,18 @@ configured* in the admin and returns a clean "not configured" over MCP.

Set the env for whichever targets you want, grant a role or token
`nimbuscms.blog:syndicate`, and the target appears on the Syndication page.
Hacker News and Reddit are aggregators, not blogs — they arrive in a later slice as
prefilled **share links** (a human clicks), never an auto-post.

### Share links (Hacker News, Reddit)

Hacker News and Reddit are link-submission communities, not blogs, so the plugin
does **not** auto-post to them — the Syndication page shows a **Share** column with a
prefilled link per post that opens the community's own submit form (URL + title
filled in) in a new tab, for you to review and submit. No credential, no stored
state, no outbound call — just a shortcut past copy-pasting. (Hacker News has no
submit API at all; if a link was already submitted it opens the existing thread
rather than duplicating.) The same links are returned by the `blog_syndication_status`
MCP tool, so an agent can hand them to a human. API auto-submit for Reddit is a
separate, opt-in conversation (see `docs/DESIGN-syndication.md`).

## Install

Expand Down
6 changes: 4 additions & 2 deletions src/BlogToolset.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ protected function tools(): array
new PluginTool(
'syndication_status',
'syndicate',
'Where a published blog post has been syndicated, and to what URLs.',
'Where a published blog post has been syndicated and to what URLs, plus prefilled "share" links (Hacker News, Reddit) a human can open to submit it (these are not auto-posted).',
[
'type' => 'object',
'required' => ['slug'],
Expand Down Expand Up @@ -80,10 +80,12 @@ private function syndicatePost(array $a, TokenPrincipal $p, EntryOpContext $c):
*/
private function syndicationStatus(array $a, TokenPrincipal $p, EntryOpContext $c): array
{
$status = $this->syndicator->statusFor($this->str($a, 'slug'));
$slug = $this->str($a, 'slug');
$status = $this->syndicator->statusFor($slug);
if ($status === null) {
return ToolResult::error('No published post with that slug.', 'not_found');
}
$status['share'] = $this->syndicator->shareLinks($slug);
return ToolResult::ok($status);
}

Expand Down
31 changes: 31 additions & 0 deletions src/ShareLinks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog;

/**
* Prefilled submit links for the link-aggregator communities — Hacker News and
* Reddit. These are not auto-post targets: each is a URL that opens the aggregator's
* own submit form with the post's URL and title already filled in, so the logged-in
* human reviews and submits. No credential, no stored state, no outbound call.
*
* (Hacker News has no submit API at all; Reddit's is OAuth-gated and spam-sensitive.
* Auto-submit is a separate, opt-in conversation — see docs/DESIGN-syndication.md.)
*/
final class ShareLinks
{
/**
* @return list<array{id:string,label:string,url:string}>
*/
public static function for(string $title, string $url): array
{
$u = rawurlencode($url);
$t = rawurlencode($title);

return [
['id' => 'hn', 'label' => 'Hacker News', 'url' => 'https://news.ycombinator.com/submitlink?u=' . $u . '&t=' . $t],
['id' => 'reddit', 'label' => 'Reddit', 'url' => 'https://www.reddit.com/submit?url=' . $u . '&title=' . $t],
];
}
}
22 changes: 20 additions & 2 deletions src/SyndicationAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public function render(string $csrf, ?string $notice, string $nonce): string
foreach ($targets as $target) {
$html .= '<th>' . self::e($target->label()) . '</th>';
}
$html .= '</tr></thead><tbody>';
$html .= '<th>Share</th></tr></thead><tbody>';

foreach ($posts as $post) {
$slug = (string) $post['slug'];
Expand All @@ -57,10 +57,25 @@ public function render(string $csrf, ?string $notice, string $nonce): string
foreach ($targets as $target) {
$html .= '<td>' . $this->cell($target, $slug, $records[$target->id()] ?? null, $csrf) . '</td>';
}
$html .= '<td>' . $this->shareCell($slug) . '</td>';
$html .= '</tr>';
}

return $html . '</tbody></table>';
return $html . '</tbody></table>'
. '<p class="nb-muted rz-foot">Auto-post targets publish through their API and set the canonical back here. <b>Share</b> opens the community\'s own submit form with the link and title pre-filled — you pick where it goes and post it yourself (nothing is stored). If a link was already submitted, Hacker News opens the existing thread rather than making a duplicate; that\'s expected.</p>';
}

/**
* The aggregator share links for a post — each opens a prefilled submit form in a
* new tab for the human to review and post. Not an auto-post, so no button/action.
*/
private function shareCell(string $slug): string
{
$out = '';
foreach ($this->syndicator->shareLinks($slug) as $link) {
$out .= '<a class="rz-syn-link" href="' . self::e($link['url']) . '" target="_blank" rel="noopener nofollow">' . self::e($link['label']) . '</a>';
}
return $out;
}

/**
Expand Down Expand Up @@ -97,6 +112,9 @@ private function styles(string $nonce): string
. '.rz-syn-form{display:inline}'
. '.rz-syn-link{margin-left:.5rem;font-size:.85rem}'
. '.rz-syn-err{margin-left:.5rem;font-size:.8rem;color:#c0392b}'
. '.rz-table td:last-child .rz-syn-link:first-child{margin-left:0}'
. '.rz-table td:last-child{white-space:nowrap}'
. '.rz-foot{max-width:70ch;margin-top:1rem;font-size:.85rem}'
. '</style>';
}

Expand Down
19 changes: 19 additions & 0 deletions src/Syndicator.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ public function statusFor(string $slug): ?array
return ['slug' => $slug, 'entry_id' => $entryId, 'records' => $this->store->forEntry($entryId)];
}

/**
* Prefilled aggregator submit links for a post (Hacker News, Reddit): the human
* clicks and submits, nothing is stored and no credential is used. The link points
* at the post's true canonical (its declared original if it has one, else self),
* matching the etiquette of not submitting a syndicated copy. Empty if no such
* published post.
*
* @return list<array{id:string,label:string,url:string}>
*/
public function shareLinks(string $slug): array
{
$post = ($this->fetchBySlug)($slug);
if ($post === null) {
return [];
}
$fields = is_array($post['fields'] ?? null) ? $post['fields'] : [];
return ShareLinks::for((string) ($post['title'] ?? ''), $this->canonical($post, $fields));
}

/**
* @param array<string,mixed> $post
* @return array{title:string,body:string,tags:list<string>,canonical:string}
Expand Down
37 changes: 37 additions & 0 deletions tests/ShareLinksTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog\Tests;

use NimbusCMS\Blog\ShareLinks;
use PHPUnit\Framework\TestCase;

/**
* The aggregator share-link builder: the two prefilled submit URLs, with the post URL
* and title percent-encoded into each community's own parameter names.
*/
final class ShareLinksTest extends TestCase
{
public function test_it_builds_hn_and_reddit_prefilled_submit_urls(): void
{
$links = ShareLinks::for('Hello & Goodbye', 'https://danmat.dev/blog/hello');
$by = [];
foreach ($links as $l) {
$by[$l['id']] = $l;
}

self::assertSame(['hn', 'reddit'], array_column($links, 'id'));
self::assertSame('Hacker News', $by['hn']['label']);
self::assertSame(
'https://news.ycombinator.com/submitlink?u=https%3A%2F%2Fdanmat.dev%2Fblog%2Fhello&t=Hello%20%26%20Goodbye',
$by['hn']['url'],
'HN uses u/t; both values are encoded',
);
self::assertSame(
'https://www.reddit.com/submit?url=https%3A%2F%2Fdanmat.dev%2Fblog%2Fhello&title=Hello%20%26%20Goodbye',
$by['reddit']['url'],
'Reddit uses url/title; both values are encoded',
);
}
}
18 changes: 18 additions & 0 deletions tests/SyndicatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ public function test_a_target_failure_records_error_and_rethrows(): void
}
}

public function test_share_links_point_at_the_self_canonical(): void
{
$links = $this->make(new FakeTarget(), new FakeStore())->shareLinks('hello');
self::assertSame(['hn', 'reddit'], array_column($links, 'id'));
self::assertStringContainsString('u=https%3A%2F%2Fdanmat.dev%2Fblog%2Fhello', $links[0]['url']);
}

public function test_share_links_honour_a_declared_canonical(): void
{
$links = $this->make(new FakeTarget(), new FakeStore(), ['canonical_url' => 'https://elsewhere.dev/x'])->shareLinks('hello');
self::assertStringContainsString('url=https%3A%2F%2Felsewhere.dev%2Fx', $links[1]['url']);
}

public function test_share_links_are_empty_for_a_missing_post(): void
{
self::assertSame([], $this->make(new FakeTarget(), new FakeStore())->shareLinks('nope'));
}

public function test_status_for_lists_records(): void
{
$s = new FakeStore();
Expand Down
Loading