--- slug: blog-date-prefix title: Move /blog/ → /blog// — data-driven refactor portability test date: 2026-05-25 branch: blog-date-prefix pr_number: 55 merge_sha: 72919e8 status: shipped related_posts: [sama-v2-second-url-refactor-postmortem] --- Goal: Execute the THIRD URL refactor under the b32__url_redirect pattern — this time DATA-DRIVEN. Move every blog post URL from /blog/ to /blog//, where the yyyy-mm prefix is computed from each post's `date` field in ALL_POSTS. Inbound links 301 to the new shape. **Primary purpose: portability-boundary test** — PR #53 confirmed cost-flattening for FIXED-ENUM refactors (4 slugs hard-coded in the regex). This /goal tests whether the same pattern works when the new URL depends on per-entity DATA, not a fixed enumeration. The postmortem of PR #53 predicted ≤30 minutes wall-clock for this shape; the clock from `git checkout -b blog-date-prefix` to deploy+live-verify is the measurement. Done when: - All blog post URLs work under the new shape: * /blog/2026-05/sama-v2-second-url-refactor-postmortem → 200 * /blog/2026-05/sama-v2-on-ramp-gap → 200 * ... (every entry in ALL_POSTS, ~28 posts) - Old URLs are permanent redirects: * curl -I https://tdd.md/blog/sama-v2-on-ramp-gap → HTTP/2 301, location: /blog/2026-05/sama-v2-on-ramp-gap * Same shape for every existing post slug - The Layer-1 helper at src/b32_blog_date_url_redirect.ts IS data-driven (not a fixed enum): import { ALL_POSTS } from "./a31_blog.ts"; const OLD_PATTERN = /^\/blog\/([a-z0-9-]+)$/; export const rewriteOldBlogUrl = (pathname): string | null => { const m = OLD_PATTERN.exec(pathname); if (m === null) return null; const slug = m[1]!; const post = ALL_POSTS.find((p) => p.slug === slug); if (post === undefined) return null; return `/blog/${post.date.slice(0, 7)}/${slug}`; }; Sibling test src/b32_blog_date_url_redirect.test.ts covers: matches existing slugs, returns null for unknown slugs, returns null for already-new-form URLs, returns null for non-/blog paths, handles different month dates correctly. - Layer 3 wiring: * src/d21_handlers_fallback.ts: 301 redirect block placed adjacent to the existing rewriteOldGitUrl + rewriteOldSamaDisciplineUrl blocks (third instance of the pattern). * src/d21_app.ts Bun routes: replace "/blog/:slug" with "/blog/:yyyymm/:slug"; the blog landing /blog stays at /blog (no change). * src/d21_handlers_blog.ts (or wherever the blog detail handler lives): /blog/:yyyymm/:slug handler extracts the slug, looks up ALL_POSTS, validates yyyymm matches the post's date prefix (404 if mismatched to prevent spoofing), then renders. * Sitemap handler in src/d21_app.ts: ALL_POSTS mapping must emit /blog// URLs (not /blog/). * Blog index handler at /blog: row links emit the new URL shape. - A migration script at scripts/migrate-blog-urls.ts (or inline in this PR): for each post in ALL_POSTS sorted by slug length DESCENDING (to avoid prefix collisions where one slug is a prefix of another), iterate every .md file in content/ + every .ts file in src/, replace `/blog/` (literal) with `/blog//`. After: grep for `/blog/` not followed by `/` returns 0 in content/ + src/ (excluding helper + helper test + this goal file). - All 419+ tests still pass; new helper test adds 6-8 cases. Tests that hard-code blog URLs in assertions get updated to the new shape mechanically. - /sama/v2/verify still reports 7/7 ✓ (anti-fudge). - This goal file (goals/blog-date-prefix.md) flipped from status: pending → shipped in the final commit before deploy, with merge_sha filled. - Deployed; live-verify with curl: * /blog → 200 with HTML index, row links use /blog// * /blog/2026-05/sama-v2-on-ramp-gap → 200 with content * /blog/sama-v2-on-ramp-gap → 301 with Location /blog/2026-05/sama-v2-on-ramp-gap * /sitemap.xml | grep -E '/blog/[0-9]{4}-[0-9]{2}/' | wc -l → matches the count of ALL_POSTS * /sama/v2/verify → 7/7 ✓ - HYPOTHESIS TEST CLAIM: wall-clock from first commit on branch to live-verify pass is ≤30 minutes. Wall-clock recorded in the postmortem post that follows. Boundary test: confirm pattern portability when data-driven, OR document the cost when it isn't portable. Constraints (anti-fudge): - Helper imports ALL_POSTS from a31_blog.ts (Layer-1 importing Layer-0 is allowed per SAMA). No filesystem reads, no fetch, no I/O — the data is already resolved at module load. - Sort replacements by slug length DESC in the migration to avoid prefix collisions (if one slug is `foo` and another is `foo-bar`, replace `/blog/foo-bar` first so the subsequent `/blog/foo` substitution doesn't accidentally match inside the first one). - The yyyymm extracted from `date` MUST match what the new handler validates against — both compute via `date.slice(0, 7)` for consistency. - Don't change /blog (the landing page) — only /blog/ moves. - If a future post slug ever conflicts with a literal yyyy-mm string (e.g. someone names a slug `2026-05`), the new pattern would 404; out of scope (anti-fudge says don't pre-solve hypotheticals). - No alias mode — 301 forces consolidation. - Site language English-only. - GitHub flow via flatpak-spawn. - Do NOT change any §4 verifier logic. - PR body MUST include this verbatim /goal under a "## /goal" heading per feedback_goal_authoring_workflow.md. Load-bearing files to read FIRST: - src/b32_sama_discipline_url_redirect.ts (the second template — close kin to this one, just with fixed enum where this one has data-lookup) - src/b32_git_url_redirect.ts (the original template — same shape) - src/a31_blog.ts (ALL_POSTS — the data source the helper imports) - src/d21_handlers_fallback.ts (where the new redirect block lands) - src/d21_app.ts (Bun route /blog/:slug to be replaced with /blog/:yyyymm/:slug; sitemap handler ALL_POSTS mapping) - The blog detail handler — find it via grep for "ALL_POSTS.find" or "blogSlugHandler" - /blog/sama-v2-second-url-refactor-postmortem (the hypothesis being tested — both subclaims #1 fixed-enum and #2 data-driven were predicted there)