@heroiclands/package-build 22.4.2 → 22.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,12 +82,13 @@ carries no hook files of its own and cannot drift from the others. `|| true` so
82
82
  that an install outside a git checkout — a Docker build, a CI cache step —
83
83
  does not fail on it.
84
84
 
85
- Three hooks arrive with it, each with its own switch, read with git's normal
85
+ Four hooks arrive with it, each with its own switch, read with git's normal
86
86
  precedence: a plain `git config` sets one clone, `--global` sets a machine.
87
87
 
88
88
  | Hook | What it does | Key | Default |
89
89
  | -------------------------------- | --------------------------------------------------------------- | ------------------------------------ | ------- |
90
90
  | `pre-commit`, `pre-merge-commit` | Refuse a commit on a protected branch. | `hooks.allowCommitOnMain` (inverted) | on |
91
+ | `pre-commit` | Lint any `.changeset/*.md` the commit stages. | `hooks.changelogCheck` | on |
91
92
  | `commit-msg` | Refuse AI attribution in a commit message. | `hooks.noAttribution` | on |
92
93
  | `pre-push` | Run this repository's own Build & Test workflow in a container. | `hooks.prePushCi` | off |
93
94
 
@@ -130,19 +131,20 @@ anything to remove.
130
131
 
131
132
  ### `lint:*` — the checks, one per question
132
133
 
133
- Six checks, each answering one question and blind to what the others see. They
134
- are separate scripts rather than one because a failing chain stops at its first
135
- failure, and knowing _which_ question failed is most of the diagnosis.
134
+ Seven checks, each answering one question and blind to what the others see.
135
+ They are separate scripts rather than one because a failing chain stops at its
136
+ first failure, and knowing _which_ question failed is most of the diagnosis.
136
137
 
137
138
  ```json
138
- "lint": "run-s lint:format lint:markdown lint:addresses lint:content-links lint:lang lint:labels",
139
+ "lint": "run-s lint:format lint:markdown lint:addresses lint:content-links lint:lang lint:labels lint:changelog",
139
140
  "lint:format": "content-build format",
140
141
  "lint:markdown": "content-build markdown",
141
142
  "lint:markdown:fix": "content-build markdown --fix",
142
143
  "lint:addresses": "content-build lint",
143
144
  "lint:content-links": "content-build links",
144
145
  "lint:lang": "package-build lang check",
145
- "lint:labels": "package-build labels check"
146
+ "lint:labels": "package-build labels check",
147
+ "lint:changelog": "package-build changelog check"
146
148
  ```
147
149
 
148
150
  - **`lint:format`** — is every file formatted to the shared Prettier options?
@@ -165,6 +167,10 @@ failure, and knowing _which_ question failed is most of the diagnosis.
165
167
  - **`lint:labels`** — do `.github/labels.yml` and §3 of
166
168
  `.github/ISSUE_REPORTING.md` still list the same labels? Neither derives from
167
169
  the other, so nothing else notices when they drift.
170
+ - **`lint:changelog`** — does every pending `.changeset/*.md` read like a
171
+ release note rather than a pasted pull-request description? Passes
172
+ trivially when nothing is pending: no pull request is required to carry a
173
+ changeset, only to write one that reads well when it does.
168
174
 
169
175
  `run-s` comes from `npm-run-all`, a devDependency. It runs scripts in sequence
170
176
  and stops at the first failure, which is what makes a named chain readable.
@@ -227,16 +233,19 @@ reads better with one and a CI workflow reads better with the other.
227
233
 
228
234
  ```json
229
235
  "changeset": "changeset",
230
- "changeset:check": "changeset status --since=origin/main",
231
236
  "changeset:version": "changeset version && npm install --package-lock-only"
232
237
  ```
233
238
 
