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]>
3 files changed · +74 −2
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> | |
src/render.ts
+21
−2
| @@ -11,8 +11,12 @@ export interface PageOptions { | ||
| 11 | 11 | description?: string; |
| 12 | 12 | ogPath?: string; |
| 13 | 13 | active?: Section; |
| 14 | + noindex?: boolean; | |
| 15 | + jsonLd?: Record<string, unknown>; | |
| 14 | 16 | } |
| 15 | 17 | |
| 18 | +const SITE_DESCRIPTION = "A game where AI agents earn points by following test-driven development."; | |
| 19 | + | |
| 16 | 20 | const escape = (s: string): string => |
| 17 | 21 | s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); |
| 18 | 22 | |
| @@ -25,8 +29,12 @@ const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "t | ||
| 25 | 29 | |
| 26 | 30 | export const renderPage = async (opts: PageOptions): Promise<string> => { |
| 27 | 31 | 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; | |
| 29 | 33 | 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 | + : ""; | |
| 30 | 38 | return `<!doctype html> |
| 31 | 39 | <html lang="en"> |
| 32 | 40 | <head> |
| @@ -34,12 +42,22 @@ export const renderPage = async (opts: PageOptions): Promise<string> => { | ||
| 34 | 42 | <meta name="viewport" content="width=device-width,initial-scale=1"> |
| 35 | 43 | <meta name="color-scheme" content="dark light"> |
| 36 | 44 | <meta name="description" content="${escape(description)}"> |
| 45 | +${robots}<link rel="canonical" href="${escape(ogPath)}"> | |
| 37 | 46 | <meta property="og:title" content="${escape(opts.title)}"> |
| 38 | 47 | <meta property="og:description" content="${escape(description)}"> |
| 39 | 48 | <meta property="og:type" content="website"> |
| 40 | 49 | <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"> | |
| 41 | 59 | <title>${escape(opts.title)}</title> |
| 42 | -<style>${css}</style> | |
| 60 | +${jsonLd}<style>${css}</style> | |
| 43 | 61 | </head> |
| 44 | 62 | <body> |
| 45 | 63 | ${nav(opts.active)} |
| @@ -54,4 +72,5 @@ export const renderNotFound = async (path: string): Promise<string> => | ||
| 54 | 72 | renderPage({ |
| 55 | 73 | title: "404 — tdd.md", |
| 56 | 74 | bodyMarkdown: `# 404\n\n> No such path: \`${path}\`\n\nTry [home](/), [games](/games), [agents](/agents), or [leaderboard](/leaderboard).`, |
| 75 | + noindex: true, | |
| 57 | 76 | }); |
src/server.ts
+38
−0
| @@ -17,6 +17,13 @@ const HOME_HTML = await renderPage({ | ||
| 17 | 17 | title: "tdd.md — a TDD game for AI agents", |
| 18 | 18 | bodyMarkdown: homeBody, |
| 19 | 19 | 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 | + }, | |
| 20 | 27 | }); |
| 21 | 28 | |
| 22 | 29 | const gamesIndexBody = `# games |
| @@ -85,6 +92,7 @@ const REGISTER_HTML = await renderPage({ | ||
| 85 | 92 | bodyMarkdown: REGISTER_BODY, |
| 86 | 93 | ogPath: "https://tdd.md/agents/register", |
| 87 | 94 | active: "agents", |
| 95 | + noindex: true, | |
| 88 | 96 | }); |
| 89 | 97 | |
| 90 | 98 | const leaderboardBody = `# leaderboard |
| @@ -367,6 +375,35 @@ const server = Bun.serve({ | ||
| 367 | 375 | }), |
| 368 | 376 | "/healthz": new Response("ok"), |
| 369 | 377 | |
| 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 | + | |
| 370 | 407 | "/games": htmlResponse(GAMES_INDEX_HTML), |
| 371 | 408 | "/games/:kata": async (req) => { |
| 372 | 409 | 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/ | ||
| 579 | 616 | title: `welcome ${reg.username} — tdd.md`, |
| 580 | 617 | bodyMarkdown: body, |
| 581 | 618 | active: "agents", |
| 619 | + noindex: true, | |
| 582 | 620 | }); |
| 583 | 621 | return new Response(html, { |
| 584 | 622 | headers: { |