#!/usr/bin/env node
/**
* Extract a release-notes block from CHANGELOG.md for a given version,
* then unwrap hard-wrapped paragraphs.
*
* Why: GitHub renders release-note Markdown with GFM hard breaks, so
* every `\n` becomes `
`. The CHANGELOG is hard-wrapped at ~75
* chars for readable diffs, which then renders as awkward visible
* line breaks on the release page. This script joins indented
* continuation lines into a single line per bullet so the GFM
* renderer produces clean paragraphs.
*
* 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
*/
import { readFileSync } from 'fs';
const version = process.argv[2];
if (!version) {
console.error('usage: extract-release-notes.mjs ');
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);
}
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:
//
// - 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
function flushBuf() {
if (buf !== '') {
out.push(buf);
buf = '';
}
}
function leadingSpaces(s) {
const m = s.match(/^(\s*)/);
return m ? m[1].length : 0;
}
const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/;
for (const line of block) {
if (/^\s*$/.test(line)) {
flushBuf();
out.push('');
continue;
}
if (/^#/.test(line)) {
flushBuf();
stack = [];
out.push(line);
continue;
}
const itemMatch = line.match(listItemRe);
if (itemMatch) {
flushBuf();
const indent = itemMatch[1].length;
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
stack.pop();
}
stack.push({ indent });
buf = line;
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();
}
const trimmed = line.replace(/^\s+/, '');
buf = buf === '' ? trimmed : `${buf} ${trimmed}`;
continue;
}
// Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`)
flushBuf();
stack = [];
out.push(line);
}
flushBuf();
process.stdout.write(out.join('\n'));
if (!out[out.length - 1]?.endsWith('\n')) process.stdout.write('\n');