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
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/BlogPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
140 changes: 140 additions & 0 deletions src/HashnodeTarget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog;

/**
* Hashnode syndication target. Publishes with the `publishPost` GraphQL mutation and
* updates with `updatePost`, against the single endpoint `https://gql.hashnode.com`,
* authenticated by the operator's personal access token. The canonical is sent as
* `originalArticleURL` so Hashnode credits the original, and tags are sent as the
* `{name, slug}` objects Hashnode requires.
*
* GraphQL replies 200 even on failure, carrying an `errors` array, so this adapter
* treats a non-2xx **or** an `errors` payload as a failure. Writing over the API
* requires a Hashnode **Pro** account and a publication id — both operator settings
* (`HASHNODE_TOKEN`, `HASHNODE_PUBLICATION_ID`); with either missing the target is
* "not configured" and never called.
*/
final class HashnodeTarget implements SyndicationTarget
{
private const ENDPOINT = 'https://gql.hashnode.com';

private const PUBLISH = <<<'GQL'
mutation Publish($input: PublishPostInput!) {
publishPost(input: $input) { post { id url } }
}
GQL;

private const UPDATE = <<<'GQL'
mutation Update($input: UpdatePostInput!) {
updatePost(input: $input) { post { id url } }
}
GQL;

public function __construct(
private HttpClient $http,
private ?string $token,
private ?string $publicationId,
) {
}

public function id(): string
{
return 'hashnode';
}

public function label(): string
{
return 'Hashnode';
}

public function isConfigured(): bool
{
return $this->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<string> $tags
* @return list<array{name:string,slug:string}>
*/
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;
}
}
86 changes: 86 additions & 0 deletions tests/HashnodeTargetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog\Tests;

use NimbusCMS\Blog\HashnodeTarget;
use NimbusCMS\Blog\SyndicationError;
use PHPUnit\Framework\TestCase;

/**
* The Hashnode adapter: it builds the publishPost / updatePost GraphQL mutations,
* sends the canonical as originalArticleURL and tags as {name, slug} objects, and
* fails on both a non-2xx and the GraphQL 200-with-errors reply. The fake HTTP
* client captures the request, so nothing touches the network.
*/
final class HashnodeTargetTest extends TestCase
{
/** @return array{title:string,body:string,tags:list<string>,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);
}
}
Loading