/goal pending: blog-date-prefix
Data-driven URL refactor — portability-boundary test of the b32_<old>_url_redirect pattern. Helper imports ALL_POSTS and computes new URL from each post's date field. Clock from this commit; postmortem predicts ≤30 min. Co-Authored-By: Claude Opus 4.7 <[email protected]>
1 file changed · +71 −0
goals/blog-date-prefix.md
+71
−0
| @@ -0,0 +1,71 @@ | ||
| 1 | +--- | |
| 2 | +slug: blog-date-prefix | |
| 3 | +title: Move /blog/<slug> → /blog/<yyyy-mm>/<slug> — data-driven refactor portability test | |
| 4 | +date: 2026-05-25 | |
| 5 | +branch: blog-date-prefix | |
| 6 | +pr_number: null | |
| 7 | +merge_sha: null | |
| 8 | +status: pending | |
| 9 | +related_posts: [sama-v2-second-url-refactor-postmortem] | |
| 10 | +--- | |
| 11 | + | |
| 12 | +Goal: Execute the THIRD URL refactor under the b32_<old>_url_redirect pattern — this time DATA-DRIVEN. Move every blog post URL from /blog/<slug> to /blog/<yyyy-mm>/<slug>, 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. | |
| 13 | + | |
| 14 | +Done when: | |
| 15 | +- All blog post URLs work under the new shape: | |
| 16 | + * /blog/2026-05/sama-v2-second-url-refactor-postmortem → 200 | |
| 17 | + * /blog/2026-05/sama-v2-on-ramp-gap → 200 | |
| 18 | + * ... (every entry in ALL_POSTS, ~28 posts) | |
| 19 | +- Old URLs are permanent redirects: | |
| 20 | + * curl -I https://tdd.md/blog/sama-v2-on-ramp-gap → HTTP/2 301, location: /blog/2026-05/sama-v2-on-ramp-gap | |
| 21 | + * Same shape for every existing post slug | |
| 22 | +- The Layer-1 helper at src/b32_blog_date_url_redirect.ts IS data-driven (not a fixed enum): | |
| 23 | + import { ALL_POSTS } from "./a31_blog.ts"; | |
| 24 | + const OLD_PATTERN = /^\/blog\/([a-z0-9-]+)$/; | |
| 25 | + export const rewriteOldBlogUrl = (pathname): string | null => { | |
| 26 | + const m = OLD_PATTERN.exec(pathname); | |
| 27 | + if (m === null) return null; | |
| 28 | + const slug = m[1]!; | |
| 29 | + const post = ALL_POSTS.find((p) => p.slug === slug); | |
| 30 | + if (post === undefined) return null; | |
| 31 | + return `/blog/${post.date.slice(0, 7)}/${slug}`; | |
| 32 | + }; | |
| 33 | + 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. | |
| 34 | +- Layer 3 wiring: | |
| 35 | + * src/d21_handlers_fallback.ts: 301 redirect block placed adjacent to the existing rewriteOldGitUrl + rewriteOldSamaDisciplineUrl blocks (third instance of the pattern). | |
| 36 | + * src/d21_app.ts Bun routes: replace "/blog/:slug" with "/blog/:yyyymm/:slug"; the blog landing /blog stays at /blog (no change). | |
| 37 | + * 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. | |
| 38 | + * Sitemap handler in src/d21_app.ts: ALL_POSTS mapping must emit /blog/<yyyy-mm>/<slug> URLs (not /blog/<slug>). | |
| 39 | + * Blog index handler at /blog: row links emit the new URL shape. | |
| 40 | +- 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). | |
| 41 | +- 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. | |
| 42 | +- /sama/v2/verify still reports 7/7 ✓ (anti-fudge). | |
| 43 | +- This goal file (goals/blog-date-prefix.md) flipped from status: pending → shipped in the final commit before deploy, with merge_sha filled. | |
| 44 | +- Deployed; live-verify with curl: | |
| 45 | + * /blog → 200 with HTML index, row links use /blog/<yyyy-mm>/<slug> | |
| 46 | + * /blog/2026-05/sama-v2-on-ramp-gap → 200 with content | |
| 47 | + * /blog/sama-v2-on-ramp-gap → 301 with Location /blog/2026-05/sama-v2-on-ramp-gap | |
| 48 | + * /sitemap.xml | grep -E '/blog/[0-9]{4}-[0-9]{2}/' | wc -l → matches the count of ALL_POSTS | |
| 49 | + * /sama/v2/verify → 7/7 ✓ | |
| 50 | +- 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. | |
| 51 | + | |
| 52 | +Constraints (anti-fudge): | |
| 53 | +- 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. | |
| 54 | +- 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). | |
| 55 | +- The yyyymm extracted from `date` MUST match what the new handler validates against — both compute via `date.slice(0, 7)` for consistency. | |
| 56 | +- Don't change /blog (the landing page) — only /blog/<slug> moves. | |
| 57 | +- 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). | |
| 58 | +- No alias mode — 301 forces consolidation. | |
| 59 | +- Site language English-only. | |
| 60 | +- GitHub flow via flatpak-spawn. | |
| 61 | +- Do NOT change any §4 verifier logic. | |
| 62 | +- PR body MUST include this verbatim /goal under a "## /goal" heading per feedback_goal_authoring_workflow.md. | |
| 63 | + | |
| 64 | +Load-bearing files to read FIRST: | |
| 65 | +- 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) | |
| 66 | +- src/b32_git_url_redirect.ts (the original template — same shape) | |
| 67 | +- src/a31_blog.ts (ALL_POSTS — the data source the helper imports) | |
| 68 | +- src/d21_handlers_fallback.ts (where the new redirect block lands) | |
| 69 | +- src/d21_app.ts (Bun route /blog/:slug to be replaced with /blog/:yyyymm/:slug; sitemap handler ALL_POSTS mapping) | |
| 70 | +- The blog detail handler — find it via grep for "ALL_POSTS.find" or "blogSlugHandler" | |
| 71 | +- /blog/sama-v2-second-url-refactor-postmortem (the hypothesis being tested — both subclaims #1 fixed-enum and #2 data-driven were predicted there) | |