blog-date-prefix.md
raw
· source
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_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/<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 ✓
- /blog → 200 with HTML index, row links use /blog/
- 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
fooand another isfoo-bar, replace/blog/foo-barfirst so the subsequent/blog/foosubstitution doesn't accidentally match inside the first one). - The yyyymm extracted from
dateMUST match what the new handler validates against — both compute viadate.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)