* fix(#3001): indent continuation lines in serializeChangelog serializeChangelog interpolated bullet bodies verbatim into a single - ${body} (#${pr}) line. Any embedded newline became a column-0 line; parseChangelog's continuation-fold (/^\s+/) didn't pick it up, so flushBullet terminated the bullet early — dropping the continuation text and the (#NNNN) PR trailer (recorded as pr: null). The round-trip property serialize(IR) → parse(text) === IR was false for any body containing \n. Fix: indent continuation lines (body.replace(/\n/g, '\n ')) so the parser folds them correctly. Round-trip test asserts both paragraphs' content AND the PR number survive. * chore(#3001): backfill changeset PR number 3101 --------- Co-authored-by: sim <sim@local>
135 lines
4.9 KiB
JavaScript
135 lines
4.9 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Markdown serializer + parser for the changelog IR. The two are inverses
|
|
* over the well-formed subset; tests assert via round-trip (parse(serialize(ir)))
|
|
* rather than by inspecting serialized text — see CONTRIBUTING.md
|
|
* "Prohibited: Raw Text Matching on Test Outputs".
|
|
*
|
|
* Serialized form (Keep a Changelog):
|
|
*
|
|
* ## [1.42.0] - 2026-05-01
|
|
*
|
|
* ### Fixed
|
|
*
|
|
* - body of the bullet (#NNNN)
|
|
*
|
|
* <priorChangelog appended verbatim>
|
|
*/
|
|
|
|
function serializeChangelog(ir) {
|
|
const lines = [];
|
|
const { version, date } = ir.releaseHeader;
|
|
lines.push(`## [${version}] - ${date}`);
|
|
lines.push('');
|
|
for (const section of ir.sections) {
|
|
lines.push(`### ${section.type}`);
|
|
lines.push('');
|
|
for (const b of section.bullets) {
|
|
// #3001: indent continuation lines so parseChangelog's continuation-fold
|
|
// (/^\s+/) picks them up instead of terminating the bullet at the first
|
|
// blank line. A multi-paragraph body round-trips with content preserved.
|
|
const body = b.body.replace(/\n/g, '\n ');
|
|
lines.push(`- ${body} (#${b.pr})`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
let out = lines.join('\n');
|
|
if (ir.priorChangelog) {
|
|
out += '\n' + ir.priorChangelog;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Inverse parser: extracts the structured releases from a CHANGELOG.md
|
|
* text. Returns { releases: [{ version, date, sections: [{ type, bullets:
|
|
* [{ pr, body }] }] }] }. Tolerates the actual repo's CHANGELOG dialect.
|
|
*
|
|
* Multi-line bullets are supported: a bullet opens on a line starting with
|
|
* `- ` and continues on lines starting with two or more spaces (or a tab).
|
|
* The `(#NNNN)` PR trailer may appear on any continuation line. Single-line
|
|
* bullets (entire entry on one `- ` line) are still handled as before.
|
|
*
|
|
* Fix for #3496: the previous implementation only matched single-line bullets
|
|
* whose `(#NNNN)` suffix was on the same line as the opening `- `. Long
|
|
* bullets — which wrap onto indented continuation lines — returned 0 entries
|
|
* for their section even when the markdown was well-formed.
|
|
*/
|
|
function parseChangelog(text) {
|
|
const releases = [];
|
|
const lines = text.split(/\r?\n/);
|
|
let cur = null;
|
|
let curSection = null;
|
|
// Accumulates lines belonging to the current in-flight bullet (may span
|
|
// multiple lines). Flushed when a new block-level element is encountered.
|
|
let bulletLines = null;
|
|
|
|
function flushBullet() {
|
|
if (bulletLines === null || !curSection) return;
|
|
const joined = bulletLines.join(' ').trim();
|
|
// Locate the (# pr) trailer anywhere in the joined text. The trailer is
|
|
// expected to be at the very end, but we tolerate trailing whitespace.
|
|
const trailMatch = joined.match(/^(.*?)\s*\(#(\d+)\)\s*$/);
|
|
if (trailMatch) {
|
|
curSection.bullets.push({ body: trailMatch[1].trim(), pr: Number(trailMatch[2]) });
|
|
} else {
|
|
// Bullet has no PR trailer — preserve it with pr: null so callers
|
|
// (e.g. cmdExtract) do not silently drop authored content.
|
|
curSection.bullets.push({ body: joined, pr: null });
|
|
}
|
|
bulletLines = null;
|
|
}
|
|
|
|
for (const line of lines) {
|
|
// F3: match linked headers: ## [1.42.1](url) - 2026-05-15
|
|
// The (?:\([^)]*\))? group skips an optional (url) after the closing ]
|
|
// before looking for the optional date suffix.
|
|
// F6: strip a leading `v` from the captured version so `## [v1.0.0]`
|
|
// parses as version "1.0.0" instead of "v1.0.0".
|
|
const releaseMatch = line.match(/^##\s+\[([^\]]+)\](?:\([^)]*\))?\s*(?:-\s*(\S+))?/);
|
|
if (releaseMatch) {
|
|
flushBullet();
|
|
const rawVersion = releaseMatch[1];
|
|
const version = rawVersion.replace(/^v/, '');
|
|
cur = { version, date: releaseMatch[2] || null, sections: [] };
|
|
curSection = null;
|
|
releases.push(cur);
|
|
continue;
|
|
}
|
|
if (!cur) continue;
|
|
const sectionMatch = line.match(/^###\s+(.+?)\s*$/);
|
|
if (sectionMatch) {
|
|
flushBullet();
|
|
curSection = { type: sectionMatch[1], bullets: [] };
|
|
cur.sections.push(curSection);
|
|
continue;
|
|
}
|
|
if (!curSection) continue;
|
|
|
|
// New bullet: line begins with `- ` (after optional leading spaces that
|
|
// would indicate a nested list — we only handle top-level bullets here).
|
|
if (/^-\s+/.test(line)) {
|
|
flushBullet();
|
|
bulletLines = [line.replace(/^-\s+/, '')];
|
|
continue;
|
|
}
|
|
|
|
// Continuation line: any indentation (F7: relaxed from /^[ \t]{2}/ so that
|
|
// 1-space-indented continuations also fold) BUT NOT a nested bullet marker
|
|
// (F4: ` - nested item` terminates the current bullet rather than folding).
|
|
if (bulletLines !== null && /^\s+/.test(line) && !/^\s+-\s/.test(line)) {
|
|
bulletLines.push(line.trim());
|
|
continue;
|
|
}
|
|
|
|
// Any other line (blank, heading, nested bullet, etc.) terminates a pending bullet.
|
|
flushBullet();
|
|
}
|
|
flushBullet();
|
|
|
|
return { releases };
|
|
}
|
|
|
|
module.exports = { serializeChangelog, parseChangelog };
|