Files
phokus/src/changelog.ts
T
LyAhn af1a443a64 feat(ui-lab): preview What's New entries via ?changelog=
Add a UI-Lab-only (mode === "ui", dead-code-eliminated from real builds)
URL override for the What's New modal: ?changelog=unreleased shows the
in-progress notes before they ship, ?changelog=small serves a synthetic
hotfix-sized entry to exercise the compact layout (including a no-lead
bullet), and ?changelog=<version> previews any released entry. Documented
in docs/ui-lab.md alongside the ?scenario= table.
2026-07-04 21:15:17 +01:00

177 lines
5.7 KiB
TypeScript

// 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)
// Synthetic small release for UI Lab (`?changelog=small`). The What's New modal
// switches layout by release size, and every real entry in CHANGELOG.md is
// large — so the compact single-column layout can't be exercised from real
// data. This stays below the modal's rail threshold on purpose.
const SMALL_PREVIEW_ENTRY: ChangelogEntry = {
version: '0.0.0-preview',
date: '2026-01-01',
sections: [
{
title: 'Added',
items: [
{
lead: 'Sample setting',
body: 'a small toggle that exists purely so this preview has an Added section.',
},
],
},
{
title: 'Changed',
items: [
{
lead: 'Snappier previews',
body: 'the preview fixture now loads instantly, because it is made up.',
},
{ lead: null, body: 'A plain full-sentence bullet without a bold lead, for coverage.' },
],
},
{
title: 'Fixed',
items: [
{
lead: 'Hotfix-sized fix',
body: 'a believable one-liner about a bug that never shipped.',
},
{
lead: 'Another small fix',
body: 'keeps the total item count comfortably under the rail threshold.',
},
],
},
],
}
// UI Lab affordance (browser-only `ui` mode): `?changelog=` previews an entry
// other than the running version's, mirroring the `?scenario=` pattern.
// ?changelog=unreleased — the in-progress [Unreleased] notes (large release)
// ?changelog=small — synthetic hotfix-sized entry (compact layout)
// ?changelog=0.1.1 — any specific released version
function previewOverride(): ChangelogEntry | null {
if (import.meta.env.MODE !== 'ui') return null
const preview = new URLSearchParams(window.location.search).get('changelog')
if (!preview) return null
if (preview === 'small') return SMALL_PREVIEW_ENTRY
return ENTRIES.find((e) => e.version.toLowerCase() === preview.toLowerCase()) ?? null
}
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
const override = previewOverride()
if (override) return override
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
}