// The in-app "What's new" notes (src/release-notes/<version>.json), written// from a release's lead (.github/release-notes/v<version>.md) when// bump-version.mjs bumps to it.//// The lead is the maintainer's: "## Highlights since …" with one// `- **Title.** Body` item per highlight, then "## Notable fixes" with one// `- …` item per fix. The app shows them in the interface language, so each// text carries `en` and `zh`; a rewrite keeps the Chinese of every English// text that did not change, and leaves the rest empty for the maintainer to// translate (src/release-notes/release-notes.test.ts refuses an empty one).// A highlight may also name an `icon` (whats-new-dialog.tsx lists them);// a rewrite keeps it for an unchanged title, and the sheet draws a plain// sparkle for one without.//// Usage: node scripts/release-notes.mjs <version> [YYYY-MM-DD] import { existsSync, readFileSync, writeFileSync } from "node:fs";import { join } from "node:path";import { isMain, projectRoot } from "./lib/util.mjs"; /** Markdown down to the plain text the sheet shows: no emphasis markers or code ticks. */const plain = (text) => text.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1").trim(); /** A lead's highlights and fixes, in English. */export function parseReleaseLead(markdown) { const highlights = []; const fixes = []; let section = null; for (const line of markdown.split("\n")) { const heading = /^##\s+(.*)$/.exec(line); if (heading) { section = /^highlights/i.test(heading[1]) ? "highlights" : /fix/i.test(heading[1]) ? "fixes" : null; continue; } const item = /^-\s+(.*)$/.exec(line); if (!item || !section) continue; if (section === "fixes") { fixes.push(plain(item[1])); continue; } // `**Title.** Body`: the bold run is the title, without its closing stop. const titled = /^\*\*(.+?)\*\*\s*(.*)$/.exec(item[1]); const title = plain(titled ? titled[1] : item[1]).replace(/[.:]$/, ""); highlights.push({ title, body: titled ? plain(titled[2]) : "" }); } return { highlights, fixes };} /** The notes for `version`, keeping `previous`'s Chinese wherever the English is unchanged. */export function releaseNotes(version, date, lead, previous = null) { const zh = new Map(); const icons = new Map(); const remember = (text) => { if (text?.en && text.zh) zh.set(text.en, text.zh); }; for (const note of previous?.highlights ?? []) { remember(note.title); remember(note.body); if (note.icon) icons.set(note.title.en, note.icon); } for (const fix of previous?.fixes ?? []) remember(fix); const bilingual = (en) => ({ en, zh: zh.get(en) ?? "" }); return { version, date, highlights: lead.highlights.map(({ title, body }) => ({ ...(icons.has(title) ? { icon: icons.get(title) } : {}), title: bilingual(title), ...(body ? { body: bilingual(body) } : {}), })), fixes: lead.fixes.map(bilingual), };} /** * Write src/release-notes/<version>.json from its lead. Returns the path and * how many texts still need Chinese, or null when the release has no lead. */export function writeReleaseNotes(version, date, root = projectRoot) { const leadPath = join(root, ".github/release-notes", `v${version}.md`); if (!existsSync(leadPath)) return null; const notesPath = join(root, "src/release-notes", `${version}.json`); const previous = existsSync(notesPath) ? JSON.parse(readFileSync(notesPath, "utf8")) : null; const notes = releaseNotes(version, date, parseReleaseLead(readFileSync(leadPath, "utf8")), previous); writeFileSync(notesPath, `${JSON.stringify(notes, null, 2)}\n`); const texts = [...notes.highlights.flatMap((note) => [note.title, note.body].filter(Boolean)), ...notes.fixes]; return { path: notesPath, untranslated: texts.filter((text) => !text.zh).length };} if (isMain(import.meta.url)) { const [version, date = new Date().toISOString().slice(0, 10)] = process.argv.slice(2); if (!/^\d+\.\d+\.\d+$/.test(version ?? "")) throw new Error("Usage: node scripts/release-notes.mjs <x.y.z> [YYYY-MM-DD]"); const written = writeReleaseNotes(version, date); if (!written) throw new Error(`No .github/release-notes/v${version}.md to write the notes from.`); console.log(`Wrote ${written.path}; ${written.untranslated} text(s) need Chinese.`);}
// The in-app "What's new" notes (src/release-notes/<version>.json), written
// from a release's lead (.github/release-notes/v<version>.md) when
// bump-version.mjs bumps to it.
//
// The lead is the maintainer's: "## Highlights since …" with one
// `- **Title.** Body` item per highlight, then "## Notable fixes" with one
// `- …` item per fix. The app shows them in the interface language, so each
// text carries `en` and `zh`; a rewrite keeps the Chinese of every English
// text that did not change, and leaves the rest empty for the maintainer to
// translate (src/release-notes/release-notes.test.ts refuses an empty one).
// A highlight may also name an `icon` (whats-new-dialog.tsx lists them);
// a rewrite keeps it for an unchanged title, and the sheet draws a plain
// sparkle for one without.
//
// Usage: node scripts/release-notes.mjs <version> [YYYY-MM-DD]
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { isMain, projectRoot } from "./lib/util.mjs";
/** Markdown down to the plain text the sheet shows: no emphasis markers or code ticks. */
const plain = (text) => text.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1").trim();
/** A lead's highlights and fixes, in English. */
export function parseReleaseLead(markdown) {
const highlights = [];
const fixes = [];
let section = null;
for (const line of markdown.split("\n")) {
const heading = /^##\s+(.*)$/.exec(line);
if (heading) {
section = /^highlights/i.test(heading[1]) ? "highlights" : /fix/i.test(heading[1]) ? "fixes" : null;
continue;
}
const item = /^-\s+(.*)$/.exec(line);
if (!item || !section) continue;
if (section === "fixes") {
fixes.push(plain(item[1]));
continue;
}
// `**Title.** Body`: the bold run is the title, without its closing stop.
const titled = /^\*\*(.+?)\*\*\s*(.*)$/.exec(item[1]);
const title = plain(titled ? titled[1] : item[1]).replace(/[.:]$/, "");
highlights.push({ title, body: titled ? plain(titled[2]) : "" });
}
return { highlights, fixes };
}
/** The notes for `version`, keeping `previous`'s Chinese wherever the English is unchanged. */
export function releaseNotes(version, date, lead, previous = null) {
const zh = new Map();
const icons = new Map();
const remember = (text) => { if (text?.en && text.zh) zh.set(text.en, text.zh); };
for (const note of previous?.highlights ?? []) {
remember(note.title);
remember(note.body);
if (note.icon) icons.set(note.title.en, note.icon);
}
for (const fix of previous?.fixes ?? []) remember(fix);
const bilingual = (en) => ({ en, zh: zh.get(en) ?? "" });
return {
version,
date,
highlights: lead.highlights.map(({ title, body }) => ({
...(icons.has(title) ? { icon: icons.get(title) } : {}),
title: bilingual(title),
...(body ? { body: bilingual(body) } : {}),
})),
fixes: lead.fixes.map(bilingual),
};
}
/**
* Write src/release-notes/<version>.json from its lead. Returns the path and
* how many texts still need Chinese, or null when the release has no lead.
*/
export function writeReleaseNotes(version, date, root = projectRoot) {
const leadPath = join(root, ".github/release-notes", `v${version}.md`);
if (!existsSync(leadPath)) return null;
const notesPath = join(root, "src/release-notes", `${version}.json`);
const previous = existsSync(notesPath) ? JSON.parse(readFileSync(notesPath, "utf8")) : null;
const notes = releaseNotes(version, date, parseReleaseLead(readFileSync(leadPath, "utf8")), previous);
writeFileSync(notesPath, `${JSON.stringify(notes, null, 2)}\n`);
const texts = [...notes.highlights.flatMap((note) => [note.title, note.body].filter(Boolean)), ...notes.fixes];
return { path: notesPath, untranslated: texts.filter((text) => !text.zh).length };
}
if (isMain(import.meta.url)) {
const [version, date = new Date().toISOString().slice(0, 10)] = process.argv.slice(2);
if (!/^\d+\.\d+\.\d+$/.test(version ?? "")) throw new Error("Usage: node scripts/release-notes.mjs <x.y.z> [YYYY-MM-DD]");
const written = writeReleaseNotes(version, date);
if (!written) throw new Error(`No .github/release-notes/v${version}.md to write the notes from.`);
console.log(`Wrote ${written.path}; ${written.untranslated} text(s) need Chinese.`);
}