The release body markdown, if any.
Sections in document order; empty when there's nothing to show.
parseReleaseNotes("### Added\n\n- Options page\n- Advanced mode\n");
// [{ title: "Added", items: ["Options page", "Advanced mode"] }]
export function parseReleaseNotes(body: string | undefined): ReleaseSection[] {
if (!body) return [];
const sections: ReleaseSection[] = [];
let current: ReleaseSection | undefined;
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim();
// GitHub appends this to generated notes; it's noise in the modal.
if (/^\*{0,2}Full Changelog\*{0,2}\s*:/i.test(line)) continue;
const heading = /^#{2,4}\s+(.*\S)\s*$/.exec(line);
if (heading) {
current = { title: stripMarkdown(heading[1]), items: [] };
sections.push(current);
continue;
}
const bullet = /^[-*+]\s+(.*)$/.exec(line);
if (bullet) {
const item = stripMarkdown(bullet[1]);
if (!item) continue;
current ??= { items: [] };
if (sections.at(-1) !== current) sections.push(current);
if (current.items.length < MAX_NOTE_ITEMS_PER_SECTION) {
current.items.push(item.slice(0, MAX_NOTE_ITEM_LENGTH));
}
continue;
}
// A wrapped continuation of the previous bullet.
const items = current?.items;
if (line && items && items.length > 0) {
const merged = `${items[items.length - 1]} ${stripMarkdown(line)}`.trim();
items[items.length - 1] = merged.slice(0, MAX_NOTE_ITEM_LENGTH);
}
}
return sections.filter((section) => section.items.length > 0).slice(0, MAX_NOTE_SECTIONS);
}
Parses a GitHub release body into headed groups of bullets for display. Recognizes
##/###headings and-/*/+bullets, ignoring prose, and drops the trailing "Full Changelog" link GitHub appends. Continuation lines of a wrapped bullet are folded into that bullet.