extract-release-notes.mjs 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. #!/usr/bin/env node
  2. /**
  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.
  5. *
  6. * Why: GitHub renders release-note Markdown with GFM hard breaks, so
  7. * every `\n` becomes `<br>`. The CHANGELOG is hard-wrapped at ~75
  8. * chars for readable diffs, which then renders as awkward visible
  9. * line breaks on the release page. This script joins indented
  10. * continuation lines into a single line per bullet so the GFM
  11. * renderer produces clean paragraphs.
  12. *
  13. * Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats
  14. * newlines as spaces there).
  15. *
  16. * Usage:
  17. * extract-release-notes.mjs <version> # read CHANGELOG.md
  18. * extract-release-notes.mjs --stdin # read from stdin (any text)
  19. */
  20. import { readFileSync } from 'fs';
  21. const arg = process.argv[2];
  22. if (!arg) {
  23. console.error('usage: extract-release-notes.mjs <version> | --stdin');
  24. process.exit(1);
  25. }
  26. let block;
  27. if (arg === '--stdin') {
  28. block = readFileSync(0, 'utf8').replace(/\r\n?/g, '\n').split('\n');
  29. } else {
  30. const version = arg;
  31. const escaped = version.replace(/\./g, '\\.');
  32. const headerRe = new RegExp(`^## \\[${escaped}\\]`);
  33. const anyHeaderRe = /^## \[/;
  34. const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n');
  35. const start = lines.findIndex((l) => headerRe.test(l));
  36. if (start === -1) {
  37. console.error(`no '## [${version}]' entry found in CHANGELOG.md`);
  38. process.exit(1);
  39. }
  40. const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l));
  41. block = lines.slice(start, after === -1 ? lines.length : after);
  42. }
  43. // Track a stack of `{ indent: number }` frames so a continuation line
  44. // can attach to the right ancestor. Handles the post-nested-list
  45. // continuation pattern:
  46. //
  47. // - top-level
  48. // - nested
  49. // back to top-level <- 2-space indent, joins the top-level bullet
  50. const out = [];
  51. let buf = '';
  52. let stack = [];
  53. function flushBuf() {
  54. if (buf !== '') {
  55. out.push(buf);
  56. buf = '';
  57. }
  58. }
  59. function leadingSpaces(s) {
  60. const m = s.match(/^(\s*)/);
  61. return m ? m[1].length : 0;
  62. }
  63. // Bullets: `-`, `*`, `digit.` only. `+` is intentionally excluded — the
  64. // CHANGELOG uses literal `+` inline (`config + instructions`) and we
  65. // don't want to misread those as nested bullets.
  66. const listItemRe = /^(\s*)([-*]|\d+\.)\s+/;
  67. const fenceRe = /^\s*```/;
  68. let inFence = false;
  69. for (const line of block) {
  70. // Fenced code blocks: pass through verbatim, no joining.
  71. if (fenceRe.test(line)) {
  72. flushBuf();
  73. stack = [];
  74. out.push(line);
  75. inFence = !inFence;
  76. continue;
  77. }
  78. if (inFence) {
  79. out.push(line);
  80. continue;
  81. }
  82. if (/^\s*$/.test(line)) {
  83. flushBuf();
  84. out.push('');
  85. continue;
  86. }
  87. if (/^#/.test(line)) {
  88. flushBuf();
  89. stack = [];
  90. out.push(line);
  91. continue;
  92. }
  93. const itemMatch = line.match(listItemRe);
  94. if (itemMatch) {
  95. flushBuf();
  96. const indent = itemMatch[1].length;
  97. while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
  98. stack.pop();
  99. }
  100. stack.push({ indent });
  101. buf = line;
  102. continue;
  103. }
  104. if (/^\s/.test(line)) {
  105. const indent = leadingSpaces(line);
  106. while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
  107. flushBuf();
  108. stack.pop();
  109. }
  110. const trimmed = line.replace(/^\s+/, '');
  111. buf = buf === '' ? trimmed : `${buf} ${trimmed}`;
  112. continue;
  113. }
  114. flushBuf();
  115. stack = [];
  116. out.push(line);
  117. }
  118. flushBuf();
  119. process.stdout.write(out.join('\n'));
  120. if (!out[out.length - 1]?.endsWith('\n')) process.stdout.write('\n');