// Parses the project CHANGELOG.md (imported raw at build time) into structured // data so the "What's New" UI can render it nicely instead of dumping markdown. // Keeping the changelog as the single source of truth means there's no separate // per-release copy to maintain — whatever ships in CHANGELOG.md is what users see. import changelogRaw from "../CHANGELOG.md?raw"; export interface ChangelogItem { /** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */ lead: string | null; /** The remaining descriptive text. May still contain inline `code` / **bold** markers. */ body: string; } export interface ChangelogSection { /** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */ title: string; items: ChangelogItem[]; } export interface ChangelogEntry { version: string; date: string | null; sections: ChangelogSection[]; } // "## [0.1.1] — 2026-06-23" / "## [Unreleased]" const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/; // "### Added" const SECTION_HEADING = /^###\s+(.+?)\s*$/; // "- bullet text" const BULLET = /^[-*]\s+(.*)$/; // Leading "**Title**" optionally followed by an em dash, used as the item's lead-in. // (Item text is whitespace-collapsed before matching, so no dotAll flag needed.) const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/; function toItem(text: string): ChangelogItem { const collapsed = text.replace(/\s+/g, " ").trim(); const match = collapsed.match(LEAD); if (match) { return { lead: match[1].trim(), body: match[2].trim() }; } return { lead: null, body: collapsed }; } function parseChangelog(raw: string): ChangelogEntry[] { const lines = raw.split(/\r?\n/); const entries: ChangelogEntry[] = []; let entry: ChangelogEntry | null = null; let section: ChangelogSection | null = null; let buffer: string[] = []; const flushItem = () => { if (section && buffer.length > 0) { section.items.push(toItem(buffer.join(" "))); } buffer = []; }; for (const line of lines) { const versionMatch = line.match(VERSION_HEADING); if (versionMatch) { flushItem(); section = null; entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] }; entries.push(entry); continue; } // Stop collecting once we leave the changelog body (e.g. link-reference defs at EOF). if (!entry) continue; const sectionMatch = line.match(SECTION_HEADING); if (sectionMatch) { flushItem(); section = { title: sectionMatch[1].trim(), items: [] }; entry.sections.push(section); continue; } const bulletMatch = line.match(BULLET); if (bulletMatch) { flushItem(); buffer.push(bulletMatch[1]); continue; } if (line.trim() === "") { // A blank line ends a (possibly wrapped) multi-line bullet. flushItem(); continue; } // Indented continuation of the current wrapped bullet. if (buffer.length > 0) { buffer.push(line.trim()); } } flushItem(); return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) })); } const ENTRIES = parseChangelog(changelogRaw); export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null { if (!version) return null; // Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so // dev/UI-lab builds still resolve to the correct changelog entry. const normalized = version.replace(/^v/, "").replace(/-[a-z].*/i, ""); // Never surface the in-progress [Unreleased] section to users. if (normalized.toLowerCase() === "unreleased") return null; return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null; }