11#!/usr/bin/env node
22/**
3- * Extract a release-notes block from CHANGELOG.md for a given version,
4- * then unwrap hard-wrapped paragraphs.
3+ * Extract a release-notes block from CHANGELOG.md for a given version
4+ * (or unwrap text supplied on stdin), then join hard-wrapped paragraphs.
55 *
66 * Why: GitHub renders release-note Markdown with GFM hard breaks, so
77 * every `\n` becomes `<br>`. The CHANGELOG is hard-wrapped at ~75
1313 * Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats
1414 * newlines as spaces there).
1515 *
16- * Usage: extract-release-notes.mjs <version>
17- * e.g. extract-release-notes.mjs 0.7.10
16+ * Usage:
17+ * extract-release-notes.mjs <version> # read CHANGELOG.md
18+ * extract-release-notes.mjs --stdin # read from stdin (any text)
1819 */
1920
2021import { readFileSync } from 'fs' ;
2122
22- const version = process . argv [ 2 ] ;
23- if ( ! version ) {
24- console . error ( 'usage: extract-release-notes.mjs <version>' ) ;
23+ const arg = process . argv [ 2 ] ;
24+ if ( ! arg ) {
25+ console . error ( 'usage: extract-release-notes.mjs <version> | --stdin ' ) ;
2526 process . exit ( 1 ) ;
2627}
2728
28- const escaped = version . replace ( / \. / g, '\\.' ) ;
29- const headerRe = new RegExp ( `^## \\[${ escaped } \\]` ) ;
30- const anyHeaderRe = / ^ # # \[ / ;
31-
32- const lines = readFileSync ( 'CHANGELOG.md' , 'utf8' ) . split ( '\n' ) ;
33- const start = lines . findIndex ( ( l ) => headerRe . test ( l ) ) ;
34- if ( start === - 1 ) {
35- console . error ( `no '## [${ version } ]' entry found in CHANGELOG.md` ) ;
36- process . exit ( 1 ) ;
29+ let block ;
30+ if ( arg === '--stdin' ) {
31+ block = readFileSync ( 0 , 'utf8' ) . replace ( / \r \n ? / g, '\n' ) . split ( '\n' ) ;
32+ } else {
33+ const version = arg ;
34+ const escaped = version . replace ( / \. / g, '\\.' ) ;
35+ const headerRe = new RegExp ( `^## \\[${ escaped } \\]` ) ;
36+ const anyHeaderRe = / ^ # # \[ / ;
37+ const lines = readFileSync ( 'CHANGELOG.md' , 'utf8' ) . split ( '\n' ) ;
38+ const start = lines . findIndex ( ( l ) => headerRe . test ( l ) ) ;
39+ if ( start === - 1 ) {
40+ console . error ( `no '## [${ version } ]' entry found in CHANGELOG.md` ) ;
41+ process . exit ( 1 ) ;
42+ }
43+ const after = lines . findIndex ( ( l , i ) => i > start && anyHeaderRe . test ( l ) ) ;
44+ block = lines . slice ( start , after === - 1 ? lines . length : after ) ;
3745}
38- const after = lines . findIndex ( ( l , i ) => i > start && anyHeaderRe . test ( l ) ) ;
39- const block = lines . slice ( start , after === - 1 ? lines . length : after ) ;
4046
41- // Find the indent of the most recent list item; a continuation line
42- // whose indent is GREATER than that belongs to that item, otherwise
43- // it might belong to an ancestor item further up the stack.
44- //
45- // Track a stack of `{ indent: number }` frames so we can attach a
46- // continuation to the right ancestor. This correctly handles the
47- // post-nested-list continuation pattern:
47+ // Track a stack of `{ indent: number }` frames so a continuation line
48+ // can attach to the right ancestor. Handles the post-nested-list
49+ // continuation pattern:
4850//
4951// - top-level
5052// - nested
5153// back to top-level <- 2-space indent, joins the top-level bullet
5254const out = [ ] ;
53- let buf = '' ; // pending list-item text being built
54- let stack = [ ] ; // [{ indent: number }] open list items
55+ let buf = '' ;
56+ let stack = [ ] ;
5557
5658function flushBuf ( ) {
5759 if ( buf !== '' ) {
@@ -65,9 +67,27 @@ function leadingSpaces(s) {
6567 return m ? m [ 1 ] . length : 0 ;
6668}
6769
68- const listItemRe = / ^ ( \s * ) ( [ - * + ] | \d + \. ) \s + / ;
70+ // Bullets: `-`, `*`, `digit.` only. `+` is intentionally excluded — the
71+ // CHANGELOG uses literal `+` inline (`config + instructions`) and we
72+ // don't want to misread those as nested bullets.
73+ const listItemRe = / ^ ( \s * ) ( [ - * ] | \d + \. ) \s + / ;
74+ const fenceRe = / ^ \s * ` ` ` / ;
75+
76+ let inFence = false ;
6977
7078for ( const line of block ) {
79+ // Fenced code blocks: pass through verbatim, no joining.
80+ if ( fenceRe . test ( line ) ) {
81+ flushBuf ( ) ;
82+ stack = [ ] ;
83+ out . push ( line ) ;
84+ inFence = ! inFence ;
85+ continue ;
86+ }
87+ if ( inFence ) {
88+ out . push ( line ) ;
89+ continue ;
90+ }
7191 if ( / ^ \s * $ / . test ( line ) ) {
7292 flushBuf ( ) ;
7393 out . push ( '' ) ;
@@ -91,21 +111,15 @@ for (const line of block) {
91111 continue ;
92112 }
93113 if ( / ^ \s / . test ( line ) ) {
94- // Continuation. Pop any list frames deeper than this indent — the
95- // continuation belongs to the nearest enclosing list item.
96114 const indent = leadingSpaces ( line ) ;
97115 while ( stack . length > 1 && stack [ stack . length - 1 ] . indent >= indent ) {
98- // Closes the deeper item — its buffered text is already in `buf`
99- // belonging to the most recent flush. We need to flush before
100- // re-buffering for the ancestor item.
101116 flushBuf ( ) ;
102117 stack . pop ( ) ;
103118 }
104119 const trimmed = line . replace ( / ^ \s + / , '' ) ;
105120 buf = buf === '' ? trimmed : `${ buf } ${ trimmed } ` ;
106121 continue ;
107122 }
108- // Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`)
109123 flushBuf ( ) ;
110124 stack = [ ] ;
111125 out . push ( line ) ;
0 commit comments