syntaxai/tdd.md · commit 6d10e8a

Build /goals registry + site surface (goal #1)

Persist /goal slash commands into git as first-class artifacts —
the storage layer the empirical-chain claim has been missing. Same
shape as /blog and /sama: Layer 0 registry (a31_goals.ts), Layer 1
frontmatter parser + SHA lookup (b32_goals_meta.ts), Layer 3 index
+ detail handlers (d21_handlers_goals.ts).

- goals/git-url-drop-owner.md — first archived goal (PR #42's
  verbatim /goal text). Frontmatter: slug, title, date, branch,
  pr_number, merge_sha, status, related_posts.
- src/a31_goals.ts — ALL_GOALS: GoalEntry[]. File header documents
  the slug-not-SHA naming choice + rationale (chicken-and-egg with
  the merge SHA, readable permalinks).
- src/b32_goals_meta.ts — parseGoalFrontmatter (string → struct,
  no I/O) + findGoalByMergeSha (bidirectional prefix match so
  short URL SHAs resolve full stored SHAs and vice versa). Sibling
  test covers full + minimal + malformed frontmatter, status
  validation, related_posts inline-array form, all SHA lookup
  branches.
- src/d21_handlers_goals.ts — goalsLandingHandler renders an index
  table (date · title · status · PR · commit); goalSlugHandler
  reads goals/<slug>.md, parses the frontmatter, prepends a badge
  header (status + date + PR + commit + related-post links) and
  renders the body via the docs layout.
- Routes registered in d21_app.ts. /goals link added to the main
  nav between /sama and /blog. STATIC_PATHS in b32_sitemap.ts
  gains "/goals"; the sitemap handler maps ALL_GOALS → /goals/<slug>
  with lastmod = goal.date.

Tests: 400/400 pass (388 → 400, +11 goals_meta cases + 1 STATIC_PATHS
update).

Goal #2 backfills the remaining historical /goals and writes the
workflow-lock-in memory so future /goal authoring lands as a
committed file by construction.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-25 14:24:13 +01:00
parent
db83746
commit
6d10e8aa0dd72842eb8eb69aff8945929ba57ac1

9 files changed · +517 −3

added goals/git-url-drop-owner.md +78 −0
@@ -0,0 +1,78 @@
1+---
2+slug: git-url-drop-owner
3+title: Drop redundant :owner segment from /GIT/ URLs
4+date: 2026-05-25
5+branch: git-url-drop-owner
6+pr_number: 42
7+merge_sha: 684f257
8+status: shipped
9+related_posts: [sama-v2-git-url-refactor-plan, sama-v2-git-url-refactor-postmortem]
10+---
11+
12+Goal: Execute the URL refactor described in https://tdd.md/blog/sama-v2-git-url-refactor-plan — drop the redundant `:owner` segment from /GIT/:owner/:repo/ URLs so /GIT/syntaxai/tdd.md/blob/main/src/b32_sama_v2_verify.ts becomes /GIT/tdd.md/blob/main/src/b32_sama_v2_verify.ts. The blog post is the design spec; this /goal is the execution checklist. Single-tenant is already enforced by isAllowedRepo() in src/d21_handlers_repo_browse.ts:26-30 (404s anything that isn't syntaxai/tdd.md), so the owner segment is policy overhead, not data. Inbound links survive via ONE regex 301 redirect in the fallback handler — no hand-maintained URL map.
13+
14+Done when:
15+- The four /GIT/ URL kinds work under the new shape:
16+ * /GIT/tdd.md/tree/:ref/<path> → 200, directory listing
17+ * /GIT/tdd.md/blob/:ref/<path> → 200, file viewer HTML
18+ * /GIT/tdd.md/raw/:ref/<path> → 200, text/plain raw
19+ * /GIT/tdd.md/commit/<sha> → 200, commit detail
20+- Old URL form is a permanent redirect:
21+ * curl -I https://tdd.md/GIT/syntaxai/tdd.md/blob/main/src/b32_sama_v2_verify.ts
22+ → HTTP/2 301
23+ → location: /GIT/tdd.md/blob/main/src/b32_sama_v2_verify.ts
24+ → cache-control: public, max-age=86400
25+ * Implemented as ONE regex in src/d21_handlers_fallback.ts:
26+ /^\/GIT\/syntaxai\/tdd\.md\/(.+)$/ → 301 to /GIT/tdd.md/$1
27+ The regex must sit BEFORE the gitBrowseMatch block at line ~102 so the old URL never reaches the browse handler.
28+- parseRepoBrowsePath in src/d21_handlers_repo_browse.ts keeps its shape (returns {kind, ref, path}) — only its callers drop the owner argument.
29+- isAllowedRepo collapses to isAllowedRepo(repo: string): boolean — owner is implicit, dropped from signature and from the gitBrowseMatch regex (the new regex captures only `:repo` + suffix).
30+- repoBrowseHandler and commitViewHandler drop the `owner` parameter from their signatures. All call sites updated.
31+- The explicit Bun route in src/d21_app.ts:486 changes from "/GIT/:owner/:repo/commit/:sha" → "/GIT/:repo/commit/:sha".
32+- Every link builder emits the new shape:
33+ * src/b51_render_repo.ts — 8 call sites (breadcrumbs, parent-dir, raw link, source-view link)
34+ * src/b51_render_commit.ts — 2 call sites (commit-parent, raw .diff)
35+ * src/b51_render_edit.ts:27 — hard-coded /GIT/syntaxai/tdd.md/commit/... string
36+- All hard-coded "/GIT/syntaxai/tdd.md/" strings in content/ and src/ rewritten to "/GIT/tdd.md/":
37+ * content/home.md
38+ * content/sama/v2.md
39+ * content/blog/sama-v2-rust-project-ripgrep.md
40+ * content/blog/sama-v2-workingset-cross-repo-baseline.md
41+ * content/blog/sama-v2-metrics-emitter.md
42+ * content/blog/sama-v2-go-project-dive.md
43+ * content/blog/sama-v2-sitemap-implementation-plan.md
44+ * content/blog/sama-v2-git-url-refactor-plan.md (the plan post itself uses old-form examples — those become AFTER-references in postmortem style)
45+ * src/d21_handlers_sama.ts:137 (markdown embedded in /sama/v2/verify page body)
46+ After: `grep -rn '/GIT/syntaxai/tdd.md/' content/ src/` returns 0 lines.
47+- Test expectations updated in src/b51_render_repo.test.ts and src/b51_render_commit.test.ts to match the new URL strings.
48+- All 379+ tests pass; no test count regression. New helper test (~3 cases) covers the redirect regex: matches a tree/blob/raw/commit URL and produces the expected Location; non-matching URLs (e.g. /GIT/otherorg/repo/...) fall through and don't redirect.
49+- /sama/v2/verify still reports 7/7 ✓ (anti-fudge).
50+- Sitemap unchanged — /GIT/ URLs aren't listed there.
51+- Deployed; live-verify with curl:
52+ * /GIT/tdd.md/blob/main/src/b32_sama_v2_verify.ts → 200, body contains "export"
53+ * /GIT/tdd.md/tree/main → 200, body contains "src" and "content"
54+ * /GIT/tdd.md/raw/main/sama.profile.toml → 200, text/plain
55+ * /GIT/syntaxai/tdd.md/blob/main/README.md → 301 with Location /GIT/tdd.md/blob/main/README.md
56+ * curl -sL of the old URL lands on the new one and returns the file content
57+ * Visit https://tdd.md/blog/sama-v2-git-url-refactor-plan and click any /GIT/ link in the rendered HTML → lands on a 200 (no broken navigation after rewrite).
58+
59+Constraints (anti-fudge):
60+- One regex for the redirect — no per-URL hand-maintained mapping. If the regex grows into "a list" the anti-fudge clause is violated.
61+- Do NOT remove LIVE_REPO_OWNER from src/a31_site_config.ts; it's still the truthful owner constant used by c14_git operations + Forgejo proxy. It just stops appearing in user-facing URLs.
62+- OUT OF SCOPE: the bare git protocol endpoint /syntaxai/tdd.md.git and the two-segment bare-repo view at /<owner>/<repo>. Those go through isGitProtocol + repoMatch — git-client-facing, copy-pasted into clone commands, breakage risk for cosmetics. Touch only the /GIT/ prefix.
63+- No alias mode — both URL forms working forever lets the old one quietly remain canonical. The 301 forces consolidation; the old URL is a redirect, not a working endpoint.
64+- Site language English-only — any new comments or response strings in English.
65+- GitHub flow via flatpak-spawn (branch → PR → merge → push p620 → deploy via flatpak-spawn --host scripts/p620/deploy-tdd-md.sh).
66+- Do NOT change any §4 verifier logic.
67+
68+Load-bearing files to read FIRST:
69+- The plan post: https://tdd.md/blog/sama-v2-git-url-refactor-plan (the design spec — read this before any code; the SAMA-layer mapping and design rationale are there, not duplicated in this /goal)
70+- src/d21_handlers_fallback.ts (lines 89-117 — bareGitUrl block + gitBrowseMatch regex; the new 301 redirect goes BEFORE gitBrowseMatch)
71+- src/d21_handlers_repo_browse.ts (parseRepoBrowsePath at line 56, isAllowedRepo at line 26, repoBrowseHandler signature at line 69)
72+- src/d21_handlers_commit_view.ts (commitViewHandler signature — drop owner arg)
73+- src/d21_app.ts (line 484-486 — the explicit commit route + import wiring)
74+- src/b51_render_repo.ts (8 link-emit sites — breadcrumbs, parent-dir, raw/source/dir links)
75+- src/b51_render_commit.ts (2 link-emit sites — commit-parent, raw .diff)
76+- src/b51_render_edit.ts (1 hard-coded edit→commit URL at line 27)
77+- src/a31_site_config.ts (LIVE_REPO_OWNER stays exported — confirm callers before touching)
78+- src/b51_render_repo.test.ts + src/b51_render_commit.test.ts (test strings to update mechanically with the link-builder changes)
added src/a31_goals.ts +46 −0
@@ -0,0 +1,46 @@
1+// c31 — model: /goal slash-command registry. Each /goal that drove a
2+// PR on this site lives as a markdown file in goals/<slug>.md with
3+// YAML frontmatter, and this registry indexes them so /goals,
4+// /goals/:slug, and the sitemap have a single source of truth.
5+//
6+// FILENAME CONVENTION: every goal file is named goals/<slug>.md, NOT
7+// goals/<sha>.md. The merge SHA is in the frontmatter as merge_sha:
8+// — looked up via b32_goals_meta.findGoalByMergeSha. Rationale: the
9+// SHA doesn't exist when the /goal is written (chicken-and-egg), and
10+// /goals/<slug> is a more readable canonical permalink. SHA lookup
11+// still works in one grep: `grep -l "merge_sha: 968890f" goals/`.
12+// See /blog/sama-v2-goal-chain-gap for the design rationale.
13+
14+export type GoalStatus = "pending" | "shipped" | "abandoned";
15+
16+export interface GoalEntry {
17+ slug: string;
18+ title: string;
19+ // ISO date — when the /goal was authored.
20+ date: string;
21+ // Git branch name used during the work (matches the slug by convention).
22+ branch: string;
23+ // Null for in-flight goals; populated post-merge.
24+ prNumber: number | null;
25+ // Short SHA of the merge commit on main. Null until merged.
26+ mergeSha: string | null;
27+ status: GoalStatus;
28+ // Slugs of blog posts that plan, execute, or postmortem this goal.
29+ relatedPosts: string[];
30+}
31+
32+export const ALL_GOALS: GoalEntry[] = [
33+ {
34+ slug: "git-url-drop-owner",
35+ title: "Drop redundant :owner segment from /GIT/ URLs",
36+ date: "2026-05-25",
37+ branch: "git-url-drop-owner",
38+ prNumber: 42,
39+ mergeSha: "684f257",
40+ status: "shipped",
41+ relatedPosts: [
42+ "sama-v2-git-url-refactor-plan",
43+ "sama-v2-git-url-refactor-postmortem",
44+ ],
45+ },
46+];
added src/b32_goals_meta.test.ts +150 −0
@@ -0,0 +1,150 @@
1+import { describe, expect, test } from "bun:test";
2+import {
3+ findGoalByMergeSha,
4+ parseGoalFrontmatter,
5+} from "./b32_goals_meta.ts";
6+import type { GoalEntry } from "./a31_goals.ts";
7+
8+const fullFrontmatter = `---
9+slug: example-goal
10+title: An example goal
11+date: 2026-05-25
12+branch: example-goal
13+pr_number: 42
14+merge_sha: abc1234
15+status: shipped
16+related_posts: [plan-post, postmortem-post]
17+---
18+Goal: do the thing.
19+
20+Done when:
21+- it is done.
22+`;
23+
24+const minimalFrontmatter = `---
25+slug: minimal
26+title: Minimal goal
27+date: 2026-05-25
28+branch: minimal
29+status: pending
30+---
31+Body here.
32+`;
33+
34+describe("parseGoalFrontmatter", () => {
35+ test("full frontmatter parses every field", () => {
36+ const parsed = parseGoalFrontmatter(fullFrontmatter);
37+ expect(parsed).not.toBeNull();
38+ expect(parsed!.meta.slug).toBe("example-goal");
39+ expect(parsed!.meta.title).toBe("An example goal");
40+ expect(parsed!.meta.date).toBe("2026-05-25");
41+ expect(parsed!.meta.branch).toBe("example-goal");
42+ expect(parsed!.meta.prNumber).toBe(42);
43+ expect(parsed!.meta.mergeSha).toBe("abc1234");
44+ expect(parsed!.meta.status).toBe("shipped");
45+ expect(parsed!.meta.relatedPosts).toEqual(["plan-post", "postmortem-post"]);
46+ expect(parsed!.body).toContain("Goal: do the thing.");
47+ });
48+
49+ test("missing optional fields default sensibly", () => {
50+ const parsed = parseGoalFrontmatter(minimalFrontmatter);
51+ expect(parsed).not.toBeNull();
52+ expect(parsed!.meta.prNumber).toBeNull();
53+ expect(parsed!.meta.mergeSha).toBeNull();
54+ expect(parsed!.meta.relatedPosts).toEqual([]);
55+ expect(parsed!.meta.status).toBe("pending");
56+ });
57+
58+ test("missing required field returns null", () => {
59+ const noTitle = `---
60+slug: x
61+date: 2026-05-25
62+branch: x
63+status: pending
64+---
65+body`;
66+ expect(parseGoalFrontmatter(noTitle)).toBeNull();
67+ });
68+
69+ test("malformed frontmatter (no opening ---) returns null", () => {
70+ expect(parseGoalFrontmatter("just some text")).toBeNull();
71+ });
72+
73+ test("unclosed frontmatter returns null", () => {
74+ expect(parseGoalFrontmatter("---\nslug: x\ntitle: y\n")).toBeNull();
75+ });
76+
77+ test("invalid status returns null", () => {
78+ const bad = `---
79+slug: x
80+title: y
81+date: 2026-05-25
82+branch: x
83+status: half-done
84+---
85+body`;
86+ expect(parseGoalFrontmatter(bad)).toBeNull();
87+ });
88+});
89+
90+const sampleGoals: ReadonlyArray<GoalEntry> = [
91+ {
92+ slug: "alpha",
93+ title: "Alpha",
94+ date: "2026-05-25",
95+ branch: "alpha",
96+ prNumber: 1,
97+ mergeSha: "968890f8a3bc1234deadbeef0000000000000000",
98+ status: "shipped",
99+ relatedPosts: [],
100+ },
101+ {
102+ slug: "beta",
103+ title: "Beta",
104+ date: "2026-05-24",
105+ branch: "beta",
106+ prNumber: 2,
107+ mergeSha: "684f257",
108+ status: "shipped",
109+ relatedPosts: [],
110+ },
111+ {
112+ slug: "pending-thing",
113+ title: "Pending",
114+ date: "2026-05-26",
115+ branch: "pending",
116+ prNumber: null,
117+ mergeSha: null,
118+ status: "pending",
119+ relatedPosts: [],
120+ },
121+];
122+
123+describe("findGoalByMergeSha", () => {
124+ test("short query hits long stored SHA (prefix match)", () => {
125+ const found = findGoalByMergeSha("968890f", sampleGoals);
126+ expect(found?.slug).toBe("alpha");
127+ });
128+
129+ test("long query hits short stored SHA (reverse prefix)", () => {
130+ const found = findGoalByMergeSha("684f257b24c002f6", sampleGoals);
131+ expect(found?.slug).toBe("beta");
132+ });
133+
134+ test("exact short-to-short match", () => {
135+ const found = findGoalByMergeSha("684f257", sampleGoals);
136+ expect(found?.slug).toBe("beta");
137+ });
138+
139+ test("no match returns null", () => {
140+ expect(findGoalByMergeSha("deadbeef", sampleGoals)).toBeNull();
141+ });
142+
143+ test("empty query returns null", () => {
144+ expect(findGoalByMergeSha("", sampleGoals)).toBeNull();
145+ });
146+
147+ test("skips pending goals (null mergeSha)", () => {
148+ expect(findGoalByMergeSha("pending-thing", sampleGoals)).toBeNull();
149+ });
150+});
added src/b32_goals_meta.ts +124 −0
@@ -0,0 +1,124 @@
1+// b32 — Layer 1 pure helpers for goal files:
2+// parseGoalFrontmatter(raw) → { meta, body } | null
3+// findGoalByMergeSha(sha, all) → GoalEntry | null
4+// Both functions are pure: string in, struct out, no I/O. The
5+// handler (d21_handlers_goals) reads the file from disk and hands
6+// the raw string here; the registry (a31_goals) is what's iterated
7+// for the SHA lookup. Sibling test pins the parse + lookup
8+// contracts per /sama/v2 §4.3.
9+
10+import type { GoalEntry, GoalStatus } from "./a31_goals.ts";
11+
12+export interface GoalMeta {
13+ slug: string;
14+ title: string;
15+ date: string;
16+ branch: string;
17+ prNumber: number | null;
18+ mergeSha: string | null;
19+ status: GoalStatus;
20+ relatedPosts: string[];
21+}
22+
23+export interface ParsedGoal {
24+ meta: GoalMeta;
25+ body: string;
26+}
27+
28+const STATUS_VALUES: ReadonlyArray<GoalStatus> = ["pending", "shipped", "abandoned"];
29+
30+const parseNullableInt = (v: string | undefined): number | null => {
31+ if (v === undefined || v === "null" || v === "") return null;
32+ const n = Number(v);
33+ return Number.isFinite(n) ? n : NaN;
34+};
35+
36+const parseNullableString = (v: string | undefined): string | null => {
37+ if (v === undefined || v === "null" || v === "") return null;
38+ return v;
39+};
40+
41+// Inline-array form only: `related_posts: [a, b, c]`. The block
42+// list form (`- a\n- b\n`) is intentionally unsupported to keep
43+// the parser small — every goal file should use the inline form.
44+const parseInlineArray = (v: string | undefined): string[] | null => {
45+ if (v === undefined || v === "") return [];
46+ const m = v.match(/^\[(.*)\]$/);
47+ if (!m) return null;
48+ const inner = m[1]!.trim();
49+ if (inner === "") return [];
50+ return inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
51+};
52+
53+export const parseGoalFrontmatter = (raw: string): ParsedGoal | null => {
54+ const lines = raw.split("\n");
55+ if (lines[0]?.trim() !== "---") return null;
56+
57+ let closeIdx = -1;
58+ for (let i = 1; i < lines.length; i++) {
59+ if (lines[i]?.trim() === "---") {
60+ closeIdx = i;
61+ break;
62+ }
63+ }
64+ if (closeIdx === -1) return null;
65+
66+ const fields: Record<string, string> = {};
67+ for (let i = 1; i < closeIdx; i++) {
68+ const line = lines[i]!;
69+ const t = line.trim();
70+ if (t === "" || t.startsWith("#")) continue;
71+ const colonIdx = line.indexOf(":");
72+ if (colonIdx === -1) return null;
73+ const key = line.slice(0, colonIdx).trim();
74+ const value = line.slice(colonIdx + 1).trim();
75+ fields[key] = value;
76+ }
77+
78+ for (const required of ["slug", "title", "date", "branch", "status"]) {
79+ if (fields[required] === undefined || fields[required] === "") return null;
80+ }
81+
82+ const status = fields.status as GoalStatus;
83+ if (!STATUS_VALUES.includes(status)) return null;
84+
85+ const prNumber = parseNullableInt(fields.pr_number);
86+ if (Number.isNaN(prNumber)) return null;
87+
88+ const mergeSha = parseNullableString(fields.merge_sha);
89+
90+ const relatedPosts = parseInlineArray(fields.related_posts);
91+ if (relatedPosts === null) return null;
92+
93+ const body = lines.slice(closeIdx + 1).join("\n").replace(/^\n+/, "");
94+
95+ return {
96+ meta: {
97+ slug: fields.slug!,
98+ title: fields.title!,
99+ date: fields.date!,
100+ branch: fields.branch!,
101+ prNumber,
102+ mergeSha,
103+ status,
104+ relatedPosts,
105+ },
106+ body,
107+ };
108+};
109+
110+// SHA prefix matching in either direction so a 7-char URL SHA
111+// resolves a 40-char stored SHA AND vice versa.
112+export const findGoalByMergeSha = (
113+ sha: string,
114+ all: ReadonlyArray<GoalEntry>,
115+): GoalEntry | null => {
116+ if (sha === "") return null;
117+ for (const goal of all) {
118+ if (goal.mergeSha === null) continue;
119+ if (goal.mergeSha.startsWith(sha) || sha.startsWith(goal.mergeSha)) {
120+ return goal;
121+ }
122+ }
123+ return null;
124+};
modified src/b32_sitemap.test.ts +2 −1
@@ -89,11 +89,12 @@ describe("renderSitemap", () => {
8989 });
9090
9191 describe("STATIC_PATHS", () => {
92- test("covers the eleven load-bearing routes from the /goal", () => {
92+ test("covers the load-bearing routes (incl. /goals from goal #1)", () => {
9393 expect(STATIC_PATHS).toEqual([
9494 "/",
9595 "/blog",
9696 "/games",
97+ "/goals",
9798 "/leaderboard",
9899 "/sama",
99100 "/sama/v2",
modified src/b32_sitemap.ts +1 −0
@@ -17,6 +17,7 @@ export const STATIC_PATHS: ReadonlyArray<string> = [
1717 "/",
1818 "/blog",
1919 "/games",
20+ "/goals",
2021 "/leaderboard",
2122 "/sama",
2223 "/sama/v2",
modified src/b51_render_layout.ts +2 −2
@@ -11,7 +11,7 @@ import type { Phase } from "./a31_commits.ts";
1111 const STYLE_CSS = "./public/style.css";
1212 const css = await Bun.file(STYLE_CSS).text();
1313
14-export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama";
14+export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama" | "goals";
1515
1616 export interface PageOptions {
1717 title: string;
@@ -44,7 +44,7 @@ const navLink = (href: string, label: string, active: boolean): string => {
4444 return `<a href="${href}"${cls}>${label}</a>`;
4545 };
4646
47-const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/sama", "sama", active === "sama")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`;
47+const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/sama", "sama", active === "sama")} <span class="md-nav-sep">·</span> ${navLink("/goals", "goals", active === "goals")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`;
4848
4949 export const renderPage = async (opts: PageOptions): Promise<string> => {
5050 const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false });
modified src/d21_app.ts +14 −0
@@ -10,6 +10,7 @@ import {
1010 import { renderDocsPage } from "./b51_render_docs_layout.ts";
1111 import { listGames, loadGame } from "./a31_games.ts";
1212 import { ALL_POSTS } from "./a31_blog.ts";
13+import { ALL_GOALS } from "./a31_goals.ts";
1314 import { ALL_GUIDES } from "./a31_guides.ts";
1415 import { ALL_SAMA } from "./a31_sama.ts";
1516 import { SITE_BASE_URL } from "./a31_site_config.ts";
@@ -46,6 +47,10 @@ import {
4647 samaLandingHandler,
4748 samaSlugHandler,
4849 } from "./d21_handlers_sama.ts";
50+import {
51+ goalsLandingHandler,
52+ goalSlugHandler,
53+} from "./d21_handlers_goals.ts";
4954 import { editPageHandler } from "./d21_handlers_edit.ts";
5055 import {
5156 adminListHandler,
@@ -201,11 +206,16 @@ export const createApp = (port: number) => Bun.serve({
201206 const guideUrls: SitemapUrl[] = ALL_GUIDES.map((g) => ({
202207 loc: `${SITE_BASE_URL}/guides/${g.slug}`,
203208 }));
209+ const goalUrls: SitemapUrl[] = ALL_GOALS.map((g) => ({
210+ loc: `${SITE_BASE_URL}/goals/${g.slug}`,
211+ lastmod: g.date,
212+ }));
204213 const xml = renderSitemap([
205214 ...staticUrls,
206215 ...blogUrls,
207216 ...samaUrls,
208217 ...guideUrls,
218+ ...goalUrls,
209219 ]);
210220 return new Response(xml, {
211221 headers: {
@@ -406,6 +416,10 @@ ${rows}
406416
407417 "/sama/:slug": samaSlugHandler,
408418
419+ "/goals": goalsLandingHandler,
420+
421+ "/goals/:slug": goalSlugHandler,
422+
409423 "/games/:kata": async (req) => {
410424 const res = await renderKata(req.params.kata);
411425 if (res) return res;
added src/d21_handlers_goals.ts +100 −0
@@ -0,0 +1,100 @@
1+// c21 — handlers: /goals index + /goals/:slug detail. Mirrors the
2+// /sama and /blog index+detail pattern. ALL_GOALS is the registry;
3+// b32_goals_meta parses the YAML frontmatter on each detail page.
4+
5+import { ALL_GOALS, type GoalEntry, type GoalStatus } from "./a31_goals.ts";
6+import { parseGoalFrontmatter } from "./b32_goals_meta.ts";
7+import { renderDocsPage } from "./b51_render_docs_layout.ts";
8+import { htmlResponse, renderNotFound } from "./b51_render_layout.ts";
9+
10+const STATUS_LABEL: Record<GoalStatus, string> = {
11+ shipped: "✓ shipped",
12+ pending: "⏳ pending",
13+ abandoned: "✗ abandoned",
14+};
15+
16+const prLink = (n: number | null): string =>
17+ n === null ? "—" : `[#${n}](https://github.com/syntaxai/tdd.md/pull/${n})`;
18+
19+const commitLink = (sha: string | null): string =>
20+ sha === null ? "—" : `[\`${sha}\`](/GIT/tdd.md/commit/${sha})`;
21+
22+const relatedLinks = (slugs: ReadonlyArray<string>): string =>
23+ slugs.map((s) => `[${s}](/blog/${s})`).join(" · ");
24+
25+const goalsLandingBody = (): string => {
26+ const sorted = [...ALL_GOALS].sort((a, b) => b.date.localeCompare(a.date));
27+ if (sorted.length === 0) {
28+ return `# goals\n\n*No /goals archived yet.*`;
29+ }
30+ const rows = sorted
31+ .map(
32+ (g: GoalEntry) =>
33+ `| ${g.date} | [${g.title}](/goals/${g.slug}) | ${STATUS_LABEL[g.status]} | ${prLink(g.prNumber)} | ${commitLink(g.mergeSha)} |`,
34+ )
35+ .join("\n");
36+ return `# goals
37+
38+The /goal slash commands that drove the work on this site. Each entry is the verbatim contract the agent was held against — *Done when* post-conditions, *Constraints (anti-fudge)*, and *Load-bearing files to read FIRST*. See [the drama post](/blog/sama-v2-goal-chain-gap) for why these now live in git.
39+
40+| date | title | status | PR | commit |
41+|---|---|---|---|---|
42+${rows}
43+
44+Lookup by merge SHA: \`grep -l "merge_sha: <short-sha>" goals/\`. The frontmatter on every file carries the SHA; the canonical permalink is \`/goals/<slug>\`.
45+`;
46+};
47+
48+export const goalsLandingHandler = async (): Promise<Response> => {
49+ const html = await renderDocsPage({
50+ title: "goals — tdd.md",
51+ description:
52+ "The /goal slash commands that drove every PR on tdd.md, archived as first-class artifacts in git.",
53+ bodyMarkdown: goalsLandingBody(),
54+ ogPath: "https://tdd.md/goals",
55+ active: "goals",
56+ pathForDocs: "/goals",
57+ editPathOverride: null,
58+ });
59+ return htmlResponse(html);
60+};
61+
62+export const goalSlugHandler = async (
63+ req: { params: { slug: string } },
64+): Promise<Response> => {
65+ const slug = req.params.slug;
66+ const entry = ALL_GOALS.find((g) => g.slug === slug);
67+ if (!entry) {
68+ const html = await renderNotFound(`/goals/${slug}`);
69+ return htmlResponse(html, 404);
70+ }
71+ const file = Bun.file(`./goals/${slug}.md`);
72+ if (!(await file.exists())) {
73+ const html = await renderNotFound(`/goals/${slug}`);
74+ return htmlResponse(html, 404);
75+ }
76+ const raw = await file.text();
77+ const parsed = parseGoalFrontmatter(raw);
78+ if (parsed === null) {
79+ const html = await renderNotFound(`/goals/${slug}`);
80+ return htmlResponse(html, 404);
81+ }
82+
83+ const badges =
84+ `**Status:** ${STATUS_LABEL[entry.status]} · **Date:** ${entry.date} · **PR:** ${prLink(entry.prNumber)} · **Commit:** ${commitLink(entry.mergeSha)}`;
85+ const related = entry.relatedPosts.length === 0
86+ ? ""
87+ : `\n\n**Related posts:** ${relatedLinks(entry.relatedPosts)}`;
88+
89+ const body = `# ${entry.title}\n\n${badges}${related}\n\n---\n\n${parsed.body}`;
90+
91+ const html = await renderDocsPage({
92+ title: `${entry.title} — tdd.md`,
93+ description: `The /goal command that drove ${entry.title}. Status: ${entry.status}.`,
94+ bodyMarkdown: body,
95+ ogPath: `https://tdd.md/goals/${slug}`,
96+ active: "goals",
97+ pathForDocs: `/goals/${slug}`,
98+ });
99+ return htmlResponse(html);
100+};