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);