From 09f15784d90fbd454b9741a040a5127c9a9a7889 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 6 Sep 2026 20:40:55 -0400 Subject: [PATCH] Syndication slice 3: Hashnode adapter + operator-config docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Hashnode as a second syndication target behind the existing SyndicationTarget interface — a new adapter, not a redesign. It publishes with the `publishPost` GraphQL mutation and updates with `updatePost` against gql.hashnode.com, sends the canonical as `originalArticleURL`, and formats tags as Hashnode's `{name, slug}` objects. GraphQL replies 200 even on failure, so the adapter treats a non-2xx *or* an `errors` payload as an error (with a clear "requires Hashnode Pro" message surfaced through). Credentials stay server-side env (HASHNODE_TOKEN, HASHNODE_PUBLICATION_ID); a target missing either shows as "Not configured" and is never called. No core change, no new capability — it reuses the slice-1/2 Syndicator, table, capability, admin page and MCP tool. README gains a Syndication section (both surfaces, the wildcard-immune capability, and the per-target env table incl. the Hashnode Pro caveat), and the stale "declares no capability" line is corrected. Tests: the adapter's create/update request shape, tag objects, the GraphQL-200-with-errors path, non-2xx, and the not-configured refusal. Co-Authored-By: Claude Opus 4.8 --- README.md | 39 +++++++++- src/BlogPlugin.php | 5 +- src/HashnodeTarget.php | 140 +++++++++++++++++++++++++++++++++++ tests/HashnodeTargetTest.php | 86 +++++++++++++++++++++ 4 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 src/HashnodeTarget.php create mode 100644 tests/HashnodeTargetTest.php diff --git a/README.md b/README.md index 84272c1..77f7725 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,16 @@ can't do: cross-posts, else self), Open Graph `article`, Twitter card, and JSON-LD `Article`. - **RSS feed** of the latest published posts. - **Tag archives** at `/tag/{tag}`. +- **Syndication** — cross-post a published post to Dev.to and Hashnode from the admin + or over MCP, canonical set back to your site, re-posting updates rather than + duplicating. See [Syndication](#syndication). The posts themselves are ordinary core content (a `blog` collection, authored over the normal content tools/MCP) and **your theme renders** the list and detail pages -via its own `collection-blog` / `entry-blog` templates. The plugin touches no core -tables and declares no capability — every surface is public read. +via its own `collection-blog` / `entry-blog` templates. The public reading surfaces +touch no core tables and need no capability; **syndication** is the one guarded +action — it owns a small `blog_syndication` table and the wildcard-immune +`nimbuscms.blog:syndicate` capability. ## How it works @@ -36,6 +41,36 @@ Create it as normal content (e.g. over MCP) with these fields: (`title`, `slug`, `published_at` are the reserved attributes.) +## Syndication + +Cross-post a published post to an external dev platform. The same action drives two +front doors — a capability-gated **Syndication** admin page (a Post / Update button +per post per target) and an **MCP** tool (`blog_syndicate_post`) — through one +service, one capability, and one audit trail. The canonical is always set back to +this site (or the post's own `canonical_url` if it already originated elsewhere), and +re-posting **updates** the existing external copy rather than creating a second one +(a `(post, target)` row remembers the external id). + +Syndication is off until an operator grants the **`nimbuscms.blog:syndicate`** +capability. It is wildcard-immune: a content `*:write` role or token can never reach +it — the explicit grant *is* the authorization to publish externally. + +### Targets and their credentials + +Credentials are **operator env** only — read server-side, never in content, the DB, +the audit log, or over MCP. A target with a missing credential shows as *Not +configured* in the admin and returns a clean "not configured" over MCP. + +| Target | Env | Notes | +|--------|-----|-------| +| **Dev.to** | `DEVTO_API_KEY` | A personal API key (Settings → Extensions → DEV API Keys). | +| **Hashnode** | `HASHNODE_TOKEN`, `HASHNODE_PUBLICATION_ID` | A personal access token (gql.hashnode.com) **and** the publication id to post into. **Writing over the Hashnode API requires a Hashnode Pro account** — without Pro the call fails with a clear "requires Hashnode Pro" error. | + +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. + ## Install ```bash diff --git a/src/BlogPlugin.php b/src/BlogPlugin.php index 60430bd..db78f77 100644 --- a/src/BlogPlugin.php +++ b/src/BlogPlugin.php @@ -88,7 +88,10 @@ public function register(PluginContext $context): void // MCP toolset, both on nimbuscms.blog:syndicate. Target credentials are read // from server env only; the reader is published-only (ADR 0029). $syndicator = new Syndicator( - ['devto' => new DevToTarget(new CurlHttpClient(), Env::get('DEVTO_API_KEY'))], + [ + 'devto' => new DevToTarget(new CurlHttpClient(), Env::get('DEVTO_API_KEY')), + 'hashnode' => new HashnodeTarget(new CurlHttpClient(), Env::get('HASHNODE_TOKEN'), Env::get('HASHNODE_PUBLICATION_ID')), + ], new SyndicationRepository(static fn (): PluginStorage => $context->storage()), static fn (string $slug): ?array => $context->content()->entryBySlug(self::COLLECTION, $slug), Config::appUrl(), diff --git a/src/HashnodeTarget.php b/src/HashnodeTarget.php new file mode 100644 index 0000000..2b65f5c --- /dev/null +++ b/src/HashnodeTarget.php @@ -0,0 +1,140 @@ +token !== null && $this->token !== '' + && $this->publicationId !== null && $this->publicationId !== ''; + } + + public function push(array $post, ?string $externalId): array + { + if (!$this->isConfigured()) { + throw new SyndicationError('Hashnode is not configured (set HASHNODE_TOKEN and HASHNODE_PUBLICATION_ID).'); + } + + $create = $externalId === null || $externalId === ''; + if ($create) { + $query = self::PUBLISH; + $input = [ + 'title' => $post['title'], + 'contentMarkdown' => $post['body'], + 'publicationId' => $this->publicationId, + 'originalArticleURL' => $post['canonical'], + 'tags' => $this->tags($post['tags']), + ]; + } else { + $query = self::UPDATE; + $input = [ + 'id' => $externalId, + 'title' => $post['title'], + 'contentMarkdown' => $post['body'], + 'originalArticleURL' => $post['canonical'], + 'tags' => $this->tags($post['tags']), + ]; + } + + $body = json_encode(['query' => $query, 'variables' => ['input' => $input]], JSON_THROW_ON_ERROR); + $resp = $this->http->send('POST', self::ENDPOINT, [ + 'Authorization' => (string) $this->token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], $body); + + if ($resp['status'] < 200 || $resp['status'] >= 300) { + throw new SyndicationError('Hashnode returned HTTP ' . $resp['status'] . '.'); + } + + $data = json_decode($resp['body'], true); + $data = is_array($data) ? $data : []; + if (isset($data['errors'][0]['message']) && is_string($data['errors'][0]['message'])) { + throw new SyndicationError('Hashnode: ' . $data['errors'][0]['message']); + } + + $key = $create ? 'publishPost' : 'updatePost'; + $node = $data['data'][$key]['post'] ?? null; + if (!is_array($node)) { + throw new SyndicationError('Hashnode returned no post.'); + } + + return [ + 'external_id' => (string) ($node['id'] ?? $externalId ?? ''), + 'external_url' => (string) ($node['url'] ?? ''), + ]; + } + + /** + * Hashnode wants tags as {name, slug} objects: the slug is lowercase, hyphenated, + * alphanumeric; the name keeps the author's wording. It recommends at most five. + * + * @param list $tags + * @return list + */ + private function tags(array $tags): array + { + $clean = []; + $seen = []; + foreach ($tags as $tag) { + $name = trim($tag); + $slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-'); + if ($name === '' || $slug === '' || isset($seen[$slug])) { + continue; + } + $seen[$slug] = true; + $clean[] = ['name' => $name, 'slug' => $slug]; + if (count($clean) === 5) { + break; + } + } + return $clean; + } +} diff --git a/tests/HashnodeTargetTest.php b/tests/HashnodeTargetTest.php new file mode 100644 index 0000000..d2c88f9 --- /dev/null +++ b/tests/HashnodeTargetTest.php @@ -0,0 +1,86 @@ +,canonical:string} */ + private function post(): array + { + return ['title' => 'Hello', 'body' => '# Hi', 'tags' => ['PHP', 'Web Dev', 'a b', 'x', 'y', 'z'], 'canonical' => 'https://danmat.dev/blog/hello']; + } + + private function target(FakeHttpClient $http): HashnodeTarget + { + return new HashnodeTarget($http, 'tok', 'pub-1'); + } + + public function test_create_publishes_with_publication_canonical_and_tag_objects(): void + { + $http = new FakeHttpClient(200, '{"data":{"publishPost":{"post":{"id":"p42","url":"https://x.hashnode.dev/hello"}}}}'); + $result = $this->target($http)->push($this->post(), null); + + self::assertSame('POST', $http->last['method']); + self::assertSame('https://gql.hashnode.com', $http->last['url']); + self::assertSame('tok', $http->last['headers']['Authorization']); + + $sent = json_decode((string) $http->last['body'], true); + self::assertStringContainsString('publishPost', $sent['query']); + $input = $sent['variables']['input']; + self::assertSame('pub-1', $input['publicationId']); + self::assertSame('https://danmat.dev/blog/hello', $input['originalArticleURL']); + self::assertSame( + [['name' => 'PHP', 'slug' => 'php'], ['name' => 'Web Dev', 'slug' => 'web-dev'], ['name' => 'a b', 'slug' => 'a-b'], ['name' => 'x', 'slug' => 'x'], ['name' => 'y', 'slug' => 'y']], + $input['tags'], + 'tag objects, slug lowercase-hyphenated, at most five', + ); + self::assertSame('p42', $result['external_id']); + self::assertSame('https://x.hashnode.dev/hello', $result['external_url']); + } + + public function test_update_uses_update_mutation_with_the_stored_id_and_no_publication(): void + { + $http = new FakeHttpClient(200, '{"data":{"updatePost":{"post":{"id":"p42","url":"https://x.hashnode.dev/hello"}}}}'); + $this->target($http)->push($this->post(), 'p42'); + + $sent = json_decode((string) $http->last['body'], true); + $input = $sent['variables']['input']; + self::assertStringContainsString('updatePost', $sent['query']); + self::assertSame('p42', $input['id'], 'a stored id makes it an update'); + self::assertArrayNotHasKey('publicationId', $input, 'update targets an existing post'); + } + + public function test_a_graphql_errors_payload_on_a_200_becomes_a_clear_error(): void + { + $http = new FakeHttpClient(200, '{"errors":[{"message":"This action requires Hashnode Pro"}]}'); + $this->expectException(SyndicationError::class); + $this->expectExceptionMessage('Hashnode Pro'); + $this->target($http)->push($this->post(), null); + } + + public function test_a_non_2xx_becomes_a_clear_error(): void + { + $this->expectException(SyndicationError::class); + $this->target(new FakeHttpClient(500, ''))->push($this->post(), null); + } + + public function test_unconfigured_without_a_publication_reports_and_refuses(): void + { + $target = new HashnodeTarget(new FakeHttpClient(200, '{}'), 'tok', null); + self::assertFalse($target->isConfigured()); + $this->expectException(SyndicationError::class); + $target->push($this->post(), null); + } +}