extract-release-notes.mjs 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. #!/usr/bin/env node
  2. /**
  3. * Extract a release-notes block from CHANGELOG.md for a given version,
  4. * then unwrap 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: extract-release-notes.mjs <version>
  17. * e.g. extract-release-notes.mjs 0.7.10
  18. */
  19. import { readFileSync } from 'fs';
  20. const version = process.argv[2];
  21. if (!version) {
  22. console.error('usage: extract-release-notes.mjs <version>');
  23. process.exit(1);
  24. }
  25. const escaped = version.replace(/\./g, '\\.');
  26. const headerRe = new RegExp(`^## \\[${escaped}\\]`);
  27. const anyHeaderRe = /^## \[/;
  28. const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n');
  29. const start = lines.findIndex((l) => headerRe.test(l));
  30. if (start === -1) {
  31. console.error(`no '## [${version}]' entry found in CHANGELOG.md`);
  32. process.exit(1);
  33. }
  34. const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l));
  35. const block = lines.slice(start, after === -1 ? lines.length : after);
  36. // Find the indent of the most recent list item; a continuation line
  37. // whose indent is GREATER than that belongs to that item, otherwise
  38. // it might belong to an ancestor item further up the stack.
  39. //
  40. // Track a stack of `{ indent: number }` frames so we can attach a
  41. // continuation to the right ancestor. This correctly handles the
  42. // post-nested-list continuation pattern:
  43. //
  44. // - top-level
  45. // - nested
  46. // back to top-level <- 2-space indent, joins the top-level bullet
  47. const out = [];
  48. let buf = ''; // pending list-item text being built
  49. let stack = []; // [{ indent: number }] open list items
  50. function flushBuf() {
  51. if (buf !== '') {
  52. out.push(buf);
  53. buf = '';
  54. }
  55. }
  56. function leadingSpaces(s) {
  57. const m = s.match(/^(\s*)/);
  58. return m ? m[1].length : 0;
  59. }
  60. const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/;
  61. for (const line of block) {
  62. if (/^\s*$/.test(line)) {
  63. flushBuf();
  64. out.push('');
  65. continue;
  66. }
  67. if (/^#/.test(line)) {
  68. flushBuf();
  69. stack = [];
  70. out.push(line);
  71. continue;
  72. }
  73. const itemMatch = line.match(listItemRe);
  74. if (itemMatch) {
  75. flushBuf();
  76. const indent = itemMatch[1].length;
  77. while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
  78. stack.pop();
  79. }
  80. stack.push({ indent });
  81. buf = line;
  82. continue;
  83. }
  84. if (/^\s/.test(line)) {
  85. // Continuation. Pop any list frames deeper than this indent — the
  86. // continuation belongs to the nearest enclosing list item.
  87. const indent = leadingSpaces(line);
  88. while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
  89. // Closes the deeper item — its buffered text is already in `buf`
  90. // belonging to the most recent flush. We need to flush before
  91. // re-buffering for the ancestor item.
  92. flushBuf();
  93. stack.pop();
  94. }
  95. const trimmed = line.replace(/^\s+/, '');
  96. buf = buf === '' ? trimmed : `${buf} ${trimmed}`;
  97. continue;
  98. }
  99. // Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`)
  100. flushBuf();
  101. stack = [];
  102. out.push(line);
  103. }
  104. flushBuf();
  105. process.stdout.write(out.join('\n'));
  106. if (!out[out.length - 1]?.endsWith('\n')) process.stdout.write('\n');