// One-off migration: converts content/posts/*.md (legacy Zola posts) into // content/writings//index.md (new schema). Additive only — never // touches content/posts/. Re-runnable: overwrites its own prior output. import fs from "node:fs"; import path from "node:path"; import { parse as parseToml, stringify as stringifyToml, TomlDate, } from "smol-toml"; const root = process.cwd(); const postsDir = path.join(root, "content", "posts"); const writingsDir = path.join(root, "content", "writings"); const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/; function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = path.join(dir, entry.name); if (entry.isDirectory()) return walk(full); return entry.isFile() && entry.name.endsWith(".md") ? [full] : []; }); } function slugFor(file) { const rel = path.relative(postsDir, file).split(path.sep).join("/"); return rel.endsWith("/index.md") ? rel.slice(0, -"/index.md".length) : rel.slice(0, -".md".length); } function compact(obj) { const out = {}; for (const [key, value] of Object.entries(obj)) { if (value === undefined || value === null || value === "") continue; if (Array.isArray(value) && value.length === 0) continue; if ( typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) ) { const nested = compact(value); if (Object.keys(nested).length === 0) continue; out[key] = nested; continue; } out[key] = value; } return out; } function normalizeTag(tag) { return tag.trim().toLowerCase().replace(/\s+/g, "-"); } function toIsoDate(value) { if (value instanceof Date) return new TomlDate(value.toISOString().slice(0, 10)); if (typeof value === "string") return new TomlDate(value.slice(0, 10)); return undefined; } /** Converts the one unambiguous Zola shortcode in use (youtube embeds) to a * plain HTML iframe, and strips Zola's `{% raw %}`/`{% endraw %}` escape * tags (meaningless now — there's no template engine processing the body). * Anything else Zola-specific (note/tip/warning/danger callouts) is left * as-is: those need real Astro components before they can be translated, * so they're flagged for manual follow-up instead of guessed at. */ function migrateBody(body) { const flags = []; const out = body .replace(/\{%\s*raw\s*%\}\r?\n?/g, "") .replace(/\{%\s*endraw\s*%\}\r?\n?/g, "") .replace(/\{\{<\s*youtube\s+id="([^"]+)"[\s\S]*?\/>\s*\}\}/g, (_m, id) => { return ``; }); if (/\{%\s*?\s*%\}/.test(out)) { flags.push("callout-shortcode"); } if (/\{\{|\{%/.test(out)) { flags.push("unresolved-template-syntax"); } return { body: out, flags }; } const files = walk(postsDir).filter((f) => path.basename(f) !== "_index.md"); fs.mkdirSync(writingsDir, { recursive: true }); let migrated = 0; const flaggedFiles = []; const migratedSlugs = []; for (const file of files) { const raw = fs.readFileSync(file, "utf8"); const match = raw.match(FRONTMATTER_RE); if (!match) continue; const [, fmSource, body] = match; const fm = parseToml(fmSource); const slug = slugFor(file); const { body: newBody, flags } = migrateBody(body.trim()); if (flags.length) flaggedFiles.push({ slug, flags }); const frontmatter = stringifyToml( compact({ title: fm.title, description: fm.description, author: fm.author, date: toIsoDate(fm.date), updated: toIsoDate(fm.updated), draft: fm.draft ?? false, canonical_url: fm.canonical_url, taxonomies: { tags: (fm.taxonomies?.tags ?? []).map(normalizeTag) }, extra: { series: fm.extra?.series, series_index: fm.extra?.series_index, llms_description: fm.extra?.llms_description, }, }), ); const outDir = path.join(writingsDir, slug); fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync( path.join(outDir, "index.md"), `+++\n${frontmatter}\n+++\n\n${newBody}\n`, ); migrated += 1; migratedSlugs.push(slug); } // Keep old /posts// URLs alive (RSS subscribers, inbound links) by // redirecting to the new /writings// path. Regenerated idempotently // inside marker comments so re-running this script doesn't duplicate or // clobber the rest of sws.toml. const swsPath = path.join(root, "sws.toml"); const BEGIN = "# BEGIN migrated-posts-redirects (generated by scripts/migrate-posts-to-writings.mjs)"; const END = "# END migrated-posts-redirects"; const redirectBlock = [ BEGIN, ...migratedSlugs .sort() .flatMap((slug) => [ "[[advanced.redirects]]", `source = "/posts/${slug}/"`, `destination = "/writings/${slug}/"`, "kind = 301", "", ]), END, ] .join("\n") .trimEnd(); function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } const swsSource = fs.readFileSync(swsPath, "utf8"); const markerRe = new RegExp( `${escapeRegExp(BEGIN)}[\\s\\S]*?${escapeRegExp(END)}`, ); const updatedSws = markerRe.test(swsSource) ? swsSource.replace(markerRe, redirectBlock) : `${swsSource.trimEnd()}\n\n${redirectBlock}\n`; fs.writeFileSync(swsPath, `${updatedSws.trimEnd()}\n`); console.log( `Migrated ${migrated} post(s) from content/posts/ to content/writings/.`, ); console.log(`Wrote ${migratedSlugs.length} redirect(s) to sws.toml.`); if (flaggedFiles.length) { console.log( "\nNeeds manual follow-up (Zola-specific syntax with no Astro equivalent yet):", ); for (const { slug, flags } of flaggedFiles) { console.log(` - ${slug}: ${flags.join(", ")}`); } }