// c21 — handlers: the route table + fallback fetch. Composes the lower // layers (c13 db, c14 secondary I/O, c31 models, c32 logic, c51 render) // into the HTTP surface served by Bun.serve in c11_server. import { renderPage, renderNotFound, htmlResponse, } from "./b51_render_layout.ts"; import { renderDocsPage } from "./b51_render_docs_layout.ts"; import { listGames, loadGame } from "./a31_games.ts"; import { ALL_POSTS } from "./a31_blog.ts"; import { ALL_GOALS } from "./a31_goals.ts"; import { ALL_GUIDES } from "./a31_guides.ts"; import { ALL_SAMA } from "./a31_sama.ts"; import { SITE_BASE_URL } from "./a31_site_config.ts"; import { renderSitemap, STATIC_PATHS, type SitemapUrl, } from "./b32_sitemap.ts"; import { getViewer, sessionCookieHeader, } from "./b32_session.ts"; import { renderAgentsIndex, renderAgentDetail } from "./d21_handlers_agents.ts"; import { renderLeaderboard } from "./d21_handlers_leaderboard.ts"; import { startGithubOauth, handleGithubCallback } from "./d21_handlers_auth.ts"; import { reportsLandingHandler, reportsDemoHandler, reportsDemoTestsHandler, reportsDemoAgentHandler, reportsLiveHandler, reportsLiveTestsHandler, reportsLiveAgentHandler, } from "./d21_handlers_reports.ts"; import { skillsSamaMdHandler, samaCliResponse, samaSkillHandler, samaV2Handler, samaV2ExampleCrudHandler, samaV2ExampleWordpressHandler, samaV2VerifyHandler, samaVerifyHandler, samaLandingHandler, samaSlugHandler, } from "./d21_handlers_sama.ts"; import { goalsLandingHandler, goalSlugHandler, } from "./d21_handlers_goals.ts"; import { contributingHandler } from "./d21_handlers_contributing.ts"; import { editPageHandler } from "./d21_handlers_edit.ts"; import { adminListHandler, adminNewHandler, adminEditHandler, adminDeleteHandler, } from "./d21_handlers_admin.ts"; import { bundleAdminClient } from "./c14_client_bundle.ts"; import { publicPageHandler } from "./d21_handlers_content.ts"; import { rawSourceHandler } from "./d21_handlers_source.ts"; import { buildSamaCliInstallScript } from "./b32_sama_cli_install.ts"; import { commitViewHandler } from "./d21_handlers_commit_view.ts"; import { appFetch, appError } from "./d21_handlers_fallback.ts"; import { projectsLandingHandler, projectsNewHandler, projectDetailHandler, } from "./d21_handlers_projects.ts"; import { judgeApiHandler, agentVisibilityHandler, } from "./d21_handlers_api_agents.ts"; import { forgejoWebhookHandler } from "./d21_handlers_webhook.ts"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; // — /install — self-contained sama-cli installer ------------------ // // All tools/sama-cli/* runtime files are read into memory at boot // and stitched into a single bash script via buildSamaCliInstall- // Script. Users run `curl -fsSL https://tdd.md/install | bash` and // get the shell verifier dropped into ~/.sama-cli/ with no extra // network calls. // // File list mirrors tools/sama-cli/ excluding tests + run-ts- // verifier.ts (those are dev-only artifacts). const SAMA_CLI_INSTALL_FILES = [ { path: "sama", abs: "./tools/sama-cli/sama", executable: true }, { path: "sama.profile.toml", abs: "./tools/sama-cli/sama.profile.toml" }, { path: "cross-verify.sh", abs: "./tools/sama-cli/cross-verify.sh", executable: true }, { path: "README.md", abs: "./tools/sama-cli/README.md" }, { path: "src/a31_constants.sh", abs: "./tools/sama-cli/src/a31_constants.sh" }, { path: "src/b32_utils.sh", abs: "./tools/sama-cli/src/b32_utils.sh" }, { path: "src/b32_checks.sh", abs: "./tools/sama-cli/src/b32_checks.sh" }, { path: "src/c14_graph.sh", abs: "./tools/sama-cli/src/c14_graph.sh" }, { path: "src/d21_main.sh", abs: "./tools/sama-cli/src/d21_main.sh" }, ] as const; const samaCliInstallFiles = await Promise.all( SAMA_CLI_INSTALL_FILES.map(async (f) => ({ path: f.path, content: await Bun.file(f.abs).text(), executable: f.executable === true, })), ); const SAMA_CLI_INSTALL_SCRIPT = buildSamaCliInstallScript(samaCliInstallFiles); const HOME_DESCRIPTION = "SAMA — architecture as code, for code AI writes. Sorted, Architecture, Modeled, Atomic: four pillars your CI verifier enforces so your AI coding agents stop drifting."; const homeBody = await Bun.file(HOME_MD).text(); const HOME_HTML = await renderPage({ title: "SAMA — architecture as code, for code AI writes", description: HOME_DESCRIPTION, bodyMarkdown: homeBody, active: "home", jsonLd: { "@context": "https://schema.org", "@type": "WebSite", name: "tdd.md", url: "https://tdd.md", description: HOME_DESCRIPTION, }, }); const ALL_GAMES = await listGames(); const gamesIndexBody = `# games ${ALL_GAMES.length === 0 ? "_No katas registered yet._" : `| kata | description | steps |\n|---|---|---|\n${ALL_GAMES.map( (g) => `| [${g.id}](/games/${g.id}) | ${g.description} | ${g.steps.length} |`, ).join("\n")}` } > Ready to play? [Register your agent →](/agents/register) > Using a specific agent? See the [agent-specific guides](/guides) — Claude Code, Cursor, Aider. `; const GAMES_INDEX_HTML = await renderPage({ title: "TDD katas — tdd.md", description: "Browse the TDD katas. Pick a challenge, push red→green→refactor commits, and earn a public verdict graded against hidden tests.", bodyMarkdown: gamesIndexBody, ogPath: "https://tdd.md/games", active: "games", }); const renderKata = async (kata: string): Promise => { const file = Bun.file(`${GAME_DIR}/${kata}/spec.md`); if (!(await file.exists())) return null; const md = await file.text(); // Pull the kata's own description from spec.ts when available — it's // the canonical short copy (rendered on /games + sitemap previews). let description: string | undefined; try { const game = await loadGame(kata); description = game.description; } catch { // unknown kata; use the site default } const html = await renderPage({ title: `${kata} TDD kata — tdd.md`, description, bodyMarkdown: md, ogPath: `https://tdd.md/games/${kata}`, active: "games", }); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); }; const REGISTER_BODY = `# register > Sign in with GitHub to create your tdd.md agent. ## what we ask GitHub for - your username - your primary verified email That's it — no repo access, no anything else. ## what you get - a public agent account at \`git.tdd.md/\` - a push token (shown once) - an empty repo for the first kata, ready to push to [ sign in with github → ](/auth/github/start) `; const REGISTER_HTML = await renderPage({ title: "Register your AI agent — tdd.md", description: "Sign in with GitHub to register your AI agent on tdd.md and start solving TDD katas. Public-signup, verified-identity, no extra forms.", bodyMarkdown: REGISTER_BODY, ogPath: "https://tdd.md/agents/register", active: "agents", noindex: true, }); // --------------------------------------------------------------------- // App factory — c11 calls createApp(port) to start the server. The // routes literal stays inline here so Bun's path-parameter inference // (`:slug` → `req.params.slug`) flows through to the handler types. // --------------------------------------------------------------------- export const createApp = (port: number) => Bun.serve({ port, error: appError, fetch: appFetch, routes: { "/": htmlResponse(HOME_HTML), "/raw": new Response(Bun.file(HOME_MD), { headers: { "Content-Type": "text/markdown; charset=utf-8" }, }), "/install": new Response(SAMA_CLI_INSTALL_SCRIPT, { headers: { "Content-Type": "text/x-shellscript; charset=utf-8", // Five minutes — long enough to absorb a curl|bash burst, // short enough that pushing a fix to the verifier propagates // promptly. "Cache-Control": "public, max-age=300", }, }), "/healthz": new Response("ok"), "/robots.txt": new Response( // tdd.md is built for AI agents to read, audit, and learn from. We // explicitly ALLOW the major AI crawlers + training agents. The site's // entire empirical-chain argument depends on those agents being able // to fetch the spec, the verifier output, the /goals archive, and the // measurement posts. // // NOTE on Cloudflare: if "Block AI Crawlers" or "AI Audit / Content // Signals" is enabled at the Cloudflare edge, CF injects Disallow // blocks for these bots BEFORE this response body. App-level Allows // here are defense-in-depth; the canonical fix is to disable that CF // setting (Dashboard → Security → Bots → "Block AI Crawlers" off, or // Content Signals → ai-train=yes). `# tdd.md welcomes AI crawlers, agents, and training bots.\n` + `# The empirical chain is meant to be read.\n\n` + `User-agent: *\nAllow: /\nDisallow: /auth/\nDisallow: /api/\n\n` + `User-agent: ClaudeBot\nAllow: /\n\n` + `User-agent: Claude-Web\nAllow: /\n\n` + `User-agent: GPTBot\nAllow: /\n\n` + `User-agent: ChatGPT-User\nAllow: /\n\n` + `User-agent: CCBot\nAllow: /\n\n` + `User-agent: Google-Extended\nAllow: /\n\n` + `User-agent: Applebot-Extended\nAllow: /\n\n` + `User-agent: Amazonbot\nAllow: /\n\n` + `User-agent: Bytespider\nAllow: /\n\n` + `User-agent: meta-externalagent\nAllow: /\n\n` + `User-agent: PerplexityBot\nAllow: /\n\n` + `User-agent: Perplexity-User\nAllow: /\n\n` + `Sitemap: https://tdd.md/sitemap.xml\n`, { headers: { "Content-Type": "text/plain; charset=utf-8" } }, ), "/sitemap.xml": () => { const staticUrls: SitemapUrl[] = STATIC_PATHS.map((p) => ({ loc: `${SITE_BASE_URL}${p}`, })); const blogUrls: SitemapUrl[] = ALL_POSTS.map((p) => ({ loc: `${SITE_BASE_URL}/blog/${p.date.slice(0, 7)}/${p.slug}`, lastmod: p.date, })); const samaUrls: SitemapUrl[] = ALL_SAMA.map((d) => ({ loc: `${SITE_BASE_URL}/sama/discipline/${d.slug}`, })); const guideUrls: SitemapUrl[] = ALL_GUIDES.map((g) => ({ loc: `${SITE_BASE_URL}/guides/${g.slug}`, })); const goalUrls: SitemapUrl[] = ALL_GOALS.map((g) => ({ loc: `${SITE_BASE_URL}/goals/${g.slug}`, lastmod: g.date, })); const xml = renderSitemap([ ...staticUrls, ...blogUrls, ...samaUrls, ...guideUrls, ...goalUrls, ]); return new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=3600", }, }); }, "/og.svg": new Response(Bun.file("./public/og.svg"), { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600", }, }), "/og.png": new Response(Bun.file("./public/og.png"), { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600", }, }), "/sama-hero.svg": new Response(Bun.file("./public/sama-hero.svg"), { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" }, }), "/sama-hero.png": new Response(Bun.file("./public/sama-hero.png"), { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600" }, }), "/sama-layers.svg": new Response(Bun.file("./public/sama-layers.svg"), { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" }, }), "/sama-layers.png": new Response(Bun.file("./public/sama-layers.png"), { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600" }, }), "/sama-metrics.svg": new Response(Bun.file("./public/sama-metrics.svg"), { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" }, }), "/sama-metrics.png": new Response(Bun.file("./public/sama-metrics.png"), { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600" }, }), "/sitemap-layers.svg": new Response(Bun.file("./public/sitemap-layers.svg"), { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" }, }), "/sitemap-layers.png": new Response(Bun.file("./public/sitemap-layers.png"), { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600" }, }), "/sitemap-flow.svg": new Response(Bun.file("./public/sitemap-flow.svg"), { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" }, }), "/sitemap-flow.png": new Response(Bun.file("./public/sitemap-flow.png"), { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600" }, }), "/games": htmlResponse(GAMES_INDEX_HTML), "/blog": async () => { const rows = ALL_POSTS .map((p) => `| ${p.date} | [${p.title}](/blog/${p.date.slice(0, 7)}/${p.slug}) |`) .join("\n"); const body = `# blog Notes on TDD, agentic coding, and the discipline that ties them together. | date | post | |---|---| ${rows} > RSS feed coming when there's a second post. [← back to tdd.md](/) · [the guides](/guides) · [the katas](/games) `; const html = await renderDocsPage({ title: "Blog — tdd.md", description: "Posts on test-driven development for AI coding agents — how to apply TDD with Claude Code, Cursor, and Aider, what we learn from the verdicts.", bodyMarkdown: body, ogPath: "https://tdd.md/blog", active: "blog", pathForDocs: "/blog", editPathOverride: null, }); return htmlResponse(html); }, "/blog/:yyyymm/:slug": async (req) => { const { yyyymm, slug } = req.params; const entry = ALL_POSTS.find((p) => p.slug === slug); const fullPath = `/blog/${yyyymm}/${slug}`; // 404 if slug doesn't exist, OR if the yyyymm in the URL doesn't // match the post's actual date prefix (prevents URL-spoofing where // someone constructs /blog/9999-99/ and gets a 200). if (!entry || entry.date.slice(0, 7) !== yyyymm) { const html = await renderNotFound(fullPath); return htmlResponse(html, 404); } const file = Bun.file(`./content/blog/${slug}.md`); if (!(await file.exists())) { const html = await renderNotFound(fullPath); return htmlResponse(html, 404); } const md = await file.text(); const html = await renderDocsPage({ title: `${entry.title} — tdd.md`, description: entry.description, bodyMarkdown: md, ogPath: `https://tdd.md${fullPath}`, active: "blog", pathForDocs: fullPath, jsonLd: { "@context": "https://schema.org", "@type": "BlogPosting", headline: entry.title, description: entry.description, datePublished: entry.date, url: `https://tdd.md${fullPath}`, author: { "@type": "Organization", name: "tdd.md" }, }, }); return htmlResponse(html); }, "/projects": projectsLandingHandler, "/projects/new": projectsNewHandler, "/projects/:repoOwner/:repoName": projectDetailHandler, "/reports": reportsLandingHandler, "/reports/demo": reportsDemoHandler, "/reports/demo/tests": reportsDemoTestsHandler, "/reports/demo/agents/:slug": reportsDemoAgentHandler, "/reports/live": reportsLiveHandler, "/reports/live/tests": reportsLiveTestsHandler, "/reports/live/agents/:slug": reportsLiveAgentHandler, "/guides": async () => { const rows = ALL_GUIDES .map((g) => `| [${g.title}](/guides/${g.slug}) | ${g.description} |`) .join("\n"); const body = `# guides Agent-specific walkthroughs for using tdd.md with the major agentic-coding tools. Each guide covers setup, prompt patterns that keep the agent in TDD, and the common pitfalls that cost score. | guide | what it covers | |---|---| ${rows} > Missing your agent? [The mechanics are the same](/) — push commits tagged \`red:\` / \`green:\` / \`refactor:\` to your kata repo. Send a PR with a new guide and we'll list it here. [← play a kata](/games) · [register your agent →](/you) `; const html = await renderDocsPage({ title: "TDD guides for agentic coding tools — tdd.md", description: "Practical TDD walkthroughs for Claude Code, Cursor, Aider and other AI coding agents — keep your agent honest with red→green→refactor commits, scored by tdd.md.", bodyMarkdown: body, ogPath: "https://tdd.md/guides", active: "guides", pathForDocs: "/guides", editPathOverride: null, }); return htmlResponse(html); }, "/guides/:slug": async (req) => { const slug = req.params.slug; const entry = ALL_GUIDES.find((g) => g.slug === slug); if (!entry) { const html = await renderNotFound(`/guides/${slug}`); return htmlResponse(html, 404); } const file = Bun.file(`./content/guides/${slug}.md`); if (!(await file.exists())) { const html = await renderNotFound(`/guides/${slug}`); return htmlResponse(html, 404); } const md = await file.text(); const html = await renderDocsPage({ title: `${entry.title} — tdd.md`, description: entry.description, bodyMarkdown: md, ogPath: `https://tdd.md/guides/${slug}`, active: "guides", pathForDocs: `/guides/${slug}`, }); return htmlResponse(html); }, "/skills/sama.md": skillsSamaMdHandler, "/tools/sama-cli": samaCliResponse(), "/sama/skill": samaSkillHandler, "/sama/v2": samaV2Handler, "/sama/v2/verify": samaV2VerifyHandler, "/sama/v2/example-crud": samaV2ExampleCrudHandler, "/sama/v2/example-wordpress": samaV2ExampleWordpressHandler, "/sama/verify": samaVerifyHandler, "/sama": samaLandingHandler, "/sama/discipline/:slug": samaSlugHandler, "/goals": goalsLandingHandler, "/goals/:slug": goalSlugHandler, "/contributing": contributingHandler, "/games/:kata": async (req) => { const res = await renderKata(req.params.kata); if (res) return res; const html = await renderNotFound(`/games/${req.params.kata}`); return htmlResponse(html, 404); }, "/agents": () => renderAgentsIndex(), "/agents/register": htmlResponse(REGISTER_HTML), "/agents/:name": async (req) => { const viewer = await getViewer(req); return renderAgentDetail(req.params.name, viewer); }, // Redirect the legacy URL to the canonical /:owner/:repo path — // /agents/:name/:kata used to render a placeholder before the // GitHub-style routing landed. "/agents/:name/:kata": (req) => Response.redirect(`/${req.params.name}/${req.params.kata}`, 301), "/leaderboard": () => renderLeaderboard(), "/api/judge/:owner/:repo": judgeApiHandler, "/api/agents/:name/visibility": agentVisibilityHandler, "/api/forgejo/webhook": forgejoWebhookHandler, "/you": async (req) => { const viewer = await getViewer(req); const target = viewer ? `/agents/${viewer}` : "/auth/github/start"; return new Response(null, { status: 302, headers: { Location: target } }); }, "/auth/logout": (_req) => { // Clear the session cookie and bounce back home. return new Response(null, { status: 302, headers: { Location: "/", "Set-Cookie": sessionCookieHeader("", 0), }, }); }, "/edit/:section/:slug": editPageHandler, // Admin UI — sxdoc-backed CRUD on pages + posts. Replaces the legacy // /edit flow in Fase 6; both live alongside until migration cutover. "/admin": adminListHandler, "/admin/new": adminNewHandler, "/admin/edit/:type/:slug": adminEditHandler, "/admin/delete/:type/:slug": adminDeleteHandler, // Public sxdoc-backed pages — single-segment fast path. Multi-segment // slugs fall through to appFetch's regex matcher above. "/p/:slug": publicPageHandler, "/admin/assets/blockeditor.js": async (req) => { const { code, etag } = await bundleAdminClient(); if (req.headers.get("if-none-match") === etag) { return new Response(null, { status: 304, headers: { ETag: etag } }); } return new Response(code, { headers: { "Content-Type": "application/javascript; charset=utf-8", "ETag": etag, "Cache-Control": "no-cache", }, }); }, // Raw markdown source — replaces the previous git.tdd.md "view source" // link so docs pages don't depend on the Forgejo subdomain. The // route uses `:filename` (with trailing `.md` validated in the // handler) because Bun's parser treats `:slug.md` as a single param. "/content/:section/:filename": rawSourceHandler, // SAMA-native commit view — Bun-rendered alternative to Forgejo's // ///commit/ page. The :sha param may carry a // trailing ".diff" which the handler handles inline. "/GIT/:repo/commit/:sha": commitViewHandler, "/auth/github/start": (req) => startGithubOauth(req), "/auth/github/callback": async (req) => handleGithubCallback(req), }, });