// Generates CHANGELOG.md from frontend/src/lib/releaseNotes.ts — the same entries the app shows // under Release Notes, so a self-hoster deciding whether to upgrade reads the same text an // in-app user does, without having to install the app first (DX-14). // // Why derive rather than hand-write: two lists of "what changed in 0.80.0" WILL drift, and the // in-app one is the one that gets written during a release. This makes the file a build product of // it. RELEASE_NOTES is a plain data literal by design, so it is imported by stripping the single TS // type annotation off the declaration — no transpiler, no dependency. // // node scripts/gen-changelog.mjs # write CHANGELOG.md // node scripts/gen-changelog.mjs --check # exit 1 if CHANGELOG.md is stale (used by `siftlode release`) import { readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const SOURCE = path.join(root, "frontend", "src", "lib", "releaseNotes.ts"); const TARGET = path.join(root, "CHANGELOG.md"); async function loadEntries() { const src = await readFile(SOURCE, "utf8"); const decl = "export const RELEASE_NOTES"; const at = src.indexOf(decl); if (at === -1) { throw new Error(`${SOURCE} no longer declares ${decl} — the generator's assumption broke, fix it rather than hand-writing the changelog.`); } const body = src.slice(at).replace(/^export const RELEASE_NOTES\s*:[^=]*=/, "export const RELEASE_NOTES ="); const mod = await import(`data:text/javascript,${encodeURIComponent(body)}`); if (!Array.isArray(mod.RELEASE_NOTES) || mod.RELEASE_NOTES.length === 0) { throw new Error("RELEASE_NOTES imported as something other than a non-empty array."); } return mod.RELEASE_NOTES; } function section(title, items) { if (!items || items.length === 0) return ""; return `### ${title}\n\n${items.map((line) => `- ${line}`).join("\n")}\n\n`; } function render(entries) { const head = [ "# Changelog", "", "Every released version of Siftlode, newest first.", "", "**Generated from `frontend/src/lib/releaseNotes.ts` by `scripts/gen-changelog.mjs` — do not edit by hand;**", "edit the release notes and re-run the generator.", "", "Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).", "", "", ].join("\n"); const body = entries .map((e) => { const sha = e.sha ? ` (\`${e.sha}\`)` : ""; let out = `## ${e.version} — ${e.date}${sha}\n\n`; if (e.summary) out += `${e.summary}\n\n`; out += section("Features", e.features); out += section("Fixes", e.fixes); out += section("Internal", e.chores); return out; }) .join(""); return `${head}${body}`.replace(/\n{3,}$/, "\n"); } const entries = await loadEntries(); const rendered = render(entries); if (process.argv.includes("--check")) { const current = await readFile(TARGET, "utf8").catch(() => null); if (current !== rendered) { console.error( `CHANGELOG.md is out of date with releaseNotes.ts (newest entry: ${entries[0].version}).\n` + "Run: node scripts/gen-changelog.mjs — then commit the result.", ); process.exit(1); } console.log(`CHANGELOG.md is current (${entries.length} releases, newest ${entries[0].version}).`); } else { await writeFile(TARGET, rendered, "utf8"); console.log(`Wrote CHANGELOG.md — ${entries.length} releases, newest ${entries[0].version}.`); }