diff --git a/scripts/extract-release-notes.mjs b/scripts/extract-release-notes.mjs index 3bcf7f3..b909bcd 100755 --- a/scripts/extract-release-notes.mjs +++ b/scripts/extract-release-notes.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Extract a release-notes block from CHANGELOG.md for a given version, - * then unwrap hard-wrapped paragraphs. + * Extract a release-notes block from CHANGELOG.md for a given version + * (or unwrap text supplied on stdin), then join hard-wrapped paragraphs. * * Why: GitHub renders release-note Markdown with GFM hard breaks, so * every `\n` becomes `
`. The CHANGELOG is hard-wrapped at ~75 @@ -13,45 +13,47 @@ * Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats * newlines as spaces there). * - * Usage: extract-release-notes.mjs - * e.g. extract-release-notes.mjs 0.7.10 + * Usage: + * extract-release-notes.mjs # read CHANGELOG.md + * extract-release-notes.mjs --stdin # read from stdin (any text) */ import { readFileSync } from 'fs'; -const version = process.argv[2]; -if (!version) { - console.error('usage: extract-release-notes.mjs '); +const arg = process.argv[2]; +if (!arg) { + console.error('usage: extract-release-notes.mjs | --stdin'); process.exit(1); } -const escaped = version.replace(/\./g, '\\.'); -const headerRe = new RegExp(`^## \\[${escaped}\\]`); -const anyHeaderRe = /^## \[/; - -const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n'); -const start = lines.findIndex((l) => headerRe.test(l)); -if (start === -1) { - console.error(`no '## [${version}]' entry found in CHANGELOG.md`); - process.exit(1); +let block; +if (arg === '--stdin') { + block = readFileSync(0, 'utf8').replace(/\r\n?/g, '\n').split('\n'); +} else { + const version = arg; + const escaped = version.replace(/\./g, '\\.'); + const headerRe = new RegExp(`^## \\[${escaped}\\]`); + const anyHeaderRe = /^## \[/; + const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n'); + const start = lines.findIndex((l) => headerRe.test(l)); + if (start === -1) { + console.error(`no '## [${version}]' entry found in CHANGELOG.md`); + process.exit(1); + } + const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l)); + block = lines.slice(start, after === -1 ? lines.length : after); } -const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l)); -const block = lines.slice(start, after === -1 ? lines.length : after); -// Find the indent of the most recent list item; a continuation line -// whose indent is GREATER than that belongs to that item, otherwise -// it might belong to an ancestor item further up the stack. -// -// Track a stack of `{ indent: number }` frames so we can attach a -// continuation to the right ancestor. This correctly handles the -// post-nested-list continuation pattern: +// Track a stack of `{ indent: number }` frames so a continuation line +// can attach to the right ancestor. Handles the post-nested-list +// continuation pattern: // // - top-level // - nested // back to top-level <- 2-space indent, joins the top-level bullet const out = []; -let buf = ''; // pending list-item text being built -let stack = []; // [{ indent: number }] open list items +let buf = ''; +let stack = []; function flushBuf() { if (buf !== '') { @@ -65,9 +67,27 @@ function leadingSpaces(s) { return m ? m[1].length : 0; } -const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/; +// Bullets: `-`, `*`, `digit.` only. `+` is intentionally excluded — the +// CHANGELOG uses literal `+` inline (`config + instructions`) and we +// don't want to misread those as nested bullets. +const listItemRe = /^(\s*)([-*]|\d+\.)\s+/; +const fenceRe = /^\s*```/; + +let inFence = false; for (const line of block) { + // Fenced code blocks: pass through verbatim, no joining. + if (fenceRe.test(line)) { + flushBuf(); + stack = []; + out.push(line); + inFence = !inFence; + continue; + } + if (inFence) { + out.push(line); + continue; + } if (/^\s*$/.test(line)) { flushBuf(); out.push(''); @@ -91,13 +111,8 @@ for (const line of block) { continue; } if (/^\s/.test(line)) { - // Continuation. Pop any list frames deeper than this indent — the - // continuation belongs to the nearest enclosing list item. const indent = leadingSpaces(line); while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { - // Closes the deeper item — its buffered text is already in `buf` - // belonging to the most recent flush. We need to flush before - // re-buffering for the ancestor item. flushBuf(); stack.pop(); } @@ -105,7 +120,6 @@ for (const line of block) { buf = buf === '' ? trimmed : `${buf} ${trimmed}`; continue; } - // Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`) flushBuf(); stack = []; out.push(line);