diff --git a/docs/DESIGN-syndication.md b/docs/DESIGN-syndication.md new file mode 100644 index 0000000..d73ccdc --- /dev/null +++ b/docs/DESIGN-syndication.md @@ -0,0 +1,187 @@ +# nimbuscms/blog — design: syndication (cross-post to dev platforms) + +**Status:** design (pre-build). A feature of the **blog plugin** (it depends on the +blog: the post model, the slug, the canonical). Adds a way to push a published post +out to external dev blogging platforms, from **both** the admin UI and MCP, with the +external copy's canonical pointed back at danmat.dev automatically. First consumer: +danmat.dev. + +## Goal + +From a published post, syndicate it to an external platform in one deliberate act, +and never have to hand-set the canonical again. The whole point of the blog's +`canonical_url` field was that danmat.dev is the original and the copies point back; +this closes that loop instead of doing it by hand. + +## Targets, and the honest state of each + +Cross-posting the **content** (with a canonical) is only clean where the platform +has a real write API that supports a canonical URL: + +| Target | Content cross-post | Mechanism | Notes | +|--------|--------------------|-----------|-------| +| **Dev.to** | ✅ | Forem REST: `POST /api/articles`, header `api-key`; `PUT /api/articles/{id}` to update | `canonical_url`, `body_markdown`, `tags`, `series`, `published`. Free. | +| **Hashnode** | ✅ | GraphQL `gql.hashnode.com`: `publishPost` / `updatePost` | canonical is `originalArticleURL`; needs a personal token + publication id. **API writes require Hashnode Pro** (documented for the operator). | +| **Medium** | ❌ | — | Medium stopped issuing API integration tokens in Jan 2025. Excluded; documented so nobody wonders why. | +| **Hacker News, Reddit, daily.dev** | ❌ (not blogs) | link submission | Aggregators, not cross-post targets. Handled as a **share link** (see the aggregators section, pending research), never an auto-post. | + +**Extensible by design:** each target is an adapter behind one interface, so adding +Ghost, write.as, or another platform later is a new adapter, not a redesign. + +## Architecture: one action, two front doors + +The core principle: a person in the admin and an agent over MCP both drive the same +action through the same service and the same capability. + +- **`Syndicator`** (service) does the real work: given a post slug and a target, it + reads the post (via the plugin's published-only content reader), computes the + canonical, calls the target adapter, and records the result. One method: + `syndicate(slug, target): SyndicationResult`. +- **`SyndicationTarget`** (interface): `id()`, `label()`, `push(PostPayload $post, + string $canonical, ?string $externalId): {externalId, externalUrl}`. Implemented by + `DevToTarget` and `HashnodeTarget`. Adding a platform = a new implementation. +- **Admin UI**: a capability-gated "Syndication" admin page (ADR 0020) lists published + posts, each with per-target buttons (Post to Dev.to / Update on Dev.to, same for + Hashnode), the resulting external links, and the aggregator share links. The button + is a CSRF-protected admin action that calls `Syndicator`. +- **MCP**: a `syndicate_post {slug, target}` tool (ADR 0016) gated by the **same** + capability, calling the **same** `Syndicator`. Plus a read-only `syndication_status + {slug}` tool. Non-enumerating, audited. + +Neither surface reimplements anything. Same authority, same audit, same idempotency. + +## The capability: `nimbuscms.blog:syndicate` + +Until now the blog plugin declared no capability (everything it did was public read). +Syndication is a real external publish, so it gets its own fine-grained action +(possible because of ADR 0030): the plugin declares `Blog` with the action +`syndicate`, i.e. `nimbuscms.blog:syndicate`. It is wildcard-immune (ADR 0015): a +content `*:write` grant can never reach it. + +- In the **UI**, only a role granted `blog:syndicate` sees the buttons. +- Over **MCP**, only a token explicitly scoped `blog:syndicate` can call the tool. A + normal content-write token cannot syndicate. The operator granting that scope **is** + the authorization for external publishing, so an agent can never quietly post to + Dev.to unless it was handed that exact scope. + +This is how "MCP-first-class" coexists with "no surprise external posts." + +## Secrets stay server-side + +Each target's credential is an **operator setting** (env, e.g. `DEVTO_API_KEY`, +`HASHNODE_TOKEN`, `HASHNODE_PUBLICATION_ID`), read by the adapter inside the server. +Never in content, never in the DB rows below, never over MCP, never in the audit log. +The MCP tool call carries only a slug and a target name; the server holds the keys and +makes the outbound call. A target with no configured credential is shown as +unavailable in the UI and returns a clean "not configured" over MCP. + +## Idempotency: update, never duplicate (the plugin's first table) + +Cross-posting the same post twice must update the existing external article, not +create a second one. So the plugin gains its **first table** (ADR 0005 plugin storage; +it has been table-less until now), `blog_syndication`: + +``` +entry_id the blog entry's core id +target 'devto' | 'hashnode' +external_id the id the platform returned +external_url the live URL on that platform +status 'ok' | 'error' +synced_at datetime +(primary key: entry_id + target) +``` + +On syndicate: look up `(entry_id, target)`; if a row exists, the adapter updates by +`external_id`; else it creates and stores the new id. This table holds no post +content and no secrets, only the external mapping. It touches no core data. + +## Canonical, computed server-side + +The external canonical is always the post's true original: + +- if the post's `canonical_url` field is set (this post is itself a copy of something + elsewhere), use that; +- otherwise use the self URL, `/blog/`. + +The operator never types a canonical into the external platform again. + +## Aggregators (Hacker News, Reddit): share, do not auto-post + +Researched against current (2025/2026) docs. Both are link-submission communities, +not blogs, and the honest answer for both is a **prefilled share link the human +clicks**, not an auto-post. Zero stored secrets, rules-safe, human in the loop. + +- **Hacker News.** There is **no submit API at all** (the official Firebase API is + read-only), so an auto-post is not even possible. The Syndication page shows a + "Submit to HN" link that opens `https://news.ycombinator.com/submitlink?u=&t=` + in a new tab; the logged-in human reviews and submits. Gotcha to handle in the copy: + if the URL was already posted, HN takes the user to the **existing thread** rather + than creating a duplicate, which is expected, not an error. +- **Reddit.** A `submit` API does exist (OAuth, `submit` scope), but auto-posting is a + bad default: self-service app registration closed in late 2025 (manual approval, + slow and opaque), it needs OAuth secret storage plus a **forced target subreddit**, + and automated self-links are the textbook trigger for spam filtering and invisible + shadowbans, on top of the 90/10 self-promotion norm and per-subreddit rules. So the + default is a "Share to Reddit" link, `https://www.reddit.com/submit?url=<url>&title=<title>`, + where the human picks the subreddit and submits. API auto-submit is out of scope for + v1; it would only ever be an advanced, opt-in, single-user, non-commercial mode with + the operator accepting all of that risk. + +Both prefilled-submit URLs still work in 2025/2026. Neither aggregator stores a +credential or appears in `blog_syndication` (there is no external id to track for a +link the human submits). + +## Security review (Attacker / Defender / QA) + +- **Outbound target, SSRF?** The endpoints are fixed, known hosts (Dev.to, Hashnode), + never a user-supplied URL, so there is no server-side request forgery surface. The + only user input is a slug (resolved against the blog collection) and a target name + (validated against the adapter registry). +- **Secret exposure?** Credentials live in operator env, read server-side, never + logged, never returned by a tool, never stored in `blog_syndication`. A misconfigured + target fails closed as "not configured." +- **Escalation?** `blog:syndicate` is wildcard-immune (ADR 0015) and fine-grained (ADR + 0030); only an explicit grant or admin reaches it, on both surfaces. Over MCP the + tool is non-enumerating and every push is audited (who, which post, which target, + resulting URL, not the secret). +- **Spam / double-post?** The `(entry_id, target)` key makes a repeat call an update, + not a duplicate, so an agent re-running "syndicate my latest" cannot flood a platform. +- **Publishing on the user's behalf.** The admin button is the human's deliberate act; + the MCP tool requires an explicitly syndicate-scoped token. Neither surface publishes + externally on a generic content grant. +- **QA:** unit tests for the canonical computation (self vs declared), the adapter + request shape (Dev.to body, Hashnode mutation) with a faked HTTP client, the + create-vs-update decision from the stored id, and the capability gate on both the + admin action and the MCP tool (a non-syndicate role/token is refused). Security-green + target: no Critical/High. + +## Platform review (three hats) + +- **Product:** syndication is wanted by any dev-blog author; it belongs with the blog + plugin because it is blog-dependent (post model, slug, canonical). Not core, not a + separate plugin (it has no life without the blog). +- **Architect:** classification is an **official-plugin feature**. Reuses ADR 0005 + (storage), 0015/0030 (capability), 0016 (MCP), 0020 (admin pages). No core change. + The adapter interface keeps the target list open without touching the service. +- **Engineer:** one shared service behind two surfaces; secrets server-side; idempotent + by external id; outbound calls to fixed hosts; testable with a faked HTTP client. + Mobile: the Syndication admin page verified at 375px. + +## Build slices + +1. `Syndicator` + `SyndicationTarget` interface + `DevToTarget` (create/update) + + `blog_syndication` table + the `blog:syndicate` capability + canonical logic. + Unit-tested with a faked HTTP client. +2. The Syndication admin page (per-post per-target buttons, status, links) + the + `syndicate_post` / `syndication_status` MCP tools, both gated `blog:syndicate`. +3. `HashnodeTarget` (GraphQL) + the operator-config docs (which env vars, the Hashnode + Pro caveat). +4. The aggregator share links (HN, Reddit) per the research pass. + +## Definition of done + +A published post can be pushed to Dev.to and Hashnode from the admin and from MCP, +gated on `blog:syndicate`, with the canonical auto-set and a repeat call updating not +duplicating; credentials are server-side only; the Syndication page shows status + +links + aggregator share links; plugin CI green (PHPStan L6 + cs-fixer + tests); the +page verified at 375px. diff --git a/src/BlogPlugin.php b/src/BlogPlugin.php index 147f5e9..bd8d8b9 100644 --- a/src/BlogPlugin.php +++ b/src/BlogPlugin.php @@ -37,6 +37,14 @@ final class BlogPlugin implements Plugin public function register(PluginContext $context): void { + // Syndication storage (ADR 0005) and its capability (ADR 0015 wildcard-immune, + // ADR 0030 fine-grained): pushing a post to an external platform is a real + // publish, so it gets its own action `nimbuscms.blog:syndicate` that a content + // wildcard can never reach. The admin action and MCP tool that use it land in + // slice 2; the table and grantable capability are set up here. + $context->migrations()->register('001_syndication', Schema::all()); + $context->capabilities()->declare('Blog', ['syndicate']); + // Per-post SEO head — the reason this is a plugin. Emits nothing off blog pages. $context->head()->register(new BlogHead(self::COLLECTION)); diff --git a/src/CurlHttpClient.php b/src/CurlHttpClient.php new file mode 100644 index 0000000..e910f27 --- /dev/null +++ b/src/CurlHttpClient.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog; + +/** + * The default {@see HttpClient}, a thin wrapper over curl. Outbound only, to the + * fixed platform endpoints a target names (never a user-supplied URL), with a + * bounded timeout. No global state; a test uses a fake client instead of this. + */ +final class CurlHttpClient implements HttpClient +{ + public function __construct(private int $timeoutSeconds = 15) + { + } + + public function send(string $method, string $url, array $headers, ?string $body): array + { + $ch = curl_init($url); + if ($ch === false) { + throw new SyndicationError('Could not initialise an HTTP request.'); + } + $headerLines = []; + foreach ($headers as $name => $value) { + $headerLines[] = $name . ': ' . $value; + } + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headerLines, + CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_CONNECTTIMEOUT => $this->timeoutSeconds, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + if ($response === false) { + $error = curl_error($ch); + curl_close($ch); + throw new SyndicationError('HTTP request failed: ' . $error); + } + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + return ['status' => $status, 'body' => is_string($response) ? $response : '']; + } +} diff --git a/src/DevToTarget.php b/src/DevToTarget.php new file mode 100644 index 0000000..17a9b58 --- /dev/null +++ b/src/DevToTarget.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog; + +/** + * Dev.to (Forem) syndication target. Creates an article with `POST /api/articles` + * and updates it with `PUT /api/articles/{id}`, authenticated by the operator's + * `api-key`. The canonical URL is sent so Dev.to credits the original, and tags are + * sanitised to Dev.to's rules (lowercase alphanumeric, at most four). + */ +final class DevToTarget implements SyndicationTarget +{ + private const ENDPOINT = 'https://dev.to/api/articles'; + + public function __construct(private HttpClient $http, private ?string $apiKey) + { + } + + public function id(): string + { + return 'devto'; + } + + public function label(): string + { + return 'Dev.to'; + } + + public function isConfigured(): bool + { + return $this->apiKey !== null && $this->apiKey !== ''; + } + + public function push(array $post, ?string $externalId): array + { + if (!$this->isConfigured()) { + throw new SyndicationError('Dev.to is not configured (set DEVTO_API_KEY).'); + } + + $payload = ['article' => [ + 'title' => $post['title'], + 'body_markdown' => $post['body'], + 'published' => true, + 'canonical_url' => $post['canonical'], + 'tags' => $this->tags($post['tags']), + ]]; + $body = json_encode($payload, JSON_THROW_ON_ERROR); + + $create = $externalId === null || $externalId === ''; + $resp = $this->http->send( + $create ? 'POST' : 'PUT', + $create ? self::ENDPOINT : self::ENDPOINT . '/' . rawurlencode($externalId), + [ + 'api-key' => (string) $this->apiKey, + 'Content-Type' => 'application/json', + 'Accept' => 'application/vnd.forem.api-v1+json', + ], + $body, + ); + + if ($resp['status'] < 200 || $resp['status'] >= 300) { + throw new SyndicationError('Dev.to returned HTTP ' . $resp['status'] . '.'); + } + $data = json_decode($resp['body'], true); + $data = is_array($data) ? $data : []; + + return [ + 'external_id' => (string) ($data['id'] ?? $externalId ?? ''), + 'external_url' => (string) ($data['url'] ?? ''), + ]; + } + + /** + * Dev.to tags must be lowercase alphanumeric and it accepts at most four. + * + * @param list<string> $tags + * @return list<string> + */ + private function tags(array $tags): array + { + $clean = []; + foreach ($tags as $tag) { + $slug = strtolower((string) preg_replace('/[^a-zA-Z0-9]/', '', $tag)); + if ($slug !== '' && !in_array($slug, $clean, true)) { + $clean[] = $slug; + } + if (count($clean) === 4) { + break; + } + } + return $clean; + } +} diff --git a/src/HttpClient.php b/src/HttpClient.php new file mode 100644 index 0000000..2bded5a --- /dev/null +++ b/src/HttpClient.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog; + +/** + * The narrow outbound-HTTP seam a syndication target uses. Kept as an interface so + * an adapter can be unit-tested with a canned client and never touches the network + * in tests. The real implementation is {@see CurlHttpClient}. + */ +interface HttpClient +{ + /** + * @param array<string,string> $headers + * @return array{status:int,body:string} + */ + public function send(string $method, string $url, array $headers, ?string $body): array; +} diff --git a/src/Schema.php b/src/Schema.php new file mode 100644 index 0000000..93d315d --- /dev/null +++ b/src/Schema.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog; + +/** + * The blog plugin's own storage (ADR 0005). Until syndication the plugin was + * table-less; this is its first and only table. It maps a blog entry to the copy + * it created on an external platform, so a repeat push updates that copy instead + * of creating a duplicate. It holds no post content and no secrets, only the + * external mapping, and touches no core data. + */ +final class Schema +{ + public const SYNDICATION = 'blog_syndication'; + + /** @return list<string> each statement individually idempotent */ + public static function all(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::SYNDICATION . ' ( + entry_id BIGINT UNSIGNED NOT NULL, + target VARCHAR(32) NOT NULL, + external_id VARCHAR(191) NULL, + external_url VARCHAR(512) NULL, + status VARCHAR(16) NOT NULL DEFAULT "ok", + synced_at DATETIME NOT NULL, + PRIMARY KEY (entry_id, target) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + ]; + } +} diff --git a/src/SyndicationRepository.php b/src/SyndicationRepository.php new file mode 100644 index 0000000..9fa4e08 --- /dev/null +++ b/src/SyndicationRepository.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog; + +use Nimbus\Plugin\PluginStorage; + +/** + * The database-backed {@see SyndicationStore}, over the plugin's own + * {@see Schema::SYNDICATION} table (ADR 0005). Storage is resolved lazily so + * constructing this runs no query. Every statement is parameter-bound. + */ +final class SyndicationRepository implements SyndicationStore +{ + /** @param \Closure():PluginStorage $storage */ + public function __construct(private \Closure $storage) + { + } + + public function get(int $entryId, string $target): ?array + { + $row = $this->storage()->selectOne( + 'SELECT external_id, external_url, status FROM ' . Schema::SYNDICATION . ' WHERE entry_id = :e AND target = :t', + ['e' => $entryId, 't' => $target], + ); + if ($row === null) { + return null; + } + return [ + 'external_id' => ($row['external_id'] ?? null) === null ? null : (string) $row['external_id'], + 'external_url' => ($row['external_url'] ?? null) === null ? null : (string) $row['external_url'], + 'status' => (string) $row['status'], + ]; + } + + public function record(int $entryId, string $target, ?string $externalId, string $externalUrl, string $status, string $now): void + { + // Upsert via the MySQL 8 row-alias form (no reused named placeholder). + $this->storage()->execute( + 'INSERT INTO ' . Schema::SYNDICATION . ' (entry_id, target, external_id, external_url, status, synced_at) + VALUES (:e, :t, :xid, :url, :st, :now) AS new + ON DUPLICATE KEY UPDATE external_id = new.external_id, external_url = new.external_url, status = new.status, synced_at = new.synced_at', + ['e' => $entryId, 't' => $target, 'xid' => $externalId, 'url' => $externalUrl, 'st' => $status, 'now' => $now], + ); + } + + public function forEntry(int $entryId): array + { + $rows = $this->storage()->select( + 'SELECT target, external_id, external_url, status, synced_at FROM ' . Schema::SYNDICATION . ' WHERE entry_id = :e ORDER BY target', + ['e' => $entryId], + ); + return array_map(static fn (array $r): array => [ + 'target' => (string) $r['target'], + 'external_id' => ($r['external_id'] ?? null) === null ? null : (string) $r['external_id'], + 'external_url' => ($r['external_url'] ?? null) === null ? null : (string) $r['external_url'], + 'status' => (string) $r['status'], + 'synced_at' => (string) $r['synced_at'], + ], $rows); + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/src/SyndicationStore.php b/src/SyndicationStore.php new file mode 100644 index 0000000..d37b246 --- /dev/null +++ b/src/SyndicationStore.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog; + +/** + * Persistence of the entry-to-external-copy mapping, as the {@see Syndicator} sees + * it. An interface so the service is unit-testable with an in-memory fake; + * {@see SyndicationRepository} is the database implementation. + */ +interface SyndicationStore +{ + /** @return array{external_id:?string,external_url:?string,status:string}|null */ + public function get(int $entryId, string $target): ?array; + + public function record(int $entryId, string $target, ?string $externalId, string $externalUrl, string $status, string $now): void; + + /** @return list<array{target:string,external_id:?string,external_url:?string,status:string,synced_at:string}> */ + public function forEntry(int $entryId): array; +} diff --git a/src/SyndicationTarget.php b/src/SyndicationTarget.php new file mode 100644 index 0000000..3a7355b --- /dev/null +++ b/src/SyndicationTarget.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog; + +use RuntimeException; + +/** A syndication failure (not configured, a non-2xx from the platform, a transport error). */ +final class SyndicationError extends RuntimeException +{ +} + +/** + * One external platform a post can be pushed to (Dev.to, Hashnode, ...). Each target + * is an adapter, so adding a platform is a new implementation, not a change to the + * {@see Syndicator}. A target owns how it talks to its API and where the canonical + * goes; it never touches the plugin's storage or the site's content. + */ +interface SyndicationTarget +{ + /** A stable id used in URLs, storage rows, and the MCP tool argument (e.g. "devto"). */ + public function id(): string; + + /** A human label for the admin (e.g. "Dev.to"). */ + public function label(): string; + + /** Whether the operator has configured this target's credentials. */ + public function isConfigured(): bool; + + /** + * Create the post on the platform, or update it when $externalId is given. + * + * @param array{title:string,body:string,tags:list<string>,canonical:string} $post + * @return array{external_id:string,external_url:string} + */ + public function push(array $post, ?string $externalId): array; +} diff --git a/src/Syndicator.php b/src/Syndicator.php new file mode 100644 index 0000000..44e1260 --- /dev/null +++ b/src/Syndicator.php @@ -0,0 +1,130 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog; + +/** + * The one place syndication happens, called by both the admin action and the MCP + * tool so the two surfaces share behaviour, authority, and audit. Given a post slug + * and a target id it reads the published post, computes the canonical, calls the + * target adapter (create, or update when a prior external id is stored), and records + * the result so a repeat push updates rather than duplicates. + * + * The post is read through a fetch closure wired to the plugin's published-only + * content reader (ADR 0029), so this service is unit-testable without a database and + * can never syndicate a draft. + */ +final class Syndicator +{ + /** + * @param array<string,SyndicationTarget> $targets keyed by target id + * @param \Closure(string):(array<string,mixed>|null) $fetchBySlug published post view-model, or null + */ + public function __construct( + private array $targets, + private SyndicationStore $store, + private \Closure $fetchBySlug, + private string $siteUrl, + private string $basePath = '/blog', + ) { + } + + /** @return list<SyndicationTarget> */ + public function targets(): array + { + return array_values($this->targets); + } + + /** + * Push a published post to one target. Records the outcome either way. + * + * @return array{target:string,external_id:string,external_url:string} + */ + public function syndicate(string $slug, string $targetId, string $now): array + { + $target = $this->targets[$targetId] ?? null; + if ($target === null) { + throw new SyndicationError('Unknown syndication target: ' . $targetId); + } + $post = ($this->fetchBySlug)($slug); + if ($post === null) { + throw new SyndicationError('No published post with slug: ' . $slug); + } + $entryId = (int) ($post['id'] ?? 0); + $existing = $this->store->get($entryId, $targetId); + $payload = $this->payload($post); + + try { + $result = $target->push($payload, $existing['external_id'] ?? null); + } catch (\Throwable $e) { + // Keep any prior mapping, mark the attempt failed, and surface the error. + $this->store->record($entryId, $targetId, $existing['external_id'] ?? null, $existing['external_url'] ?? '', 'error', $now); + throw $e instanceof SyndicationError ? $e : new SyndicationError($e->getMessage()); + } + + $this->store->record($entryId, $targetId, $result['external_id'], $result['external_url'], 'ok', $now); + + return ['target' => $targetId, 'external_id' => $result['external_id'], 'external_url' => $result['external_url']]; + } + + /** + * The syndication state of a post across targets (for the admin page / status tool), + * or null if there is no such published post. + * + * @return array{slug:string,entry_id:int,records:list<array{target:string,external_id:?string,external_url:?string,status:string,synced_at:string}>}|null + */ + public function statusFor(string $slug): ?array + { + $post = ($this->fetchBySlug)($slug); + if ($post === null) { + return null; + } + $entryId = (int) ($post['id'] ?? 0); + return ['slug' => $slug, 'entry_id' => $entryId, 'records' => $this->store->forEntry($entryId)]; + } + + /** + * @param array<string,mixed> $post + * @return array{title:string,body:string,tags:list<string>,canonical:string} + */ + private function payload(array $post): array + { + $fields = is_array($post['fields'] ?? null) ? $post['fields'] : []; + return [ + 'title' => (string) ($post['title'] ?? ''), + 'body' => (string) ($fields['body'] ?? ''), + 'tags' => $this->tags((string) ($fields['tags'] ?? '')), + 'canonical' => $this->canonical($post, $fields), + ]; + } + + /** + * The true original: the post's declared canonical_url if it is itself a copy, + * otherwise this site's own /blog/{slug} URL. + * + * @param array<string,mixed> $post + * @param array<string,mixed> $fields + */ + private function canonical(array $post, array $fields): string + { + $declared = trim((string) ($fields['canonical_url'] ?? '')); + if ($declared !== '' && preg_match('#^https?://#i', $declared) === 1) { + return $declared; + } + return rtrim($this->siteUrl, '/') . '/' . trim($this->basePath, '/') . '/' . (string) ($post['slug'] ?? ''); + } + + /** @return list<string> */ + private function tags(string $csv): array + { + $out = []; + foreach (explode(',', $csv) as $tag) { + $tag = trim($tag); + if ($tag !== '') { + $out[] = $tag; + } + } + return $out; + } +} diff --git a/tests/DevToTargetTest.php b/tests/DevToTargetTest.php new file mode 100644 index 0000000..7a3d1f4 --- /dev/null +++ b/tests/DevToTargetTest.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog\Tests; + +use NimbusCMS\Blog\DevToTarget; +use NimbusCMS\Blog\HttpClient; +use NimbusCMS\Blog\SyndicationError; +use PHPUnit\Framework\TestCase; + +/** A fake HTTP client that records the last request and returns a canned response. */ +final class FakeHttpClient implements HttpClient +{ + /** @var array<string,mixed> */ + public array $last = []; + + public function __construct(private int $status, private string $body) + { + } + + public function send(string $method, string $url, array $headers, ?string $body): array + { + $this->last = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $body]; + return ['status' => $this->status, 'body' => $this->body]; + } +} + +/** + * The Dev.to adapter: it builds the right request to create and to update, sends the + * canonical and sanitised tags, and turns a non-2xx into a clear error. The fake HTTP + * client captures the request, so nothing touches the network. + */ +final class DevToTargetTest 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']; + } + + public function test_create_posts_to_the_collection_with_key_canonical_and_tags(): void + { + $http = new FakeHttpClient(201, '{"id":42,"url":"https://dev.to/dan/hello-42"}'); + $result = (new DevToTarget($http, 'secret-key'))->push($this->post(), null); + + self::assertSame('POST', $http->last['method']); + self::assertSame('https://dev.to/api/articles', $http->last['url']); + self::assertSame('secret-key', $http->last['headers']['api-key']); + $sent = json_decode((string) $http->last['body'], true); + self::assertTrue($sent['article']['published']); + self::assertSame('https://danmat.dev/blog/hello', $sent['article']['canonical_url']); + self::assertSame(['php', 'webdev', 'ab', 'x'], $sent['article']['tags'], 'lowercase alphanumeric, at most four'); + self::assertSame('42', $result['external_id']); + self::assertSame('https://dev.to/dan/hello-42', $result['external_url']); + } + + public function test_update_puts_to_the_article_id(): void + { + $http = new FakeHttpClient(200, '{"id":42,"url":"https://dev.to/dan/hello-42"}'); + (new DevToTarget($http, 'secret-key'))->push($this->post(), '42'); + + self::assertSame('PUT', $http->last['method']); + self::assertSame('https://dev.to/api/articles/42', $http->last['url']); + } + + public function test_a_non_2xx_becomes_a_clear_error(): void + { + $this->expectException(SyndicationError::class); + (new DevToTarget(new FakeHttpClient(422, '{"error":"nope"}'), 'k'))->push($this->post(), null); + } + + public function test_unconfigured_reports_and_refuses(): void + { + $target = new DevToTarget(new FakeHttpClient(200, '{}'), null); + self::assertFalse($target->isConfigured()); + $this->expectException(SyndicationError::class); + $target->push($this->post(), null); + } +} diff --git a/tests/SyndicationRepositoryTest.php b/tests/SyndicationRepositoryTest.php new file mode 100644 index 0000000..c83aa96 --- /dev/null +++ b/tests/SyndicationRepositoryTest.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog\Tests; + +use Nimbus\Database\Connection; +use Nimbus\Plugin\PluginStorage; +use NimbusCMS\Blog\Schema; +use NimbusCMS\Blog\SyndicationRepository; +use PHPUnit\Framework\TestCase; + +/** + * The database-backed store: a first push inserts, a second for the same + * (entry, target) updates in place (never a duplicate), and reads come back shaped. + * Backed by the plugin's own table. + */ +final class SyndicationRepositoryTest extends TestCase +{ + private SyndicationRepository $repo; + private PluginStorage $storage; + + protected function setUp(): void + { + $db = new Connection([ + 'host' => getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach (Schema::all() as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::SYNDICATION); + $this->storage = new PluginStorage($db); + $this->repo = new SyndicationRepository(fn (): PluginStorage => $this->storage); + } + + public function test_record_inserts_then_updates_in_place(): void + { + self::assertNull($this->repo->get(7, 'devto')); + + $this->repo->record(7, 'devto', '42', 'https://dev.to/dan/hello-42', 'ok', '2026-01-01 09:00:00'); + $first = $this->repo->get(7, 'devto'); + self::assertSame('42', $first['external_id']); + self::assertSame('ok', $first['status']); + + // Same (entry, target) again: update, not a second row. + $this->repo->record(7, 'devto', '42', 'https://dev.to/dan/hello-42', 'error', '2026-01-02 10:00:00'); + self::assertSame('error', $this->repo->get(7, 'devto')['status']); + self::assertCount(1, $this->repo->forEntry(7), 'still one row for the pair'); + } + + public function test_for_entry_lists_each_target(): void + { + $this->repo->record(7, 'devto', '42', 'https://dev.to/x', 'ok', '2026-01-01 09:00:00'); + $this->repo->record(7, 'hashnode', 'abc', 'https://hn.example/x', 'ok', '2026-01-01 09:00:00'); + $this->repo->record(8, 'devto', '99', 'https://dev.to/y', 'ok', '2026-01-01 09:00:00'); + + $rows = $this->repo->forEntry(7); + self::assertCount(2, $rows); + self::assertSame(['devto', 'hashnode'], array_map(static fn (array $r): string => $r['target'], $rows)); + } +} diff --git a/tests/SyndicatorTest.php b/tests/SyndicatorTest.php new file mode 100644 index 0000000..0f496f9 --- /dev/null +++ b/tests/SyndicatorTest.php @@ -0,0 +1,164 @@ +<?php + +declare(strict_types=1); + +namespace NimbusCMS\Blog\Tests; + +use NimbusCMS\Blog\SyndicationError; +use NimbusCMS\Blog\SyndicationStore; +use NimbusCMS\Blog\SyndicationTarget; +use NimbusCMS\Blog\Syndicator; +use PHPUnit\Framework\TestCase; + +/** A fake target that records its last call and can be told to fail. */ +final class FakeTarget implements SyndicationTarget +{ + /** @var array{post:array<string,mixed>,externalId:?string}|null */ + public ?array $lastCall = null; + public bool $throw = false; + + public function id(): string + { + return 'devto'; + } + + public function label(): string + { + return 'Dev.to'; + } + + public function isConfigured(): bool + { + return true; + } + + public function push(array $post, ?string $externalId): array + { + $this->lastCall = ['post' => $post, 'externalId' => $externalId]; + if ($this->throw) { + throw new SyndicationError('boom'); + } + return ['external_id' => '42', 'external_url' => 'https://dev.to/dan/hello-42']; + } +} + +/** An in-memory {@see SyndicationStore}. */ +final class FakeStore implements SyndicationStore +{ + /** @var array<string,array{external_id:?string,external_url:?string,status:string}> */ + public array $rows = []; + + public function get(int $entryId, string $target): ?array + { + return $this->rows[$entryId . ':' . $target] ?? null; + } + + public function record(int $entryId, string $target, ?string $externalId, string $externalUrl, string $status, string $now): void + { + $this->rows[$entryId . ':' . $target] = ['external_id' => $externalId, 'external_url' => $externalUrl, 'status' => $status]; + } + + public function forEntry(int $entryId): array + { + $out = []; + foreach ($this->rows as $key => $row) { + [$e, $t] = explode(':', $key, 2); + if ((int) $e === $entryId) { + $out[] = ['target' => $t, 'external_id' => $row['external_id'], 'external_url' => $row['external_url'], 'status' => $row['status'], 'synced_at' => '2026-01-01 00:00:00']; + } + } + return $out; + } +} + +/** + * The Syndicator: canonical computation (self vs a declared original), create vs + * update from the stored external id, recording success and failure, and the + * unknown-target / missing-post guards. Fully faked, no DB and no network. + */ +final class SyndicatorTest extends TestCase +{ + /** @param array<string,mixed> $fields */ + private function make(FakeTarget $target, FakeStore $store, array $fields = [], string $slug = 'hello'): Syndicator + { + $post = ['id' => 7, 'slug' => $slug, 'title' => 'Hello', 'fields' => array_merge(['body' => '# Hi', 'tags' => 'php, web', 'canonical_url' => ''], $fields)]; + return new Syndicator(['devto' => $target], $store, static fn (string $s): ?array => $s === $slug ? $post : null, 'https://danmat.dev', '/blog'); + } + + public function test_it_creates_when_nothing_is_stored_and_records_ok(): void + { + $t = new FakeTarget(); + $s = new FakeStore(); + $out = $this->make($t, $s)->syndicate('hello', 'devto', '2026-01-02 09:00:00'); + + self::assertNotNull($t->lastCall); + self::assertNull($t->lastCall['externalId'], 'a create passes no external id'); + self::assertSame('https://danmat.dev/blog/hello', $t->lastCall['post']['canonical'], 'self canonical'); + self::assertSame('42', $out['external_id']); + self::assertSame('ok', $s->get(7, 'devto')['status']); + self::assertSame('42', $s->get(7, 'devto')['external_id']); + } + + public function test_it_updates_when_an_external_id_is_stored(): void + { + $t = new FakeTarget(); + $s = new FakeStore(); + $s->record(7, 'devto', '42', 'https://dev.to/dan/hello-42', 'ok', '2026-01-01 00:00:00'); + + $this->make($t, $s)->syndicate('hello', 'devto', '2026-01-02 09:00:00'); + self::assertNotNull($t->lastCall); + self::assertSame('42', $t->lastCall['externalId'], 'a stored id makes it an update'); + } + + public function test_a_declared_canonical_url_wins_over_self(): void + { + $t = new FakeTarget(); + $this->make($t, new FakeStore(), ['canonical_url' => 'https://dev.to/original'])->syndicate('hello', 'devto', 'now'); + self::assertNotNull($t->lastCall); + self::assertSame('https://dev.to/original', $t->lastCall['post']['canonical']); + } + + public function test_a_malformed_declared_canonical_falls_back_to_self(): void + { + $t = new FakeTarget(); + $this->make($t, new FakeStore(), ['canonical_url' => 'javascript:alert(1)'])->syndicate('hello', 'devto', 'now'); + self::assertNotNull($t->lastCall); + self::assertSame('https://danmat.dev/blog/hello', $t->lastCall['post']['canonical']); + } + + public function test_an_unknown_target_is_rejected(): void + { + $this->expectException(SyndicationError::class); + $this->make(new FakeTarget(), new FakeStore())->syndicate('hello', 'nope', 'now'); + } + + public function test_a_missing_post_is_rejected(): void + { + $this->expectException(SyndicationError::class); + $this->make(new FakeTarget(), new FakeStore())->syndicate('does-not-exist', 'devto', 'now'); + } + + public function test_a_target_failure_records_error_and_rethrows(): void + { + $t = new FakeTarget(); + $t->throw = true; + $s = new FakeStore(); + try { + $this->make($t, $s)->syndicate('hello', 'devto', 'now'); + self::fail('expected a SyndicationError'); + } catch (SyndicationError) { + self::assertSame('error', $s->get(7, 'devto')['status']); + } + } + + public function test_status_for_lists_records(): void + { + $s = new FakeStore(); + $s->record(7, 'devto', '42', 'https://dev.to/dan/hello-42', 'ok', '2026-01-01 00:00:00'); + $status = $this->make(new FakeTarget(), $s)->statusFor('hello'); + self::assertNotNull($status); + self::assertSame(7, $status['entry_id']); + self::assertCount(1, $status['records']); + self::assertSame('devto', $status['records'][0]['target']); + } +}