From 80e49fed5cb2b1d37cbba4e05a6d5c1afb9434c3 Mon Sep 17 00:00:00 2001 From: Robbie Wagner Date: Sun, 23 Aug 2026 10:13:51 -0400 Subject: [PATCH 1/6] Poll RSS feed for new episodes and trigger rebuild before ATProto publish (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Poll RSS feed for new episodes and trigger rebuild before ATProto publish - Publish workflow now polls the feed every 30 min (offset from top of hour) instead of chaining off the daily rebuild via workflow_run - Cheap gate job hashes the feed body and skips the publish job when nothing changed, persisting the hash via actions/cache only after a successful publish so failures retry on the next poll - On feed change the gate triggers the Vercel rebuild webhook, and the publish script waits for the new episode page to return 200 before publishing, so ATProto documents never link to unbuilt pages - Manual workflow_dispatch bypasses the feed-changed gate Co-Authored-By: Claude Fable 5 * Wait for all unpublished episode pages, not just the first The first-unpublished-episode shortcut assumed newest-first feed ordering; with older unpublished episodes (e.g. a previously failed publish) the wait could pass on an already-existing page while the newest page was still building. Wait on every unpublished page concurrently instead. Co-Authored-By: Claude Fable 5 * Make feed-hash cache key unique per run Cache keys are immutable, so re-publishing a previously seen feed hash would fail to save while the prefix restore kept returning a newer, different hash — causing the gate to see "changed" on every poll. Appending run id/attempt gives last-writer-wins semantics. Co-Authored-By: Claude Fable 5 * Exit nonzero when any episode fails to publish Non-duplicate publishDocument failures were only logged, so the run exited 0, the workflow recorded the feed hash, and the failed episode was never retried until the next feed change. Track failures and throw after the loop so the hash isn't saved and the next poll retries. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/publish-episodes.yml | 100 ++++++++++++++++++++++--- README.md | 6 +- scripts/publish-episodes.ts | 60 ++++++++++++++- 3 files changed, 151 insertions(+), 15 deletions(-) diff --git a/.github/workflows/publish-episodes.yml b/.github/workflows/publish-episodes.yml index e5713ce..e0deea6 100644 --- a/.github/workflows/publish-episodes.yml +++ b/.github/workflows/publish-episodes.yml @@ -1,22 +1,29 @@ name: Publish Episodes to ATProto on: - # Run after the daily site rebuild to catch new episodes - workflow_run: - workflows: ["Rebuild Astro Site"] - types: [completed] - # Allow manual trigger + # Poll the RSS feed for new episodes. Offset from the top of the hour — + # GitHub delays cron runs scheduled at :00/:30 the most. + schedule: + - cron: "7,37 * * * *" + # Allow manual trigger (skips the feed-changed gate) workflow_dispatch: +concurrency: + group: publish-episodes + cancel-in-progress: false + jobs: - # Skip publishing entirely if standard.site/ATProto secrets aren't configured, - # so forks without standard.site set up don't fail this workflow. - check: + # Gate: skip publishing entirely if standard.site/ATProto secrets aren't + # configured (so forks without standard.site set up don't fail this + # workflow), and skip scheduled runs when the feed hasn't changed since the + # last successful publish. + gate: runs-on: ubuntu-latest - # Only run if the triggering workflow succeeded (or manual dispatch) - if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} outputs: configured: ${{ steps.config.outputs.configured }} + changed: ${{ steps.feed.outputs.changed }} + feed-hash: ${{ steps.feed.outputs.hash }} + rebuild-triggered: ${{ steps.rebuild.outputs.triggered }} steps: - name: Check ATProto configuration id: config @@ -32,10 +39,64 @@ jobs: echo "configured=false" >> "$GITHUB_OUTPUT" echo "::notice::standard.site/ATProto secrets not set — skipping episode publishing." fi + - uses: actions/checkout@v4 + if: steps.config.outputs.configured == 'true' + - name: Restore last published feed hash + if: steps.config.outputs.configured == 'true' + uses: actions/cache/restore@v4 + with: + path: .feed-hash + key: feed-hash- + restore-keys: | + feed-hash- + - name: Check feed for changes + if: steps.config.outputs.configured == 'true' + id: feed + run: | + set -euo pipefail + FEED_URL=$(sed -n "s/.*rssFeed: *['\"]\([^'\"]*\)['\"].*/\1/p" starpod.config.ts | head -1) + if [ -z "$FEED_URL" ]; then + echo "::warning::Could not extract rssFeed from starpod.config.ts — publishing unconditionally." + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! FEED_BODY=$(curl -sfL "$FEED_URL"); then + echo "::warning::Could not fetch RSS feed — publishing unconditionally." + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + HASH=$(printf '%s' "$FEED_BODY" | sha256sum | cut -d' ' -f1) + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + PREV=$(cat .feed-hash 2>/dev/null || true) + if [ "$HASH" = "$PREV" ]; then + echo "Feed unchanged since last publish." + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "Feed changed (or no previous hash) — publishing." + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + # Kick off the Vercel rebuild as soon as a feed change is detected, so + # the site build runs while the publish job is installing dependencies. + # The publish script then waits for the new episode pages to be live + # before publishing documents that link to them. + - name: Trigger site rebuild + if: steps.config.outputs.configured == 'true' && steps.feed.outputs.changed == 'true' + id: rebuild + env: + REBUILD_WEBHOOK: ${{ secrets.REBUILD_WEBHOOK }} + run: | + if [ -n "$REBUILD_WEBHOOK" ]; then + curl -sf -X POST "$REBUILD_WEBHOOK" > /dev/null + echo "triggered=true" >> "$GITHUB_OUTPUT" + echo "Triggered site rebuild." + else + echo "triggered=false" >> "$GITHUB_OUTPUT" + echo "::notice::REBUILD_WEBHOOK not set — publishing without waiting for a site rebuild." + fi publish: - needs: check - if: ${{ needs.check.outputs.configured == 'true' }} + needs: gate + if: ${{ needs.gate.outputs.configured == 'true' && (github.event_name == 'workflow_dispatch' || needs.gate.outputs.changed == 'true') }} runs-on: ubuntu-latest permissions: contents: read @@ -64,3 +125,18 @@ jobs: ATPROTO_APP_PASSWORD: ${{ secrets.ATPROTO_APP_PASSWORD }} STANDARD_SITE_URL: ${{ secrets.STANDARD_SITE_URL }} STANDARD_SITE_PUBLICATION_RKEY: ${{ secrets.STANDARD_SITE_PUBLICATION_RKEY }} + WAIT_FOR_SITE: ${{ needs.gate.outputs.rebuild-triggered }} + # Record the feed hash only after a successful publish, so a failed run + # is retried on the next scheduled poll. + - name: Record published feed hash + if: ${{ needs.gate.outputs.feed-hash != '' }} + run: printf '%s' "${{ needs.gate.outputs.feed-hash }}" > .feed-hash + - name: Save feed hash + if: ${{ needs.gate.outputs.feed-hash != '' }} + uses: actions/cache/save@v4 + with: + path: .feed-hash + # Include run id/attempt so re-publishing a previously seen hash + # still saves (cache keys are immutable); the prefix restore picks + # the most recently created entry. + key: feed-hash-${{ needs.gate.outputs.feed-hash }}-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/README.md b/README.md index 7516dc5..ca08401 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,10 @@ variables → Actions → New repository secret**: Episodes are published to ATProto as individual documents automatically: -- **Automatic** — The `Publish Episodes to ATProto` workflow runs after each - daily site rebuild and publishes any new episodes +- **Automatic** — The `Publish Episodes to ATProto` workflow polls the RSS + feed every 30 minutes; when it finds new episodes it triggers a site rebuild + (via the `REBUILD_WEBHOOK` secret), waits for the new episode pages to be + live, and then publishes the episodes - **Manual** — Trigger the workflow manually from the Actions tab - **Backfill** — Use the `Backfill Episodes to ATProto` workflow (Actions tab → Run workflow → type "backfill") to publish all existing episodes diff --git a/scripts/publish-episodes.ts b/scripts/publish-episodes.ts index 7374d4e..e2a1126 100644 --- a/scripts/publish-episodes.ts +++ b/scripts/publish-episodes.ts @@ -29,6 +29,38 @@ import starpodConfig from '../starpod.config'; import { dasherize } from '../src/utils/dasherize'; const BACKFILL = process.argv.includes('--backfill'); +// Set by the GitHub workflow when it has just triggered a site rebuild: +// published documents link to episode pages, so wait for the rebuilt site to +// serve them before publishing. +const WAIT_FOR_SITE = process.env.WAIT_FOR_SITE === 'true'; + +const PAGE_WAIT_TIMEOUT_MS = 10 * 60 * 1000; +const PAGE_WAIT_INTERVAL_MS = 15 * 1000; + +async function waitForPage(url: string) { + const deadline = Date.now() + PAGE_WAIT_TIMEOUT_MS; + + for (;;) { + try { + const response = await fetch(url, { method: 'HEAD' }); + if (response.ok) { + return; + } + console.log(` ⏳ ${url} → ${response.status}, waiting for rebuild...`); + } catch (err) { + console.log(` ⏳ ${url} unreachable, waiting for rebuild... (${err})`); + } + + if (Date.now() >= deadline) { + throw new Error( + `Timed out waiting for ${url} — site rebuild may have failed. ` + + 'Episodes will be retried on the next scheduled run.' + ); + } + + await new Promise((resolve) => setTimeout(resolve, PAGE_WAIT_INTERVAL_MS)); + } +} const FeedSchema = object({ items: array( @@ -112,8 +144,27 @@ async function main() { cursor = response.data.cursor; } while (cursor); + // Vercel deploys are atomic, so new pages go live together — but wait on + // every unpublished page so publishing can't outrun the rebuild regardless + // of feed ordering or previously failed publishes. + if (WAIT_FOR_SITE && !BACKFILL) { + const pendingUrls = episodes + .filter((episode) => !existingPaths.has(`/${dasherize(episode.title)}`)) + .map( + (episode) => `${siteUrl.replace(/\/$/, '')}/${dasherize(episode.title)}` + ); + if (pendingUrls.length > 0) { + console.log( + `⏳ Waiting for rebuilt site to serve ${pendingUrls.length} new episode page(s)...` + ); + await Promise.all(pendingUrls.map((url) => waitForPage(url))); + console.log('✅ Site rebuild is live.'); + } + } + let published = 0; let skipped = 0; + let failed = 0; for (const episode of episodes) { const slug = dasherize(episode.title); @@ -155,13 +206,20 @@ async function main() { skipped++; } else { console.error(` ❌ ${episode.title}: ${message}`); + failed++; } } } console.log( - `\n🎉 Done! Published: ${published}, Skipped: ${skipped}, Total episodes: ${episodes.length}` + `\n🎉 Done! Published: ${published}, Skipped: ${skipped}, Failed: ${failed}, Total episodes: ${episodes.length}` ); + + // Exit nonzero so the workflow doesn't record the feed hash and the failed + // episodes are retried on the next scheduled run. + if (failed > 0) { + throw new Error(`${failed} episode(s) failed to publish.`); + } } main().catch((err) => { From 8f6ad3eb57c7a9a4c841506b93ebc6336b570ff2 Mon Sep 17 00:00:00 2001 From: Robbie Wagner Date: Sun, 23 Aug 2026 10:56:48 -0400 Subject: [PATCH 2/6] Return structured JSON errors from the contact API (#50) Co-authored-by: Claude Fable 5 --- src/lib/api-errors.ts | 26 +++++++ src/pages/api/contact.ts | 133 +++++++++++++++++++++++---------- tests/e2e/api-errors.spec.ts | 32 ++++++++ tests/unit/contact-api.test.ts | 93 +++++++++++++++++++++++ 4 files changed, 245 insertions(+), 39 deletions(-) create mode 100644 src/lib/api-errors.ts create mode 100644 tests/e2e/api-errors.spec.ts create mode 100644 tests/unit/contact-api.test.ts diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts new file mode 100644 index 0000000..810bb22 --- /dev/null +++ b/src/lib/api-errors.ts @@ -0,0 +1,26 @@ +/** + * Structured JSON error responses for API routes. Agents can't parse HTML + * error pages, so every API error carries a stable code, a human-readable + * message, and a hint for resolving it. + */ +export function jsonError( + status: number, + code: string, + message: string, + hint?: string, + headers?: Record +): Response { + return new Response( + JSON.stringify({ + message, + error: { code, message, ...(hint ? { hint } : {}) } + }), + { + status, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + ...headers + } + } + ); +} diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 7d5e22c..8884e64 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -1,61 +1,116 @@ import type { APIRoute } from 'astro'; +import { jsonError } from '../../lib/api-errors'; + export const prerender = false; export const POST: APIRoute = async ({ request }) => { - const data = await request.formData(); + let data: FormData; + try { + data = await request.formData(); + } catch { + return jsonError( + 400, + 'invalid_body', + 'Request body could not be parsed as form data', + 'Send a multipart/form-data or application/x-www-form-urlencoded body with name, email, and message fields.' + ); + } + const name = data.get('name'); const email = data.get('email'); const message = data.get('message'); // Validate the data - you'll probably want to do more than this if (!name || !email || !message) { - return new Response( - JSON.stringify({ - message: 'Missing required fields' - }), - { status: 400 } + const missing = [ + !name && 'name', + !email && 'email', + !message && 'message' + ].filter(Boolean); + return jsonError( + 400, + 'missing_fields', + `Missing required fields: ${missing.join(', ')}`, + 'Provide name, email, and message form fields.' + ); + } + + if (!import.meta.env.DISCORD_WEBHOOK) { + return jsonError( + 500, + 'not_configured', + 'The contact form is not configured on this deployment', + 'Set the DISCORD_WEBHOOK environment variable, or reach the hosts via the links on /contact.' ); } - await fetch(import.meta.env.DISCORD_WEBHOOK, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - embeds: [ - { - title: 'New Contact Form Submission', - color: 0x5865f2, - fields: [ - { - name: 'Name', - value: String(name), - inline: true - }, - { - name: 'Email', - value: String(email), - inline: true - }, - { - name: 'Message', - value: String(message), - inline: false - } - ], - timestamp: new Date().toISOString() - } - ] - }) - }); + try { + const webhookResponse = await fetch(import.meta.env.DISCORD_WEBHOOK, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + embeds: [ + { + title: 'New Contact Form Submission', + color: 0x5865f2, + fields: [ + { + name: 'Name', + value: String(name), + inline: true + }, + { + name: 'Email', + value: String(email), + inline: true + }, + { + name: 'Message', + value: String(message), + inline: false + } + ], + timestamp: new Date().toISOString() + } + ] + }) + }); + + if (!webhookResponse.ok) { + throw new Error(`Webhook responded with ${webhookResponse.status}`); + } + } catch { + return jsonError( + 502, + 'delivery_failed', + 'Your message could not be delivered', + 'Try again in a few minutes, or reach the hosts via the links on /contact.' + ); + } // Do something with the data, then return a success response return new Response( JSON.stringify({ message: `Thanks for contacting us! We'll be in touch soon.` }), - { status: 200 } + { + status: 200, + headers: { 'Content-Type': 'application/json; charset=utf-8' } + } + ); +}; + +// Any other method on this endpoint gets a structured JSON 405, not an HTML +// error page. +export const ALL: APIRoute = () => { + return jsonError( + 405, + 'method_not_allowed', + 'Only POST is supported on this endpoint', + 'Send a POST request with name, email, and message form fields.', + { Allow: 'POST' } ); }; diff --git a/tests/e2e/api-errors.spec.ts b/tests/e2e/api-errors.spec.ts new file mode 100644 index 0000000..d6f76f9 --- /dev/null +++ b/tests/e2e/api-errors.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; + +test.describe('contact API errors', () => { + test('missing fields return a structured JSON 400', async ({ + baseURL, + request + }) => { + // Astro's CSRF protection rejects form POSTs without a matching Origin, + // which browsers always send. + const response = await request.post('/api/contact', { + headers: { Origin: baseURL! }, + multipart: { name: 'Only Name' } + }); + + expect(response.status()).toBe(400); + expect(response.headers()['content-type']).toContain('application/json'); + + const body = await response.json(); + expect(body.error.code).toBe('missing_fields'); + expect(body.error.hint).toBeTruthy(); + }); + + test('non-POST methods return a structured JSON 405', async ({ + request + }) => { + const response = await request.get('/api/contact'); + + expect(response.status()).toBe(405); + const body = await response.json(); + expect(body.error.code).toBe('method_not_allowed'); + }); +}); diff --git a/tests/unit/contact-api.test.ts b/tests/unit/contact-api.test.ts new file mode 100644 index 0000000..46879ec --- /dev/null +++ b/tests/unit/contact-api.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ALL, POST } from '../../src/pages/api/contact'; + +type ApiContext = Parameters[0]; + +function postContext(body: FormData | string): ApiContext { + const request = new Request('http://localhost/api/contact', { + method: 'POST', + body + }); + return { request } as ApiContext; +} + +function validForm(): FormData { + const form = new FormData(); + form.set('name', 'Test Person'); + form.set('email', 'test@example.com'); + form.set('message', 'Hello!'); + return form; +} + +describe('contact API', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('returns structured JSON 400 when fields are missing', async () => { + const form = new FormData(); + form.set('name', 'Only Name'); + + const response = await POST(postContext(form)); + expect(response.status).toBe(400); + expect(response.headers.get('Content-Type')).toContain('application/json'); + + const body = await response.json(); + expect(body.error.code).toBe('missing_fields'); + expect(body.error.message).toContain('email'); + expect(body.error.message).toContain('message'); + expect(body.error.hint).toBeTruthy(); + }); + + it('returns structured JSON 500 when the webhook is not configured', async () => { + const response = await POST(postContext(validForm())); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error.code).toBe('not_configured'); + }); + + it('returns structured JSON 502 when delivery fails', async () => { + vi.stubEnv('DISCORD_WEBHOOK', 'https://discord.example.com/webhook'); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('nope', { status: 500 })) + ); + + const response = await POST(postContext(validForm())); + + expect(response.status).toBe(502); + const body = await response.json(); + expect(body.error.code).toBe('delivery_failed'); + expect(body.error.hint).toBeTruthy(); + }); + + it('returns JSON success when delivery works', async () => { + vi.stubEnv('DISCORD_WEBHOOK', 'https://discord.example.com/webhook'); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('ok', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const response = await POST(postContext(validForm())); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toContain('application/json'); + const body = await response.json(); + expect(body.message).toContain('Thanks'); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('returns structured JSON 405 with Allow header for other methods', async () => { + const response = await ALL({ + request: new Request('http://localhost/api/contact', { method: 'GET' }) + } as ApiContext); + + expect(response.status).toBe(405); + expect(response.headers.get('Allow')).toBe('POST'); + const body = await response.json(); + expect(body.error.code).toBe('method_not_allowed'); + }); +}); From 043d48a40bacac94ccd2af556563796c7d18a7a4 Mon Sep 17 00:00:00 2001 From: Robbie Wagner Date: Sun, 23 Aug 2026 12:19:54 -0400 Subject: [PATCH 3/6] Publish an OpenAPI 3.1 spec at /openapi.json (#51) Co-authored-by: Claude Fable 5 --- src/lib/openapi.ts | 281 +++++++++++++++++++++++++++++++++++++ src/pages/openapi.json.ts | 17 +++ tests/e2e/openapi.spec.ts | 14 ++ tests/unit/openapi.test.ts | 66 +++++++++ 4 files changed, 378 insertions(+) create mode 100644 src/lib/openapi.ts create mode 100644 src/pages/openapi.json.ts create mode 100644 tests/e2e/openapi.spec.ts create mode 100644 tests/unit/openapi.test.ts diff --git a/src/lib/openapi.ts b/src/lib/openapi.ts new file mode 100644 index 0000000..51e8935 --- /dev/null +++ b/src/lib/openapi.ts @@ -0,0 +1,281 @@ +import type { Show } from './rss'; +import type { StarpodConfig } from '../utils/config'; + +/** + * Generate an OpenAPI 3.1 specification describing every public machine-usable + * endpoint the site exposes, so agents can discover the API surface + * automatically at /openapi.json. + */ +export function generateOpenApiSpec( + show: Show, + config: StarpodConfig, + siteUrl?: URL +) { + const baseUrl = siteUrl?.origin || ''; + + return { + openapi: '3.1.0', + info: { + title: `${show.title} API`, + description: + `Public API and machine-readable content endpoints for ${show.title}. ` + + `${config.blurb} ` + + `Content pages also serve markdown via the Accept header (Accept: text/markdown) or at their .html.md twin URL. ` + + `See ${baseUrl}/llms.txt for a structured overview of all resources.`, + version: '1.0.0', + contact: { + url: `${baseUrl}/contact` + } + }, + servers: [{ url: baseUrl }], + paths: { + '/api/episodes/search.json': { + get: { + operationId: 'listAllEpisodes', + summary: 'List every episode as a single JSON array', + description: + 'Returns all episodes with title, description, publish date, duration, slug, and audio URL. Intended for search and lookup.', + responses: { + '200': { + description: 'All episodes', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Episode' } + } + } + } + } + } + } + }, + '/api/episodes/{page}.json': { + get: { + operationId: 'listEpisodesPage', + summary: 'List episodes in pages of 15', + parameters: [ + { + name: 'page', + in: 'path', + required: true, + description: '1-based page number', + schema: { type: 'integer', minimum: 1 } + } + ], + responses: { + '200': { + description: 'One page of episodes', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + canLoadMore: { type: 'boolean' }, + episodes: { + type: 'object', + description: + 'Astro pagination object; episodes are in the data property', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/Episode' } + }, + currentPage: { type: 'integer' }, + lastPage: { type: 'integer' }, + total: { type: 'integer' } + } + } + } + } + } + } + }, + '404': { + description: 'Page number out of range', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + } + } + } + }, + '/api/contact': { + post: { + operationId: 'sendContactMessage', + summary: 'Send a message to the show', + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + required: ['name', 'email', 'message'], + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + message: { type: 'string' } + } + } + } + } + }, + responses: { + '200': { + description: 'Message delivered', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { message: { type: 'string' } } + } + } + } + }, + '400': { + description: 'Missing or invalid fields', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + }, + '405': { + description: 'Method not allowed (only POST is supported)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + }, + '502': { + description: 'Message could not be delivered upstream', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + } + } + } + }, + '/llms.txt': { + get: { + operationId: 'getLlmsTxt', + summary: 'llms.txt overview of all resources for AI agents', + responses: { + '200': { + description: 'llms.txt content', + content: { 'text/plain': { schema: { type: 'string' } } } + } + } + } + }, + '/episodes-index.html.md': { + get: { + operationId: 'getEpisodesIndexMarkdown', + summary: 'Complete episode listing as markdown', + responses: { + '200': { + description: 'Markdown listing of every episode', + content: { 'text/markdown': { schema: { type: 'string' } } } + } + } + } + }, + '/{episodeSlug}.html.md': { + get: { + operationId: 'getEpisodeMarkdown', + summary: + 'Single episode as markdown, including the full transcript when available', + parameters: [ + { + name: 'episodeSlug', + in: 'path', + required: true, + description: + 'Episode slug or episode number, as listed in the episodes index', + schema: { type: 'string' } + } + ], + responses: { + '200': { + description: 'Episode details and transcript as markdown', + content: { 'text/markdown': { schema: { type: 'string' } } } + }, + '404': { + description: 'Unknown episode', + content: { 'text/markdown': { schema: { type: 'string' } } } + } + } + } + }, + '/openapi.json': { + get: { + operationId: 'getOpenApiSpec', + summary: 'This OpenAPI specification', + responses: { + '200': { + description: 'OpenAPI 3.1 specification', + content: { 'application/json': { schema: { type: 'object' } } } + } + } + } + } + }, + components: { + schemas: { + Episode: { + type: 'object', + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + published: { + type: 'integer', + description: 'Publish date as a Unix timestamp in milliseconds' + }, + description: { type: 'string' }, + duration: { type: 'integer', description: 'Duration in seconds' }, + content: { + type: 'string', + description: 'Full show notes as HTML' + }, + episodeNumber: { type: 'string' }, + episodeSlug: { type: 'string' }, + episodeImage: { type: 'string' }, + episodeThumbnail: { type: 'string' }, + audio: { + type: 'object', + properties: { + src: { type: 'string', format: 'uri' }, + type: { type: 'string' } + } + } + } + }, + Error: { + type: 'object', + properties: { + message: { type: 'string' }, + error: { + type: 'object', + properties: { + code: { + type: 'string', + description: 'Stable machine-readable error code' + }, + message: { type: 'string' }, + hint: { + type: 'string', + description: 'How to resolve the error' + } + } + } + } + } + } + } + }; +} diff --git a/src/pages/openapi.json.ts b/src/pages/openapi.json.ts new file mode 100644 index 0000000..bf4d506 --- /dev/null +++ b/src/pages/openapi.json.ts @@ -0,0 +1,17 @@ +import type { APIRoute } from 'astro'; + +import { generateOpenApiSpec } from '../lib/openapi'; +import { getShowInfo } from '../lib/rss'; +import starpodConfig from '../../starpod.config'; + +export const GET: APIRoute = async ({ site }) => { + const show = await getShowInfo(); + + const spec = generateOpenApiSpec(show, starpodConfig, site); + + return new Response(JSON.stringify(spec, null, 2), { + headers: { + 'Content-Type': 'application/json; charset=utf-8' + } + }); +}; diff --git a/tests/e2e/openapi.spec.ts b/tests/e2e/openapi.spec.ts new file mode 100644 index 0000000..0ce2bbf --- /dev/null +++ b/tests/e2e/openapi.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@playwright/test'; + +test.describe('OpenAPI spec', () => { + test('is published at /openapi.json', async ({ request }) => { + const response = await request.get('/openapi.json'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('application/json'); + + const spec = await response.json(); + expect(spec.openapi).toBe('3.1.0'); + expect(spec.paths['/api/contact']).toBeTruthy(); + expect(spec.paths['/api/episodes/search.json']).toBeTruthy(); + }); +}); diff --git a/tests/unit/openapi.test.ts b/tests/unit/openapi.test.ts new file mode 100644 index 0000000..2a498e0 --- /dev/null +++ b/tests/unit/openapi.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { generateOpenApiSpec } from '../../src/lib/openapi'; +import type { Show } from '../../src/lib/rss'; +import type { StarpodConfig } from '../../src/utils/config'; + +const mockShow: Show = { + title: 'Test Podcast', + description: 'A test podcast', + image: 'https://example.com/image.jpg', + link: 'https://example.com' +}; + +const mockConfig: StarpodConfig = { + blurb: 'Test blurb', + description: 'Test description', + hosts: [{ name: 'Host One', bio: 'Bio', img: 'host.jpg' }], + platforms: {}, + rssFeed: 'https://example.com/rss.xml' +}; + +describe('generateOpenApiSpec', () => { + const siteUrl = new URL('https://podcast.example.com'); + const spec = generateOpenApiSpec(mockShow, mockConfig, siteUrl); + + it('produces a valid OpenAPI 3.1 skeleton', () => { + expect(spec.openapi).toBe('3.1.0'); + expect(spec.info.title).toBe('Test Podcast API'); + expect(spec.info.version).toBeTruthy(); + expect(spec.servers).toEqual([{ url: 'https://podcast.example.com' }]); + }); + + it('documents every public endpoint', () => { + expect(Object.keys(spec.paths)).toEqual( + expect.arrayContaining([ + '/api/episodes/search.json', + '/api/episodes/{page}.json', + '/api/contact', + '/llms.txt', + '/episodes-index.html.md', + '/{episodeSlug}.html.md', + '/openapi.json' + ]) + ); + }); + + it('documents the contact endpoint request and error responses', () => { + const contact = spec.paths['/api/contact'].post; + const formSchema = + contact.requestBody.content['multipart/form-data'].schema; + + expect(formSchema.required).toEqual(['name', 'email', 'message']); + expect(Object.keys(contact.responses)).toEqual( + expect.arrayContaining(['200', '400', '405', '502']) + ); + }); + + it('defines Episode and Error schemas', () => { + expect(spec.components.schemas.Episode.properties.episodeSlug).toBeTruthy(); + expect(spec.components.schemas.Error.properties.error).toBeTruthy(); + }); + + it('is JSON-serializable', () => { + expect(() => JSON.stringify(spec)).not.toThrow(); + }); +}); From a86c3e7f9647d83632e3cfb859d9b270b21928f4 Mon Sep 17 00:00:00 2001 From: Robbie Wagner Date: Sun, 23 Aug 2026 12:32:13 -0400 Subject: [PATCH 4/6] Agent-friendly 404s with markdown and JSON bodies (#54) * Return structured JSON errors from the contact API Co-Authored-By: Claude Fable 5 * Agent-friendly 404s with markdown and JSON bodies Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- src/components/NotFoundContent.astro | 42 +++++++++++++++++++++++++ src/lib/not-found.ts | 26 ++++++++++++++++ src/pages/404.astro | 18 ++--------- src/pages/[...notFound].astro | 46 ++++++++++++++++++++++++++++ tests/e2e/not-found.spec.ts | 43 ++++++++++++++++++++++++++ tests/unit/not-found.test.ts | 28 +++++++++++++++++ 6 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 src/components/NotFoundContent.astro create mode 100644 src/lib/not-found.ts create mode 100644 src/pages/[...notFound].astro create mode 100644 tests/e2e/not-found.spec.ts create mode 100644 tests/unit/not-found.test.ts diff --git a/src/components/NotFoundContent.astro b/src/components/NotFoundContent.astro new file mode 100644 index 0000000..9d8f840 --- /dev/null +++ b/src/components/NotFoundContent.astro @@ -0,0 +1,42 @@ +--- +import FourZeroFourIllustration from './illustrations/404Illustration.astro'; +--- + +
+ + +
+ +
+ + +
diff --git a/src/lib/not-found.ts b/src/lib/not-found.ts new file mode 100644 index 0000000..572a21f --- /dev/null +++ b/src/lib/not-found.ts @@ -0,0 +1,26 @@ +import type { Show } from './rss'; + +/** + * Generate a markdown 404 body pointing agents at recovery entry points, per + * agent-friendly 404 guidance: never a bare error, always where to look next. + */ +export function generateNotFoundMarkdown( + pathname: string, + show: Show, + siteUrl?: URL +): string { + const baseUrl = siteUrl?.origin || ''; + + let markdown = `# 404 - Page Not Found\n\n`; + markdown += `\`${pathname}\` does not exist on ${show.title}.\n\n`; + markdown += `## Where To Look Next\n\n`; + markdown += `- [Homepage](${baseUrl}/index.html.md): Show overview and latest episodes\n`; + markdown += `- [Episodes Index](${baseUrl}/episodes-index.html.md): Every episode with links\n`; + markdown += `- [llms.txt](${baseUrl}/llms.txt): Structured overview of all resources\n`; + markdown += `- [Sitemap](${baseUrl}/sitemap-index.xml): Every page on the site\n`; + markdown += `- [OpenAPI Specification](${baseUrl}/openapi.json): JSON API endpoints\n\n`; + markdown += `Episode pages live at \`/{episode-slug}\` (or \`/{episode-number}\`), `; + markdown += `with markdown versions at \`/{episode-slug}.html.md\`.\n`; + + return markdown; +} diff --git a/src/pages/404.astro b/src/pages/404.astro index 028a6ad..1cadd86 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -1,22 +1,8 @@ --- import Layout from '../layouts/Layout.astro'; -import FourZeroFourIllustration from '../components/illustrations/404Illustration.astro'; +import NotFoundContent from '../components/NotFoundContent.astro'; --- -
- - -
- -
-
+
diff --git a/src/pages/[...notFound].astro b/src/pages/[...notFound].astro new file mode 100644 index 0000000..0170f24 --- /dev/null +++ b/src/pages/[...notFound].astro @@ -0,0 +1,46 @@ +--- +import Layout from '../layouts/Layout.astro'; +import NotFoundContent from '../components/NotFoundContent.astro'; +import { jsonError } from '../lib/api-errors'; +import { generateNotFoundMarkdown } from '../lib/not-found'; +import { getShowInfo } from '../lib/rss'; + +// Rendered on demand so nonexistent paths return a real HTTP 404 with a body +// agents can recover from: JSON for API paths, markdown for markdown-accepting +// agents, and the regular 404 page for browsers. +export const prerender = false; + +const pathname = Astro.url.pathname; + +if (pathname.startsWith('/api/')) { + return jsonError( + 404, + 'not_found', + `No API endpoint exists at ${pathname}`, + 'See /openapi.json for the available endpoints.' + ); +} + +const accept = Astro.request.headers.get('accept') ?? ''; + +if (accept.includes('text/markdown')) { + const show = await getShowInfo(); + const markdown = generateNotFoundMarkdown(pathname, show, Astro.site); + + return new Response(markdown, { + status: 404, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + Vary: 'Accept' + } + }); +} + +Astro.response.status = 404; +Astro.response.statusText = 'Not Found'; +Astro.response.headers.set('Vary', 'Accept'); +--- + + + + diff --git a/tests/e2e/not-found.spec.ts b/tests/e2e/not-found.spec.ts new file mode 100644 index 0000000..9313255 --- /dev/null +++ b/tests/e2e/not-found.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; + +test.describe('agent-friendly 404s', () => { + test('nonexistent paths return a real 404 with the styled page', async ({ + page + }) => { + const response = await page.goto('/this-page-does-not-exist'); + expect(response?.status()).toBe(404); + await expect( + page.getByRole('link', { name: 'All episodes' }) + ).toBeVisible(); + }); + + test('markdown-accepting agents get a markdown 404 body', async ({ + request + }) => { + const response = await request.get('/this-page-does-not-exist', { + headers: { Accept: 'text/markdown' } + }); + + expect(response.status()).toBe(404); + expect(response.headers()['content-type']).toContain('text/markdown'); + expect(response.headers()['vary']).toContain('Accept'); + + const body = await response.text(); + expect(body).toContain('## Where To Look Next'); + expect(body).toContain('/llms.txt'); + expect(body).toContain('/episodes-index.html.md'); + }); + + test('unknown API paths return a structured JSON 404', async ({ + request + }) => { + const response = await request.get('/api/this-endpoint-does-not-exist'); + + expect(response.status()).toBe(404); + expect(response.headers()['content-type']).toContain('application/json'); + + const body = await response.json(); + expect(body.error.code).toBe('not_found'); + expect(body.error.hint).toContain('/openapi.json'); + }); +}); diff --git a/tests/unit/not-found.test.ts b/tests/unit/not-found.test.ts new file mode 100644 index 0000000..0655256 --- /dev/null +++ b/tests/unit/not-found.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { generateNotFoundMarkdown } from '../../src/lib/not-found'; +import type { Show } from '../../src/lib/rss'; + +describe('generateNotFoundMarkdown', () => { + const mockShow: Show = { + title: 'Test Podcast', + description: 'A test podcast', + image: 'https://example.com/image.jpg', + link: 'https://example.com' + }; + + it('names the missing path and links recovery entry points', () => { + const siteUrl = new URL('https://podcast.example.com'); + const result = generateNotFoundMarkdown('/missing-page', mockShow, siteUrl); + + expect(result).toContain('# 404'); + expect(result).toContain('`/missing-page`'); + expect(result).toContain('## Where To Look Next'); + expect(result).toContain('https://podcast.example.com/llms.txt'); + expect(result).toContain( + 'https://podcast.example.com/episodes-index.html.md' + ); + expect(result).toContain('https://podcast.example.com/sitemap-index.xml'); + expect(result).toContain('https://podcast.example.com/openapi.json'); + }); +}); From 3466f4c1eb0ae3bc25eba0b84177ba40d7a9754b Mon Sep 17 00:00:00 2001 From: Robbie Wagner Date: Sun, 23 Aug 2026 12:34:12 -0400 Subject: [PATCH 5/6] Document agent-readiness features (#56) Co-authored-by: Claude Fable 5 --- CLAUDE.md | 10 ++++++++-- README.md | 30 +++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aec94e1..4ddd7fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,9 @@ Whatnot podcast). ## Commands - **Dev server:** `pnpm dev` (runs on localhost:4321) -- **Build:** `pnpm build` (runs `astro check` then `astro build`) +- **Build:** `pnpm build` (runs `astro check`, `astro build`, then + `scripts/vercel-md-negotiation.mjs`, which injects `Accept: text/markdown` + content-negotiation routes into the Vercel build output) - **Lint:** `pnpm lint` (ESLint with caching) - **Lint fix:** `pnpm lint:fix` - **All tests:** `pnpm test` (runs unit + e2e concurrently) @@ -56,7 +58,11 @@ connection is configured in `db/index.ts`. - `src/pages/` — Astro pages and API routes. Dynamic episode pages use `[episode].astro`. LLM-friendly `.html.md.ts` endpoints generate markdown - versions. + versions. `openapi.json.ts` publishes an OpenAPI spec for the JSON API. + `[...notFound].astro` is an on-demand (prerender=false) catch-all that + returns agent-friendly 404s: JSON errors for `/api/*`, a markdown body for + `Accept: text/markdown` clients, and the styled 404 page otherwise. API + routes return structured JSON errors via `src/lib/api-errors.ts`. - `src/components/` — Mix of `.astro` (static) and `.tsx` (Preact interactive) components. The audio player (`src/components/player/`) and search dialog are Preact. diff --git a/README.md b/README.md index ca08401..4c9416d 100644 --- a/README.md +++ b/README.md @@ -250,12 +250,40 @@ fine without them, using episode descriptions and metadata from your RSS feed. All of the following endpoints are automatically generated at build time from your `starpod.config.ts` and RSS feed: -- `/llms.txt` - Main discovery file +- `/llms.txt` - Main discovery file, including "when to use this site" guidance + for agents and a developer resources section - `/for-llms` - Human-readable guide page - `/for-llms.html.md` - Markdown version of guide +- `/index.html.md` - Markdown version of the homepage - `/about.html.md` - Markdown version of about page +- `/contact.html.md` - Markdown version of the contact page - `/episodes-index.html.md` - Complete episode listing - `/{episode-slug}.html.md` - Individual episode with transcript - `/{episode-number}.html.md` - Alternative episode URL +- `/openapi.json` - OpenAPI 3.1 spec describing the JSON API endpoints + (episode search, episode pagination, contact form) No configuration needed - it just works! + +#### Markdown Content Negotiation + +Agents can also request any page that has a markdown twin with an +`Accept: text/markdown` header and get the markdown version back from the same +URL, per [acceptmarkdown.com](https://acceptmarkdown.com). Both variants are +served with `Vary: Accept` so CDNs cache them separately. + +This is implemented by `scripts/vercel-md-negotiation.mjs`, which runs as part +of `pnpm build` and injects Accept-based rewrite routes into the Vercel build +output. If you customize the `build` script in `package.json`, keep the +`node scripts/vercel-md-negotiation.mjs` step after `astro build`. (Deploying +somewhere other than Vercel? The `.html.md` URLs still work everywhere; only +the Accept-header negotiation is Vercel-specific.) + +#### Agent-Friendly Errors + +- Nonexistent paths return a real HTTP 404: browsers get the styled 404 page, + `Accept: text/markdown` clients get a short markdown body pointing at the + sitemap, `llms.txt`, and the episodes index, and `/api/*` paths get a + structured JSON error +- API errors are structured JSON with a stable `error.code`, a message, and a + resolution `hint` - never an HTML error page From 27a90381640e589de9e7ca13e651a6cf286c7443 Mon Sep 17 00:00:00 2001 From: Robbie Wagner Date: Sun, 23 Aug 2026 22:52:02 -0400 Subject: [PATCH 6/6] Serve markdown twins via Accept: text/markdown negotiation on Vercel (#55) * Serve markdown twins via Accept: text/markdown negotiation on Vercel Co-Authored-By: Claude Fable 5 * Make idempotency check exact against the generated route set An unrelated user-added Accept-conditional markdown route no longer suppresses patching; skip only when every generated route is already present. Co-Authored-By: Claude Fable 5 * Fix astro check type error in patchConfig test astro check type-checks test files; baseConfig's inferred routes type did not allow the 'has' property used by the new regression test. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- package.json | 2 +- scripts/vercel-md-negotiation.mjs | 205 +++++++++++++++++++++++ tests/unit/vercel-md-negotiation.test.ts | 164 ++++++++++++++++++ 3 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 scripts/vercel-md-negotiation.mjs create mode 100644 tests/unit/vercel-md-negotiation.test.ts diff --git a/package.json b/package.json index da229ce..948d2c8 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "license": "MIT", "scripts": { "astro": "astro", - "build": "astro check && astro build", + "build": "astro check && astro build && node scripts/vercel-md-negotiation.mjs", "db:push": "drizzle-kit push", "db:seed": "tsx db/seed.ts", "db:studio": "drizzle-kit studio", diff --git a/scripts/vercel-md-negotiation.mjs b/scripts/vercel-md-negotiation.mjs new file mode 100644 index 0000000..ca18f9b --- /dev/null +++ b/scripts/vercel-md-negotiation.mjs @@ -0,0 +1,205 @@ +/** + * Patches `.vercel/output/config.json` after `astro build` so that pages with + * a prerendered markdown twin (`{path}.html.md`) serve that twin when a client + * asks for it with `Accept: text/markdown`, per https://acceptmarkdown.com. + * + * Both variants of a negotiated URL are stamped with `Vary: Accept` so CDNs + * never serve a cached HTML response to an agent asking for markdown (or vice + * versa). + * + * This has to happen post-build because Vercel checks the filesystem before + * applying `vercel.json` rewrites, so an Accept-based rewrite there would + * never run for prerendered pages. Routes injected before the `filesystem` + * handler in the Build Output API config do run first. + * + * Runs as part of `pnpm build`. No-ops when there is no Vercel build output. + */ + +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +const MD_SUFFIX = '.html.md'; + +// Vercel route `has` condition matching any Accept header that mentions +// text/markdown. +const ACCEPT_MARKDOWN = [ + { type: 'header', key: 'accept', value: '.*text/markdown.*' } +]; + +// How many slugs to pack into a single route regex alternation. +const CHUNK_SIZE = 50; + +/** + * Find every prerendered markdown twin in the static output directory and + * return the negotiated URL paths they belong to ('/index' for the homepage). + */ +export function collectMarkdownPaths(staticDir) { + const paths = []; + + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith(MD_SUFFIX)) { + const rel = relative(staticDir, full).split(sep).join('/'); + paths.push('/' + rel.slice(0, -MD_SUFFIX.length)); + } + } + }; + + walk(staticDir); + return paths.sort(); +} + +const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const chunk = (items, size) => { + const chunks = []; + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + return chunks; +}; + +/** + * Build the routes that implement the negotiation for the given markdown + * paths. Order matters: Vary stamps first (they `continue`), then the + * Accept-conditional rewrites to the markdown twins. + */ +export function buildNegotiationRoutes(mdPaths) { + const routes = []; + const hasHome = mdPaths.includes('/index'); + const slugChunks = chunk( + mdPaths.filter((p) => p !== '/index').map((p) => escapeRegex(p.slice(1))), + CHUNK_SIZE + ); + + // Stamp Vary: Accept on every negotiated URL, whichever variant ends up + // being served. + if (hasHome) { + routes.push({ src: '^/$', headers: { vary: 'Accept' }, continue: true }); + } + for (const slugs of slugChunks) { + routes.push({ + src: `^/(?:${slugs.join('|')})$`, + headers: { vary: 'Accept' }, + continue: true + }); + } + + // Rewrite to the markdown twin when the client asks for markdown. + if (hasHome) { + routes.push({ + src: '^/$', + has: ACCEPT_MARKDOWN, + dest: '/index.html.md' + }); + } + for (const slugs of slugChunks) { + routes.push({ + src: `^/(${slugs.join('|')})$`, + has: ACCEPT_MARKDOWN, + dest: '/$1.html.md' + }); + } + + return routes; +} + +/** + * Return a copy of the Vercel Build Output config with the negotiation routes + * inserted ahead of the `filesystem` handler. Idempotent: an already patched + * config is returned unchanged. + */ +// Canonical JSON encoding (sorted object keys) so routes can be compared for +// exact equality regardless of key order. +const canonical = (value) => { + if (Array.isArray(value)) { + return value.map(canonical); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonical(value[key])]) + ); + } + return value; +}; + +const routeKey = (route) => JSON.stringify(canonical(route)); + +export function patchConfig(config, mdPaths) { + if (!Array.isArray(config.routes)) { + throw new Error('config.json has no routes array'); + } + + const negotiationRoutes = buildNegotiationRoutes(mdPaths); + if (negotiationRoutes.length === 0) { + return { config, inserted: 0 }; + } + + // Idempotency: only skip when every generated route is already present + // exactly. A user-added conditional markdown route must not suppress the + // generated set. + const existingRoutes = new Set(config.routes.map(routeKey)); + const alreadyPatched = negotiationRoutes.every((route) => + existingRoutes.has(routeKey(route)) + ); + if (alreadyPatched) { + return { config, inserted: 0 }; + } + + const filesystemIndex = config.routes.findIndex( + (route) => route.handle === 'filesystem' + ); + if (filesystemIndex === -1) { + throw new Error( + 'config.json has no `handle: "filesystem"` route; the Vercel build output format may have changed' + ); + } + + const routes = [ + ...config.routes.slice(0, filesystemIndex), + ...negotiationRoutes, + ...config.routes.slice(filesystemIndex) + ]; + + return { config: { ...config, routes }, inserted: negotiationRoutes.length }; +} + +export function main(outputDir = '.vercel/output') { + const configPath = join(outputDir, 'config.json'); + const staticDir = join(outputDir, 'static'); + + if (!existsSync(configPath) || !existsSync(staticDir)) { + console.log( + `[md-negotiation] No Vercel build output at ${outputDir}, skipping` + ); + return; + } + + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + const mdPaths = collectMarkdownPaths(staticDir); + const { config: patched, inserted } = patchConfig(config, mdPaths); + + if (inserted === 0) { + console.log('[md-negotiation] Nothing to patch'); + return; + } + + writeFileSync(configPath, JSON.stringify(patched, null, 2)); + console.log( + `[md-negotiation] Added ${inserted} routes negotiating markdown for ${mdPaths.length} pages` + ); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main(process.argv[2]); +} diff --git a/tests/unit/vercel-md-negotiation.test.ts b/tests/unit/vercel-md-negotiation.test.ts new file mode 100644 index 0000000..b1d7bd5 --- /dev/null +++ b/tests/unit/vercel-md-negotiation.test.ts @@ -0,0 +1,164 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { + buildNegotiationRoutes, + collectMarkdownPaths, + patchConfig +} from '../../scripts/vercel-md-negotiation.mjs'; + +const ACCEPT_MD = [{ type: 'header', key: 'accept', value: '.*text/markdown.*' }]; + +describe('vercel-md-negotiation', () => { + describe('collectMarkdownPaths', () => { + const dir = mkdtempSync(join(tmpdir(), 'starpod-md-')); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('finds every .html.md twin, including nested ones', () => { + writeFileSync(join(dir, 'index.html.md'), '# home'); + writeFileSync(join(dir, 'about.html.md'), '# about'); + writeFileSync(join(dir, 'about.html'), ''); + writeFileSync(join(dir, 'llms.txt'), 'llms'); + mkdirSync(join(dir, 'nested')); + writeFileSync(join(dir, 'nested', 'page.html.md'), '# nested'); + + expect(collectMarkdownPaths(dir)).toEqual([ + '/about', + '/index', + '/nested/page' + ]); + }); + }); + + describe('buildNegotiationRoutes', () => { + it('emits Vary stamps and Accept-conditional rewrites', () => { + const routes = buildNegotiationRoutes(['/index', '/about', '/contact']); + + // Vary stamps come first and continue to later routes. + expect(routes[0]).toEqual({ + src: '^/$', + headers: { vary: 'Accept' }, + continue: true + }); + expect(routes[1]).toEqual({ + src: '^/(?:about|contact)$', + headers: { vary: 'Accept' }, + continue: true + }); + + // Rewrites only fire for markdown-accepting clients. + expect(routes[2]).toEqual({ + src: '^/$', + has: ACCEPT_MD, + dest: '/index.html.md' + }); + expect(routes[3]).toEqual({ + src: '^/(about|contact)$', + has: ACCEPT_MD, + dest: '/$1.html.md' + }); + }); + + it('escapes regex metacharacters in slugs', () => { + const routes = buildNegotiationRoutes(['/what+is.this']); + expect(routes[0].src).toBe('^/(?:what\\+is\\.this)$'); + }); + + it('chunks large slug lists into multiple routes', () => { + const paths = Array.from({ length: 120 }, (_, i) => `/episode-${i}`); + const routes = buildNegotiationRoutes(paths); + + // 3 chunks of Vary stamps + 3 chunks of rewrites, no homepage. + expect(routes).toHaveLength(6); + expect(routes.every((r) => r.src.startsWith('^/'))).toBe(true); + }); + + it('returns no routes when there are no markdown twins', () => { + expect(buildNegotiationRoutes([])).toEqual([]); + }); + }); + + describe('patchConfig', () => { + type Route = { + src?: string; + dest?: string; + headers?: Record; + status?: number; + handle?: string; + has?: Array<{ type: string; key: string; value: string }>; + continue?: boolean; + }; + + const baseConfig = (): { version: number; routes: Route[] } => ({ + version: 3, + routes: [ + { src: '^/old-path$', headers: { Location: '/new-path' }, status: 308 }, + { handle: 'filesystem' }, + { src: '^/.*$', dest: '_render' } + ] + }); + + it('inserts negotiation routes before the filesystem handler', () => { + const { config, inserted } = patchConfig(baseConfig(), [ + '/index', + '/about' + ]); + + expect(inserted).toBe(4); + const filesystemIndex = config.routes.findIndex( + (r: { handle?: string }) => r.handle === 'filesystem' + ); + const rewriteIndex = config.routes.findIndex( + (r: { dest?: string }) => r.dest === '/about.html.md' || r.dest === '/$1.html.md' + ); + + // Redirects stay first, negotiation routes go before filesystem. + expect(config.routes[0].status).toBe(308); + expect(rewriteIndex).toBeGreaterThan(0); + expect(rewriteIndex).toBeLessThan(filesystemIndex); + }); + + it('is idempotent', () => { + const { config } = patchConfig(baseConfig(), ['/index', '/about']); + const { config: again, inserted } = patchConfig(config, [ + '/index', + '/about' + ]); + + expect(inserted).toBe(0); + expect(again.routes).toHaveLength(config.routes.length); + }); + + it('still patches when an unrelated conditional markdown route exists', () => { + const config = baseConfig(); + // A user-added Accept-conditional route pointing at a markdown file must + // not suppress the generated negotiation set. + config.routes.unshift({ + src: '^/custom$', + has: [{ type: 'header', key: 'accept', value: '.*text/markdown.*' }], + dest: '/custom-page.html.md' + }); + + const { config: patched, inserted } = patchConfig(config, [ + '/index', + '/about' + ]); + + expect(inserted).toBe(4); + expect( + patched.routes.some((r: { dest?: string }) => r.dest === '/index.html.md') + ).toBe(true); + }); + + it('throws when the filesystem handler is missing', () => { + expect(() => + patchConfig({ version: 3, routes: [] }, ['/about']) + ).toThrow(/filesystem/); + }); + }); +});