syntaxai/tdd.md · main · src / d21_app.ts

d21_app.ts 583 lines · 20891 bytes raw
// 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<Response | null> => {
  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/<your-github-name>\`
- 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/<valid-slug> 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
  // /<owner>/<repo>/commit/<sha> 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),

  },
});