Skip to content
Merged
1,412 changes: 1,412 additions & 0 deletions docs/superpowers/plans/marketing/2026-05-17-channel-adapter-devto.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions marketing/channels/MANUAL-SMOKE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<handle>/<slug>` 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.
30 changes: 29 additions & 1 deletion marketing/channels/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -45,6 +45,34 @@ Prerequisites: create an X v2 app at <https://developer.x.com/en/portal/dashboar

When an access token expires, the adapter automatically calls `/2/oauth2/token` to refresh and prints the new refresh token to stderr (X rotates refresh tokens on use; update your `.env` for the next process start).

## 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=<paste>
```

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/<id>.json` instead of hitting any API. Safe for local development and CI.
Expand Down
68 changes: 57 additions & 11 deletions marketing/channels/scripts/smoke.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<void> {
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(),
Expand All @@ -55,6 +95,12 @@ async function main(): Promise<void> {
`${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));
}
}

Expand Down
29 changes: 29 additions & 0 deletions marketing/channels/src/devto/index.ts
Original file line number Diff line number Diff line change
@@ -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<PostResult> {
validateDraft(draft, { adapterId: 'devto' });
return postDevTo(this.apiKey, draft);
}

async metrics(postId: string): Promise<PostMetrics> {
return fetchDevToMetrics(this.apiKey, postId);
}
}
74 changes: 74 additions & 0 deletions marketing/channels/src/devto/metrics.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions marketing/channels/src/devto/metrics.ts
Original file line number Diff line number Diff line change
@@ -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<PostMetrics> {
let response: DevToArticleDetail;
try {
response = await http<DevToArticleDetail>({
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(),
};
}
Loading
Loading