mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 09:22:53 +00:00
scripts/migrate-posts-to-writings.mjs converts all 20 posts under
content/posts/ into the new writings schema — normalizes dates to
bare TOML local-dates, converts the one unambiguous Zola shortcode in
use (youtube embeds) to a plain <iframe>, and strips the now-meaningless
{% raw %}/{% endraw %} escape tags. content/posts/ itself is untouched;
this only adds content/writings/*.
Also generates 301 redirects from the old /posts/<slug>/ URLs to the
new /writings/<slug>/ ones into sws.toml, inside marker comments so
re-running the script regenerates that block idempotently instead of
duplicating it.
Two posts (from-c-to-machine-code, self-hosting-gitea-and-mirroring-github)
use note/tip/warning/danger callout shortcodes that have no Astro
equivalent yet — flagged by the script rather than guessed at, since
that needs a real component once the design system exists.
Verified: astro check clean, full site builds (80 pages, all real
migrated posts render), redirect generation confirmed idempotent
across repeated runs.
179 lines
5.7 KiB
JavaScript
179 lines
5.7 KiB
JavaScript
// One-off migration: converts content/posts/*.md (legacy Zola posts) into
|
|
// content/writings/<slug>/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 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 `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${id}" title="YouTube video" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
|
|
});
|
|
|
|
if (/\{%\s*<?\/?(note|tip|warning|danger)>?\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 ?? [] },
|
|
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/<slug>/ URLs alive (RSS subscribers, inbound links) by
|
|
// redirecting to the new /writings/<slug>/ 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(", ")}`);
|
|
}
|
|
}
|