73d14f1800561fa124e25420c65930cc3f4e75e9 diff --git a/src/a31_site_config.ts b/src/a31_site_config.ts index 7c3f4e8b3ef2f04128a0391aca9e4dd6f7a565e4..42cb8446df8da8d0582c73fce0b35482233bc40e 100644 --- a/src/a31_site_config.ts +++ b/src/a31_site_config.ts @@ -3,6 +3,10 @@ // reports/live, sitemap, etc.) reference the same values without // circular imports between c21_handlers_*. +// Canonical absolute base for every public URL the site emits +// (sitemap, og:url, canonical link, RSS, etc.). No trailing slash. +export const SITE_BASE_URL = "https://tdd.md"; + export const LIVE_REPO_OWNER = "syntaxai"; export const LIVE_REPO_NAME = "tdd.md"; // Number of recent commits the live-reports view samples from the diff --git a/src/b32_sitemap.test.ts b/src/b32_sitemap.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..114f6957f44997daa3912b8d86c0f4e4b36ca92f --- /dev/null +++ b/src/b32_sitemap.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { + escapeXml, + renderSitemap, + STATIC_PATHS, + type SitemapUrl, +} from "./b32_sitemap.ts"; + +describe("escapeXml", () => { + test("escapes the five named entities", () => { + expect(escapeXml("&")).toBe("&"); + expect(escapeXml("<")).toBe("<"); + expect(escapeXml(">")).toBe(">"); + expect(escapeXml('"')).toBe("""); + expect(escapeXml("'")).toBe("'"); + }); + + test("leaves regular URL characters untouched", () => { + const s = "https://tdd.md/blog/sama-v2-workingset-cross-repo-baseline"; + expect(escapeXml(s)).toBe(s); + }); + + test("ampersand always escapes first (no double-escape)", () => { + expect(escapeXml("a & b < c")).toBe("a & b < c"); + }); +}); + +describe("renderSitemap", () => { + test("empty list → valid urlset with no children", () => { + const xml = renderSitemap([]); + expect(xml).toBe(` + +`); + }); + + test("single URL with lastmod", () => { + const xml = renderSitemap([ + { loc: "https://tdd.md/blog/x", lastmod: "2026-05-25" }, + ]); + expect(xml).toBe(` + + https://tdd.md/blog/x2026-05-25 +`); + }); + + test("single URL without lastmod omits the element", () => { + const xml = renderSitemap([{ loc: "https://tdd.md/sama" }]); + expect(xml).toContain("https://tdd.md/sama"); + expect(xml).not.toContain(""); + }); + + test("multiple URLs preserve input order", () => { + const xml = renderSitemap([ + { loc: "https://tdd.md/a" }, + { loc: "https://tdd.md/b" }, + { loc: "https://tdd.md/c" }, + ]); + const aIdx = xml.indexOf("/a"); + const bIdx = xml.indexOf("/b"); + const cIdx = xml.indexOf("/c"); + expect(aIdx).toBeGreaterThan(-1); + expect(aIdx).toBeLessThan(bIdx); + expect(bIdx).toBeLessThan(cIdx); + }); + + test("XML-escapes & and < inside values", () => { + const xml = renderSitemap([ + { loc: "https://tdd.md/q?a=1&b=2" }, + { loc: "https://tdd.md/" }, + ]); + expect(xml).toContain("https://tdd.md/q?a=1&b=2"); + expect(xml).toContain("https://tdd.md/<weird>"); + expect(xml).not.toContain("a=1&b=2"); + }); + + test("opens with the XML declaration and closes with ", () => { + const xml = renderSitemap([{ loc: "https://tdd.md/" }]); + expect(xml.startsWith('')).toBe(true); + expect(xml.endsWith("")).toBe(true); + }); + + test("deterministic — same input twice → byte-identical output", () => { + const urls: ReadonlyArray = [ + { loc: "https://tdd.md/", lastmod: "2026-05-25" }, + { loc: "https://tdd.md/blog" }, + ]; + expect(renderSitemap(urls)).toBe(renderSitemap(urls)); + }); +}); + +describe("STATIC_PATHS", () => { + test("covers the eleven load-bearing routes from the /goal", () => { + expect(STATIC_PATHS).toEqual([ + "/", + "/blog", + "/games", + "/leaderboard", + "/sama", + "/sama/v2", + "/sama/v2/verify", + "/sama/v2/example-crud", + "/sama/v2/example-wordpress", + "/sama/skill", + "/guides", + ]); + }); + + test("each path is absolute (starts with /)", () => { + for (const p of STATIC_PATHS) { + expect(p.startsWith("/")).toBe(true); + } + }); +}); diff --git a/src/b32_sitemap.ts b/src/b32_sitemap.ts new file mode 100644 index 0000000000000000000000000000000000000000..d905402966429f985f60747e3a1d0cc5ba39a30c --- /dev/null +++ b/src/b32_sitemap.ts @@ -0,0 +1,53 @@ +// b32 — Layer 1 pure helper: render a sitemaps.org 0.9 urlset. +// No I/O. Deterministic: same input array → same output bytes. +// Caller (d21_app.ts) composes the URL list from ALL_POSTS, +// ALL_SAMA, ALL_GUIDES, and STATIC_PATHS, then asks this module +// for the XML string. Sibling test pins escape behaviour and +// shape; the verifier's §4.3 modeled-tests check requires it. + +export interface SitemapUrl { + readonly loc: string; + readonly lastmod?: string; +} + +// The eleven load-bearing static URLs that don't come from a +// registry. Each must correspond to a literal route registered +// in d21_app.ts; the handler iterates this list verbatim. +export const STATIC_PATHS: ReadonlyArray = [ + "/", + "/blog", + "/games", + "/leaderboard", + "/sama", + "/sama/v2", + "/sama/v2/verify", + "/sama/v2/example-crud", + "/sama/v2/example-wordpress", + "/sama/skill", + "/guides", +]; + +// Minimal XML 1.0 escape for character data + attribute values. +// The five named entities are the canonical set; anything else +// is left as-is (the sitemap spec requires UTF-8, not ASCII). +export const escapeXml = (s: string): string => + s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +const renderUrl = (u: SitemapUrl): string => { + const loc = `${escapeXml(u.loc)}`; + const lastmod = + u.lastmod !== undefined ? `${escapeXml(u.lastmod)}` : ""; + return ` ${loc}${lastmod}`; +}; + +export const renderSitemap = (urls: ReadonlyArray): string => { + const body = urls.map(renderUrl).join("\n"); + const inner = body.length > 0 ? `\n${body}\n` : "\n"; + return ` +${inner}`; +}; diff --git a/src/d21_app.ts b/src/d21_app.ts index 2299723c12c0b73a9edc680c5b47afdbe46daa61..e1f7dd0d2f7268560fec1c404a505b5f41c02fdb 100644 --- a/src/d21_app.ts +++ b/src/d21_app.ts @@ -12,6 +12,12 @@ import { listGames, loadGame } from "./a31_games.ts"; import { ALL_POSTS } from "./a31_blog.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, @@ -181,39 +187,31 @@ export const createApp = (port: number) => Bun.serve({ { headers: { "Content-Type": "text/plain; charset=utf-8" } }, ), - "/sitemap.xml": async () => { - const today = new Date().toISOString().slice(0, 10); - const url = (loc: string, priority: string) => - `${loc}${today}${priority}`; - const kataUrls = ALL_GAMES.map((g) => - url(`https://tdd.md/games/${g.id}`, "0.8"), - ).join("\n"); - const guideUrls = ALL_GUIDES.map((g) => - url(`https://tdd.md/guides/${g.slug}`, "0.8"), - ).join("\n"); - const samaUrls = ALL_SAMA.map((d) => - url(`https://tdd.md/sama/${d.slug}`, "0.8"), - ).join("\n"); - const blogUrls = ALL_POSTS.map((p) => - url(`https://tdd.md/blog/${p.slug}`, "0.8"), - ).join("\n"); - const xml = ` - -${url("https://tdd.md/", "1.0")} -${url("https://tdd.md/games", "0.9")} -${kataUrls} -${url("https://tdd.md/guides", "0.9")} -${guideUrls} -${url("https://tdd.md/sama", "0.9")} -${samaUrls} -${url("https://tdd.md/sama/skill", "0.8")} -${url("https://tdd.md/blog", "0.7")} -${blogUrls} -${url("https://tdd.md/agents", "0.7")} -${url("https://tdd.md/leaderboard", "0.7")} -`; + "/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.slug}`, + lastmod: p.date, + })); + const samaUrls: SitemapUrl[] = ALL_SAMA.map((d) => ({ + loc: `${SITE_BASE_URL}/sama/${d.slug}`, + })); + const guideUrls: SitemapUrl[] = ALL_GUIDES.map((g) => ({ + loc: `${SITE_BASE_URL}/guides/${g.slug}`, + })); + const xml = renderSitemap([ + ...staticUrls, + ...blogUrls, + ...samaUrls, + ...guideUrls, + ]); return new Response(xml, { - headers: { "Content-Type": "application/xml; charset=utf-8" }, + headers: { + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, }); },