From eb27990d7d467fcb599f7f517ee6cc0b38f403d6 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 20:54:30 -0400 Subject: [PATCH] Syndication slice 4a: Hacker News + Reddit prefilled share links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the aggregator half of syndication: HN and Reddit are link-submission communities, not auto-post targets, so instead of an API push they get a prefilled submit link the human clicks. `ShareLinks::for(title, url)` builds the two URLs (HN submitlink u/t, Reddit submit url/title, both encoded); `Syndicator::shareLinks(slug)` points them at the post's true canonical (declared original if any, else self), matching aggregator etiquette. Surfaces, same as the auto-post targets: - Admin: a "Share" column on the Syndication page, links open in a new tab (rel="noopener nofollow"), plus a footnote explaining Share vs auto-post and the expected HN "opens the existing thread" behaviour on a re-submit. - MCP: `blog_syndication_status` now also returns `share`, so an agent can hand a human the prefilled links. No credential, no stored state, no outbound call — nothing touches `blog_syndication`, since there is no external id to track for a link the human submits. README documents the Share column. Reddit API auto-submit stays out of scope (opt-in, single-user, spam/OAuth caveats in docs/DESIGN-syndication.md). Tests: ShareLinksTest (both URLs + encoding); Syndicator share-link tests (self vs declared canonical, empty for a missing post). Co-Authored-By: Claude Opus 4.8 --- README.md | 14 ++++++++++++-- src/BlogToolset.php | 6 ++++-- src/ShareLinks.php | 31 +++++++++++++++++++++++++++++++ src/SyndicationAdmin.php | 22 ++++++++++++++++++++-- src/Syndicator.php | 19 +++++++++++++++++++ tests/ShareLinksTest.php | 37 +++++++++++++++++++++++++++++++++++++ tests/SyndicatorTest.php | 18 ++++++++++++++++++ 7 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 src/ShareLinks.php create mode 100644 tests/ShareLinksTest.php diff --git a/README.md b/README.md index 77f7725..106e528 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/BlogToolset.php b/src/BlogToolset.php index 4a1a969..1625a87 100644 --- a/src/BlogToolset.php +++ b/src/BlogToolset.php @@ -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'], @@ -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); } diff --git a/src/ShareLinks.php b/src/ShareLinks.php new file mode 100644 index 0000000..e4a7912 --- /dev/null +++ b/src/ShareLinks.php @@ -0,0 +1,31 @@ + + */ + 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], + ]; + } +} diff --git a/src/SyndicationAdmin.php b/src/SyndicationAdmin.php index aacb0a6..b0e9b32 100644 --- a/src/SyndicationAdmin.php +++ b/src/SyndicationAdmin.php @@ -44,7 +44,7 @@ public function render(string $csrf, ?string $notice, string $nonce): string foreach ($targets as $target) { $html .= '' . self::e($target->label()) . ''; } - $html .= ''; + $html .= 'Share'; foreach ($posts as $post) { $slug = (string) $post['slug']; @@ -57,10 +57,25 @@ public function render(string $csrf, ?string $notice, string $nonce): string foreach ($targets as $target) { $html .= '' . $this->cell($target, $slug, $records[$target->id()] ?? null, $csrf) . ''; } + $html .= '' . $this->shareCell($slug) . ''; $html .= ''; } - return $html . ''; + return $html . '' + . '

Auto-post targets publish through their API and set the canonical back here. Share 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.

'; + } + + /** + * 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 .= '' . self::e($link['label']) . ''; + } + return $out; } /** @@ -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}' . ''; } diff --git a/src/Syndicator.php b/src/Syndicator.php index 44e1260..c9bc2a6 100644 --- a/src/Syndicator.php +++ b/src/Syndicator.php @@ -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 + */ + 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 $post * @return array{title:string,body:string,tags:list,canonical:string} diff --git a/tests/ShareLinksTest.php b/tests/ShareLinksTest.php new file mode 100644 index 0000000..0c17ef6 --- /dev/null +++ b/tests/ShareLinksTest.php @@ -0,0 +1,37 @@ +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();