From 1419d6a922c14337e47062c11f3908d49b0cf253 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:19:07 -0700 Subject: [PATCH 01/10] docs(marketing/channels): spec for Dev.to adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to channel-adapters. Extends Draft with optional DraftArticle (title, tags, canonicalUrl, description) — usable by Dev.to v1 and LinkedIn long-form later. Single API key auth, single POST endpoint, real metrics() (Dev.to read API is free). Direct syndication of blog posts with canonical_url set to cacheplane.ai/blog. Publishes immediately (Cowork is the human gate). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-17-channel-adapter-devto-design.md | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 docs/superpowers/specs/marketing/2026-05-17-channel-adapter-devto-design.md diff --git a/docs/superpowers/specs/marketing/2026-05-17-channel-adapter-devto-design.md b/docs/superpowers/specs/marketing/2026-05-17-channel-adapter-devto-design.md new file mode 100644 index 000000000..f4af0a90d --- /dev/null +++ b/docs/superpowers/specs/marketing/2026-05-17-channel-adapter-devto-design.md @@ -0,0 +1,362 @@ +--- +workstream: channel-adapter-devto +status: approved +owner: brian +phase: 1 +spec: docs/superpowers/specs/marketing/2026-05-17-channel-adapter-devto-design.md +plan: docs/superpowers/plans/marketing/2026-05-17-channel-adapter-devto.md +parent: docs/superpowers/specs/marketing/2026-05-17-channel-adapters-design.md +--- + +# Channel Adapter — Dev.to (Design) + +> Follow-up to the channel-adapters sub-spec. Implements the Dev.to adapter behind the same `ChannelAdapter` interface. Single API key auth, single endpoint, real `metrics()` (Dev.to's read API is free). Extends `Draft` with an optional `article` sub-object that LinkedIn will reuse. + +## 1. Goal + +Ship a working Dev.to adapter that turns a markdown-bearing `Draft` into a published Dev.to article. Real metrics included. Adds the article-shaped fields to the shared `Draft` type so LinkedIn long-form posts can land later without another type refactor. + +## 2. Context + +- Parent: `docs/superpowers/specs/marketing/2026-05-17-channel-adapters-design.md`. The shared infra (`validation.ts`, `http.ts`, `dry-run.ts`, `registry.ts`) is already in place; this sub-spec only adds Dev.to-specific code + extends `Draft`. +- Pipeline shape: direct syndication. The content-agent (separate sub-spec) takes a `apps/website/content/blog/.mdx` file, strips MDX-only components, builds a `Draft` with the blog body + title + tags + canonical URL pointing at `cacheplane.ai/blog/`, and calls `getAdapter('devto').post(draft)`. +- Auth model: single API key in `DEVTO_API_KEY`. Sent via `api-key` request header. No OAuth, no refresh, no bootstrapper CLI. +- Dev.to API quirks worth knowing: + - Tags must be lowercase alphanumeric only — `^[a-z0-9]+$`. No hyphens, no underscores. (`langgraph` ✓; `lang-graph` ✗.) + - Max 4 tags. Excess tags → 422 with validation error. + - `canonical_url` tells Google the post originated at the URL provided (the cacheplane blog), so duplicate-content penalties don't apply. + - `published: false` lands the post as a Dev.to-side draft; `published: true` publishes immediately. We use `true` because Cowork approval is the human gate. + - No media-upload endpoint. Images must live at external URLs and be referenced via standard markdown `![alt](https://...)` in the body. Our pipeline hosts images via `@ngaf/marketing-assets` and references them by URL. + +## 3. Scope + +**In scope:** + +- `DevToAdapter` class implementing `ChannelAdapter`. +- `Draft` type extension: optional `article: DraftArticle` sub-object with `title`, `tags?`, `canonicalUrl?`, `description?`. +- `validateDraft` rules for `'devto'`: + - `text` required, non-empty + - `article` required + - `article.title` 1-128 chars + - `article.tags`, if present: length ≤ 4, each `^[a-z0-9]+$`, each 1-30 chars + - `article.canonicalUrl`, if present, parses as `https:` URL + - `threadParts` and `media` must not be set (sanity) +- `post()` POSTs `https://dev.to/api/articles` with `published: true`. Returns `PostResult` with the Dev.to article ID + URL. +- `metrics()` GETs `https://dev.to/api/articles/` and maps: + - `page_views_count` → `PostMetrics.impressions` + - `comments_count` → `PostMetrics.replies` + - `public_reactions_count` → `PostMetrics.shares` +- Dry-run via existing `DRY_RUN=1` (inherits behavior — adapter delegates to `writeDryRunResult`). +- Unit tests with `msw/node` (~12 new tests). +- Update `marketing/channels/README.md` with a Dev.to section (auth + tag rules + quickstart). +- Update `marketing/channels/MANUAL-SMOKE.md` with a Dev.to smoke recipe (dry-run + live). +- Extend `marketing/channels/scripts/smoke.ts` to accept `--channel=devto` (default `x`). + +**Out of scope:** + +- MDX → markdown conversion. Content-agent sub-spec. +- Updates / edits to already-posted articles (Dev.to supports `PUT /api/articles/`). v1 is post-once. The adapter throws if asked to update; that's a future feature. +- Series / cover image / video-embed fields. All supported by Dev.to but not in v1. +- LinkedIn long-form using the same `article` sub-object. LinkedIn ships next sub-spec. + +**Out of scope (X-related, called out for clarity):** + +- The `Draft.article` field MUST NOT be set on X drafts. The X validator rejects it. This is enforced by the adapter-mismatch sanity check, not a separate rule. + +## 4. Architecture + +``` +marketing/channels/src/ +├── types.ts # MODIFY: add DraftArticle + Draft.article? +├── validation.ts # MODIFY: add 'devto' branch with rules +├── registry.ts # MODIFY: instantiate DevToAdapter; remove devto from buildAdapter throw branch +└── devto/ # NEW + ├── index.ts # DevToAdapter class + ├── post.ts # postDevTo(adapter, draft) → PostResult + ├── post.spec.ts # 5+ msw tests + ├── metrics.ts # fetchDevToMetrics(adapter, postId) → PostMetrics + └── metrics.spec.ts # 2+ msw tests +``` + +No `auth.ts` / `auth-cli.ts` for Dev.to — single API key read in `DevToAdapter` constructor. No state machine. No refresh path. + +## 5. Public API delta + +```ts +// types.ts (additions): + +export interface DraftArticle { + /** 1-128 chars. */ + title: string; + /** Channel-specific limits; for Dev.to, lowercase alphanumeric, ≤ 4 items, each ≤ 30 chars. */ + tags?: string[]; + /** Absolute URL — origin post location. Tells search engines where the canonical version lives. */ + canonicalUrl?: string; + /** Meta description; falls through to the platform's SEO subtitle. */ + description?: string; +} + +export interface Draft { + channel: ChannelId; + text?: string; + threadParts?: string[]; + media?: DraftMedia[]; + link?: { url: string; previewTitle?: string }; + scheduledAt?: string; + article?: DraftArticle; // NEW +} +``` + +No existing X usage breaks: X drafts never set `article`. Validation enforces this. + +## 6. Dev.to adapter contract + +### 6.1 Construction + +`DevToAdapter` constructor reads `DEVTO_API_KEY` from env. If missing or empty, throws: + +``` +Dev.to adapter missing env var: DEVTO_API_KEY. Generate one at https://dev.to/settings/extensions and add to .env. +``` + +### 6.2 `post(draft)` flow + +1. `validateDraft(draft, { adapterId: 'devto' })`. +2. If `process.env.DRY_RUN === '1'`: `return writeDryRunResult(draft)`. +3. Build request body: + ```ts + { + article: { + title: draft.article!.title, + body_markdown: draft.text!, + published: true, + tags: draft.article!.tags, // omit key if undefined + canonical_url: draft.article!.canonicalUrl, // omit key if undefined + description: draft.article!.description, // omit key if undefined + } + } + ``` + Omit keys whose values are `undefined` (don't ship `{tags: undefined}` in JSON). +4. `POST https://dev.to/api/articles` with headers: + - `api-key: ` + - `Content-Type: application/json` + - `User-Agent: cacheplane-marketing/1.0` +5. On non-2xx: throw with full response body in the message. Specific error wrapper for 401 status: "Dev.to API key rejected — re-generate at https://dev.to/settings/extensions and update DEVTO_API_KEY." +6. Map response → `PostResult`: + ```ts + { + channel: 'devto', + postId: String(response.id), + url: response.url, + postedAt: response.published_at ?? new Date().toISOString(), + } + ``` + +### 6.3 `metrics(postId)` + +1. `GET https://dev.to/api/articles/` with `api-key` header. +2. Map to `PostMetrics`: + ```ts + { + postId, + impressions: response.page_views_count, + replies: response.comments_count, + shares: response.public_reactions_count, + clicks: undefined, // Dev.to doesn't expose link-click counts + fetchedAt: new Date().toISOString(), + } + ``` +3. On 404: throw "Dev.to article not found." On other non-2xx: throw with response body. + +## 7. Validation rules (per §4 of the brainstorm) + +In `validation.ts`, replace the `'devto'` branch of the switch with: + +```ts +function validateDevTo(draft: Draft): void { + if (typeof draft.text !== 'string' || draft.text.length === 0) { + throw new ValidationError('Dev.to draft.text (body markdown) is required.', { + rule: 'devto-body-required', field: 'text', + }); + } + if (!draft.article) { + throw new ValidationError('Dev.to draft.article is required.', { + rule: 'devto-article-required', field: 'article', + }); + } + const t = draft.article.title; + if (typeof t !== 'string' || t.length === 0 || t.length > 128) { + throw new ValidationError( + `Dev.to article.title must be 1-128 characters (got ${t?.length ?? 0}).`, + { rule: 'devto-title-length', field: 'article.title' }, + ); + } + if (draft.article.tags) { + if (draft.article.tags.length > 4) { + throw new ValidationError( + `Dev.to accepts at most 4 tags (got ${draft.article.tags.length}).`, + { rule: 'devto-too-many-tags', field: 'article.tags' }, + ); + } + for (let i = 0; i < draft.article.tags.length; i++) { + const tag = draft.article.tags[i]; + if (!/^[a-z0-9]+$/.test(tag) || tag.length > 30) { + throw new ValidationError( + `Dev.to tag "${tag}" must match ^[a-z0-9]+$ and be ≤ 30 chars.`, + { rule: 'devto-tag-format', field: `article.tags[${i}]` }, + ); + } + } + } + if (draft.article.canonicalUrl !== undefined) { + try { + const u = new URL(draft.article.canonicalUrl); + if (u.protocol !== 'https:') { + throw new ValidationError( + `Dev.to canonicalUrl must use https: (got ${u.protocol}).`, + { rule: 'devto-canonical-protocol', field: 'article.canonicalUrl' }, + ); + } + } catch (err) { + if (err instanceof ValidationError) throw err; + throw new ValidationError( + `Dev.to canonicalUrl is not a valid URL: ${draft.article.canonicalUrl}.`, + { rule: 'devto-canonical-invalid', field: 'article.canonicalUrl' }, + ); + } + } + if (draft.threadParts) { + throw new ValidationError('Dev.to does not support threads. Use a single text body.', { + rule: 'devto-no-threads', field: 'threadParts', + }); + } + if (draft.media && draft.media.length > 0) { + throw new ValidationError( + 'Dev.to does not accept media uploads. Inline image URLs in the markdown body instead.', + { rule: 'devto-no-media', field: 'media' }, + ); + } +} +``` + +The `'linkedin'` and `'reddit'` branches stay on the existing "not yet implemented" error. The `'devto'` branch is the one that flips from stub to real. + +## 8. Auth getting-started (for the README) + +The README gains a new section: + +``` +## Dev.to setup + +1. Sign in to Dev.to as the author account. +2. Settings → Extensions → DEV Community API Keys → "Generate API Key". + Name it "cacheplane-marketing". +3. Copy the key. +4. Paste into `.env`: + + DEVTO_API_KEY= + +5. (Optional) Verify with a dry-run: + + DRY_RUN=1 pnpm marketing:channels:devto:smoke +``` + +The npm script `marketing:channels:devto:smoke` is added in the plan. + +## 9. Testing + +### 9.1 `devto/post.spec.ts` + +- Valid full draft → assert request URL, headers, body shape, returned `PostResult`. +- Valid minimal draft (no tags, no canonical, no description) → assert these keys are omitted from request body. +- 401 from API → throws with "re-generate" hint. +- 422 with validation error → throws with response body included. +- `DRY_RUN=1` → no HTTP, file written, synthetic `PostResult`. + +### 9.2 `devto/metrics.spec.ts` + +- Maps fields correctly (`page_views_count` → `impressions`, etc.). +- 404 → throws "article not found". + +### 9.3 `validation.spec.ts` additions + +One test per §7 rule. Existing "linkedin/devto/reddit throw not yet implemented" test gets pared down: `devto` is removed from the loop, `linkedin` and `reddit` continue to throw. + +Approximate new test count: 12. + +## 10. Manual smoke recipe addition + +`marketing/channels/MANUAL-SMOKE.md` gains a Dev.to section: + +``` +## Dev.to + +### 1. Dry-run + +DRY_RUN=1 pnpm marketing:channels:devto:smoke + +Expect: PostResult with dry- prefix; JSON in marketing/cowork/outbox/dry-runs/. + +### 2. Live post (publishes to your Dev.to) + +pnpm marketing:channels:devto:smoke + +Expect: a real https://dev.to// URL. Open it; confirm the post is published. +Then DELETE the post from the Dev.to UI (Dashboard → ⋯ → Delete). + +### 3. Metrics fetch + +The smoke prints metrics for the post just made (sleeps 5s first to give Dev.to time to index). +Expect: { impressions: 0, replies: 0, shares: 0, fetchedAt: } or near-zero counts. +``` + +## 11. Risks + non-goals + +| # | Risk | Mitigation | +|--:|------|------------| +| 1 | Dev.to tag rules reject content the agent generates (e.g., agent suggests `dev-to` for the meta-tag) | Validation catches it before any API call; agent can retry with sanitized tags. Document the regex in the README. | +| 2 | The smoke creates real noise on Dev.to during testing | The recipe explicitly says "delete after verifying." A test post stays up for ~30 seconds. | +| 3 | `Draft.article` could be misused (set on X drafts) | Validation catches it (X rules don't allow article); error message names the rule. | +| 4 | Future LinkedIn long-form might want extra fields (`subtitle`, `coverImage`) not in `DraftArticle` | We add them when LinkedIn ships. v1 keeps `DraftArticle` minimal — title/tags/canonical/description is enough for both channels' v1 needs. | +| 5 | Image hosting for inlined markdown images requires a CDN/URL outside the assets package's current scope | Out of scope for this sub-spec. Assets sub-spec ships `renderCard()` to bytes; the content-agent or asset-host sub-spec handles upload-to-URL. v1 Dev.to posts that need images either use the cacheplane blog's existing image URLs or skip images. | + +**Non-goals:** +- Dev.to comment-fetching / reply-posting. +- Cross-posting / unlisted / private articles. +- Series management. + +## 12. Phases + +1. **Phase 0 — Types + validation.** Extend `Draft.article`, add `validateDevTo`, run existing tests + 7 new validation tests green. ~2 commits. +2. **Phase 1 — Adapter post.** `devto/post.ts` + spec; `devto/index.ts` skeleton. ~2 commits. +3. **Phase 2 — Metrics.** `devto/metrics.ts` + spec. ~1 commit. +4. **Phase 3 — Registry + smoke.** Wire into `registry.ts`; extend `scripts/smoke.ts` for `--channel=devto`. Add npm script `marketing:channels:devto:smoke`. ~2 commits. +5. **Phase 4 — Docs.** README + MANUAL-SMOKE updates. ~1 commit. +6. **Phase 5 — Verification.** Build + tests green; Brian runs live smoke; PR. No commit. + +Total: ~8 commits. + +## 13. Deliverables + +- ☐ `marketing/channels/src/types.ts` updated with `DraftArticle` +- ☐ `marketing/channels/src/validation.ts` adds Dev.to branch +- ☐ `marketing/channels/src/validation.spec.ts` adds 7+ Dev.to tests +- ☐ `marketing/channels/src/devto/index.ts` +- ☐ `marketing/channels/src/devto/post.ts` + `post.spec.ts` +- ☐ `marketing/channels/src/devto/metrics.ts` + `metrics.spec.ts` +- ☐ `marketing/channels/src/registry.ts` updated to construct `DevToAdapter` +- ☐ `marketing/channels/scripts/smoke.ts` extended for `--channel=devto` +- ☐ Root `package.json` script: `marketing:channels:devto:smoke` +- ☐ `marketing/channels/README.md` Dev.to section +- ☐ `marketing/channels/MANUAL-SMOKE.md` Dev.to section +- ☐ `marketing/.env.example` already has `DEVTO_API_KEY=` from the meta-spec (no change) +- ☐ `nx run marketing-channels:build` green +- ☐ `nx run marketing-channels:test` green (≥ 45 total tests, up from 33) +- ☐ Brian runs live Dev.to smoke; captures result in PR + +## 14. References + +- Parent: `docs/superpowers/specs/marketing/2026-05-17-channel-adapters-design.md` +- Dev.to API docs: `https://developers.forem.com/api/v1` (Forem is Dev.to's OSS engine) +- Article POST schema: `https://developers.forem.com/api/v1#tag/articles/operation/createArticle` +- API key management: `https://dev.to/settings/extensions` From 9ed5a025db8f5a024e86601aa6906747be578d62 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:23:18 -0700 Subject: [PATCH 02/10] docs(marketing/channels): plan for Dev.to adapter 10 tasks across 5 phases: extend Draft with DraftArticle, add Dev.to validation rules (TDD), implement post() and metrics() with msw mocks, DevToAdapter class, wire registry, extend smoke script, README + MANUAL-SMOKE updates, build/test verification, PR (stacked on #425). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-17-channel-adapter-devto.md | 1412 +++++++++++++++++ 1 file changed, 1412 insertions(+) create mode 100644 docs/superpowers/plans/marketing/2026-05-17-channel-adapter-devto.md diff --git a/docs/superpowers/plans/marketing/2026-05-17-channel-adapter-devto.md b/docs/superpowers/plans/marketing/2026-05-17-channel-adapter-devto.md new file mode 100644 index 000000000..793cb8f43 --- /dev/null +++ b/docs/superpowers/plans/marketing/2026-05-17-channel-adapter-devto.md @@ -0,0 +1,1412 @@ +# Dev.to Channel Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the Dev.to adapter behind the existing `ChannelAdapter` interface, extend `Draft` with an `article` sub-object that Dev.to (and future LinkedIn) needs, and ship real `metrics()` since Dev.to's read API is free. + +**Architecture:** TDD throughout. Phase 0 adds the type + validation extension. Phase 1 adds adapter `post()`. Phase 2 adds `metrics()`. Phase 3 wires the registry + smoke script. Phase 4 docs. Phase 5 verification + PR. Tests use `msw/node` to mock dev.to's API. + +**Tech Stack:** TypeScript 5.x, Vitest 4.x, `msw@^2`, Node 22. No new runtime dependencies. + +**Spec reference:** `docs/superpowers/specs/marketing/2026-05-17-channel-adapter-devto-design.md`. Branch: `marketing-channel-devto` (stacked on `marketing-channel-adapters` PR #425). When PR #425 merges to main, rebase this branch on main; if it merges cleanly there should be no conflicts. + +--- + +## File Structure + +**Modified (all live in PR #425's diff if it hasn't merged yet — this branch stacks on top):** + +- `marketing/channels/src/types.ts` — add `DraftArticle` + optional `Draft.article` +- `marketing/channels/src/validation.ts` — add `validateDevTo` branch +- `marketing/channels/src/validation.spec.ts` — append Dev.to test block +- `marketing/channels/src/registry.ts` — instantiate `DevToAdapter`, remove `devto` from the not-implemented branch +- `marketing/channels/scripts/smoke.ts` — accept `--channel=devto` +- `marketing/channels/README.md` — Dev.to section +- `marketing/channels/MANUAL-SMOKE.md` — Dev.to recipe +- `package.json` (root) — add `marketing:channels:devto:smoke` script + +**New:** + +- `marketing/channels/src/devto/index.ts` +- `marketing/channels/src/devto/post.ts` + `post.spec.ts` +- `marketing/channels/src/devto/metrics.ts` + `metrics.spec.ts` + +--- + +## Task 1: Extend `types.ts` with `DraftArticle` + +**Files:** +- Modify: `marketing/channels/src/types.ts` + +- [ ] **Step 1: Add the `DraftArticle` interface and `Draft.article?` field** + +Append `DraftArticle` after the existing `DraftMedia` interface, and add `article?: DraftArticle` to `Draft`. The final file should read: + +```ts +// SPDX-License-Identifier: MIT +// +// @ngaf/marketing-channels — public types. + +export type ChannelId = 'x' | 'linkedin' | 'devto' | 'reddit'; + +export interface DraftMedia { + png: Buffer; + alt: string; +} + +export interface DraftArticle { + /** 1-128 chars. */ + title: string; + /** Channel-specific limits. Dev.to: ^[a-z0-9]+$, ≤ 4 items, each ≤ 30 chars. */ + tags?: string[]; + /** Absolute https: URL — origin post location. Tells search engines where the canonical version lives. */ + canonicalUrl?: string; + /** Meta description; falls through to the platform's SEO subtitle. */ + description?: string; +} + +export interface Draft { + channel: ChannelId; + text?: string; + threadParts?: string[]; + media?: DraftMedia[]; + link?: { url: string; previewTitle?: string }; + scheduledAt?: string; + article?: DraftArticle; +} + +export interface PostResult { + channel: ChannelId; + postId: string; + url: string; + postedAt: string; +} + +export interface PostMetrics { + postId: string; + impressions?: number; + clicks?: number; + replies?: number; + shares?: number; + fetchedAt: string; +} + +export interface ChannelAdapter { + readonly id: ChannelId; + post(draft: Draft): Promise; + metrics(postId: string): Promise; +} +``` + +- [ ] **Step 2: Verify nothing breaks** + +```bash +npx nx run marketing-channels:build +``` + +Expected: green. The X adapter doesn't set `article` so adding an optional field is backwards-compatible. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/channels/src/types.ts +git commit -m "feat(marketing/channels): add DraftArticle sub-type to Draft" +``` + +--- + +## Task 2: Add Dev.to validation rules (TDD) + +**Files:** +- Modify: `marketing/channels/src/validation.ts` +- Modify: `marketing/channels/src/validation.spec.ts` + +- [ ] **Step 1: Add failing tests** + +Append a new `describe` block to `marketing/channels/src/validation.spec.ts` (after the existing X + "other channels" blocks). Also update the existing "other channels" test to no longer include `devto`: + +Find the existing test: + +```ts +describe('validateDraft (other channels)', () => { + it('throws not-yet-implemented for linkedin/devto/reddit', () => { + for (const channel of ['linkedin', 'devto', 'reddit'] as const) { +``` + +And change `['linkedin', 'devto', 'reddit']` to `['linkedin', 'reddit']` and the description to `'throws not-yet-implemented for linkedin/reddit'`. + +Append: + +```ts +describe('validateDraft (Dev.to)', () => { + function baseDevTo(): Draft { + return { + channel: 'devto', + text: '# Title\n\nBody content here.', + article: { + title: 'My Article', + tags: ['angular', 'tutorial'], + canonicalUrl: 'https://cacheplane.ai/blog/my-article', + description: 'A description.', + }, + }; + } + + it('accepts a minimal valid devto draft', () => { + expect(() => validateDraft(baseDevTo())).not.toThrow(); + }); + + it('rejects missing text', () => { + const d = baseDevTo(); + delete d.text; + expect(() => validateDraft(d)).toThrow(/body markdown is required/i); + }); + + it('rejects empty text', () => { + const d = baseDevTo(); + d.text = ''; + expect(() => validateDraft(d)).toThrow(/body markdown is required/i); + }); + + it('rejects missing article', () => { + const d = baseDevTo(); + delete d.article; + expect(() => validateDraft(d)).toThrow(/article is required/i); + }); + + it('rejects missing title', () => { + const d = baseDevTo(); + d.article!.title = ''; + expect(() => validateDraft(d)).toThrow(/title must be 1-128/i); + }); + + it('rejects title > 128 chars', () => { + const d = baseDevTo(); + d.article!.title = 'a'.repeat(129); + expect(() => validateDraft(d)).toThrow(/title must be 1-128/i); + }); + + it('rejects > 4 tags', () => { + const d = baseDevTo(); + d.article!.tags = ['a', 'b', 'c', 'd', 'e']; + expect(() => validateDraft(d)).toThrow(/at most 4 tags/i); + }); + + it('rejects tag with hyphen', () => { + const d = baseDevTo(); + d.article!.tags = ['lang-graph']; + expect(() => validateDraft(d)).toThrow(/tag "lang-graph"/); + }); + + it('rejects tag with uppercase', () => { + const d = baseDevTo(); + d.article!.tags = ['Angular']; + expect(() => validateDraft(d)).toThrow(/tag "Angular"/); + }); + + it('rejects tag with underscore', () => { + const d = baseDevTo(); + d.article!.tags = ['lang_graph']; + expect(() => validateDraft(d)).toThrow(/tag "lang_graph"/); + }); + + it('rejects tag > 30 chars', () => { + const d = baseDevTo(); + d.article!.tags = ['a'.repeat(31)]; + expect(() => validateDraft(d)).toThrow(/tag .* must match/); + }); + + it('rejects non-https canonical URL', () => { + const d = baseDevTo(); + d.article!.canonicalUrl = 'http://insecure.example.com'; + expect(() => validateDraft(d)).toThrow(/must use https:/); + }); + + it('rejects invalid canonical URL', () => { + const d = baseDevTo(); + d.article!.canonicalUrl = 'not-a-url'; + expect(() => validateDraft(d)).toThrow(/not a valid URL/); + }); + + it('rejects threadParts set on devto draft', () => { + const d = baseDevTo(); + d.threadParts = ['part 1', 'part 2']; + expect(() => validateDraft(d)).toThrow(/does not support threads/i); + }); + + it('rejects media set on devto draft', () => { + const d = baseDevTo(); + d.media = [{ png: Buffer.from('a'), alt: 'a' }]; + expect(() => validateDraft(d)).toThrow(/does not accept media uploads/i); + }); + + it('accepts draft with no tags', () => { + const d = baseDevTo(); + delete d.article!.tags; + expect(() => validateDraft(d)).not.toThrow(); + }); + + it('accepts draft with no canonical URL', () => { + const d = baseDevTo(); + delete d.article!.canonicalUrl; + expect(() => validateDraft(d)).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run tests — they fail (or some pre-existing ones break on the `delete devto` line)** + +```bash +npx nx run marketing-channels:test +``` + +Expected: validation.spec.ts has multiple failures because (a) `validateDevTo` doesn't exist yet, and (b) the "linkedin/devto/reddit" loop changed to "linkedin/reddit". + +- [ ] **Step 3: Implement `validateDevTo`** + +Edit `marketing/channels/src/validation.ts`. Add the `validateDevTo` function and wire it into the `switch` statement. The final file should read: + +```ts +// SPDX-License-Identifier: MIT +import type { ChannelId, Draft } from './types'; + +export class ValidationError extends Error { + public readonly rule: string; + public readonly field?: string; + constructor(message: string, opts: { rule: string; field?: string }) { + super(message); + this.name = 'ValidationError'; + this.rule = opts.rule; + this.field = opts.field; + } +} + +const MAX_X_CHARS = 280; +const MAX_X_MEDIA = 4; +const MAX_ALT = 1000; +const MAX_PNG_BYTES = 5 * 1024 * 1024; + +const MAX_DEVTO_TITLE = 128; +const MAX_DEVTO_TAGS = 4; +const MAX_DEVTO_TAG_LEN = 30; +const DEVTO_TAG_RE = /^[a-z0-9]+$/; + +function codePointLength(s: string): number { + return [...s].length; +} + +function validateX(draft: Draft): void { + const hasText = typeof draft.text === 'string'; + const hasThread = Array.isArray(draft.threadParts); + + if (hasText && hasThread) { + throw new ValidationError('Draft cannot have both text and threadParts.', { + rule: 'exclusive-text-thread', + }); + } + if (!hasText && !hasThread) { + throw new ValidationError('Draft must have either text or threadParts.', { + rule: 'missing-text-or-thread', + }); + } + + if (hasText && codePointLength(draft.text!) > MAX_X_CHARS) { + throw new ValidationError( + `X text exceeds 280 characters (got ${codePointLength(draft.text!)}).`, + { rule: 'text-too-long', field: 'text' }, + ); + } + + if (hasThread) { + if (draft.threadParts!.length < 2) { + throw new ValidationError('threadParts must contain at least 2 entries.', { + rule: 'thread-too-short', + field: 'threadParts', + }); + } + for (let i = 0; i < draft.threadParts!.length; i++) { + const part = draft.threadParts![i]; + if (codePointLength(part) > MAX_X_CHARS) { + throw new ValidationError( + `threadParts[${i}] exceeds 280 characters (got ${codePointLength(part)}).`, + { rule: 'thread-part-too-long', field: `threadParts[${i}]` }, + ); + } + } + } + + if (draft.media && draft.media.length > MAX_X_MEDIA) { + throw new ValidationError( + `X accepts at most 4 media items per post (got ${draft.media.length}).`, + { rule: 'too-many-media', field: 'media' }, + ); + } + + for (let i = 0; i < (draft.media?.length ?? 0); i++) { + const m = draft.media![i]; + if (!m.alt || m.alt.length === 0) { + throw new ValidationError(`media[${i}] alt text is required.`, { + rule: 'alt-required', + field: `media[${i}].alt`, + }); + } + if (m.alt.length > MAX_ALT) { + throw new ValidationError( + `media[${i}] alt text exceeds 1000 characters (got ${m.alt.length}).`, + { rule: 'alt-too-long', field: `media[${i}].alt` }, + ); + } + if (m.png.byteLength > MAX_PNG_BYTES) { + throw new ValidationError( + `media[${i}] PNG exceeds 5MB (got ${m.png.byteLength} bytes).`, + { rule: 'png-too-large', field: `media[${i}].png` }, + ); + } + } +} + +function validateDevTo(draft: Draft): void { + if (typeof draft.text !== 'string' || draft.text.length === 0) { + throw new ValidationError('Dev.to draft.text (body markdown) is required.', { + rule: 'devto-body-required', + field: 'text', + }); + } + if (!draft.article) { + throw new ValidationError('Dev.to draft.article is required.', { + rule: 'devto-article-required', + field: 'article', + }); + } + const t = draft.article.title; + if (typeof t !== 'string' || t.length === 0 || t.length > MAX_DEVTO_TITLE) { + throw new ValidationError( + `Dev.to article.title must be 1-128 characters (got ${t?.length ?? 0}).`, + { rule: 'devto-title-length', field: 'article.title' }, + ); + } + if (draft.article.tags) { + if (draft.article.tags.length > MAX_DEVTO_TAGS) { + throw new ValidationError( + `Dev.to accepts at most 4 tags (got ${draft.article.tags.length}).`, + { rule: 'devto-too-many-tags', field: 'article.tags' }, + ); + } + for (let i = 0; i < draft.article.tags.length; i++) { + const tag = draft.article.tags[i]; + if (!DEVTO_TAG_RE.test(tag) || tag.length > MAX_DEVTO_TAG_LEN) { + throw new ValidationError( + `Dev.to tag "${tag}" must match ^[a-z0-9]+$ and be ≤ 30 chars.`, + { rule: 'devto-tag-format', field: `article.tags[${i}]` }, + ); + } + } + } + if (draft.article.canonicalUrl !== undefined) { + let parsed: URL; + try { + parsed = new URL(draft.article.canonicalUrl); + } catch { + throw new ValidationError( + `Dev.to article.canonicalUrl is not a valid URL: ${draft.article.canonicalUrl}.`, + { rule: 'devto-canonical-invalid', field: 'article.canonicalUrl' }, + ); + } + if (parsed.protocol !== 'https:') { + throw new ValidationError( + `Dev.to article.canonicalUrl must use https: (got ${parsed.protocol}).`, + { rule: 'devto-canonical-protocol', field: 'article.canonicalUrl' }, + ); + } + } + if (draft.threadParts) { + throw new ValidationError('Dev.to does not support threads. Use a single text body.', { + rule: 'devto-no-threads', + field: 'threadParts', + }); + } + if (draft.media && draft.media.length > 0) { + throw new ValidationError( + 'Dev.to does not accept media uploads. Inline image URLs in the markdown body instead.', + { rule: 'devto-no-media', field: 'media' }, + ); + } +} + +export function validateDraft( + draft: Draft, + opts: { adapterId?: ChannelId } = {}, +): void { + if (opts.adapterId && opts.adapterId !== draft.channel) { + throw new ValidationError( + `Channel mismatch: adapter is "${opts.adapterId}" but draft.channel is "${draft.channel}".`, + { rule: 'channel-mismatch', field: 'channel' }, + ); + } + switch (draft.channel) { + case 'x': + return validateX(draft); + case 'devto': + return validateDevTo(draft); + case 'linkedin': + case 'reddit': + throw new ValidationError( + `Channel "${draft.channel}" adapter is not yet implemented.`, + { rule: 'not-implemented', field: 'channel' }, + ); + default: { + const _exhaustive: never = draft.channel; + throw new ValidationError(`Unknown channel: ${String(_exhaustive)}.`, { + rule: 'unknown-channel', + field: 'channel', + }); + } + } +} +``` + +- [ ] **Step 4: Run tests — they pass** + +```bash +npx nx run marketing-channels:test +``` + +Expected: PASS. All previously-passing X tests still pass; new Dev.to tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add marketing/channels/src/validation.ts marketing/channels/src/validation.spec.ts +git commit -m "feat(marketing/channels): add Dev.to validation rules" +``` + +--- + +## Task 3: `devto/post.ts` + tests (TDD) + +**Files:** +- Create: `marketing/channels/src/devto/post.ts` +- Create: `marketing/channels/src/devto/post.spec.ts` + +- [ ] **Step 1: Write failing tests** + +`marketing/channels/src/devto/post.spec.ts`: + +```ts +import { describe, expect, it, beforeAll, afterAll, afterEach, beforeEach } from 'vitest'; +import { setupServer } from 'msw/node'; +import { http as mswHttp, HttpResponse } from 'msw'; +import { postDevTo } from './post'; +import type { Draft } from '../types'; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const apiKey = 'devto-key-123'; + +function baseDraft(): Draft { + return { + channel: 'devto', + text: '# Hello\n\nBody', + article: { + title: 'Hello', + tags: ['angular', 'tutorial'], + canonicalUrl: 'https://cacheplane.ai/blog/hello', + description: 'A hello article.', + }, + }; +} + +describe('postDevTo', () => { + it('POSTs the full article shape and returns a PostResult', async () => { + let receivedBody: unknown; + let receivedHeaders: Headers | undefined; + server.use( + mswHttp.post('https://dev.to/api/articles', async ({ request }) => { + receivedBody = await request.json(); + receivedHeaders = request.headers; + return HttpResponse.json({ + id: 42, + url: 'https://dev.to/brian/hello-1abc', + published_at: '2026-05-17T12:00:00Z', + }); + }), + ); + + const result = await postDevTo(apiKey, baseDraft()); + + expect(receivedHeaders?.get('api-key')).toBe(apiKey); + expect(receivedHeaders?.get('content-type')).toMatch(/application\/json/); + expect(receivedHeaders?.get('user-agent')).toBe('cacheplane-marketing/1.0'); + expect(receivedBody).toEqual({ + article: { + title: 'Hello', + body_markdown: '# Hello\n\nBody', + published: true, + tags: ['angular', 'tutorial'], + canonical_url: 'https://cacheplane.ai/blog/hello', + description: 'A hello article.', + }, + }); + expect(result).toEqual({ + channel: 'devto', + postId: '42', + url: 'https://dev.to/brian/hello-1abc', + postedAt: '2026-05-17T12:00:00Z', + }); + }); + + it('omits optional fields when not set', async () => { + let receivedBody: { article: Record } | undefined; + server.use( + mswHttp.post('https://dev.to/api/articles', async ({ request }) => { + receivedBody = (await request.json()) as typeof receivedBody; + return HttpResponse.json({ + id: 7, + url: 'https://dev.to/brian/min-7', + published_at: '2026-05-17T12:00:00Z', + }); + }), + ); + + const draft: Draft = { + channel: 'devto', + text: 'Just body.', + article: { title: 'Minimal' }, + }; + await postDevTo(apiKey, draft); + + expect(receivedBody?.article).toEqual({ + title: 'Minimal', + body_markdown: 'Just body.', + published: true, + }); + expect(Object.keys(receivedBody!.article)).not.toContain('tags'); + expect(Object.keys(receivedBody!.article)).not.toContain('canonical_url'); + expect(Object.keys(receivedBody!.article)).not.toContain('description'); + }); + + it('falls back to current time when published_at is missing in response', async () => { + server.use( + mswHttp.post('https://dev.to/api/articles', () => + HttpResponse.json({ id: 1, url: 'https://dev.to/brian/x' }), + ), + ); + const result = await postDevTo(apiKey, baseDraft()); + expect(result.postedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('throws with regenerate hint on 401', async () => { + server.use( + mswHttp.post('https://dev.to/api/articles', () => + new HttpResponse('{"error":"unauthorized"}', { status: 401 }), + ), + ); + await expect(postDevTo(apiKey, baseDraft())).rejects.toThrow( + /Dev\.to API key rejected.*re-generate/i, + ); + }); + + it('throws with response body on 422 validation error', async () => { + server.use( + mswHttp.post('https://dev.to/api/articles', () => + new HttpResponse( + '{"error":"Tag is not allowed: bad_tag","status":422}', + { status: 422 }, + ), + ), + ); + await expect(postDevTo(apiKey, baseDraft())).rejects.toThrow(/bad_tag/); + }); + + it('writes a dry-run file and skips HTTP when DRY_RUN=1', async () => { + server.use( + mswHttp.post('https://dev.to/api/articles', () => { + throw new Error('should not be called during dry-run'); + }), + ); + process.env.DRY_RUN = '1'; + try { + const result = await postDevTo(apiKey, baseDraft()); + expect(result.postId).toMatch(/^dry-/); + expect(result.channel).toBe('devto'); + expect(result.url).toMatch(/dry-run\.local/); + } finally { + delete process.env.DRY_RUN; + } + }); +}); +``` + +- [ ] **Step 2: Run tests — they fail (module not found)** + +```bash +npx nx run marketing-channels:test +``` + +- [ ] **Step 3: Implement `post.ts`** + +```ts +// SPDX-License-Identifier: MIT +import { http } from '../http'; +import { writeDryRunResult } from '../dry-run'; +import type { Draft, PostResult } from '../types'; + +const ARTICLES_URL = 'https://dev.to/api/articles'; + +interface DevToArticleResponse { + id: number; + url: string; + published_at?: string; +} + +interface ArticleBody { + title: string; + body_markdown: string; + published: boolean; + tags?: string[]; + canonical_url?: string; + description?: string; +} + +export async function postDevTo(apiKey: string, draft: Draft): Promise { + if (process.env.DRY_RUN === '1') { + return writeDryRunResult(draft); + } + + const article: ArticleBody = { + title: draft.article!.title, + body_markdown: draft.text!, + published: true, + }; + if (draft.article!.tags !== undefined) article.tags = draft.article!.tags; + if (draft.article!.canonicalUrl !== undefined) article.canonical_url = draft.article!.canonicalUrl; + if (draft.article!.description !== undefined) article.description = draft.article!.description; + + let response: DevToArticleResponse; + try { + response = await http({ + method: 'POST', + url: ARTICLES_URL, + headers: { + 'api-key': apiKey, + 'Content-Type': 'application/json', + 'User-Agent': 'cacheplane-marketing/1.0', + }, + body: JSON.stringify({ article }), + retryOn5xx: true, + }); + } catch (err) { + const message = (err as Error).message; + if (message.startsWith('HTTP 401')) { + throw new Error( + 'Dev.to API key rejected — re-generate at https://dev.to/settings/extensions and update DEVTO_API_KEY.', + ); + } + throw err; + } + + return { + channel: 'devto', + postId: String(response.id), + url: response.url, + postedAt: response.published_at ?? new Date().toISOString(), + }; +} +``` + +- [ ] **Step 4: Run tests — they pass** + +```bash +npx nx run marketing-channels:test +``` + +Expected: 6 new tests pass; everything previously passing still passes. + +- [ ] **Step 5: Commit** + +```bash +git add marketing/channels/src/devto/post.ts marketing/channels/src/devto/post.spec.ts +git commit -m "feat(marketing/channels): Dev.to post()" +``` + +--- + +## Task 4: `devto/metrics.ts` + tests (TDD) + +**Files:** +- Create: `marketing/channels/src/devto/metrics.ts` +- Create: `marketing/channels/src/devto/metrics.spec.ts` + +- [ ] **Step 1: Write failing tests** + +`marketing/channels/src/devto/metrics.spec.ts`: + +```ts +import { describe, expect, it, beforeAll, afterAll, afterEach } from 'vitest'; +import { setupServer } from 'msw/node'; +import { http as mswHttp, HttpResponse } from 'msw'; +import { fetchDevToMetrics } from './metrics'; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const apiKey = 'devto-key-123'; + +describe('fetchDevToMetrics', () => { + it('maps Dev.to response fields to PostMetrics', async () => { + server.use( + mswHttp.get('https://dev.to/api/articles/42', () => + HttpResponse.json({ + id: 42, + page_views_count: 1234, + comments_count: 5, + public_reactions_count: 17, + }), + ), + ); + const metrics = await fetchDevToMetrics(apiKey, '42'); + expect(metrics.postId).toBe('42'); + expect(metrics.impressions).toBe(1234); + expect(metrics.replies).toBe(5); + expect(metrics.shares).toBe(17); + expect(metrics.clicks).toBeUndefined(); + expect(metrics.fetchedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('sends api-key + user-agent headers', async () => { + let receivedHeaders: Headers | undefined; + server.use( + mswHttp.get('https://dev.to/api/articles/9', ({ request }) => { + receivedHeaders = request.headers; + return HttpResponse.json({ + id: 9, + page_views_count: 0, + comments_count: 0, + public_reactions_count: 0, + }); + }), + ); + await fetchDevToMetrics(apiKey, '9'); + expect(receivedHeaders?.get('api-key')).toBe(apiKey); + expect(receivedHeaders?.get('user-agent')).toBe('cacheplane-marketing/1.0'); + }); + + it('throws a clear error on 404', async () => { + server.use( + mswHttp.get('https://dev.to/api/articles/999', () => + new HttpResponse('{"error":"not found"}', { status: 404 }), + ), + ); + await expect(fetchDevToMetrics(apiKey, '999')).rejects.toThrow( + /Dev\.to article 999 not found/, + ); + }); + + it('returns zeroes when fields are missing in response', async () => { + server.use( + mswHttp.get('https://dev.to/api/articles/1', () => + HttpResponse.json({ id: 1 }), + ), + ); + const metrics = await fetchDevToMetrics(apiKey, '1'); + expect(metrics.impressions).toBe(0); + expect(metrics.replies).toBe(0); + expect(metrics.shares).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests — they fail (module not found)** + +```bash +npx nx run marketing-channels:test +``` + +- [ ] **Step 3: Implement `metrics.ts`** + +```ts +// SPDX-License-Identifier: MIT +import { http } from '../http'; +import type { PostMetrics } from '../types'; + +interface DevToArticleDetail { + id: number; + page_views_count?: number; + comments_count?: number; + public_reactions_count?: number; +} + +export async function fetchDevToMetrics( + apiKey: string, + postId: string, +): Promise { + let response: DevToArticleDetail; + try { + response = await http({ + method: 'GET', + url: `https://dev.to/api/articles/${postId}`, + headers: { + 'api-key': apiKey, + 'User-Agent': 'cacheplane-marketing/1.0', + }, + }); + } catch (err) { + const message = (err as Error).message; + if (message.startsWith('HTTP 404')) { + throw new Error(`Dev.to article ${postId} not found.`); + } + throw err; + } + + return { + postId, + impressions: response.page_views_count ?? 0, + replies: response.comments_count ?? 0, + shares: response.public_reactions_count ?? 0, + clicks: undefined, + fetchedAt: new Date().toISOString(), + }; +} +``` + +- [ ] **Step 4: Run tests — they pass** + +```bash +npx nx run marketing-channels:test +``` + +- [ ] **Step 5: Commit** + +```bash +git add marketing/channels/src/devto/metrics.ts marketing/channels/src/devto/metrics.spec.ts +git commit -m "feat(marketing/channels): Dev.to metrics() — real read API" +``` + +--- + +## Task 5: `devto/index.ts` — DevToAdapter class + +**Files:** +- Create: `marketing/channels/src/devto/index.ts` + +- [ ] **Step 1: Implement DevToAdapter** + +```ts +// SPDX-License-Identifier: MIT +import type { ChannelAdapter, Draft, PostMetrics, PostResult } from '../types'; +import { validateDraft } from '../validation'; +import { postDevTo } from './post'; +import { fetchDevToMetrics } from './metrics'; + +export class DevToAdapter implements ChannelAdapter { + readonly id = 'devto' as const; + private readonly apiKey: string; + + constructor() { + const key = process.env.DEVTO_API_KEY; + if (!key || key.length === 0) { + throw new Error( + 'Dev.to adapter missing env var: DEVTO_API_KEY. Generate one at https://dev.to/settings/extensions and add to .env.', + ); + } + this.apiKey = key; + } + + async post(draft: Draft): Promise { + validateDraft(draft, { adapterId: 'devto' }); + return postDevTo(this.apiKey, draft); + } + + async metrics(postId: string): Promise { + return fetchDevToMetrics(this.apiKey, postId); + } +} +``` + +- [ ] **Step 2: Verify typecheck + build** + +```bash +npx nx run marketing-channels:build +``` + +Expected: green. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/channels/src/devto/index.ts +git commit -m "feat(marketing/channels): add DevToAdapter class" +``` + +--- + +## Task 6: Wire `DevToAdapter` into the registry + +**Files:** +- Modify: `marketing/channels/src/registry.ts` + +- [ ] **Step 1: Update registry** + +Replace `marketing/channels/src/registry.ts` with: + +```ts +// SPDX-License-Identifier: MIT +import type { ChannelAdapter, ChannelId } from './types'; +import { XAdapter } from './x'; +import { DevToAdapter } from './devto'; + +const KNOWN: ChannelId[] = ['x', 'linkedin', 'devto', 'reddit']; + +const instances = new Map(); + +function buildAdapter(id: ChannelId): ChannelAdapter { + switch (id) { + case 'x': + return new XAdapter(); + case 'devto': + return new DevToAdapter(); + case 'linkedin': + case 'reddit': + throw new Error( + `Channel "${id}" adapter is not yet implemented. Known channels with implementations: x, devto.`, + ); + default: { + const _exhaustive: never = id; + throw new Error( + `Unknown channel "${String(_exhaustive)}". Known: ${KNOWN.join(', ')}.`, + ); + } + } +} + +export function getAdapter(id: ChannelId): ChannelAdapter { + if (!KNOWN.includes(id)) { + throw new Error(`Unknown channel "${id}". Known: ${KNOWN.join(', ')}.`); + } + let inst = instances.get(id); + if (!inst) { + inst = buildAdapter(id); + instances.set(id, inst); + } + return inst; +} +``` + +- [ ] **Step 2: Verify build** + +```bash +npx nx run marketing-channels:build +``` + +Expected: green. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/channels/src/registry.ts +git commit -m "feat(marketing/channels): wire DevToAdapter into registry" +``` + +--- + +## Task 7: Extend `scripts/smoke.ts` for `--channel=devto` + +**Files:** +- Modify: `marketing/channels/scripts/smoke.ts` + +- [ ] **Step 1: Read current smoke.ts to confirm shape** + +```bash +cat marketing/channels/scripts/smoke.ts | head -20 +``` + +- [ ] **Step 2: Replace `marketing/channels/scripts/smoke.ts`** + +```ts +// Standalone smoke runner for channel adapters. NOT exported by the package. +// +// Usage: +// pnpm marketing:channels:x:auth # one-time, fills .env (X only) +// DRY_RUN=1 pnpm marketing:channels:x:smoke +// pnpm marketing:channels:x:smoke +// SMOKE_MEDIA=1 pnpm marketing:channels:x:smoke +// SMOKE_THREAD=1 pnpm marketing:channels:x:smoke +// DRY_RUN=1 pnpm marketing:channels:devto:smoke +// pnpm marketing:channels:devto:smoke +// +// The default channel is 'x'. Override with --channel=devto. + +import fs from 'node:fs'; +import path from 'node:path'; +import { getAdapter, type ChannelId, type Draft } from '../src'; + +const PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=', + 'base64', +); + +function parseChannel(): ChannelId { + const arg = process.argv.find((a) => a.startsWith('--channel=')); + if (!arg) return 'x'; + const value = arg.split('=')[1]; + if (value !== 'x' && value !== 'devto') { + throw new Error(`smoke.ts: --channel=${value} not supported. Use x or devto.`); + } + return value; +} + +function buildXDraft(): Draft { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + if (process.env.SMOKE_THREAD === '1') { + return { + channel: 'x', + threadParts: [ + `Marketing pipeline smoke test — please ignore. (${stamp}) [1/2]`, + 'This is the second tweet of the smoke thread. [2/2]', + ], + }; + } + if (process.env.SMOKE_MEDIA === '1') { + return { + channel: 'x', + text: `Marketing pipeline smoke test with media — please ignore. (${stamp})`, + media: [{ png: PIXEL_PNG, alt: 'A 1x1 transparent pixel — test image.' }], + }; + } + return { + channel: 'x', + text: `Marketing pipeline smoke test — please ignore. (${stamp})`, + }; +} + +function buildDevToDraft(): Draft { + const stamp = new Date().toISOString(); + return { + channel: 'devto', + text: [ + '# Marketing Pipeline Smoke Test', + '', + 'This is an automated smoke test of the @ngaf/marketing-channels Dev.to adapter.', + '', + `Posted at ${stamp}. Please ignore — this article will be deleted.`, + '', + '## Why this exists', + '', + 'The Cacheplane marketing pipeline syndicates blog content to Dev.to. This run verifies the live wire works end-to-end.', + ].join('\n'), + article: { + title: `Marketing Pipeline Smoke Test — ${stamp}`, + tags: ['test'], + canonicalUrl: 'https://cacheplane.ai', + description: 'Automated smoke test of the Cacheplane marketing pipeline Dev.to adapter.', + }, + }; +} + +async function main(): Promise { + const channel = parseChannel(); + const adapter = getAdapter(channel); + const draft = channel === 'devto' ? buildDevToDraft() : buildXDraft(); + const result = await adapter.post(draft); + console.log(JSON.stringify(result, null, 2)); + + if (result.url.startsWith('https://dry-run.local')) { + const outFile = path.join( + process.cwd(), + 'marketing', + 'cowork', + 'outbox', + 'dry-runs', + `${result.postId}.json`, + ); + if (fs.existsSync(outFile)) console.log(`Dry-run file written: ${outFile}`); + } else if (channel === 'devto' && process.env.SMOKE_METRICS !== '0') { + // Brief wait so Dev.to has time to index the article before fetching metrics. + console.log('Sleeping 5s before fetching metrics…'); + await new Promise((r) => setTimeout(r, 5000)); + const metrics = await adapter.metrics(result.postId); + console.log('Metrics:', JSON.stringify(metrics, null, 2)); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); +``` + +- [ ] **Step 3: Add npm script** + +Edit root `package.json` `scripts` block. Find the existing `marketing:channels:x:smoke` line and add immediately after: + +```json +"marketing:channels:devto:smoke": "tsx --env-file=.env marketing/channels/scripts/smoke.ts --channel=devto", +``` + +- [ ] **Step 4: Dry-run sanity check** + +```bash +DRY_RUN=1 pnpm marketing:channels:devto:smoke +``` + +Expected: prints a `PostResult` with `postId` starting `dry-`, `channel: 'devto'`, and a `Dry-run file written:` line. + +- [ ] **Step 5: Commit** + +```bash +git add marketing/channels/scripts/smoke.ts package.json +git commit -m "feat(marketing/channels): smoke script supports --channel=devto" +``` + +--- + +## Task 8: Update `README.md` + `MANUAL-SMOKE.md` + +**Files:** +- Modify: `marketing/channels/README.md` +- Modify: `marketing/channels/MANUAL-SMOKE.md` + +- [ ] **Step 1: Update README** + +In `marketing/channels/README.md`, find the `## Implemented` section and replace it with: + +```markdown +## Implemented + +- **X** (`getAdapter('x')`) — post single tweets, threads, and image media (PNG ≤ 5MB, alt text required). `metrics()` is a stub until the X tier upgrades to Basic+. +- **Dev.to** (`getAdapter('devto')`) — post articles with title, tags, canonical URL, description. Real `metrics()` (Dev.to's read API is free). +``` + +In the `## Planned` section, remove `- Dev.to — next` (Dev.to is implemented now). Final list should read: + +```markdown +## Planned (follow-up commits in this package — no separate spec) + +- LinkedIn +- Reddit +``` + +Append a new section after the `## Auth (X)` section: + +```markdown +## Auth (Dev.to) + +Dev.to uses a single static API key. + +1. Sign in to Dev.to. +2. **Settings** → **Extensions** → **DEV Community API Keys** → **Generate API Key**. + Name it `cacheplane-marketing` (or anything you like). +3. Copy the key into `.env`: + + ``` + DEVTO_API_KEY= + ``` + +4. Verify with a dry-run: + + ```bash + DRY_RUN=1 pnpm marketing:channels:devto:smoke + ``` + +### Tag rules (Dev.to) + +Dev.to is strict about tags. The validator catches violations before the API call: + +- Maximum 4 tags per post. +- Each tag: lowercase letters and digits only — `^[a-z0-9]+$`. +- No hyphens (`lang-graph` ✗), underscores (`lang_graph` ✗), or uppercase (`Angular` ✗). +- Each tag ≤ 30 chars. +``` + +- [ ] **Step 2: Update MANUAL-SMOKE.md** + +Append a new section to `marketing/channels/MANUAL-SMOKE.md`: + +```markdown + +# Dev.to adapter — manual smoke + +Run after `DEVTO_API_KEY` is in `.env`. + +## 1. Dry-run (no API calls) + +```bash +DRY_RUN=1 pnpm marketing:channels:devto:smoke +``` + +Expect: a JSON `PostResult` with `postId` prefixed `dry-`, `channel: "devto"`, and a file under `marketing/cowork/outbox/dry-runs/`. + +## 2. Live article + +```bash +pnpm marketing:channels:devto:smoke +``` + +Expect: a real `https://dev.to//` URL. Open it; confirm the article is published. The script also fetches metrics after a 5-second pause — expect a `Metrics:` block with near-zero counts. **Then delete the article from Dev.to** (Dashboard → ⋯ → Delete). + +## If anything fails + +Capture the printed error and the part of the JSON response surfaced in the error message. File the result in the PR description. +``` + +- [ ] **Step 3: Commit** + +```bash +git add marketing/channels/README.md marketing/channels/MANUAL-SMOKE.md +git commit -m "docs(marketing/channels): document Dev.to adapter" +``` + +--- + +## Task 9: Final build + test verification + +**Files:** none (verification only) + +- [ ] **Step 1: Build** + +```bash +npx nx run marketing-channels:build +``` + +Expected: green. + +- [ ] **Step 2: Run all tests** + +```bash +npx nx run marketing-channels:test +``` + +Expected: green; test count ≥ 45 (33 from PR #425 + ~17 new from this branch — 17 validation tests including the modified existing test, 6 post tests, 4 metrics tests). + +- [ ] **Step 3: Confirm website still builds** + +```bash +npx nx run website:build +``` + +Expected: green. + +- [ ] **Step 4: Dry-run smoke (sanity check)** + +```bash +DRY_RUN=1 pnpm marketing:channels:devto:smoke +``` + +Expected: prints a `PostResult` with `channel: "devto"` and `postId` starting `dry-`. + +- [ ] **Step 5: No commit** — verification only. + +--- + +## Task 10: Push + PR + +**Files:** none (PR creation) + +- [ ] **Step 1: Confirm PR #425 status before pushing** + +```bash +gh pr view 425 --json state,mergedAt +``` + +- If `state: MERGED`: rebase this branch on main (`git fetch origin main && git rebase origin/main`). Resolve any conflicts (unlikely — this branch only touches files PR #425 created or modified in known patterns). +- If `state: OPEN`: push as-is. The PR will appear as stacked on PR #425. + +- [ ] **Step 2: Push** + +```bash +git push -u origin marketing-channel-devto +``` + +- [ ] **Step 3: Open PR** + +```bash +gh pr create --title "feat(marketing/channels): Dev.to adapter" --body "$(cat <<'EOF' +## Summary + +Follow-up to PR #425 (X channel adapter). Implements the Dev.to adapter behind the same ChannelAdapter interface. + +**Changes:** +- Extends \`Draft\` with optional \`article: DraftArticle\` (title, tags, canonicalUrl, description). Future LinkedIn long-form posts will reuse this shape. +- New \`DevToAdapter\` class: single API key auth (\`DEVTO_API_KEY\`), single POST endpoint, real \`metrics()\` (Dev.to's read API is free). +- \`post()\` publishes immediately (\`published: true\`) — Cowork approval is the human gate. +- \`metrics()\` maps \`page_views_count\` → impressions, \`comments_count\` → replies, \`public_reactions_count\` → shares. +- Validation enforces Dev.to's tag rules (\`^[a-z0-9]+$\`, ≤ 4 tags, ≤ 30 chars), 1-128 char title, https-only canonical URL. + +**Stacking:** This branch is stacked on \`marketing-channel-adapters\` (PR #425). Merge #425 first, then rebase this branch on main. + +Spec: \`docs/superpowers/specs/marketing/2026-05-17-channel-adapter-devto-design.md\` +Plan: \`docs/superpowers/plans/marketing/2026-05-17-channel-adapter-devto.md\` + +## Test plan +- [x] \`npx nx run marketing-channels:build\` green +- [x] \`npx nx run marketing-channels:test\` green (~17 new tests, total ≥ 45) +- [x] \`npx nx run website:build\` green +- [x] Dry-run smoke green +- [ ] Brian runs live Dev.to smoke; result pasted below before auto-merge + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 4: Wait for Brian to run the live smoke** + +The controller pauses here and asks Brian to run: + +```bash +pnpm marketing:channels:devto:smoke +``` + +Then delete the test article from Dev.to. Once Brian confirms, enable auto-merge. + +- [ ] **Step 5: Enable auto-merge (only after Brian confirms)** + +```bash +gh pr merge --auto --squash +``` + +--- + +## Self-review + +**Spec coverage** (against §13 deliverables): + +- ✅ `types.ts` updated with `DraftArticle` — Task 1 +- ✅ `validation.ts` adds Dev.to branch — Task 2 +- ✅ `validation.spec.ts` adds Dev.to tests — Task 2 +- ✅ `devto/index.ts` — Task 5 +- ✅ `devto/post.ts` + spec — Task 3 +- ✅ `devto/metrics.ts` + spec — Task 4 +- ✅ `registry.ts` updated — Task 6 +- ✅ `scripts/smoke.ts` extended — Task 7 +- ✅ `package.json` script `marketing:channels:devto:smoke` — Task 7 +- ✅ `README.md` Dev.to section — Task 8 +- ✅ `MANUAL-SMOKE.md` Dev.to section — Task 8 +- ✅ Build + test verification — Task 9 +- ✅ Live smoke verification — Task 10 PR test plan + +**Placeholder scan:** Every code block complete. The only `not yet implemented` strings are intentional (`linkedin`, `reddit`). + +**Type consistency:** +- `DraftArticle` defined Task 1; consumed in validation (Task 2), post.ts (Task 3), and smoke script (Task 7). +- `postDevTo(apiKey, draft)` signature consistent between Task 3 (definition) and Task 5 (consumer). +- `fetchDevToMetrics(apiKey, postId)` signature consistent between Task 4 and Task 5. +- All env var names: `DEVTO_API_KEY`. +- Channel id string `'devto'` consistent across Tasks 2, 5, 6, 7. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/marketing/2026-05-17-channel-adapter-devto.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — Fresh subagent per task with two-stage review. + +**2. Inline Execution** — Execute tasks in this session with batch checkpoints. + +Which approach? From 164c4b092e844d28b03ca2c2e6bbef24f1f41780 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:27:32 -0700 Subject: [PATCH 03/10] feat(marketing/channels): add DraftArticle sub-type to Draft --- marketing/channels/src/types.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/marketing/channels/src/types.ts b/marketing/channels/src/types.ts index 51e22c609..a6827e0f4 100644 --- a/marketing/channels/src/types.ts +++ b/marketing/channels/src/types.ts @@ -9,6 +9,17 @@ export interface DraftMedia { alt: string; } +export interface DraftArticle { + /** 1-128 chars. */ + title: string; + /** Channel-specific limits. Dev.to: ^[a-z0-9]+$, ≤ 4 items, each ≤ 30 chars. */ + tags?: string[]; + /** Absolute https: URL — origin post location. Tells search engines where the canonical version lives. */ + canonicalUrl?: string; + /** Meta description; falls through to the platform's SEO subtitle. */ + description?: string; +} + export interface Draft { channel: ChannelId; text?: string; @@ -16,6 +27,7 @@ export interface Draft { media?: DraftMedia[]; link?: { url: string; previewTitle?: string }; scheduledAt?: string; + article?: DraftArticle; } export interface PostResult { From 111bf02b671f721f5982dd95f97e04f7bb02e3b0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:30:02 -0700 Subject: [PATCH 04/10] feat(marketing/channels): add Dev.to validation rules --- marketing/channels/src/validation.spec.ts | 119 +++++++++++++++++++++- marketing/channels/src/validation.ts | 77 +++++++++++++- 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/marketing/channels/src/validation.spec.ts b/marketing/channels/src/validation.spec.ts index 4118fed03..93705c749 100644 --- a/marketing/channels/src/validation.spec.ts +++ b/marketing/channels/src/validation.spec.ts @@ -92,10 +92,125 @@ describe('validateDraft (X)', () => { }); describe('validateDraft (other channels)', () => { - it('throws not-yet-implemented for linkedin/devto/reddit', () => { - for (const channel of ['linkedin', 'devto', 'reddit'] as const) { + it('throws not-yet-implemented for linkedin/reddit', () => { + for (const channel of ['linkedin', 'reddit'] as const) { const d: Draft = { channel, text: 'hi' }; expect(() => validateDraft(d)).toThrow(/not yet implemented/i); } }); }); + +describe('validateDraft (Dev.to)', () => { + function baseDevTo(): Draft { + return { + channel: 'devto', + text: '# Title\n\nBody content here.', + article: { + title: 'My Article', + tags: ['angular', 'tutorial'], + canonicalUrl: 'https://cacheplane.ai/blog/my-article', + description: 'A description.', + }, + }; + } + + it('accepts a minimal valid devto draft', () => { + expect(() => validateDraft(baseDevTo())).not.toThrow(); + }); + + it('rejects missing text', () => { + const d = baseDevTo(); + delete d.text; + expect(() => validateDraft(d)).toThrow(/body markdown.*is required/i); + }); + + it('rejects empty text', () => { + const d = baseDevTo(); + d.text = ''; + expect(() => validateDraft(d)).toThrow(/body markdown.*is required/i); + }); + + it('rejects missing article', () => { + const d = baseDevTo(); + delete d.article; + expect(() => validateDraft(d)).toThrow(/article is required/i); + }); + + it('rejects missing title', () => { + const d = baseDevTo(); + d.article!.title = ''; + expect(() => validateDraft(d)).toThrow(/title must be 1-128/i); + }); + + it('rejects title > 128 chars', () => { + const d = baseDevTo(); + d.article!.title = 'a'.repeat(129); + expect(() => validateDraft(d)).toThrow(/title must be 1-128/i); + }); + + it('rejects > 4 tags', () => { + const d = baseDevTo(); + d.article!.tags = ['a', 'b', 'c', 'd', 'e']; + expect(() => validateDraft(d)).toThrow(/at most 4 tags/i); + }); + + it('rejects tag with hyphen', () => { + const d = baseDevTo(); + d.article!.tags = ['lang-graph']; + expect(() => validateDraft(d)).toThrow(/tag "lang-graph"/); + }); + + it('rejects tag with uppercase', () => { + const d = baseDevTo(); + d.article!.tags = ['Angular']; + expect(() => validateDraft(d)).toThrow(/tag "Angular"/); + }); + + it('rejects tag with underscore', () => { + const d = baseDevTo(); + d.article!.tags = ['lang_graph']; + expect(() => validateDraft(d)).toThrow(/tag "lang_graph"/); + }); + + it('rejects tag > 30 chars', () => { + const d = baseDevTo(); + d.article!.tags = ['a'.repeat(31)]; + expect(() => validateDraft(d)).toThrow(/tag .* must match/); + }); + + it('rejects non-https canonical URL', () => { + const d = baseDevTo(); + d.article!.canonicalUrl = 'http://insecure.example.com'; + expect(() => validateDraft(d)).toThrow(/must use https:/); + }); + + it('rejects invalid canonical URL', () => { + const d = baseDevTo(); + d.article!.canonicalUrl = 'not-a-url'; + expect(() => validateDraft(d)).toThrow(/not a valid URL/); + }); + + it('rejects threadParts set on devto draft', () => { + const d = baseDevTo(); + d.threadParts = ['part 1', 'part 2']; + expect(() => validateDraft(d)).toThrow(/does not support threads/i); + }); + + it('rejects media set on devto draft', () => { + const d = baseDevTo(); + d.media = [{ png: Buffer.from('a'), alt: 'a' }]; + expect(() => validateDraft(d)).toThrow(/does not accept media uploads/i); + }); + + it('accepts draft with no tags', () => { + const d = baseDevTo(); + delete d.article!.tags; + expect(() => validateDraft(d)).not.toThrow(); + }); + + it('accepts draft with no canonical URL', () => { + const d = baseDevTo(); + delete d.article!.canonicalUrl; + expect(() => validateDraft(d)).not.toThrow(); + }); +}); diff --git a/marketing/channels/src/validation.ts b/marketing/channels/src/validation.ts index aaea87de0..9f6ec9407 100644 --- a/marketing/channels/src/validation.ts +++ b/marketing/channels/src/validation.ts @@ -17,8 +17,12 @@ const MAX_X_MEDIA = 4; const MAX_ALT = 1000; const MAX_PNG_BYTES = 5 * 1024 * 1024; +const MAX_DEVTO_TITLE = 128; +const MAX_DEVTO_TAGS = 4; +const MAX_DEVTO_TAG_LEN = 30; +const DEVTO_TAG_RE = /^[a-z0-9]+$/; + function codePointLength(s: string): number { - // Counts Unicode code points (handles surrogate pairs correctly). return [...s].length; } @@ -92,6 +96,74 @@ function validateX(draft: Draft): void { } } +function validateDevTo(draft: Draft): void { + if (typeof draft.text !== 'string' || draft.text.length === 0) { + throw new ValidationError('Dev.to draft.text (body markdown) is required.', { + rule: 'devto-body-required', + field: 'text', + }); + } + if (!draft.article) { + throw new ValidationError('Dev.to draft.article is required.', { + rule: 'devto-article-required', + field: 'article', + }); + } + const t = draft.article.title; + if (typeof t !== 'string' || t.length === 0 || t.length > MAX_DEVTO_TITLE) { + throw new ValidationError( + `Dev.to article.title must be 1-128 characters (got ${t?.length ?? 0}).`, + { rule: 'devto-title-length', field: 'article.title' }, + ); + } + if (draft.article.tags) { + if (draft.article.tags.length > MAX_DEVTO_TAGS) { + throw new ValidationError( + `Dev.to accepts at most 4 tags (got ${draft.article.tags.length}).`, + { rule: 'devto-too-many-tags', field: 'article.tags' }, + ); + } + for (let i = 0; i < draft.article.tags.length; i++) { + const tag = draft.article.tags[i]; + if (!DEVTO_TAG_RE.test(tag) || tag.length > MAX_DEVTO_TAG_LEN) { + throw new ValidationError( + `Dev.to tag "${tag}" must match ^[a-z0-9]+$ and be ≤ 30 chars.`, + { rule: 'devto-tag-format', field: `article.tags[${i}]` }, + ); + } + } + } + if (draft.article.canonicalUrl !== undefined) { + let parsed: URL; + try { + parsed = new URL(draft.article.canonicalUrl); + } catch { + throw new ValidationError( + `Dev.to article.canonicalUrl is not a valid URL: ${draft.article.canonicalUrl}.`, + { rule: 'devto-canonical-invalid', field: 'article.canonicalUrl' }, + ); + } + if (parsed.protocol !== 'https:') { + throw new ValidationError( + `Dev.to article.canonicalUrl must use https: (got ${parsed.protocol}).`, + { rule: 'devto-canonical-protocol', field: 'article.canonicalUrl' }, + ); + } + } + if (draft.threadParts) { + throw new ValidationError('Dev.to does not support threads. Use a single text body.', { + rule: 'devto-no-threads', + field: 'threadParts', + }); + } + if (draft.media && draft.media.length > 0) { + throw new ValidationError( + 'Dev.to does not accept media uploads. Inline image URLs in the markdown body instead.', + { rule: 'devto-no-media', field: 'media' }, + ); + } +} + export function validateDraft( draft: Draft, opts: { adapterId?: ChannelId } = {}, @@ -105,8 +177,9 @@ export function validateDraft( switch (draft.channel) { case 'x': return validateX(draft); - case 'linkedin': case 'devto': + return validateDevTo(draft); + case 'linkedin': case 'reddit': throw new ValidationError( `Channel "${draft.channel}" adapter is not yet implemented.`, From 9ab6a1cca3a9e87463f807d03a9f0fd9e52837cd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:31:00 -0700 Subject: [PATCH 05/10] feat(marketing/channels): Dev.to post() --- marketing/channels/src/devto/post.spec.ts | 145 ++++++++++++++++++++++ marketing/channels/src/devto/post.ts | 66 ++++++++++ 2 files changed, 211 insertions(+) create mode 100644 marketing/channels/src/devto/post.spec.ts create mode 100644 marketing/channels/src/devto/post.ts diff --git a/marketing/channels/src/devto/post.spec.ts b/marketing/channels/src/devto/post.spec.ts new file mode 100644 index 000000000..5961ca706 --- /dev/null +++ b/marketing/channels/src/devto/post.spec.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, beforeAll, afterAll, afterEach, beforeEach } from 'vitest'; +import { setupServer } from 'msw/node'; +import { http as mswHttp, HttpResponse } from 'msw'; +import { postDevTo } from './post'; +import type { Draft } from '../types'; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const apiKey = 'devto-key-123'; + +function baseDraft(): Draft { + return { + channel: 'devto', + text: '# Hello\n\nBody', + article: { + title: 'Hello', + tags: ['angular', 'tutorial'], + canonicalUrl: 'https://cacheplane.ai/blog/hello', + description: 'A hello article.', + }, + }; +} + +describe('postDevTo', () => { + it('POSTs the full article shape and returns a PostResult', async () => { + let receivedBody: unknown; + let receivedHeaders: Headers | undefined; + server.use( + mswHttp.post('https://dev.to/api/articles', async ({ request }) => { + receivedBody = await request.json(); + receivedHeaders = request.headers; + return HttpResponse.json({ + id: 42, + url: 'https://dev.to/brian/hello-1abc', + published_at: '2026-05-17T12:00:00Z', + }); + }), + ); + + const result = await postDevTo(apiKey, baseDraft()); + + expect(receivedHeaders?.get('api-key')).toBe(apiKey); + expect(receivedHeaders?.get('content-type')).toMatch(/application\/json/); + expect(receivedHeaders?.get('user-agent')).toBe('cacheplane-marketing/1.0'); + expect(receivedBody).toEqual({ + article: { + title: 'Hello', + body_markdown: '# Hello\n\nBody', + published: true, + tags: ['angular', 'tutorial'], + canonical_url: 'https://cacheplane.ai/blog/hello', + description: 'A hello article.', + }, + }); + expect(result).toEqual({ + channel: 'devto', + postId: '42', + url: 'https://dev.to/brian/hello-1abc', + postedAt: '2026-05-17T12:00:00Z', + }); + }); + + it('omits optional fields when not set', async () => { + let receivedBody: { article: Record } | undefined; + server.use( + mswHttp.post('https://dev.to/api/articles', async ({ request }) => { + receivedBody = (await request.json()) as typeof receivedBody; + return HttpResponse.json({ + id: 7, + url: 'https://dev.to/brian/min-7', + published_at: '2026-05-17T12:00:00Z', + }); + }), + ); + + const draft: Draft = { + channel: 'devto', + text: 'Just body.', + article: { title: 'Minimal' }, + }; + await postDevTo(apiKey, draft); + + expect(receivedBody?.article).toEqual({ + title: 'Minimal', + body_markdown: 'Just body.', + published: true, + }); + expect(Object.keys(receivedBody!.article)).not.toContain('tags'); + expect(Object.keys(receivedBody!.article)).not.toContain('canonical_url'); + expect(Object.keys(receivedBody!.article)).not.toContain('description'); + }); + + it('falls back to current time when published_at is missing in response', async () => { + server.use( + mswHttp.post('https://dev.to/api/articles', () => + HttpResponse.json({ id: 1, url: 'https://dev.to/brian/x' }), + ), + ); + const result = await postDevTo(apiKey, baseDraft()); + expect(result.postedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('throws with regenerate hint on 401', async () => { + server.use( + mswHttp.post('https://dev.to/api/articles', () => + new HttpResponse('{"error":"unauthorized"}', { status: 401 }), + ), + ); + await expect(postDevTo(apiKey, baseDraft())).rejects.toThrow( + /Dev\.to API key rejected.*re-generate/i, + ); + }); + + it('throws with response body on 422 validation error', async () => { + server.use( + mswHttp.post('https://dev.to/api/articles', () => + new HttpResponse( + '{"error":"Tag is not allowed: bad_tag","status":422}', + { status: 422 }, + ), + ), + ); + await expect(postDevTo(apiKey, baseDraft())).rejects.toThrow(/bad_tag/); + }); + + it('writes a dry-run file and skips HTTP when DRY_RUN=1', async () => { + server.use( + mswHttp.post('https://dev.to/api/articles', () => { + throw new Error('should not be called during dry-run'); + }), + ); + process.env.DRY_RUN = '1'; + try { + const result = await postDevTo(apiKey, baseDraft()); + expect(result.postId).toMatch(/^dry-/); + expect(result.channel).toBe('devto'); + expect(result.url).toMatch(/dry-run\.local/); + } finally { + delete process.env.DRY_RUN; + } + }); +}); diff --git a/marketing/channels/src/devto/post.ts b/marketing/channels/src/devto/post.ts new file mode 100644 index 000000000..d66bd4493 --- /dev/null +++ b/marketing/channels/src/devto/post.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +import { http } from '../http'; +import { writeDryRunResult } from '../dry-run'; +import type { Draft, PostResult } from '../types'; + +const ARTICLES_URL = 'https://dev.to/api/articles'; + +interface DevToArticleResponse { + id: number; + url: string; + published_at?: string; +} + +interface ArticleBody { + title: string; + body_markdown: string; + published: boolean; + tags?: string[]; + canonical_url?: string; + description?: string; +} + +export async function postDevTo(apiKey: string, draft: Draft): Promise { + if (process.env.DRY_RUN === '1') { + return writeDryRunResult(draft); + } + + const article: ArticleBody = { + title: draft.article!.title, + body_markdown: draft.text!, + published: true, + }; + if (draft.article!.tags !== undefined) article.tags = draft.article!.tags; + if (draft.article!.canonicalUrl !== undefined) article.canonical_url = draft.article!.canonicalUrl; + if (draft.article!.description !== undefined) article.description = draft.article!.description; + + let response: DevToArticleResponse; + try { + response = await http({ + method: 'POST', + url: ARTICLES_URL, + headers: { + 'api-key': apiKey, + 'Content-Type': 'application/json', + 'User-Agent': 'cacheplane-marketing/1.0', + }, + body: JSON.stringify({ article }), + retryOn5xx: true, + }); + } catch (err) { + const message = (err as Error).message; + if (message.startsWith('HTTP 401')) { + throw new Error( + 'Dev.to API key rejected — re-generate at https://dev.to/settings/extensions and update DEVTO_API_KEY.', + ); + } + throw err; + } + + return { + channel: 'devto', + postId: String(response.id), + url: response.url, + postedAt: response.published_at ?? new Date().toISOString(), + }; +} From e7f17abfa2a0531d050c18c005abf349b8b2798b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:31:39 -0700 Subject: [PATCH 06/10] =?UTF-8?q?feat(marketing/channels):=20Dev.to=20metr?= =?UTF-8?q?ics()=20=E2=80=94=20real=20read=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- marketing/channels/src/devto/metrics.spec.ts | 74 ++++++++++++++++++++ marketing/channels/src/devto/metrics.ts | 42 +++++++++++ 2 files changed, 116 insertions(+) create mode 100644 marketing/channels/src/devto/metrics.spec.ts create mode 100644 marketing/channels/src/devto/metrics.ts diff --git a/marketing/channels/src/devto/metrics.spec.ts b/marketing/channels/src/devto/metrics.spec.ts new file mode 100644 index 000000000..b43b0b496 --- /dev/null +++ b/marketing/channels/src/devto/metrics.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, beforeAll, afterAll, afterEach } from 'vitest'; +import { setupServer } from 'msw/node'; +import { http as mswHttp, HttpResponse } from 'msw'; +import { fetchDevToMetrics } from './metrics'; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const apiKey = 'devto-key-123'; + +describe('fetchDevToMetrics', () => { + it('maps Dev.to response fields to PostMetrics', async () => { + server.use( + mswHttp.get('https://dev.to/api/articles/42', () => + HttpResponse.json({ + id: 42, + page_views_count: 1234, + comments_count: 5, + public_reactions_count: 17, + }), + ), + ); + const metrics = await fetchDevToMetrics(apiKey, '42'); + expect(metrics.postId).toBe('42'); + expect(metrics.impressions).toBe(1234); + expect(metrics.replies).toBe(5); + expect(metrics.shares).toBe(17); + expect(metrics.clicks).toBeUndefined(); + expect(metrics.fetchedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('sends api-key + user-agent headers', async () => { + let receivedHeaders: Headers | undefined; + server.use( + mswHttp.get('https://dev.to/api/articles/9', ({ request }) => { + receivedHeaders = request.headers; + return HttpResponse.json({ + id: 9, + page_views_count: 0, + comments_count: 0, + public_reactions_count: 0, + }); + }), + ); + await fetchDevToMetrics(apiKey, '9'); + expect(receivedHeaders?.get('api-key')).toBe(apiKey); + expect(receivedHeaders?.get('user-agent')).toBe('cacheplane-marketing/1.0'); + }); + + it('throws a clear error on 404', async () => { + server.use( + mswHttp.get('https://dev.to/api/articles/999', () => + new HttpResponse('{"error":"not found"}', { status: 404 }), + ), + ); + await expect(fetchDevToMetrics(apiKey, '999')).rejects.toThrow( + /Dev\.to article 999 not found/, + ); + }); + + it('returns zeroes when fields are missing in response', async () => { + server.use( + mswHttp.get('https://dev.to/api/articles/1', () => + HttpResponse.json({ id: 1 }), + ), + ); + const metrics = await fetchDevToMetrics(apiKey, '1'); + expect(metrics.impressions).toBe(0); + expect(metrics.replies).toBe(0); + expect(metrics.shares).toBe(0); + }); +}); diff --git a/marketing/channels/src/devto/metrics.ts b/marketing/channels/src/devto/metrics.ts new file mode 100644 index 000000000..ebd582c4a --- /dev/null +++ b/marketing/channels/src/devto/metrics.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +import { http } from '../http'; +import type { PostMetrics } from '../types'; + +interface DevToArticleDetail { + id: number; + page_views_count?: number; + comments_count?: number; + public_reactions_count?: number; +} + +export async function fetchDevToMetrics( + apiKey: string, + postId: string, +): Promise { + let response: DevToArticleDetail; + try { + response = await http({ + method: 'GET', + url: `https://dev.to/api/articles/${postId}`, + headers: { + 'api-key': apiKey, + 'User-Agent': 'cacheplane-marketing/1.0', + }, + }); + } catch (err) { + const message = (err as Error).message; + if (message.startsWith('HTTP 404')) { + throw new Error(`Dev.to article ${postId} not found.`); + } + throw err; + } + + return { + postId, + impressions: response.page_views_count ?? 0, + replies: response.comments_count ?? 0, + shares: response.public_reactions_count ?? 0, + clicks: undefined, + fetchedAt: new Date().toISOString(), + }; +} From 80ee966b69803ddce2525bb3516665dfe03afd60 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:31:54 -0700 Subject: [PATCH 07/10] feat(marketing/channels): add DevToAdapter class --- marketing/channels/src/devto/index.ts | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 marketing/channels/src/devto/index.ts diff --git a/marketing/channels/src/devto/index.ts b/marketing/channels/src/devto/index.ts new file mode 100644 index 000000000..234c75808 --- /dev/null +++ b/marketing/channels/src/devto/index.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +import type { ChannelAdapter, Draft, PostMetrics, PostResult } from '../types'; +import { validateDraft } from '../validation'; +import { postDevTo } from './post'; +import { fetchDevToMetrics } from './metrics'; + +export class DevToAdapter implements ChannelAdapter { + readonly id = 'devto' as const; + private readonly apiKey: string; + + constructor() { + const key = process.env.DEVTO_API_KEY; + if (!key || key.length === 0) { + throw new Error( + 'Dev.to adapter missing env var: DEVTO_API_KEY. Generate one at https://dev.to/settings/extensions and add to .env.', + ); + } + this.apiKey = key; + } + + async post(draft: Draft): Promise { + validateDraft(draft, { adapterId: 'devto' }); + return postDevTo(this.apiKey, draft); + } + + async metrics(postId: string): Promise { + return fetchDevToMetrics(this.apiKey, postId); + } +} From c5e2dc0c5e6b563ae0a9f7628696a7f9752469ab Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:32:09 -0700 Subject: [PATCH 08/10] feat(marketing/channels): wire DevToAdapter into registry --- marketing/channels/src/registry.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/marketing/channels/src/registry.ts b/marketing/channels/src/registry.ts index c31c664a8..571137beb 100644 --- a/marketing/channels/src/registry.ts +++ b/marketing/channels/src/registry.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import type { ChannelAdapter, ChannelId } from './types'; import { XAdapter } from './x'; +import { DevToAdapter } from './devto'; const KNOWN: ChannelId[] = ['x', 'linkedin', 'devto', 'reddit']; @@ -10,11 +11,12 @@ function buildAdapter(id: ChannelId): ChannelAdapter { switch (id) { case 'x': return new XAdapter(); - case 'linkedin': case 'devto': + return new DevToAdapter(); + case 'linkedin': case 'reddit': throw new Error( - `Channel "${id}" adapter is not yet implemented. Known channels with implementations: x.`, + `Channel "${id}" adapter is not yet implemented. Known channels with implementations: x, devto.`, ); default: { const _exhaustive: never = id; From 447cf94f1b5c9adf0039a3b8b55d591f6ffe4aa2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:33:09 -0700 Subject: [PATCH 09/10] feat(marketing/channels): smoke script supports --channel=devto --- marketing/channels/scripts/smoke.ts | 68 ++++++++++++++++++++++++----- package.json | 1 + 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/marketing/channels/scripts/smoke.ts b/marketing/channels/scripts/smoke.ts index a42dc2f15..5ae93c5bd 100644 --- a/marketing/channels/scripts/smoke.ts +++ b/marketing/channels/scripts/smoke.ts @@ -1,22 +1,36 @@ -// Standalone smoke runner for the X adapter. NOT exported by the package. +// Standalone smoke runner for channel adapters. NOT exported by the package. +// // Usage: -// pnpm marketing:channels:x:auth # one-time, fills .env -// DRY_RUN=1 npx tsx marketing/channels/scripts/smoke.ts -// npx tsx marketing/channels/scripts/smoke.ts -// SMOKE_MEDIA=1 npx tsx marketing/channels/scripts/smoke.ts -// SMOKE_THREAD=1 npx tsx marketing/channels/scripts/smoke.ts +// pnpm marketing:channels:x:auth # one-time, fills .env (X only) +// DRY_RUN=1 pnpm marketing:channels:x:smoke +// pnpm marketing:channels:x:smoke +// SMOKE_MEDIA=1 pnpm marketing:channels:x:smoke +// SMOKE_THREAD=1 pnpm marketing:channels:x:smoke +// DRY_RUN=1 pnpm marketing:channels:devto:smoke +// pnpm marketing:channels:devto:smoke +// +// The default channel is 'x'. Override with --channel=devto. import fs from 'node:fs'; import path from 'node:path'; -import { getAdapter, type Draft } from '../src'; +import { getAdapter, type ChannelId, type Draft } from '../src'; -// 1x1 transparent PNG. const PIXEL_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=', 'base64', ); -function buildDraft(): Draft { +function parseChannel(): ChannelId { + const arg = process.argv.find((a) => a.startsWith('--channel=')); + if (!arg) return 'x'; + const value = arg.split('=')[1]; + if (value !== 'x' && value !== 'devto') { + throw new Error(`smoke.ts: --channel=${value} not supported. Use x or devto.`); + } + return value; +} + +function buildXDraft(): Draft { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); if (process.env.SMOKE_THREAD === '1') { return { @@ -40,11 +54,37 @@ function buildDraft(): Draft { }; } +function buildDevToDraft(): Draft { + const stamp = new Date().toISOString(); + return { + channel: 'devto', + text: [ + '# Marketing Pipeline Smoke Test', + '', + 'This is an automated smoke test of the @ngaf/marketing-channels Dev.to adapter.', + '', + `Posted at ${stamp}. Please ignore — this article will be deleted.`, + '', + '## Why this exists', + '', + 'The Cacheplane marketing pipeline syndicates blog content to Dev.to. This run verifies the live wire works end-to-end.', + ].join('\n'), + article: { + title: `Marketing Pipeline Smoke Test — ${stamp}`, + tags: ['test'], + canonicalUrl: 'https://cacheplane.ai', + description: 'Automated smoke test of the Cacheplane marketing pipeline Dev.to adapter.', + }, + }; +} + async function main(): Promise { - const adapter = getAdapter('x'); - const draft = buildDraft(); + const channel = parseChannel(); + const adapter = getAdapter(channel); + const draft = channel === 'devto' ? buildDevToDraft() : buildXDraft(); const result = await adapter.post(draft); console.log(JSON.stringify(result, null, 2)); + if (result.url.startsWith('https://dry-run.local')) { const outFile = path.join( process.cwd(), @@ -55,6 +95,12 @@ async function main(): Promise { `${result.postId}.json`, ); if (fs.existsSync(outFile)) console.log(`Dry-run file written: ${outFile}`); + } else if (channel === 'devto' && process.env.SMOKE_METRICS !== '0') { + // Brief wait so Dev.to has time to index the article before fetching metrics. + console.log('Sleeping 5s before fetching metrics…'); + await new Promise((r) => setTimeout(r, 5000)); + const metrics = await adapter.metrics(result.postId); + console.log('Metrics:', JSON.stringify(metrics, null, 2)); } } diff --git a/package.json b/package.json index 166e1beeb..bfe5f14a4 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "generate-whitepaper": "npx tsx apps/website/scripts/generate-whitepaper.ts", "marketing:channels:x:auth": "tsx --env-file=.env marketing/channels/src/x/auth-cli.ts", "marketing:channels:x:smoke": "tsx --env-file=.env marketing/channels/scripts/smoke.ts", + "marketing:channels:devto:smoke": "tsx --env-file=.env marketing/channels/scripts/smoke.ts --channel=devto", "posthog:sync": "nx run posthog-tools:sync:plan", "posthog:apply": "nx run posthog-tools:sync:apply", "posthog:report": "nx run posthog-tools:report", From cccefe254dda145871d6e2e3243b682b65baef31 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 12:33:40 -0700 Subject: [PATCH 10/10] docs(marketing/channels): document Dev.to adapter --- marketing/channels/MANUAL-SMOKE.md | 24 ++++++++++++++++++++++++ marketing/channels/README.md | 30 +++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/marketing/channels/MANUAL-SMOKE.md b/marketing/channels/MANUAL-SMOKE.md index b6af27b72..a3d61ea9b 100644 --- a/marketing/channels/MANUAL-SMOKE.md +++ b/marketing/channels/MANUAL-SMOKE.md @@ -39,3 +39,27 @@ Expect: two tweets posted; the second is a reply to the first. Delete both. ## If anything fails Capture the printed error message and any response body in the error. Note which step failed. File the result in the PR description so future maintainers see what shape of breakage they need to handle. + +# Dev.to adapter — manual smoke + +Run after `DEVTO_API_KEY` is in `.env`. + +## 1. Dry-run (no API calls) + +```bash +DRY_RUN=1 pnpm marketing:channels:devto:smoke +``` + +Expect: a JSON `PostResult` with `postId` prefixed `dry-`, `channel: "devto"`, and a file under `marketing/cowork/outbox/dry-runs/`. + +## 2. Live article + +```bash +pnpm marketing:channels:devto:smoke +``` + +Expect: a real `https://dev.to//` URL. Open it; confirm the article is published. The script also fetches metrics after a 5-second pause — expect a `Metrics:` block with near-zero counts. **Then delete the article from Dev.to** (Dashboard → ⋯ → Delete). + +## If anything fails + +Capture the printed error and the part of the JSON response surfaced in the error message. File the result in the PR description. diff --git a/marketing/channels/README.md b/marketing/channels/README.md index 6e0c2500d..09163ca5a 100644 --- a/marketing/channels/README.md +++ b/marketing/channels/README.md @@ -5,10 +5,10 @@ Channel adapters for the Cacheplane marketing pipeline. One adapter per channel, ## Implemented - **X** (`getAdapter('x')`) — post single tweets, threads, and image media (PNG ≤ 5MB, alt text required). `metrics()` is a stub until the X tier upgrades to Basic+. +- **Dev.to** (`getAdapter('devto')`) — post articles with title, tags, canonical URL, description. Real `metrics()` (Dev.to's read API is free). ## Planned (follow-up commits in this package — no separate spec) -- Dev.to — next - LinkedIn - Reddit @@ -45,6 +45,34 @@ Prerequisites: create an X v2 app at + ``` + +4. Verify with a dry-run: + + ```bash + DRY_RUN=1 pnpm marketing:channels:devto:smoke + ``` + +### Tag rules (Dev.to) + +Dev.to is strict about tags. The validator catches violations before the API call: + +- Maximum 4 tags per post. +- Each tag: lowercase letters and digits only — `^[a-z0-9]+$`. +- No hyphens (`lang-graph` ✗), underscores (`lang_graph` ✗), or uppercase (`Angular` ✗). +- Each tag ≤ 30 chars. + ## Dry-run Set `DRY_RUN=1` and `post()` writes the draft to `marketing/cowork/outbox/dry-runs/.json` instead of hitting any API. Safe for local development and CI.