diff --git a/.github/workflows/publish-episodes.yml b/.github/workflows/publish-episodes.yml
index e5713cee..e0deea6f 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/CLAUDE.md b/CLAUDE.md
index aec94e15..4ddd7fa5 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 3602cfb3..0567d726 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
@@ -248,16 +250,44 @@ 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
+
## Polar.sh Checkout Integration
This site uses Polar.sh for sponsor checkout. To set it up:
diff --git a/package.json b/package.json
index b125ff80..f9ff48f1 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/publish-episodes.ts b/scripts/publish-episodes.ts
index 2012db12..d368e594 100644
--- a/scripts/publish-episodes.ts
+++ b/scripts/publish-episodes.ts
@@ -10,6 +10,10 @@
* ATPROTO_APP_PASSWORD - An app password from bsky.app/settings/app-passwords
* STANDARD_SITE_PUBLICATION_RKEY - The publication record key
*
+ * Optional environment variables:
+ * STANDARD_SITE_URL - Your podcast site URL (e.g., https://whiskey.fm),
+ * used to poll for rebuilt episode pages when WAIT_FOR_SITE is set
+ *
* Usage:
* pnpm publish:episodes # publish new episodes only
* pnpm publish:episodes:backfill # publish all episodes (backfill)
@@ -29,6 +33,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(
@@ -56,6 +92,9 @@ async function main() {
const identifier = process.env.ATPROTO_HANDLE;
const password = process.env.ATPROTO_APP_PASSWORD;
const publicationRkey = process.env.STANDARD_SITE_PUBLICATION_RKEY;
+ // Only needed to poll the rebuilt site before publishing (WAIT_FOR_SITE);
+ // the published documents themselves reference the publication AT-URI.
+ const siteUrl = process.env.STANDARD_SITE_URL;
if (!identifier || !password || !publicationRkey) {
console.log(
@@ -121,8 +160,32 @@ 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 && !siteUrl) {
+ console.log(
+ '⚠️ WAIT_FOR_SITE is set but STANDARD_SITE_URL is not — skipping the site rebuild wait.'
+ );
+ }
+ if (WAIT_FOR_SITE && !BACKFILL && siteUrl) {
+ 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);
@@ -164,13 +227,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) => {
diff --git a/scripts/vercel-md-negotiation.mjs b/scripts/vercel-md-negotiation.mjs
new file mode 100644
index 00000000..ca18f9b3
--- /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/src/components/NotFoundContent.astro b/src/components/NotFoundContent.astro
new file mode 100644
index 00000000..9d8f8405
--- /dev/null
+++ b/src/components/NotFoundContent.astro
@@ -0,0 +1,42 @@
+---
+import FourZeroFourIllustration from './illustrations/404Illustration.astro';
+---
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts
new file mode 100644
index 00000000..810bb225
--- /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/lib/not-found.ts b/src/lib/not-found.ts
new file mode 100644
index 00000000..572a21f8
--- /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/lib/openapi.ts b/src/lib/openapi.ts
new file mode 100644
index 00000000..51e8935c
--- /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/404.astro b/src/pages/404.astro
index 028a6ad4..1cadd86c 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 00000000..0170f24b
--- /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/src/pages/api/contact.ts b/src/pages/api/contact.ts
index 7d5e22c4..8884e640 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/src/pages/openapi.json.ts b/src/pages/openapi.json.ts
new file mode 100644
index 00000000..bf4d506c
--- /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/api-errors.spec.ts b/tests/e2e/api-errors.spec.ts
new file mode 100644
index 00000000..d6f76f9c
--- /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/e2e/not-found.spec.ts b/tests/e2e/not-found.spec.ts
new file mode 100644
index 00000000..93132550
--- /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/e2e/openapi.spec.ts b/tests/e2e/openapi.spec.ts
new file mode 100644
index 00000000..0ce2bbf0
--- /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/contact-api.test.ts b/tests/unit/contact-api.test.ts
new file mode 100644
index 00000000..46879ecd
--- /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');
+ });
+});
diff --git a/tests/unit/not-found.test.ts b/tests/unit/not-found.test.ts
new file mode 100644
index 00000000..0655256e
--- /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');
+ });
+});
diff --git a/tests/unit/openapi.test.ts b/tests/unit/openapi.test.ts
new file mode 100644
index 00000000..2a498e06
--- /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();
+ });
+});
diff --git a/tests/unit/vercel-md-negotiation.test.ts b/tests/unit/vercel-md-negotiation.test.ts
new file mode 100644
index 00000000..b1d7bd51
--- /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/);
+ });
+ });
+});