syntaxai/tdd.md · main · scripts / migrate-blog-urls.ts

migrate-blog-urls.ts 70 lines · 2734 bytes raw
// One-shot migration: rewrite /blog/<slug> → /blog/<yyyy-mm>/<slug>
// across every .md and .ts file in content/ + src/, using ALL_POSTS
// as the single source of truth for each post's date.
//
// Sorts replacements by slug length DESCENDING so longer slugs
// substitute first — prevents the prefix-collision footgun where
// /blog/foo would match inside /blog/foo-bar before /blog/foo-bar
// got its turn.
//
// Safe to re-run: replacement looks for `/blog/<slug>` literal, and
// after one pass each occurrence becomes `/blog/<yyyymm>/<slug>` —
// which doesn't contain `/blog/<slug>` as a substring (the 7th char
// differs). No double-prefixing.

import { Glob } from "bun";
import { ALL_POSTS } from "../src/a31_blog.ts";

const root = "/var/home/scri/Documents/tdd.md";

const replacements: Array<[string, string]> = ALL_POSTS
  .map((p) => {
    const yyyymm = p.date.slice(0, 7);
    return [`/blog/${p.slug}`, `/blog/${yyyymm}/${p.slug}`] as [string, string];
  })
  // Longest first to avoid /blog/foo matching inside /blog/foo-bar.
  .sort((a, b) => b[0].length - a[0].length);

const targets: string[] = [];
for await (const path of new Glob("content/**/*.md").scan({ cwd: root })) {
  targets.push(path);
}
for await (const path of new Glob("src/**/*.ts").scan({ cwd: root })) {
  // Don't migrate the helper itself or its sibling test — they
  // legitimately reference the old shape (regex pattern + test cases).
  if (path === "src/b32_blog_date_url_redirect.ts") continue;
  if (path === "src/b32_blog_date_url_redirect.test.ts") continue;
  targets.push(path);
}
for await (const path of new Glob("goals/*.md").scan({ cwd: root })) {
  // Don't touch the migrate-historical-goals or this PR's goal file —
  // those describe historical state and would drift.
  if (path === "goals/blog-date-prefix.md") continue;
  targets.push(path);
}

let totalFilesChanged = 0;
let totalReplacements = 0;
for (const target of targets) {
  const fullPath = `${root}/${target}`;
  const original = await Bun.file(fullPath).text();
  let modified = original;
  let fileReplacements = 0;
  for (const [oldStr, newStr] of replacements) {
    const before = modified;
    modified = modified.replaceAll(oldStr, newStr);
    if (modified !== before) {
      // Count how many replacements happened on THIS pair.
      const occurrences = before.split(oldStr).length - 1;
      fileReplacements += occurrences;
    }
  }
  if (modified !== original) {
    await Bun.write(fullPath, modified);
    totalFilesChanged += 1;
    totalReplacements += fileReplacements;
    console.log(`  ${target}: ${fileReplacements} substitutions`);
  }
}
console.log(`\nDone. ${totalFilesChanged} files modified, ${totalReplacements} substitutions.`);