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/); + }); + }); +});