返回 CodeWhale
changelog-lib.mjs
根目录 / web / scripts / changelog-lib.mjs
1 /**
2 * changelog-lib.mjs — parse the repository CHANGELOG.md into the compact,
3 * deterministic shape the website renders (`lib/changelog.generated.ts`).
4 *
5 * Keep a Changelog format: `## [version] - date` (or `## [Unreleased]`),
6 * `### Section` headings, and `- ` bullets that may continue on indented
7 * lines. Compare links live at the bottom as `[version]: url`.
8 *
9 * Pure and dependency-free so the derive script, the drift test, and any
10 * future check can share one parser.
11 */
12
13 const DEFAULT_LIMIT = 6;
14 // Most Keep-a-Changelog bullets in this repository run one to three
15 // sentences; 480 chars keeps the great majority readable in place, and the
16 // per-release "Full notes" link on /changelog carries the rest.
17 const DEFAULT_ITEMS_PER_SECTION = 12;
18 const DEFAULT_ITEM_CHARS = 480;
19
20 /** Collapse Markdown emphasis and links to plain text for a one-line summary. */
21 export function plainText(markdown) {
22 return markdown
23 .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
24 .replace(/`([^`]*)`/g, "$1")
25 .replace(/\*\*([^*]+)\*\*/g, "$1")
26 .replace(/\s+/g, " ")
27 .trim();
28 }
29
30 /** Truncate at a word boundary; never mid-word, never past `max` chars. */
31 export function clip(text, max = DEFAULT_ITEM_CHARS) {
32 if (text.length <= max) return text;
33 const cut = text.lastIndexOf(" ", max - 1);
34 return `${text.slice(0, cut > max / 2 ? cut : max - 1).trimEnd()}…`;
35 }
36
37 /**
38 * Parse a CHANGELOG.md string.
39 *
40 * @returns {{ releases: Array<{version: string, date: string | null, unreleased: boolean, compareUrl: string | null, sections: Array<{heading: string, items: string[], itemCount: number}>}> }}
41 */
42 export function parseChangelog(markdown, options = {}) {
43 const limit = options.limit ?? DEFAULT_LIMIT;
44 const itemsPerSection = options.itemsPerSection ?? DEFAULT_ITEMS_PER_SECTION;
45 const itemChars = options.itemChars ?? DEFAULT_ITEM_CHARS;
46
47 const lines = markdown.split(/\r?\n/);
48 const links = new Map();
49 for (const line of lines) {
50 const m = line.match(/^\[([^\]]+)\]:\s*(\S+)\s*$/);
51 if (m) links.set(m[1], m[2]);
52 }
53
54 const releases = [];
55 let release = null;
56 let section = null;
57 let item = null;
58
59 const flushItem = () => {
60 if (section && item !== null) {
61 section.raw.push(plainText(item));
62 }
63 item = null;
64 };
65
66 for (const line of lines) {
67 const heading = line.match(/^## \[([^\]]+)\](?:\s*-\s*(.+))?\s*$/);
68 if (heading) {
69 flushItem();
70 section = null;
71 const label = heading[1].trim();
72 const unreleased = /^unreleased$/i.test(label);
73 const dateText = heading[2]?.trim() ?? null;
74 release = {
75 version: label,
76 date: dateText && /^\d{4}-\d{2}-\d{2}$/.test(dateText) ? dateText : null,
77 unreleased,
78 compareUrl: links.get(label) ?? null,
79 sections: [],
80 };
81 releases.push(release);
82 continue;
83 }
84 if (!release) continue;
85
86 const sub = line.match(/^### (.+?)\s*$/);
87 if (sub) {
88 flushItem();
89 section = { heading: sub[1], raw: [] };
90 release.sections.push(section);
91 continue;
92 }
93 if (!section) continue;
94
95 const bullet = line.match(/^- (.*)$/);
96 if (bullet) {
97 flushItem();
98 item = bullet[1];
99 continue;
100 }
101 if (item !== null && /^\s{2,}\S/.test(line)) {
102 item += ` ${line.trim()}`;
103 continue;
104 }
105 if (item !== null && line.trim() === "") {
106 flushItem();
107 }
108 }
109 flushItem();
110
111 return {
112 releases: releases.slice(0, limit).map((r) => ({
113 version: r.version,
114 date: r.date,
115 unreleased: r.unreleased,
116 compareUrl: r.compareUrl,
117 sections: r.sections
118 .filter((s) => s.raw.length > 0)
119 .map((s) => ({
120 heading: s.heading,
121 items: s.raw.slice(0, itemsPerSection).map((t) => clip(t, itemChars)),
122 itemCount: s.raw.length,
123 })),
124 })),
125 };
126 }
127
128 /**
129 * The fragment GitHub's Markdown renderer assigns to a release heading such
130 * as `## [0.9.11] - 2026-08-22`: lower-cased, punctuation other than hyphens
131 * dropped, spaces turned to hyphens — so `0911---2026-08-22`. Lets the site
132 * deep-link a version's full notes instead of the top of a 470 KB file.
133 */
134 export function changelogAnchor(release) {
135 const heading = release.unreleased
136 ? "Unreleased"
137 : release.date
138 ? `[${release.version}] - ${release.date}`
139 : `[${release.version}]`;
140 return heading
141 .toLowerCase()
142 .replace(/[^\p{L}\p{N} _-]/gu, "")
143 .replace(/ /g, "-");
144 }
145
146 /** Render the generated TypeScript module from a parse result. */
147 export function renderChangelogModule(parsed, sourcePath = "CHANGELOG.md") {
148 return `// AUTO-GENERATED by web/scripts/derive-changelog.mjs at prebuild from ${sourcePath}.
149 // DO NOT EDIT — re-run \`npm run prebuild\` (or just \`npm run build\`) after changing the changelog.
150 // Deterministic: no timestamps, so a clean rebuild leaves the tracked file unchanged.
151
152 export interface ChangelogSection {
153 heading: string;
154 /** Plain-text entries, clipped for the web; \`itemCount\` is the full count. */
155 items: string[];
156 itemCount: number;
157 }
158
159 export interface ChangelogRelease {
160 /** "Unreleased" or a semantic version such as "0.9.11". */
161 version: string;
162 /** ISO date from the heading, or null for the unreleased lane. */
163 date: string | null;
164 unreleased: boolean;
165 /** The changelog's own compare link for this version, when it has one. */
166 compareUrl: string | null;
167 sections: ChangelogSection[];
168 }
169
170 export const CHANGELOG: ChangelogRelease[] = ${JSON.stringify(parsed.releases, null, 2)};
171 `;
172 }
173
173 lines Plain Text