234
- `changeset` adds one. `changeset:check` reports what is pending against `main`,
235
- which is what a CI job gates on so that a behaviour change cannot merge without
236
- declaring its bump. `changeset:version` consumes the pending changesets, writes
237
- `CHANGELOG.md` and bumps `package.json` — and the `npm install --package-lock-only`
238
- after it is what keeps `package-lock.json`'s recorded version in step, since
239
- changesets does not touch the lockfile.
239
+ `changeset` adds one, written only when a consumer will notice the change —
240
+ nothing gates a pull request on carrying one. `changeset:version` consumes the
241
+ pending changesets, writes `CHANGELOG.md` and bumps `package.json` — and the
242
+ `npm install --package-lock-only` after it is what keeps `package-lock.json`'s
243
+ recorded version in step, since changesets does not touch the lockfile.
244
+
245
+ What a pending changeset is held to is quality, not existence: `lint:changelog`
246
+ and the `pre-commit` hook `prepare` installs both run
247
+ `package-build changelog check` against it, in CI and at commit time
248
+ respectively, and both pass trivially when there is none.
240
249
 
241
250
  ### The site scripts
242
251
 
@@ -0,0 +1,277 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Fold a release's changeset blocks together by their bold label.
16
+ *
17
+ * `@heroiclands/package-build/changelog` (`changelog.cjs`) writes each
18
+ * changeset's summary into `CHANGELOG.md` as its own block, verbatim, under
19
+ * `### <Bump> Changes` — one per changeset, in whatever order Changesets
20
+ * happened to read the files. A repository that groups its release prose by
21
+ * subject (`**Compendiums**`, `**Website**`) ends up with the same label
22
+ * opening several scattered blocks instead of one, because nothing ever
23
+ * merges them: three pull requests touching compendium content each write
24
+ * their own `**Compendiums**` block, and the release reads as three
25
+ * unrelated entries rather than one.
26
+ *
27
+ * `groupChangelogText` merges same-label blocks into one, in the display
28
+ * order `changelog.labels` declares, with the unlabelled lead paragraph
29
+ * first and any label absent from that vocabulary last. It reuses
30
+ * {@link module:engine/changelog-lint.topLevelBlocks} — the same block
31
+ * model `changelog check` reads a block's label from — rather than parsing
32
+ * the markdown a second way.
33
+ *
34
+ * @module
35
+ */
36
+
37
+ import {
38
+ codeLineSet,
39
+ lineColOf,
40
+ releaseSectionRange,
41
+ topLevelBlocks,
42
+ topLevelBullets,
43
+ } from "./changelog-lint.mjs";
44
+
45
+ /** A `### <Bump> Changes` heading — the generated scaffold, never authored. */
46
+ const CHANGES_HEADING_RE = /^### (?:Major|Minor|Patch) Changes$/gm;
47
+
48
+ /**
49
+ * A labelled block's own `**Label**` line, split from everything after it.
50
+ *
51
+ * @param {string} blockText - One block, as {@link topLevelBlocks} collects
52
+ * it — its first line is the label line for a labelled block.
53
+ * @returns {{labelLine: string, rest: string, hadBlankLine: boolean}} `rest`
54
+ * has its leading blank line dropped; a block with nothing but the label
55
+ * line yields `""`. `hadBlankLine` says whether the label line was
56
+ * followed by a blank line in `blockText` — false for a bold sentence a
57
+ * hard-wrapped paragraph continues directly beneath, with no blank line
58
+ * of its own.
59
+ */
60
+ function splitLabelLine(blockText) {
61
+ const nl = blockText.indexOf("\n");
62
+ if (nl === -1) return { labelLine: blockText, rest: "", hadBlankLine: false };
63
+ const after = blockText.slice(nl + 1);
64
+ return {
65
+ labelLine: blockText.slice(0, nl),
66
+ rest: after.replace(/^\n+/, ""),
67
+ hadBlankLine: after.startsWith("\n"),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * A block's top-level items: its bullets, or — when it carries none — the
73
+ * whole thing as one paragraph item. This is what merging concatenates and
74
+ * deduplicates, so a bulleted `**Compendiums**` block and a prose one merge
75
+ * on the same footing.
76
+ *
77
+ * @param {string} text - A block's content, label line already stripped (or
78
+ * a lead block's whole text).
79
+ * @returns {string[]}
80
+ */
81
+ function itemsOf(text) {
82
+ const trimmed = text.replace(/\s+$/, "");
83
+ if (!trimmed) return [];
84
+ const bullets = topLevelBullets(trimmed, codeLineSet(trimmed));
85
+ if (bullets.length === 0) return [trimmed];
86
+ return bullets.map((bullet) => bullet.text.replace(/\s+$/, ""));
87
+ }
88
+
89
+ /**
90
+ * The first occurrence of each item, exact-duplicate text dropped, order
91
+ * preserved — the "an exact-duplicate bullet once" rule.
92
+ *
93
+ * @param {string[]} items
94
+ * @returns {string[]}
95
+ */
96
+ function dedupeItems(items) {
97
+ const seen = new Set();
98
+ const out = [];
99
+ for (const item of items) {
100
+ const key = item.trim();
101
+ if (seen.has(key)) continue;
102
+ seen.add(key);
103
+ out.push(item);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * Join a merged group's items the way its own shape calls for: bullets
110
+ * adjacent, one to a line; a paragraph or a mix of paragraphs separated by a
111
+ * blank line, the same spacing a changeset's own multi-paragraph summary
112
+ * already uses.
113
+ *
114
+ * @param {string[]} items
115
+ * @returns {string}
116
+ */
117
+ function joinItems(items) {
118
+ if (items.length === 0) return "";
119
+ const allBullets = items.every((item) => /^-\s/.test(item));
120
+ return items.join(allBullets ? "\n" : "\n\n");
121
+ }
122
+
123
+ /**
124
+ * One label's blocks (or every unlabelled one), folded into the single
125
+ * block `group` writes for it.
126
+ *
127
+ * A single, unmerged block — the common case, one changeset writing one
128
+ * label once — keeps its own label-to-body separator rather than forcing a
129
+ * blank line: a bold sentence a hard-wrapped paragraph continues directly
130
+ * beneath must round-trip unchanged, not gain a blank line it never had.
131
+ * Merging two or more blocks, or a label whose lone block carries several
132
+ * items (bullets), still separates the label from its body with a blank
133
+ * line — the convention every existing multi-item block already uses.
134
+ *
135
+ * @param {Array<{label: string|null, text: string}>} blocks - Every block
136
+ * sharing one label, in file order. All carry the same `label`.
137
+ * @returns {string}
138
+ */
139
+ function mergeGroup(blocks) {
140
+ if (blocks[0].label === null) {
141
+ return joinItems(dedupeItems(blocks.flatMap((block) => itemsOf(block.text))));
142
+ }
143
+ const { labelLine, hadBlankLine } = splitLabelLine(blocks[0].text);
144
+ const items = dedupeItems(blocks.flatMap((block) => itemsOf(splitLabelLine(block.text).rest)));
145
+ if (items.length === 0) return labelLine;
146
+ if (blocks.length === 1 && items.length === 1) {
147
+ return `${labelLine}${hadBlankLine ? "\n\n" : "\n"}${items[0]}`;
148
+ }
149
+ return `${labelLine}\n\n${joinItems(items)}`;
150
+ }
151
+
152
+ /**
153
+ * Where a group sorts, relative to the others in its section.
154
+ *
155
+ * The unlabelled lead group always sorts first, whether or not
156
+ * `changelog.labels` is declared — it is the summary a changeset writes with
157
+ * no category, and reads like the section's own opening line. A declared
158
+ * vocabulary then orders everything else by its position in that list, with
159
+ * an undeclared label sorted after every declared one; with no vocabulary
160
+ * declared at all, every labelled group keeps the order its label first
161
+ * appeared in.
162
+ *
163
+ * @param {string|null} key - A group's label, or `null` for the lead group.
164
+ * @param {string[]} appearanceOrder - Every key, in the order its first
165
+ * block appeared in the section.
166
+ * @param {readonly string[]|null} labels - `changelog.labels`, or `null`.
167
+ * @returns {[number, number]} `[tier, rank]`, compared tier first.
168
+ */
169
+ function groupRank(key, appearanceOrder, labels) {
170
+ if (key === null) return [-1, 0];
171
+ if (labels) {
172
+ const configured = labels.indexOf(key);
173
+ return configured === -1 ? [1, appearanceOrder.indexOf(key)] : [0, configured];
174
+ }
175
+ return [0, appearanceOrder.indexOf(key)];
176
+ }
177
+
178
+ /**
179
+ * Fold one `### <Bump> Changes` section's blocks together by label.
180
+ *
181
+ * @param {string} bodyText - Everything after the heading, up to the next
182
+ * one or the end of the release section.
183
+ * @param {readonly string[]|null} labels - `changelog.labels`, in display
184
+ * order, or `null` when the repository declares none.
185
+ * @returns {{text: string, unknown: Array<{label: string, startLine: number}>}}
186
+ * The regrouped body (no leading or trailing blank lines), and every label
187
+ * it wrote that `labels` does not declare, each at its merged block's
188
+ * first-occurring line within `bodyText` — empty when `labels` is `null`,
189
+ * since nothing is "unknown" against no vocabulary.
190
+ */
191
+ function groupSection(bodyText, labels) {
192
+ const codeLines = codeLineSet(bodyText);
193
+ const blocks = topLevelBlocks(bodyText, codeLines);
194
+ if (blocks.length === 0) return { text: "", unknown: [] };
195
+
196
+ /** @type {string[]} */
197
+ const appearanceOrder = [];
198
+ /** @type {Map<string|null, Array<{label: string|null, text: string}>>} */
199
+ const groups = new Map();
200
+ for (const block of blocks) {
201
+ if (!groups.has(block.label)) {
202
+ groups.set(block.label, []);
203
+ appearanceOrder.push(block.label);
204
+ }
205
+ groups.get(block.label).push(block);
206
+ }
207
+
208
+ const sortedKeys = [...appearanceOrder].sort((a, b) => {
209
+ const [aTier, aRank] = groupRank(a, appearanceOrder, labels);
210
+ const [bTier, bRank] = groupRank(b, appearanceOrder, labels);
211
+ return aTier - bTier || aRank - bRank;
212
+ });
213
+
214
+ const unknown =
215
+ labels === null ?
216
+ []
217
+ : sortedKeys
218
+ .filter((key) => key !== null && !labels.includes(key))
219
+ .map((key) => ({ label: key, startLine: groups.get(key)[0].startLine }));
220
+
221
+ const text = sortedKeys.map((key) => mergeGroup(groups.get(key))).join("\n\n");
222
+ return { text, unknown };
223
+ }
224
+
225
+ /**
226
+ * `changelog group`: fold the newest release's changeset blocks together by
227
+ * their bold label, order them, and file an undeclared one last.
228
+ *
229
+ * Every earlier release section is untouched, byte for byte — only the
230
+ * first `## <version>` section's `### <Bump> Changes` bodies are rewritten,
231
+ * each independently (a label groups within its own bump level, never
232
+ * across one). Running this on its own output is a no-op: a release already
233
+ * in label order, with each label merged to one block, groups to itself.
234
+ *
235
+ * @param {string} text - The changelog's full contents.
236
+ * @param {object} [opts]
237
+ * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
238
+ * display order. `null`/absent orders every group by first appearance
239
+ * instead, lead paragraph first, and files nothing as unknown.
240
+ * @returns {{text: string, findings: Array<{line: number,
241
+ * severity: "warning", message: string}>}}
242
+ */
243
+ export function groupChangelogText(text, { labels = null } = {}) {
244
+ const range = releaseSectionRange(text);
245
+ if (!range) return { text, findings: [] };
246
+
247
+ const section = text.slice(range.start, range.end);
248
+ const headings = [...section.matchAll(CHANGES_HEADING_RE)];
249
+ if (headings.length === 0) return { text, findings: [] };
250
+
251
+ let rebuilt = section.slice(0, headings[0].index);
252
+ /** @type {Array<{line: number, severity: "warning", message: string}>} */
253
+ const findings = [];
254
+
255
+ for (let i = 0; i < headings.length; i++) {
256
+ const heading = headings[i];
257
+ const bodyStart = heading.index + heading[0].length;
258
+ const bodyEnd = i + 1 < headings.length ? headings[i + 1].index : section.length;
259
+ const { text: grouped, unknown } = groupSection(section.slice(bodyStart, bodyEnd), labels);
260
+
261
+ const isVeryLast = i === headings.length - 1 && range.end === text.length;
262
+ rebuilt += heading[0] + (grouped ? `\n\n${grouped}` : "") + (isVeryLast ? "\n" : "\n\n");
263
+
264
+ const { line: bodyFirstLine } = lineColOf(text, range.start + bodyStart);
265
+ for (const { label, startLine } of unknown) {
266
+ findings.push({
267
+ line: bodyFirstLine + (startLine - 1),
268
+ severity: "warning",
269
+ message:
270
+ `changelog-group/unknown-label "${label}" is not declared in ` +
271
+ "`changelog.labels` — filed last, in order of first appearance",
272
+ });
273
+ }
274
+ }
275
+
276
+ return { text: text.slice(0, range.start) + rebuilt + text.slice(range.end), findings };
277
+ }
@@ -49,6 +49,9 @@ const TOP_BULLET_RE = /^-\s+/;
49
49
  /** A list item indented under something else. */
50
50
  const NESTED_BULLET_RE = /^[ \t]+[-*+]\s+/;
51
51
 
52
+ /** A bold label opening a block at column 0: `**Compendiums**`. */
53
+ const LABEL_LINE_RE = /^\*\*([^*]+)\*\*/;
54
+
52
55
  /** A commit-hash prefix: `- abc1234: …`, or a bare hex token opening the bullet. */
53
56
  const COMMIT_HASH_RE = /^-\s+([0-9a-fA-F]{7,40})(?=[:\s]|$)/;
54
57
 
@@ -92,7 +95,7 @@ const MAX_SECTION_LINES = 60;
92
95
  * @param {number} index - 0-based character offset.
93
96
  * @returns {{line: number, column: number}}
94
97
  */
95
- function lineColOf(text, index) {
98
+ export function lineColOf(text, index) {
96
99
  const before = text.slice(0, Math.max(0, index));
97
100
  const nl = before.lastIndexOf("\n");
98
101
  return { line: before.split("\n").length, column: index - nl };
@@ -108,7 +111,7 @@ function lineColOf(text, index) {
108
111
  * @param {string} text - The section text.
109
112
  * @returns {Set<number>} Lines that fall inside a fenced or indented block.
110
113
  */
111
- function codeLineSet(text) {
114
+ export function codeLineSet(text) {
112
115
  const lines = new Set();
113
116
  for (const region of codeRegions(text, { spans: false })) {
114
117
  const from = lineColOf(text, region.start).line;
@@ -185,7 +188,7 @@ function checkCodeFences(text) {
185
188
  * {@link codeLineSet}.
186
189
  * @returns {Array<{startLine: number, text: string}>}
187
190
  */
188
- function topLevelBullets(text, codeLines) {
191
+ export function topLevelBullets(text, codeLines) {
189
192
  const lines = text.split("\n");
190
193
  /** @type {Array<{startLine: number, text: string}>} */
191
194
  const bullets = [];
@@ -211,13 +214,113 @@ function topLevelBullets(text, codeLines) {
211
214
  continue;
212
215
  }
213
216
  // Column 0, not a bullet: a bold subsection label or a scaffold
214
- // heading closes whatever bullet was open.
217
+ // heading closes whatever bullet was open — pushed here, or it is
218
+ // lost rather than merely closed.
219
+ if (current) bullets.push({ startLine: current.startLine, text: current.parts.join("\n") });
215
220
  current = null;
216
221
  }
217
222
  if (current) bullets.push({ startLine: current.startLine, text: current.parts.join("\n") });
218
223
  return bullets;
219
224
  }
220
225
 
226
+ /**
227
+ * Every top-level block of release prose — one rendered changeset entry, or
228
+ * one unlabelled paragraph standing in for one.
229
+ *
230
+ * `@heroiclands/package-build/changelog` (`changelog.cjs`) writes a
231
+ * changeset's whole summary as one block, verbatim, so a block here holds
232
+ * together the same way that summary is authored: a bold label opening a
233
+ * line at column 0 (`**Compendiums**`) starts a new block, and — unlike
234
+ * {@link topLevelBullets}, where a bullet marker *is* the top-level
235
+ * construct — a `-` bullet at column 0 belongs to whatever block is
236
+ * already open, since a label's bullets sit unindented directly under it.
237
+ * A plain column-0 line that is neither a label nor a bullet continues
238
+ * whatever block is open, because Prettier's `proseWrap` puts every line of
239
+ * a hard-wrapped paragraph at column 0 — it starts a new "lead" block
240
+ * (`label: null`) only at a paragraph boundary, the start of the section or
241
+ * a line right after a blank one, which is the case `changelog group`
242
+ * sorts first and `check` never flags. A blank line never closes a block by
243
+ * itself; it only ends the run of continuation lines a following column-0
244
+ * line can attach to. `changelog group` folds same-label blocks together;
245
+ * `check` warns when a block's label is not in the declared vocabulary.
246
+ *
247
+ * @param {string} text - Section text.
248
+ * @param {Set<number>} codeLines - Lines inside a code region, from
249
+ * {@link codeLineSet}.
250
+ * @param {Set<number>} [scaffoldLines] - Generated heading lines that close
251
+ * whatever block is open without starting one, from
252
+ * {@link scaffoldLineSet} — empty for a caller that already isolated one
253
+ * `### <Bump> Changes` body, since no heading falls inside it.
254
+ * @returns {Array<{startLine: number, label: string|null, text: string}>}
255
+ */
256
+ export function topLevelBlocks(text, codeLines, scaffoldLines = new Set()) {
257
+ const lines = text.split("\n");
258
+ /** @type {Array<{startLine: number, label: string|null, text: string}>} */
259
+ const blocks = [];
260
+ /** @type {{startLine: number, label: string|null, parts: string[]}|null} */
261
+ let current = null;
262
+ const close = () => {
263
+ if (current)
264
+ blocks.push({
265
+ startLine: current.startLine,
266
+ label: current.label,
267
+ text: current.parts.join("\n"),
268
+ });
269
+ current = null;
270
+ };
271
+ // A paragraph boundary — the start of the section, or right after a
272
+ // blank line — is the only place an unlabelled lead block may start.
273
+ let afterBlank = true;
274
+
275
+ for (let i = 0; i < lines.length; i++) {
276
+ const lineNo = i + 1;
277
+ const raw = lines[i];
278
+
279
+ if (codeLines.has(lineNo)) {
280
+ if (current) current.parts.push(raw);
281
+ afterBlank = raw.trim() === "";
282
+ continue;
283
+ }
284
+ if (scaffoldLines.has(lineNo)) {
285
+ close();
286
+ afterBlank = true;
287
+ continue;
288
+ }
289
+ const atColumnZero = raw.trim() !== "" && !/^[ \t]/.test(raw);
290
+ if (!atColumnZero) {
291
+ if (current) current.parts.push(raw);
292
+ afterBlank = raw.trim() === "";
293
+ continue;
294
+ }
295
+ const label = LABEL_LINE_RE.exec(raw)?.[1] ?? null;
296
+ if (label !== null) {
297
+ close();
298
+ current = { startLine: lineNo, label, parts: [raw] };
299
+ afterBlank = false;
300
+ continue;
301
+ }
302
+ if (TOP_BULLET_RE.test(raw) && current) {
303
+ current.parts.push(raw);
304
+ afterBlank = false;
305
+ continue;
306
+ }
307
+ // Plain prose at column 0: it continues whatever block is already
308
+ // open unless it opens a new paragraph — Prettier's `proseWrap`
309
+ // puts every line of a hard-wrapped paragraph at column 0, so a
310
+ // continuation line must never be read as a new lead block.
311
+ if (current && !afterBlank) {
312
+ current.parts.push(raw);
313
+ afterBlank = false;
314
+ continue;
315
+ }
316
+ close();
317
+ current = { startLine: lineNo, label: null, parts: [raw] };
318
+ afterBlank = false;
319
+ }
320
+ close();
321
+ return blocks;
322
+ }
323
+
221
324
  /**
222
325
  * A bullet's word count, the bullet marker and markdown decoration stripped.
223
326
  *
@@ -502,6 +605,41 @@ function checkCodeLikeTokens(text, maskedRegions) {
502
605
  return findings;
503
606
  }
504
607
 
608
+ /**
609
+ * A block's bold label absent from the declared `changelog.labels`
610
+ * vocabulary — the drift `**Character data**` beside `**Characters**`
611
+ * produces, invisible until something reads the declared list against what a
612
+ * changeset actually wrote. A warning, not an error: an undeclared label
613
+ * still ships and still groups (`changelog group` files it last), so nothing
614
+ * here blocks a release — it only says the vocabulary and the prose have
615
+ * drifted apart.
616
+ *
617
+ * @param {Array<{startLine: number, label: string|null}>} blocks - From
618
+ * {@link topLevelBlocks}.
619
+ * @param {readonly string[]|null|undefined} labels - `changelog.labels`, or
620
+ * `null`/`undefined` when the repository declares none, in which case
621
+ * nothing is checked — there is no vocabulary for a label to drift from.
622
+ * @returns {RelativeFinding[]}
623
+ */
624
+ function checkUnknownLabels(blocks, labels) {
625
+ if (!labels) return [];
626
+ /** @type {RelativeFinding[]} */
627
+ const findings = [];
628
+ for (const block of blocks) {
629
+ if (block.label === null || labels.includes(block.label)) continue;
630
+ findings.push({
631
+ line: block.startLine,
632
+ column: 3, // right after the opening `**`
633
+ severity: "warning",
634
+ message:
635
+ `changelog-check/unknown-label "${block.label}" is not declared in ` +
636
+ "`changelog.labels` (declared: " +
637
+ `${labels.join(", ")}) — add it there, or correct the label`,
638
+ });
639
+ }
640
+ return findings;
641
+ }
642
+
505
643
  /**
506
644
  * The 1-based lines a caller drops from the heading rule because they are the
507
645
  * generated scaffold, never authored prose: the section's own `## <version>`
@@ -524,13 +662,17 @@ function scaffoldLineSet(text) {
524
662
  * Run every rule over one section of release prose.
525
663
  *
526
664
  * @param {string} text - The section, already isolated by the caller.
665
+ * @param {object} [opts]
666
+ * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, for
667
+ * {@link checkUnknownLabels}. `null`/absent checks nothing.
527
668
  * @returns {RelativeFinding[]} Findings with line numbers relative to `text`.
528
669
  */
529
- function lintSection(text) {
670
+ function lintSection(text, { labels = null } = {}) {
530
671
  const codeLines = codeLineSet(text);
531
672
  const maskedRegions = codeRegions(text, { spans: true });
532
673
  const scaffoldLines = scaffoldLineSet(text);
533
674
  const bullets = topLevelBullets(text, codeLines);
675
+ const blocks = topLevelBlocks(text, codeLines, scaffoldLines);
534
676
 
535
677
  return [
536
678
  ...checkCommitHash(text, codeLines, scaffoldLines),
@@ -544,6 +686,7 @@ function lintSection(text) {
544
686
  ...checkTooManyBullets(bullets),
545
687
  ...checkTooManyLines(text),
546
688
  ...checkCodeLikeTokens(text, maskedRegions),
689
+ ...checkUnknownLabels(blocks, labels),
547
690
  ].sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0));
548
691
  }
549
692
 
@@ -593,12 +736,19 @@ function extractReleaseSection(text) {
593
736
  * Lint one pending changeset (`.changeset/*.md`).
594
737
  *
595
738
  * @param {string} text - The file's full contents, frontmatter included.
739
+ * @param {object} [opts]
740
+ * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
741
+ * display order, or `null`/absent when the repository declares none —
742
+ * {@link checkUnknownLabels} checks nothing in that case.
596
743
  * @returns {{findings: Array<{line: number, column?: number,
597
744
  * severity: "error"|"warning", message: string}>}}
598
745
  */
599
- export function lintChangesetText(text) {
746
+ export function lintChangesetText(text, { labels = null } = {}) {
600
747
  const { body, startLine } = stripFrontmatter(text);
601
- const findings = lintSection(body).map((f) => ({ ...f, line: f.line + startLine - 1 }));
748
+ const findings = lintSection(body, { labels }).map((f) => ({
749
+ ...f,
750
+ line: f.line + startLine - 1,
751
+ }));
602
752
  return { findings };
603
753
  }
604
754
 
@@ -606,10 +756,14 @@ export function lintChangesetText(text) {
606
756
  * Lint the first `## <version>` release section of a `CHANGELOG.md`.
607
757
  *
608
758
  * @param {string} text - The changelog's full contents.
759
+ * @param {object} [opts]
760
+ * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
761
+ * display order, or `null`/absent when the repository declares none —
762
+ * {@link checkUnknownLabels} checks nothing in that case.
609
763
  * @returns {{findings: Array<{line?: number, column?: number,
610
764
  * severity: "error"|"warning", message: string}>}}
611
765
  */
612
- export function lintReleaseText(text) {
766
+ export function lintReleaseText(text, { labels = null } = {}) {
613
767
  const section = extractReleaseSection(text);
614
768
  if (!section) {
615
769
  return {
@@ -621,9 +775,34 @@ export function lintReleaseText(text) {
621
775
  ],
622
776
  };
623
777
  }
624
- const findings = lintSection(section.body).map((f) => ({
778
+ const findings = lintSection(section.body, { labels }).map((f) => ({
625
779
  ...f,
626
780
  line: f.line + section.startLine - 1,
627
781
  }));
628
782
  return { findings };
629
783
  }
784
+
785
+ /**
786
+ * The first `## <version>` release section of a changelog, as raw character
787
+ * offsets rather than {@link extractReleaseSection}'s line-joined copy.
788
+ *
789
+ * `extractReleaseSection` rebuilds its `body` by joining a slice of
790
+ * `text.split("\n")`, which is fine for reporting a line number but drops
791
+ * the exact byte the next `## ` heading sits after — a caller rewriting the
792
+ * file in place, such as `changelog group`, needs `text.slice(start, end)`
793
+ * to be the section verbatim, so it can splice a replacement back in without
794
+ * guessing at the whitespace on either side.
795
+ *
796
+ * @param {string} text - The changelog's full contents.
797
+ * @returns {{start: number, end: number}|null} `null` when no `## ` heading
798
+ * is present. `text.slice(start, end)` is the section, byte-exact,
799
+ * including whatever separates it from the next `## ` heading or the end
800
+ * of the file.
801
+ */
802
+ export function releaseSectionRange(text) {
803
+ const matches = [...text.matchAll(/^## /gm)];
804
+ if (matches.length === 0) return null;
805
+ const start = matches[0].index;
806
+ const end = matches.length > 1 ? matches[1].index : text.length;
807
+ return { start, end };
808
+ }
@@ -378,15 +378,23 @@ export function homepageTitle(fm, config) {
378
378
  * redirect stub at each one. The field is retired and refused before a
379
379
  * build reaches here, which makes this a guard rather than a working path.
380
380
  *
381
+ * `related` is derived like every other page's — what links to the homepage
382
+ * is a fact about the rest of the tree — so an authored value is dropped and
383
+ * the caller's block, where it has one, is written in its place.
384
+ *
381
385
  * @param {object} fm - The note's frontmatter.
382
386
  * @param {object} options - Options.
383
387
  * @param {string} options.contentPackage - The package this build publishes.
384
388
  * @param {string} options.title - The resolved title.
389
+ * @param {object} [options.related] - The homepage's backlinks and mentions,
390
+ * where the site build found either.
385
391
  * @returns {object} The frontmatter to write.
386
392
  */
387
- export function homepageFrontmatter(fm, { contentPackage, title }) {
393
+ export function homepageFrontmatter(fm, { contentPackage, title, related }) {
388
394
  const data = { ...fm, package: contentPackage, title };
389
395
  delete data.aliases;
396
+ delete data.related;
397
+ if (related) data.related = related;
390
398
  return data;
391
399
  }
392
400