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]>
9 files changed · +517 −3
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) | |
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 | +]; | |
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 | +}); | |
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 | +}; | |
src/b32_sitemap.test.ts
+2
−1
| @@ -89,11 +89,12 @@ describe("renderSitemap", () => { | ||
| 89 | 89 | }); |
| 90 | 90 | |
| 91 | 91 | 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)", () => { | |
| 93 | 93 | expect(STATIC_PATHS).toEqual([ |
| 94 | 94 | "/", |
| 95 | 95 | "/blog", |
| 96 | 96 | "/games", |
| 97 | + "/goals", | |
| 97 | 98 | "/leaderboard", |
| 98 | 99 | "/sama", |
| 99 | 100 | "/sama/v2", |
src/b32_sitemap.ts
+1
−0
| @@ -17,6 +17,7 @@ export const STATIC_PATHS: ReadonlyArray<string> = [ | ||
| 17 | 17 | "/", |
| 18 | 18 | "/blog", |
| 19 | 19 | "/games", |
| 20 | + "/goals", | |
| 20 | 21 | "/leaderboard", |
| 21 | 22 | "/sama", |
| 22 | 23 | "/sama/v2", |
src/b51_render_layout.ts
+2
−2
| @@ -11,7 +11,7 @@ import type { Phase } from "./a31_commits.ts"; | ||
| 11 | 11 | const STYLE_CSS = "./public/style.css"; |
| 12 | 12 | const css = await Bun.file(STYLE_CSS).text(); |
| 13 | 13 | |
| 14 | -export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama"; | |
| 14 | +export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama" | "goals"; | |
| 15 | 15 | |
| 16 | 16 | export interface PageOptions { |
| 17 | 17 | title: string; |
| @@ -44,7 +44,7 @@ const navLink = (href: string, label: string, active: boolean): string => { | ||
| 44 | 44 | return `<a href="${href}"${cls}>${label}</a>`; |
| 45 | 45 | }; |
| 46 | 46 | |
| 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>`; | |
| 48 | 48 | |
| 49 | 49 | export const renderPage = async (opts: PageOptions): Promise<string> => { |
| 50 | 50 | const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false }); |
src/d21_app.ts
+14
−0
| @@ -10,6 +10,7 @@ import { | ||
| 10 | 10 | import { renderDocsPage } from "./b51_render_docs_layout.ts"; |
| 11 | 11 | import { listGames, loadGame } from "./a31_games.ts"; |
| 12 | 12 | import { ALL_POSTS } from "./a31_blog.ts"; |
| 13 | +import { ALL_GOALS } from "./a31_goals.ts"; | |
| 13 | 14 | import { ALL_GUIDES } from "./a31_guides.ts"; |
| 14 | 15 | import { ALL_SAMA } from "./a31_sama.ts"; |
| 15 | 16 | import { SITE_BASE_URL } from "./a31_site_config.ts"; |
| @@ -46,6 +47,10 @@ import { | ||
| 46 | 47 | samaLandingHandler, |
| 47 | 48 | samaSlugHandler, |
| 48 | 49 | } from "./d21_handlers_sama.ts"; |
| 50 | +import { | |
| 51 | + goalsLandingHandler, | |
| 52 | + goalSlugHandler, | |
| 53 | +} from "./d21_handlers_goals.ts"; | |
| 49 | 54 | import { editPageHandler } from "./d21_handlers_edit.ts"; |
| 50 | 55 | import { |
| 51 | 56 | adminListHandler, |
| @@ -201,11 +206,16 @@ export const createApp = (port: number) => Bun.serve({ | ||
| 201 | 206 | const guideUrls: SitemapUrl[] = ALL_GUIDES.map((g) => ({ |
| 202 | 207 | loc: `${SITE_BASE_URL}/guides/${g.slug}`, |
| 203 | 208 | })); |
| 209 | + const goalUrls: SitemapUrl[] = ALL_GOALS.map((g) => ({ | |
| 210 | + loc: `${SITE_BASE_URL}/goals/${g.slug}`, | |
| 211 | + lastmod: g.date, | |
| 212 | + })); | |
| 204 | 213 | const xml = renderSitemap([ |
| 205 | 214 | ...staticUrls, |
| 206 | 215 | ...blogUrls, |
| 207 | 216 | ...samaUrls, |
| 208 | 217 | ...guideUrls, |
| 218 | + ...goalUrls, | |
| 209 | 219 | ]); |
| 210 | 220 | return new Response(xml, { |
| 211 | 221 | headers: { |
| @@ -406,6 +416,10 @@ ${rows} | ||
| 406 | 416 | |
| 407 | 417 | "/sama/:slug": samaSlugHandler, |
| 408 | 418 | |
| 419 | + "/goals": goalsLandingHandler, | |
| 420 | + | |
| 421 | + "/goals/:slug": goalSlugHandler, | |
| 422 | + | |
| 409 | 423 | "/games/:kata": async (req) => { |
| 410 | 424 | const res = await renderKata(req.params.kata); |
| 411 | 425 | if (res) return res; |
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 | +}; | |