Files
SubMiner/src/core/utils/changelog-parse.ts
T
sudacode 4e8abcc25e fix(overlay): fix changelog modal keyboard nav and version parsing
- Extract shared modal-focus-guard module reused by changelog and session-help modals
- Only fold the selected changelog entry on Enter/Space; leave close/refresh buttons and nested folds to their own activation
- Stop re-stealing focus after a mouse click already selected an entry
- Parse prerelease and build metadata separately in version headings (e.g. 0.15.0-rc.1+build.2)
- Resolve tray menu test assertions by label instead of index
2026-08-05 02:48:10 -07:00

129 lines
4.0 KiB
TypeScript

import type { ChangelogEntry, ChangelogItem, ChangelogSection } from '../../types/changelog';
// Prerelease and build metadata are matched separately: a single `[-+]`-led
// group cannot span `-rc.1+build.2`, and an unmatched heading silently folds
// that release's notes into the previous entry.
const VERSION_HEADING =
/^##\s+v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\s*(?:\(([^)]*)\))?\s*$/;
const SECTION_HEADING = /^###\s+(.+?)\s*$/;
const BULLET = /^(\s*)[-*]\s+(.*)$/;
/**
* Entries are grouped by `major.minor` so the whole current minor line renders
* expanded, matching how docs-site/changelog.md splits current vs previous.
*/
export function resolveChangelogGroupKey(version: string): string {
const match = version.match(/^(\d+)\.(\d+)/);
if (!match) return version;
return `${match[1]}.${match[2]}`;
}
/**
* Parses the repo CHANGELOG.md into version entries. Bullets keep their inline
* markdown and their nesting: older entries group related notes under a bold
* lead bullet with indented children, and flattening them loses that structure.
*/
export function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let entry: ChangelogEntry | null = null;
let section: ChangelogSection | null = null;
let internal = false;
// Open bullets from outermost to innermost, used to place the next bullet.
let openItems: Array<{ indent: number; item: ChangelogItem }> = [];
function startSection(heading: string): void {
section = { heading, items: [], internal };
openItems = [];
entry?.sections.push(section);
}
function addBullet(indent: number, text: string): void {
if (!section) {
// Bullets before any "###" heading (older entries) land in a generic group.
startSection('Changes');
}
const item: ChangelogItem = { text, children: [] };
while (openItems.length > 0 && (openItems[openItems.length - 1]?.indent ?? 0) >= indent) {
openItems.pop();
}
const parent = openItems[openItems.length - 1];
if (parent) {
parent.item.children.push(item);
} else {
section?.items.push(item);
}
openItems.push({ indent, item });
}
function appendContinuation(text: string): void {
const current = openItems[openItems.length - 1];
if (!current) return;
current.item.text = `${current.item.text} ${text}`;
}
for (const rawLine of markdown.split(/\r?\n/)) {
const line = rawLine.trimEnd();
const trimmed = line.trim();
const versionMatch = trimmed.match(VERSION_HEADING);
if (versionMatch) {
const version = versionMatch[1] ?? '';
entry = {
version,
date: versionMatch[2]?.trim() ?? '',
groupKey: resolveChangelogGroupKey(version),
sections: [],
};
entries.push(entry);
section = null;
internal = false;
openItems = [];
continue;
}
if (!entry) continue;
if (trimmed.startsWith('<details')) {
internal = true;
section = null;
openItems = [];
continue;
}
if (trimmed.startsWith('</details')) {
internal = false;
section = null;
openItems = [];
continue;
}
if (trimmed.startsWith('<summary')) continue;
const sectionMatch = trimmed.match(SECTION_HEADING);
if (sectionMatch) {
startSection(sectionMatch[1] ?? '');
continue;
}
const bulletMatch = line.match(BULLET);
if (bulletMatch) {
addBullet((bulletMatch[1] ?? '').length, bulletMatch[2] ?? '');
continue;
}
// An indented non-bullet line continues the bullet above it, including
// across a blank line: that is CommonMark's continuation paragraph, and
// dropping the open bullets here would silently discard the text.
if (!trimmed) {
continue;
}
if (/^\s/.test(line)) {
appendContinuation(trimmed);
}
}
return entries.map((item) => ({
...item,
sections: item.sections.filter((entrySection) => entrySection.items.length > 0),
}));
}