syntaxai/tdd.md · commit 2093c3c

SEO pass: og:image, canonical, twitter card, robots, sitemap, noindex

Adds the meta and crawler signals the site was missing:

- public/og.svg — 1200×630 brand card (red→green→refactor cycle, dark
  mono) referenced as og:image and twitter:image
- /robots.txt — disallows /auth/ and /api/, points crawlers at the
  sitemap (Cloudflare's managed AI-bot blocks layer on top)
- /sitemap.xml — stable canonical URLs (home, games, kata, agents,
  leaderboard) with lastmod and priority
- render.ts — adds <link rel="canonical">, twitter:card meta, og:image
  / og:image:type / og:image:width / og:image:height / og:site_name,
  optional jsonLd payload, optional noindex flag
- Homepage gets a WebSite JSON-LD payload
- /agents/register, the post-OAuth welcome page (one-time, contains
  the push token), and 404 pages are flagged noindex,nofollow so they
  don't pollute search results

The og:image is SVG. Most modern social platforms render SVG fine;
Twitter/X may fall back to no image. PNG generation can come later
if it matters.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-03 17:42:11 +01:00
parent
533def4
commit
2093c3c86d7d27c8dc6a8aa422fc045ea9027a43

3 files changed · +74 −2

added public/og.svg +15 −0
@@ -0,0 +1,15 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
2+ <rect width="1200" height="630" fill="#0a0a0a"/>
3+ <g font-family="ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace">
4+ <text x="80" y="240" font-size="140" font-weight="600" fill="#e8e8e8" letter-spacing="-2">tdd.md</text>
5+ <text x="80" y="320" font-size="38" fill="#8a8a8a">a TDD game for AI agents</text>
6+ </g>
7+ <g font-family="ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace" font-size="44" font-weight="600">
8+ <text x="80" y="500" fill="#ff7a7a">red</text>
9+ <text x="220" y="500" fill="#8a8a8a">→</text>
10+ <text x="280" y="500" fill="#5fd97a">green</text>
11+ <text x="470" y="500" fill="#8a8a8a">→</text>
12+ <text x="530" y="500" fill="#5fb3ff">refactor</text>
13+ </g>
14+ <text x="80" y="570" font-family="ui-sans-serif, system-ui, sans-serif" font-size="22" fill="#5a5a5a">https://tdd.md</text>
15+</svg>
modified src/render.ts +21 −2
@@ -11,8 +11,12 @@ export interface PageOptions {
1111 description?: string;
1212 ogPath?: string;
1313 active?: Section;
14+ noindex?: boolean;
15+ jsonLd?: Record<string, unknown>;
1416 }
1517
18+const SITE_DESCRIPTION = "A game where AI agents earn points by following test-driven development.";
19+
1620 const escape = (s: string): string =>
1721 s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1822
@@ -25,8 +29,12 @@ const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "t
2529
2630 export const renderPage = async (opts: PageOptions): Promise<string> => {
2731 const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false });
28- const description = opts.description ?? "A game where AI agents earn points by following test-driven development.";
32+ const description = opts.description ?? SITE_DESCRIPTION;
2933 const ogPath = opts.ogPath ?? "https://tdd.md";
34+ const robots = opts.noindex ? `<meta name="robots" content="noindex,nofollow">\n` : "";
35+ const jsonLd = opts.jsonLd
36+ ? `<script type="application/ld+json">${JSON.stringify(opts.jsonLd)}</script>\n`
37+ : "";
3038 return `<!doctype html>
3139 <html lang="en">
3240 <head>
@@ -34,12 +42,22 @@ export const renderPage = async (opts: PageOptions): Promise<string> => {
3442 <meta name="viewport" content="width=device-width,initial-scale=1">
3543 <meta name="color-scheme" content="dark light">
3644 <meta name="description" content="${escape(description)}">
45+${robots}<link rel="canonical" href="${escape(ogPath)}">
3746 <meta property="og:title" content="${escape(opts.title)}">
3847 <meta property="og:description" content="${escape(description)}">
3948 <meta property="og:type" content="website">
4049 <meta property="og:url" content="${escape(ogPath)}">
50+<meta property="og:image" content="https://tdd.md/og.svg">
51+<meta property="og:image:type" content="image/svg+xml">
52+<meta property="og:image:width" content="1200">
53+<meta property="og:image:height" content="630">
54+<meta property="og:site_name" content="tdd.md">
55+<meta name="twitter:card" content="summary_large_image">
56+<meta name="twitter:title" content="${escape(opts.title)}">
57+<meta name="twitter:description" content="${escape(description)}">
58+<meta name="twitter:image" content="https://tdd.md/og.svg">
4159 <title>${escape(opts.title)}</title>
42-<style>${css}</style>
60+${jsonLd}<style>${css}</style>
4361 </head>
4462 <body>
4563 ${nav(opts.active)}
@@ -54,4 +72,5 @@ export const renderNotFound = async (path: string): Promise<string> =>
5472 renderPage({
5573 title: "404 — tdd.md",
5674 bodyMarkdown: `# 404\n\n> No such path: \`${path}\`\n\nTry [home](/), [games](/games), [agents](/agents), or [leaderboard](/leaderboard).`,
75+ noindex: true,
5776 });
modified src/server.ts +38 −0
@@ -17,6 +17,13 @@ const HOME_HTML = await renderPage({
1717 title: "tdd.md — a TDD game for AI agents",
1818 bodyMarkdown: homeBody,
1919 active: "home",
20+ jsonLd: {
21+ "@context": "https://schema.org",
22+ "@type": "WebSite",
23+ name: "tdd.md",
24+ url: "https://tdd.md",
25+ description: "A game where AI agents earn points by following test-driven development.",
26+ },
2027 });
2128
2229 const gamesIndexBody = `# games
@@ -85,6 +92,7 @@ const REGISTER_HTML = await renderPage({
8592 bodyMarkdown: REGISTER_BODY,
8693 ogPath: "https://tdd.md/agents/register",
8794 active: "agents",
95+ noindex: true,
8896 });
8997
9098 const leaderboardBody = `# leaderboard
@@ -367,6 +375,35 @@ const server = Bun.serve({
367375 }),
368376 "/healthz": new Response("ok"),
369377
378+ "/robots.txt": new Response(
379+ `User-agent: *\nAllow: /\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: https://tdd.md/sitemap.xml\n`,
380+ { headers: { "Content-Type": "text/plain; charset=utf-8" } },
381+ ),
382+
383+ "/sitemap.xml": async () => {
384+ const today = new Date().toISOString().slice(0, 10);
385+ const url = (loc: string, priority: string) =>
386+ `<url><loc>${loc}</loc><lastmod>${today}</lastmod><priority>${priority}</priority></url>`;
387+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
388+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
389+${url("https://tdd.md/", "1.0")}
390+${url("https://tdd.md/games", "0.9")}
391+${url("https://tdd.md/games/string-calc", "0.8")}
392+${url("https://tdd.md/agents", "0.7")}
393+${url("https://tdd.md/leaderboard", "0.7")}
394+</urlset>`;
395+ return new Response(xml, {
396+ headers: { "Content-Type": "application/xml; charset=utf-8" },
397+ });
398+ },
399+
400+ "/og.svg": new Response(Bun.file("./public/og.svg"), {
401+ headers: {
402+ "Content-Type": "image/svg+xml",
403+ "Cache-Control": "public, max-age=3600",
404+ },
405+ }),
406+
370407 "/games": htmlResponse(GAMES_INDEX_HTML),
371408 "/games/:kata": async (req) => {
372409 const res = await renderKata(req.params.kata);
@@ -579,6 +616,7 @@ When you push, the judge replays your commits and posts the verdict at [/agents/
579616 title: `welcome ${reg.username} — tdd.md`,
580617 bodyMarkdown: body,
581618 active: "agents",
619+ noindex: true,
582620 });
583621 return new Response(html, {
584622 headers: {