Move /blog/ → /blog// — data-driven portability test

Status: ✓ shipped · Date: 2026-05-25 · PR: #55 · Commit: 72919e8

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:
  • 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/<slug> (literal) with /blog/<yyyymm>/<slug>. After: grep for /blog/<known-slug> not followed by /<another-segment> 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)