6d10e8aa0dd72842eb8eb69aff8945929ba57ac1 diff --git a/goals/git-url-drop-owner.md b/goals/git-url-drop-owner.md new file mode 100644 index 0000000000000000000000000000000000000000..32cef27229005728be92d0850e1241552f8d12d7 --- /dev/null +++ b/goals/git-url-drop-owner.md @@ -0,0 +1,78 @@ +--- +slug: git-url-drop-owner +title: Drop redundant :owner segment from /GIT/ URLs +date: 2026-05-25 +branch: git-url-drop-owner +pr_number: 42 +merge_sha: 684f257 +status: shipped +related_posts: [sama-v2-git-url-refactor-plan, sama-v2-git-url-refactor-postmortem] +--- + +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. + +Done when: +- The four /GIT/ URL kinds work under the new shape: + * /GIT/tdd.md/tree/:ref/ → 200, directory listing + * /GIT/tdd.md/blob/:ref/ → 200, file viewer HTML + * /GIT/tdd.md/raw/:ref/ → 200, text/plain raw + * /GIT/tdd.md/commit/ → 200, commit detail +- Old URL form is a permanent redirect: + * curl -I https://tdd.md/GIT/syntaxai/tdd.md/blob/main/src/b32_sama_v2_verify.ts + → HTTP/2 301 + → location: /GIT/tdd.md/blob/main/src/b32_sama_v2_verify.ts + → cache-control: public, max-age=86400 + * Implemented as ONE regex in src/d21_handlers_fallback.ts: + /^\/GIT\/syntaxai\/tdd\.md\/(.+)$/ → 301 to /GIT/tdd.md/$1 + The regex must sit BEFORE the gitBrowseMatch block at line ~102 so the old URL never reaches the browse handler. +- parseRepoBrowsePath in src/d21_handlers_repo_browse.ts keeps its shape (returns {kind, ref, path}) — only its callers drop the owner argument. +- 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). +- repoBrowseHandler and commitViewHandler drop the `owner` parameter from their signatures. All call sites updated. +- The explicit Bun route in src/d21_app.ts:486 changes from "/GIT/:owner/:repo/commit/:sha" → "/GIT/:repo/commit/:sha". +- Every link builder emits the new shape: + * src/b51_render_repo.ts — 8 call sites (breadcrumbs, parent-dir, raw link, source-view link) + * src/b51_render_commit.ts — 2 call sites (commit-parent, raw .diff) + * src/b51_render_edit.ts:27 — hard-coded /GIT/syntaxai/tdd.md/commit/... string +- All hard-coded "/GIT/syntaxai/tdd.md/" strings in content/ and src/ rewritten to "/GIT/tdd.md/": + * content/home.md + * content/sama/v2.md + * content/blog/sama-v2-rust-project-ripgrep.md + * content/blog/sama-v2-workingset-cross-repo-baseline.md + * content/blog/sama-v2-metrics-emitter.md + * content/blog/sama-v2-go-project-dive.md + * content/blog/sama-v2-sitemap-implementation-plan.md + * content/blog/sama-v2-git-url-refactor-plan.md (the plan post itself uses old-form examples — those become AFTER-references in postmortem style) + * src/d21_handlers_sama.ts:137 (markdown embedded in /sama/v2/verify page body) + After: `grep -rn '/GIT/syntaxai/tdd.md/' content/ src/` returns 0 lines. +- Test expectations updated in src/b51_render_repo.test.ts and src/b51_render_commit.test.ts to match the new URL strings. +- 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. +- /sama/v2/verify still reports 7/7 ✓ (anti-fudge). +- Sitemap unchanged — /GIT/ URLs aren't listed there. +- Deployed; live-verify with curl: + * /GIT/tdd.md/blob/main/src/b32_sama_v2_verify.ts → 200, body contains "export" + * /GIT/tdd.md/tree/main → 200, body contains "src" and "content" + * /GIT/tdd.md/raw/main/sama.profile.toml → 200, text/plain + * /GIT/syntaxai/tdd.md/blob/main/README.md → 301 with Location /GIT/tdd.md/blob/main/README.md + * curl -sL of the old URL lands on the new one and returns the file content + * 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). + +Constraints (anti-fudge): +- One regex for the redirect — no per-URL hand-maintained mapping. If the regex grows into "a list" the anti-fudge clause is violated. +- 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. +- OUT OF SCOPE: the bare git protocol endpoint /syntaxai/tdd.md.git and the two-segment bare-repo view at //. Those go through isGitProtocol + repoMatch — git-client-facing, copy-pasted into clone commands, breakage risk for cosmetics. Touch only the /GIT/ prefix. +- 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. +- Site language English-only — any new comments or response strings in English. +- GitHub flow via flatpak-spawn (branch → PR → merge → push p620 → deploy via flatpak-spawn --host scripts/p620/deploy-tdd-md.sh). +- Do NOT change any §4 verifier logic. + +Load-bearing files to read FIRST: +- 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) +- src/d21_handlers_fallback.ts (lines 89-117 — bareGitUrl block + gitBrowseMatch regex; the new 301 redirect goes BEFORE gitBrowseMatch) +- src/d21_handlers_repo_browse.ts (parseRepoBrowsePath at line 56, isAllowedRepo at line 26, repoBrowseHandler signature at line 69) +- src/d21_handlers_commit_view.ts (commitViewHandler signature — drop owner arg) +- src/d21_app.ts (line 484-486 — the explicit commit route + import wiring) +- src/b51_render_repo.ts (8 link-emit sites — breadcrumbs, parent-dir, raw/source/dir links) +- src/b51_render_commit.ts (2 link-emit sites — commit-parent, raw .diff) +- src/b51_render_edit.ts (1 hard-coded edit→commit URL at line 27) +- src/a31_site_config.ts (LIVE_REPO_OWNER stays exported — confirm callers before touching) +- src/b51_render_repo.test.ts + src/b51_render_commit.test.ts (test strings to update mechanically with the link-builder changes) diff --git a/src/a31_goals.ts b/src/a31_goals.ts new file mode 100644 index 0000000000000000000000000000000000000000..a00f04756074cab3038599267918ed745b960bdf --- /dev/null +++ b/src/a31_goals.ts @@ -0,0 +1,46 @@ +// c31 — model: /goal slash-command registry. Each /goal that drove a +// PR on this site lives as a markdown file in goals/.md with +// YAML frontmatter, and this registry indexes them so /goals, +// /goals/:slug, and the sitemap have a single source of truth. +// +// FILENAME CONVENTION: every goal file is named goals/.md, NOT +// goals/.md. The merge SHA is in the frontmatter as merge_sha: +// — looked up via b32_goals_meta.findGoalByMergeSha. Rationale: the +// SHA doesn't exist when the /goal is written (chicken-and-egg), and +// /goals/ is a more readable canonical permalink. SHA lookup +// still works in one grep: `grep -l "merge_sha: 968890f" goals/`. +// See /blog/sama-v2-goal-chain-gap for the design rationale. + +export type GoalStatus = "pending" | "shipped" | "abandoned"; + +export interface GoalEntry { + slug: string; + title: string; + // ISO date — when the /goal was authored. + date: string; + // Git branch name used during the work (matches the slug by convention). + branch: string; + // Null for in-flight goals; populated post-merge. + prNumber: number | null; + // Short SHA of the merge commit on main. Null until merged. + mergeSha: string | null; + status: GoalStatus; + // Slugs of blog posts that plan, execute, or postmortem this goal. + relatedPosts: string[]; +} + +export const ALL_GOALS: GoalEntry[] = [ + { + slug: "git-url-drop-owner", + title: "Drop redundant :owner segment from /GIT/ URLs", + date: "2026-05-25", + branch: "git-url-drop-owner", + prNumber: 42, + mergeSha: "684f257", + status: "shipped", + relatedPosts: [ + "sama-v2-git-url-refactor-plan", + "sama-v2-git-url-refactor-postmortem", + ], + }, +]; diff --git a/src/b32_goals_meta.test.ts b/src/b32_goals_meta.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ebe56e87c4ae92adb794b0002de366bd4eea6aa --- /dev/null +++ b/src/b32_goals_meta.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { + findGoalByMergeSha, + parseGoalFrontmatter, +} from "./b32_goals_meta.ts"; +import type { GoalEntry } from "./a31_goals.ts"; + +const fullFrontmatter = `--- +slug: example-goal +title: An example goal +date: 2026-05-25 +branch: example-goal +pr_number: 42 +merge_sha: abc1234 +status: shipped +related_posts: [plan-post, postmortem-post] +--- +Goal: do the thing. + +Done when: +- it is done. +`; + +const minimalFrontmatter = `--- +slug: minimal +title: Minimal goal +date: 2026-05-25 +branch: minimal +status: pending +--- +Body here. +`; + +describe("parseGoalFrontmatter", () => { + test("full frontmatter parses every field", () => { + const parsed = parseGoalFrontmatter(fullFrontmatter); + expect(parsed).not.toBeNull(); + expect(parsed!.meta.slug).toBe("example-goal"); + expect(parsed!.meta.title).toBe("An example goal"); + expect(parsed!.meta.date).toBe("2026-05-25"); + expect(parsed!.meta.branch).toBe("example-goal"); + expect(parsed!.meta.prNumber).toBe(42); + expect(parsed!.meta.mergeSha).toBe("abc1234"); + expect(parsed!.meta.status).toBe("shipped"); + expect(parsed!.meta.relatedPosts).toEqual(["plan-post", "postmortem-post"]); + expect(parsed!.body).toContain("Goal: do the thing."); + }); + + test("missing optional fields default sensibly", () => { + const parsed = parseGoalFrontmatter(minimalFrontmatter); + expect(parsed).not.toBeNull(); + expect(parsed!.meta.prNumber).toBeNull(); + expect(parsed!.meta.mergeSha).toBeNull(); + expect(parsed!.meta.relatedPosts).toEqual([]); + expect(parsed!.meta.status).toBe("pending"); + }); + + test("missing required field returns null", () => { + const noTitle = `--- +slug: x +date: 2026-05-25 +branch: x +status: pending +--- +body`; + expect(parseGoalFrontmatter(noTitle)).toBeNull(); + }); + + test("malformed frontmatter (no opening ---) returns null", () => { + expect(parseGoalFrontmatter("just some text")).toBeNull(); + }); + + test("unclosed frontmatter returns null", () => { + expect(parseGoalFrontmatter("---\nslug: x\ntitle: y\n")).toBeNull(); + }); + + test("invalid status returns null", () => { + const bad = `--- +slug: x +title: y +date: 2026-05-25 +branch: x +status: half-done +--- +body`; + expect(parseGoalFrontmatter(bad)).toBeNull(); + }); +}); + +const sampleGoals: ReadonlyArray = [ + { + slug: "alpha", + title: "Alpha", + date: "2026-05-25", + branch: "alpha", + prNumber: 1, + mergeSha: "968890f8a3bc1234deadbeef0000000000000000", + status: "shipped", + relatedPosts: [], + }, + { + slug: "beta", + title: "Beta", + date: "2026-05-24", + branch: "beta", + prNumber: 2, + mergeSha: "684f257", + status: "shipped", + relatedPosts: [], + }, + { + slug: "pending-thing", + title: "Pending", + date: "2026-05-26", + branch: "pending", + prNumber: null, + mergeSha: null, + status: "pending", + relatedPosts: [], + }, +]; + +describe("findGoalByMergeSha", () => { + test("short query hits long stored SHA (prefix match)", () => { + const found = findGoalByMergeSha("968890f", sampleGoals); + expect(found?.slug).toBe("alpha"); + }); + + test("long query hits short stored SHA (reverse prefix)", () => { + const found = findGoalByMergeSha("684f257b24c002f6", sampleGoals); + expect(found?.slug).toBe("beta"); + }); + + test("exact short-to-short match", () => { + const found = findGoalByMergeSha("684f257", sampleGoals); + expect(found?.slug).toBe("beta"); + }); + + test("no match returns null", () => { + expect(findGoalByMergeSha("deadbeef", sampleGoals)).toBeNull(); + }); + + test("empty query returns null", () => { + expect(findGoalByMergeSha("", sampleGoals)).toBeNull(); + }); + + test("skips pending goals (null mergeSha)", () => { + expect(findGoalByMergeSha("pending-thing", sampleGoals)).toBeNull(); + }); +}); diff --git a/src/b32_goals_meta.ts b/src/b32_goals_meta.ts new file mode 100644 index 0000000000000000000000000000000000000000..f605aae4a17bf55cf4168be633ee1002b7ab78d4 --- /dev/null +++ b/src/b32_goals_meta.ts @@ -0,0 +1,124 @@ +// b32 — Layer 1 pure helpers for goal files: +// parseGoalFrontmatter(raw) → { meta, body } | null +// findGoalByMergeSha(sha, all) → GoalEntry | null +// Both functions are pure: string in, struct out, no I/O. The +// handler (d21_handlers_goals) reads the file from disk and hands +// the raw string here; the registry (a31_goals) is what's iterated +// for the SHA lookup. Sibling test pins the parse + lookup +// contracts per /sama/v2 §4.3. + +import type { GoalEntry, GoalStatus } from "./a31_goals.ts"; + +export interface GoalMeta { + slug: string; + title: string; + date: string; + branch: string; + prNumber: number | null; + mergeSha: string | null; + status: GoalStatus; + relatedPosts: string[]; +} + +export interface ParsedGoal { + meta: GoalMeta; + body: string; +} + +const STATUS_VALUES: ReadonlyArray = ["pending", "shipped", "abandoned"]; + +const parseNullableInt = (v: string | undefined): number | null => { + if (v === undefined || v === "null" || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : NaN; +}; + +const parseNullableString = (v: string | undefined): string | null => { + if (v === undefined || v === "null" || v === "") return null; + return v; +}; + +// Inline-array form only: `related_posts: [a, b, c]`. The block +// list form (`- a\n- b\n`) is intentionally unsupported to keep +// the parser small — every goal file should use the inline form. +const parseInlineArray = (v: string | undefined): string[] | null => { + if (v === undefined || v === "") return []; + const m = v.match(/^\[(.*)\]$/); + if (!m) return null; + const inner = m[1]!.trim(); + if (inner === "") return []; + return inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0); +}; + +export const parseGoalFrontmatter = (raw: string): ParsedGoal | null => { + const lines = raw.split("\n"); + if (lines[0]?.trim() !== "---") return null; + + let closeIdx = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i]?.trim() === "---") { + closeIdx = i; + break; + } + } + if (closeIdx === -1) return null; + + const fields: Record = {}; + for (let i = 1; i < closeIdx; i++) { + const line = lines[i]!; + const t = line.trim(); + if (t === "" || t.startsWith("#")) continue; + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) return null; + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + fields[key] = value; + } + + for (const required of ["slug", "title", "date", "branch", "status"]) { + if (fields[required] === undefined || fields[required] === "") return null; + } + + const status = fields.status as GoalStatus; + if (!STATUS_VALUES.includes(status)) return null; + + const prNumber = parseNullableInt(fields.pr_number); + if (Number.isNaN(prNumber)) return null; + + const mergeSha = parseNullableString(fields.merge_sha); + + const relatedPosts = parseInlineArray(fields.related_posts); + if (relatedPosts === null) return null; + + const body = lines.slice(closeIdx + 1).join("\n").replace(/^\n+/, ""); + + return { + meta: { + slug: fields.slug!, + title: fields.title!, + date: fields.date!, + branch: fields.branch!, + prNumber, + mergeSha, + status, + relatedPosts, + }, + body, + }; +}; + +// SHA prefix matching in either direction so a 7-char URL SHA +// resolves a 40-char stored SHA AND vice versa. +export const findGoalByMergeSha = ( + sha: string, + all: ReadonlyArray, +): GoalEntry | null => { + if (sha === "") return null; + for (const goal of all) { + if (goal.mergeSha === null) continue; + if (goal.mergeSha.startsWith(sha) || sha.startsWith(goal.mergeSha)) { + return goal; + } + } + return null; +}; diff --git a/src/b32_sitemap.test.ts b/src/b32_sitemap.test.ts index 114f6957f44997daa3912b8d86c0f4e4b36ca92f..8cdfb8ded7ef17d758ee34c22b4d1664f227b9dc 100644 --- a/src/b32_sitemap.test.ts +++ b/src/b32_sitemap.test.ts @@ -89,11 +89,12 @@ describe("renderSitemap", () => { }); describe("STATIC_PATHS", () => { - test("covers the eleven load-bearing routes from the /goal", () => { + test("covers the load-bearing routes (incl. /goals from goal #1)", () => { expect(STATIC_PATHS).toEqual([ "/", "/blog", "/games", + "/goals", "/leaderboard", "/sama", "/sama/v2", diff --git a/src/b32_sitemap.ts b/src/b32_sitemap.ts index d905402966429f985f60747e3a1d0cc5ba39a30c..502b666ceab1682ee7e404dd1f7a254870348c8f 100644 --- a/src/b32_sitemap.ts +++ b/src/b32_sitemap.ts @@ -17,6 +17,7 @@ export const STATIC_PATHS: ReadonlyArray = [ "/", "/blog", "/games", + "/goals", "/leaderboard", "/sama", "/sama/v2", diff --git a/src/b51_render_layout.ts b/src/b51_render_layout.ts index 852c1a0f37ca57574567b55728dc0774ac834ff8..add70e5572302cd74d404f57a9ebcfff678dcf6b 100644 --- a/src/b51_render_layout.ts +++ b/src/b51_render_layout.ts @@ -11,7 +11,7 @@ import type { Phase } from "./a31_commits.ts"; const STYLE_CSS = "./public/style.css"; const css = await Bun.file(STYLE_CSS).text(); -export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama"; +export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama" | "goals"; export interface PageOptions { title: string; @@ -44,7 +44,7 @@ const navLink = (href: string, label: string, active: boolean): string => { return `${label}`; }; -const nav = (active?: Section): string => ``; +const nav = (active?: Section): string => ``; export const renderPage = async (opts: PageOptions): Promise => { const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false }); diff --git a/src/d21_app.ts b/src/d21_app.ts index 63f41452397b700297ef6f603763a235d2f3fe7f..4809c461e071a7422cf99495b6e02703190c8f3f 100644 --- a/src/d21_app.ts +++ b/src/d21_app.ts @@ -10,6 +10,7 @@ import { import { renderDocsPage } from "./b51_render_docs_layout.ts"; import { listGames, loadGame } from "./a31_games.ts"; import { ALL_POSTS } from "./a31_blog.ts"; +import { ALL_GOALS } from "./a31_goals.ts"; import { ALL_GUIDES } from "./a31_guides.ts"; import { ALL_SAMA } from "./a31_sama.ts"; import { SITE_BASE_URL } from "./a31_site_config.ts"; @@ -46,6 +47,10 @@ import { samaLandingHandler, samaSlugHandler, } from "./d21_handlers_sama.ts"; +import { + goalsLandingHandler, + goalSlugHandler, +} from "./d21_handlers_goals.ts"; import { editPageHandler } from "./d21_handlers_edit.ts"; import { adminListHandler, @@ -201,11 +206,16 @@ export const createApp = (port: number) => Bun.serve({ const guideUrls: SitemapUrl[] = ALL_GUIDES.map((g) => ({ loc: `${SITE_BASE_URL}/guides/${g.slug}`, })); + const goalUrls: SitemapUrl[] = ALL_GOALS.map((g) => ({ + loc: `${SITE_BASE_URL}/goals/${g.slug}`, + lastmod: g.date, + })); const xml = renderSitemap([ ...staticUrls, ...blogUrls, ...samaUrls, ...guideUrls, + ...goalUrls, ]); return new Response(xml, { headers: { @@ -406,6 +416,10 @@ ${rows} "/sama/:slug": samaSlugHandler, + "/goals": goalsLandingHandler, + + "/goals/:slug": goalSlugHandler, + "/games/:kata": async (req) => { const res = await renderKata(req.params.kata); if (res) return res; diff --git a/src/d21_handlers_goals.ts b/src/d21_handlers_goals.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d75972f1caf79fdcaf1b2e347e41e302e88cf8f --- /dev/null +++ b/src/d21_handlers_goals.ts @@ -0,0 +1,100 @@ +// c21 — handlers: /goals index + /goals/:slug detail. Mirrors the +// /sama and /blog index+detail pattern. ALL_GOALS is the registry; +// b32_goals_meta parses the YAML frontmatter on each detail page. + +import { ALL_GOALS, type GoalEntry, type GoalStatus } from "./a31_goals.ts"; +import { parseGoalFrontmatter } from "./b32_goals_meta.ts"; +import { renderDocsPage } from "./b51_render_docs_layout.ts"; +import { htmlResponse, renderNotFound } from "./b51_render_layout.ts"; + +const STATUS_LABEL: Record = { + shipped: "✓ shipped", + pending: "⏳ pending", + abandoned: "✗ abandoned", +}; + +const prLink = (n: number | null): string => + n === null ? "—" : `[#${n}](https://github.com/syntaxai/tdd.md/pull/${n})`; + +const commitLink = (sha: string | null): string => + sha === null ? "—" : `[\`${sha}\`](/GIT/tdd.md/commit/${sha})`; + +const relatedLinks = (slugs: ReadonlyArray): string => + slugs.map((s) => `[${s}](/blog/${s})`).join(" · "); + +const goalsLandingBody = (): string => { + const sorted = [...ALL_GOALS].sort((a, b) => b.date.localeCompare(a.date)); + if (sorted.length === 0) { + return `# goals\n\n*No /goals archived yet.*`; + } + const rows = sorted + .map( + (g: GoalEntry) => + `| ${g.date} | [${g.title}](/goals/${g.slug}) | ${STATUS_LABEL[g.status]} | ${prLink(g.prNumber)} | ${commitLink(g.mergeSha)} |`, + ) + .join("\n"); + return `# goals + +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. + +| date | title | status | PR | commit | +|---|---|---|---|---| +${rows} + +Lookup by merge SHA: \`grep -l "merge_sha: " goals/\`. The frontmatter on every file carries the SHA; the canonical permalink is \`/goals/\`. +`; +}; + +export const goalsLandingHandler = async (): Promise => { + const html = await renderDocsPage({ + title: "goals — tdd.md", + description: + "The /goal slash commands that drove every PR on tdd.md, archived as first-class artifacts in git.", + bodyMarkdown: goalsLandingBody(), + ogPath: "https://tdd.md/goals", + active: "goals", + pathForDocs: "/goals", + editPathOverride: null, + }); + return htmlResponse(html); +}; + +export const goalSlugHandler = async ( + req: { params: { slug: string } }, +): Promise => { + const slug = req.params.slug; + const entry = ALL_GOALS.find((g) => g.slug === slug); + if (!entry) { + const html = await renderNotFound(`/goals/${slug}`); + return htmlResponse(html, 404); + } + const file = Bun.file(`./goals/${slug}.md`); + if (!(await file.exists())) { + const html = await renderNotFound(`/goals/${slug}`); + return htmlResponse(html, 404); + } + const raw = await file.text(); + const parsed = parseGoalFrontmatter(raw); + if (parsed === null) { + const html = await renderNotFound(`/goals/${slug}`); + return htmlResponse(html, 404); + } + + const badges = + `**Status:** ${STATUS_LABEL[entry.status]} · **Date:** ${entry.date} · **PR:** ${prLink(entry.prNumber)} · **Commit:** ${commitLink(entry.mergeSha)}`; + const related = entry.relatedPosts.length === 0 + ? "" + : `\n\n**Related posts:** ${relatedLinks(entry.relatedPosts)}`; + + const body = `# ${entry.title}\n\n${badges}${related}\n\n---\n\n${parsed.body}`; + + const html = await renderDocsPage({ + title: `${entry.title} — tdd.md`, + description: `The /goal command that drove ${entry.title}. Status: ${entry.status}.`, + bodyMarkdown: body, + ogPath: `https://tdd.md/goals/${slug}`, + active: "goals", + pathForDocs: `/goals/${slug}`, + }); + return htmlResponse(html); +};