@heroiclands/package-build 22.4.2 → 22.4.3
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.
- package/CHANGELOG.md +40 -0
- package/CONTENT.md +6 -6
- package/README.md +6 -6
- package/bin/package-build.mjs +68 -18
- package/changelog.cjs +55 -0
- package/content-config.mjs +34 -0
- package/docs/api.md +16 -0
- package/docs/commands.md +70 -2
- package/docs/configuration.md +43 -3
- package/docs/project-setup.md +22 -13
- package/engine/changelog-group.mjs +256 -0
- package/engine/changelog-lint.mjs +168 -9
- package/package.json +4 -3
- package/types/engine/changelog-group.d.mts +28 -0
- package/types/engine/changelog-lint.d.mts +107 -2
|
@@ -0,0 +1,256 @@
|
|
|
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}} `rest` has its leading blank
|
|
54
|
+
* line dropped; a block with nothing but the label line yields `""`.
|
|
55
|
+
*/
|
|
56
|
+
function splitLabelLine(blockText) {
|
|
57
|
+
const nl = blockText.indexOf("\n");
|
|
58
|
+
if (nl === -1) return { labelLine: blockText, rest: "" };
|
|
59
|
+
return { labelLine: blockText.slice(0, nl), rest: blockText.slice(nl + 1).replace(/^\n+/, "") };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A block's top-level items: its bullets, or — when it carries none — the
|
|
64
|
+
* whole thing as one paragraph item. This is what merging concatenates and
|
|
65
|
+
* deduplicates, so a bulleted `**Compendiums**` block and a prose one merge
|
|
66
|
+
* on the same footing.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} text - A block's content, label line already stripped (or
|
|
69
|
+
* a lead block's whole text).
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
function itemsOf(text) {
|
|
73
|
+
const trimmed = text.replace(/\s+$/, "");
|
|
74
|
+
if (!trimmed) return [];
|
|
75
|
+
const bullets = topLevelBullets(trimmed, codeLineSet(trimmed));
|
|
76
|
+
if (bullets.length === 0) return [trimmed];
|
|
77
|
+
return bullets.map((bullet) => bullet.text.replace(/\s+$/, ""));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The first occurrence of each item, exact-duplicate text dropped, order
|
|
82
|
+
* preserved — the "an exact-duplicate bullet once" rule.
|
|
83
|
+
*
|
|
84
|
+
* @param {string[]} items
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
function dedupeItems(items) {
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const item of items) {
|
|
91
|
+
const key = item.trim();
|
|
92
|
+
if (seen.has(key)) continue;
|
|
93
|
+
seen.add(key);
|
|
94
|
+
out.push(item);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Join a merged group's items the way its own shape calls for: bullets
|
|
101
|
+
* adjacent, one to a line; a paragraph or a mix of paragraphs separated by a
|
|
102
|
+
* blank line, the same spacing a changeset's own multi-paragraph summary
|
|
103
|
+
* already uses.
|
|
104
|
+
*
|
|
105
|
+
* @param {string[]} items
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
function joinItems(items) {
|
|
109
|
+
if (items.length === 0) return "";
|
|
110
|
+
const allBullets = items.every((item) => /^-\s/.test(item));
|
|
111
|
+
return items.join(allBullets ? "\n" : "\n\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One label's blocks (or every unlabelled one), folded into the single
|
|
116
|
+
* block `group` writes for it.
|
|
117
|
+
*
|
|
118
|
+
* @param {Array<{label: string|null, text: string}>} blocks - Every block
|
|
119
|
+
* sharing one label, in file order. All carry the same `label`.
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
function mergeGroup(blocks) {
|
|
123
|
+
if (blocks[0].label === null) {
|
|
124
|
+
return joinItems(dedupeItems(blocks.flatMap((block) => itemsOf(block.text))));
|
|
125
|
+
}
|
|
126
|
+
const { labelLine } = splitLabelLine(blocks[0].text);
|
|
127
|
+
const items = dedupeItems(blocks.flatMap((block) => itemsOf(splitLabelLine(block.text).rest)));
|
|
128
|
+
return items.length ? `${labelLine}\n\n${joinItems(items)}` : labelLine;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Where a group sorts, relative to the others in its section.
|
|
133
|
+
*
|
|
134
|
+
* The unlabelled lead group always sorts first, whether or not
|
|
135
|
+
* `changelog.labels` is declared — it is the summary a changeset writes with
|
|
136
|
+
* no category, and reads like the section's own opening line. A declared
|
|
137
|
+
* vocabulary then orders everything else by its position in that list, with
|
|
138
|
+
* an undeclared label sorted after every declared one; with no vocabulary
|
|
139
|
+
* declared at all, every labelled group keeps the order its label first
|
|
140
|
+
* appeared in.
|
|
141
|
+
*
|
|
142
|
+
* @param {string|null} key - A group's label, or `null` for the lead group.
|
|
143
|
+
* @param {string[]} appearanceOrder - Every key, in the order its first
|
|
144
|
+
* block appeared in the section.
|
|
145
|
+
* @param {readonly string[]|null} labels - `changelog.labels`, or `null`.
|
|
146
|
+
* @returns {[number, number]} `[tier, rank]`, compared tier first.
|
|
147
|
+
*/
|
|
148
|
+
function groupRank(key, appearanceOrder, labels) {
|
|
149
|
+
if (key === null) return [-1, 0];
|
|
150
|
+
if (labels) {
|
|
151
|
+
const configured = labels.indexOf(key);
|
|
152
|
+
return configured === -1 ? [1, appearanceOrder.indexOf(key)] : [0, configured];
|
|
153
|
+
}
|
|
154
|
+
return [0, appearanceOrder.indexOf(key)];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fold one `### <Bump> Changes` section's blocks together by label.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} bodyText - Everything after the heading, up to the next
|
|
161
|
+
* one or the end of the release section.
|
|
162
|
+
* @param {readonly string[]|null} labels - `changelog.labels`, in display
|
|
163
|
+
* order, or `null` when the repository declares none.
|
|
164
|
+
* @returns {{text: string, unknown: Array<{label: string, startLine: number}>}}
|
|
165
|
+
* The regrouped body (no leading or trailing blank lines), and every label
|
|
166
|
+
* it wrote that `labels` does not declare, each at its merged block's
|
|
167
|
+
* first-occurring line within `bodyText` — empty when `labels` is `null`,
|
|
168
|
+
* since nothing is "unknown" against no vocabulary.
|
|
169
|
+
*/
|
|
170
|
+
function groupSection(bodyText, labels) {
|
|
171
|
+
const codeLines = codeLineSet(bodyText);
|
|
172
|
+
const blocks = topLevelBlocks(bodyText, codeLines);
|
|
173
|
+
if (blocks.length === 0) return { text: "", unknown: [] };
|
|
174
|
+
|
|
175
|
+
/** @type {string[]} */
|
|
176
|
+
const appearanceOrder = [];
|
|
177
|
+
/** @type {Map<string|null, Array<{label: string|null, text: string}>>} */
|
|
178
|
+
const groups = new Map();
|
|
179
|
+
for (const block of blocks) {
|
|
180
|
+
if (!groups.has(block.label)) {
|
|
181
|
+
groups.set(block.label, []);
|
|
182
|
+
appearanceOrder.push(block.label);
|
|
183
|
+
}
|
|
184
|
+
groups.get(block.label).push(block);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const sortedKeys = [...appearanceOrder].sort((a, b) => {
|
|
188
|
+
const [aTier, aRank] = groupRank(a, appearanceOrder, labels);
|
|
189
|
+
const [bTier, bRank] = groupRank(b, appearanceOrder, labels);
|
|
190
|
+
return aTier - bTier || aRank - bRank;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const unknown =
|
|
194
|
+
labels === null ?
|
|
195
|
+
[]
|
|
196
|
+
: sortedKeys
|
|
197
|
+
.filter((key) => key !== null && !labels.includes(key))
|
|
198
|
+
.map((key) => ({ label: key, startLine: groups.get(key)[0].startLine }));
|
|
199
|
+
|
|
200
|
+
const text = sortedKeys.map((key) => mergeGroup(groups.get(key))).join("\n\n");
|
|
201
|
+
return { text, unknown };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `changelog group`: fold the newest release's changeset blocks together by
|
|
206
|
+
* their bold label, order them, and file an undeclared one last.
|
|
207
|
+
*
|
|
208
|
+
* Every earlier release section is untouched, byte for byte — only the
|
|
209
|
+
* first `## <version>` section's `### <Bump> Changes` bodies are rewritten,
|
|
210
|
+
* each independently (a label groups within its own bump level, never
|
|
211
|
+
* across one). Running this on its own output is a no-op: a release already
|
|
212
|
+
* in label order, with each label merged to one block, groups to itself.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} text - The changelog's full contents.
|
|
215
|
+
* @param {object} [opts]
|
|
216
|
+
* @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
|
|
217
|
+
* display order. `null`/absent orders every group by first appearance
|
|
218
|
+
* instead, lead paragraph first, and files nothing as unknown.
|
|
219
|
+
* @returns {{text: string, findings: Array<{line: number,
|
|
220
|
+
* severity: "warning", message: string}>}}
|
|
221
|
+
*/
|
|
222
|
+
export function groupChangelogText(text, { labels = null } = {}) {
|
|
223
|
+
const range = releaseSectionRange(text);
|
|
224
|
+
if (!range) return { text, findings: [] };
|
|
225
|
+
|
|
226
|
+
const section = text.slice(range.start, range.end);
|
|
227
|
+
const headings = [...section.matchAll(CHANGES_HEADING_RE)];
|
|
228
|
+
if (headings.length === 0) return { text, findings: [] };
|
|
229
|
+
|
|
230
|
+
let rebuilt = section.slice(0, headings[0].index);
|
|
231
|
+
/** @type {Array<{line: number, severity: "warning", message: string}>} */
|
|
232
|
+
const findings = [];
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < headings.length; i++) {
|
|
235
|
+
const heading = headings[i];
|
|
236
|
+
const bodyStart = heading.index + heading[0].length;
|
|
237
|
+
const bodyEnd = i + 1 < headings.length ? headings[i + 1].index : section.length;
|
|
238
|
+
const { text: grouped, unknown } = groupSection(section.slice(bodyStart, bodyEnd), labels);
|
|
239
|
+
|
|
240
|
+
const isVeryLast = i === headings.length - 1 && range.end === text.length;
|
|
241
|
+
rebuilt += heading[0] + (grouped ? `\n\n${grouped}` : "") + (isVeryLast ? "\n" : "\n\n");
|
|
242
|
+
|
|
243
|
+
const { line: bodyFirstLine } = lineColOf(text, range.start + bodyStart);
|
|
244
|
+
for (const { label, startLine } of unknown) {
|
|
245
|
+
findings.push({
|
|
246
|
+
line: bodyFirstLine + (startLine - 1),
|
|
247
|
+
severity: "warning",
|
|
248
|
+
message:
|
|
249
|
+
`changelog-group/unknown-label "${label}" is not declared in ` +
|
|
250
|
+
"`changelog.labels` — filed last, in order of first appearance",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { text: text.slice(0, range.start) + rebuilt + text.slice(range.end), findings };
|
|
256
|
+
}
|
|
@@ -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,93 @@ 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
|
+
* Any other column-0 line (plain prose with no label, a bullet with no
|
|
238
|
+
* block open yet) starts the one "lead" block (`label: null`) a changeset
|
|
239
|
+
* with no category writes — the case `changelog group` sorts first and
|
|
240
|
+
* `check` never flags. Nested detail — a wrapped line, a bullet's own
|
|
241
|
+
* continuation — stays indented and belongs to whatever it follows.
|
|
242
|
+
* `changelog group` folds same-label blocks together; `check` warns when a
|
|
243
|
+
* block's label is not in the declared vocabulary.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} text - Section text.
|
|
246
|
+
* @param {Set<number>} codeLines - Lines inside a code region, from
|
|
247
|
+
* {@link codeLineSet}.
|
|
248
|
+
* @param {Set<number>} [scaffoldLines] - Generated heading lines that close
|
|
249
|
+
* whatever block is open without starting one, from
|
|
250
|
+
* {@link scaffoldLineSet} — empty for a caller that already isolated one
|
|
251
|
+
* `### <Bump> Changes` body, since no heading falls inside it.
|
|
252
|
+
* @returns {Array<{startLine: number, label: string|null, text: string}>}
|
|
253
|
+
*/
|
|
254
|
+
export function topLevelBlocks(text, codeLines, scaffoldLines = new Set()) {
|
|
255
|
+
const lines = text.split("\n");
|
|
256
|
+
/** @type {Array<{startLine: number, label: string|null, text: string}>} */
|
|
257
|
+
const blocks = [];
|
|
258
|
+
/** @type {{startLine: number, label: string|null, parts: string[]}|null} */
|
|
259
|
+
let current = null;
|
|
260
|
+
const close = () => {
|
|
261
|
+
if (current)
|
|
262
|
+
blocks.push({
|
|
263
|
+
startLine: current.startLine,
|
|
264
|
+
label: current.label,
|
|
265
|
+
text: current.parts.join("\n"),
|
|
266
|
+
});
|
|
267
|
+
current = null;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
for (let i = 0; i < lines.length; i++) {
|
|
271
|
+
const lineNo = i + 1;
|
|
272
|
+
const raw = lines[i];
|
|
273
|
+
|
|
274
|
+
if (codeLines.has(lineNo)) {
|
|
275
|
+
if (current) current.parts.push(raw);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (scaffoldLines.has(lineNo)) {
|
|
279
|
+
close();
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const atColumnZero = raw.trim() !== "" && !/^[ \t]/.test(raw);
|
|
283
|
+
if (!atColumnZero) {
|
|
284
|
+
if (current) current.parts.push(raw);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const label = LABEL_LINE_RE.exec(raw)?.[1] ?? null;
|
|
288
|
+
if (label !== null) {
|
|
289
|
+
close();
|
|
290
|
+
current = { startLine: lineNo, label, parts: [raw] };
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (TOP_BULLET_RE.test(raw) && current) {
|
|
294
|
+
current.parts.push(raw);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
close();
|
|
298
|
+
current = { startLine: lineNo, label: null, parts: [raw] };
|
|
299
|
+
}
|
|
300
|
+
close();
|
|
301
|
+
return blocks;
|
|
302
|
+
}
|
|
303
|
+
|
|
221
304
|
/**
|
|
222
305
|
* A bullet's word count, the bullet marker and markdown decoration stripped.
|
|
223
306
|
*
|
|
@@ -502,6 +585,41 @@ function checkCodeLikeTokens(text, maskedRegions) {
|
|
|
502
585
|
return findings;
|
|
503
586
|
}
|
|
504
587
|
|
|
588
|
+
/**
|
|
589
|
+
* A block's bold label absent from the declared `changelog.labels`
|
|
590
|
+
* vocabulary — the drift `**Character data**` beside `**Characters**`
|
|
591
|
+
* produces, invisible until something reads the declared list against what a
|
|
592
|
+
* changeset actually wrote. A warning, not an error: an undeclared label
|
|
593
|
+
* still ships and still groups (`changelog group` files it last), so nothing
|
|
594
|
+
* here blocks a release — it only says the vocabulary and the prose have
|
|
595
|
+
* drifted apart.
|
|
596
|
+
*
|
|
597
|
+
* @param {Array<{startLine: number, label: string|null}>} blocks - From
|
|
598
|
+
* {@link topLevelBlocks}.
|
|
599
|
+
* @param {readonly string[]|null|undefined} labels - `changelog.labels`, or
|
|
600
|
+
* `null`/`undefined` when the repository declares none, in which case
|
|
601
|
+
* nothing is checked — there is no vocabulary for a label to drift from.
|
|
602
|
+
* @returns {RelativeFinding[]}
|
|
603
|
+
*/
|
|
604
|
+
function checkUnknownLabels(blocks, labels) {
|
|
605
|
+
if (!labels) return [];
|
|
606
|
+
/** @type {RelativeFinding[]} */
|
|
607
|
+
const findings = [];
|
|
608
|
+
for (const block of blocks) {
|
|
609
|
+
if (block.label === null || labels.includes(block.label)) continue;
|
|
610
|
+
findings.push({
|
|
611
|
+
line: block.startLine,
|
|
612
|
+
column: 3, // right after the opening `**`
|
|
613
|
+
severity: "warning",
|
|
614
|
+
message:
|
|
615
|
+
`changelog-check/unknown-label "${block.label}" is not declared in ` +
|
|
616
|
+
"`changelog.labels` (declared: " +
|
|
617
|
+
`${labels.join(", ")}) — add it there, or correct the label`,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return findings;
|
|
621
|
+
}
|
|
622
|
+
|
|
505
623
|
/**
|
|
506
624
|
* The 1-based lines a caller drops from the heading rule because they are the
|
|
507
625
|
* generated scaffold, never authored prose: the section's own `## <version>`
|
|
@@ -524,13 +642,17 @@ function scaffoldLineSet(text) {
|
|
|
524
642
|
* Run every rule over one section of release prose.
|
|
525
643
|
*
|
|
526
644
|
* @param {string} text - The section, already isolated by the caller.
|
|
645
|
+
* @param {object} [opts]
|
|
646
|
+
* @param {readonly string[]|null} [opts.labels] - `changelog.labels`, for
|
|
647
|
+
* {@link checkUnknownLabels}. `null`/absent checks nothing.
|
|
527
648
|
* @returns {RelativeFinding[]} Findings with line numbers relative to `text`.
|
|
528
649
|
*/
|
|
529
|
-
function lintSection(text) {
|
|
650
|
+
function lintSection(text, { labels = null } = {}) {
|
|
530
651
|
const codeLines = codeLineSet(text);
|
|
531
652
|
const maskedRegions = codeRegions(text, { spans: true });
|
|
532
653
|
const scaffoldLines = scaffoldLineSet(text);
|
|
533
654
|
const bullets = topLevelBullets(text, codeLines);
|
|
655
|
+
const blocks = topLevelBlocks(text, codeLines, scaffoldLines);
|
|
534
656
|
|
|
535
657
|
return [
|
|
536
658
|
...checkCommitHash(text, codeLines, scaffoldLines),
|
|
@@ -544,6 +666,7 @@ function lintSection(text) {
|
|
|
544
666
|
...checkTooManyBullets(bullets),
|
|
545
667
|
...checkTooManyLines(text),
|
|
546
668
|
...checkCodeLikeTokens(text, maskedRegions),
|
|
669
|
+
...checkUnknownLabels(blocks, labels),
|
|
547
670
|
].sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0));
|
|
548
671
|
}
|
|
549
672
|
|
|
@@ -593,12 +716,19 @@ function extractReleaseSection(text) {
|
|
|
593
716
|
* Lint one pending changeset (`.changeset/*.md`).
|
|
594
717
|
*
|
|
595
718
|
* @param {string} text - The file's full contents, frontmatter included.
|
|
719
|
+
* @param {object} [opts]
|
|
720
|
+
* @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
|
|
721
|
+
* display order, or `null`/absent when the repository declares none —
|
|
722
|
+
* {@link checkUnknownLabels} checks nothing in that case.
|
|
596
723
|
* @returns {{findings: Array<{line: number, column?: number,
|
|
597
724
|
* severity: "error"|"warning", message: string}>}}
|
|
598
725
|
*/
|
|
599
|
-
export function lintChangesetText(text) {
|
|
726
|
+
export function lintChangesetText(text, { labels = null } = {}) {
|
|
600
727
|
const { body, startLine } = stripFrontmatter(text);
|
|
601
|
-
const findings = lintSection(body).map((f) => ({
|
|
728
|
+
const findings = lintSection(body, { labels }).map((f) => ({
|
|
729
|
+
...f,
|
|
730
|
+
line: f.line + startLine - 1,
|
|
731
|
+
}));
|
|
602
732
|
return { findings };
|
|
603
733
|
}
|
|
604
734
|
|
|
@@ -606,10 +736,14 @@ export function lintChangesetText(text) {
|
|
|
606
736
|
* Lint the first `## <version>` release section of a `CHANGELOG.md`.
|
|
607
737
|
*
|
|
608
738
|
* @param {string} text - The changelog's full contents.
|
|
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.
|
|
609
743
|
* @returns {{findings: Array<{line?: number, column?: number,
|
|
610
744
|
* severity: "error"|"warning", message: string}>}}
|
|
611
745
|
*/
|
|
612
|
-
export function lintReleaseText(text) {
|
|
746
|
+
export function lintReleaseText(text, { labels = null } = {}) {
|
|
613
747
|
const section = extractReleaseSection(text);
|
|
614
748
|
if (!section) {
|
|
615
749
|
return {
|
|
@@ -621,9 +755,34 @@ export function lintReleaseText(text) {
|
|
|
621
755
|
],
|
|
622
756
|
};
|
|
623
757
|
}
|
|
624
|
-
const findings = lintSection(section.body).map((f) => ({
|
|
758
|
+
const findings = lintSection(section.body, { labels }).map((f) => ({
|
|
625
759
|
...f,
|
|
626
760
|
line: f.line + section.startLine - 1,
|
|
627
761
|
}));
|
|
628
762
|
return { findings };
|
|
629
763
|
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* The first `## <version>` release section of a changelog, as raw character
|
|
767
|
+
* offsets rather than {@link extractReleaseSection}'s line-joined copy.
|
|
768
|
+
*
|
|
769
|
+
* `extractReleaseSection` rebuilds its `body` by joining a slice of
|
|
770
|
+
* `text.split("\n")`, which is fine for reporting a line number but drops
|
|
771
|
+
* the exact byte the next `## ` heading sits after — a caller rewriting the
|
|
772
|
+
* file in place, such as `changelog group`, needs `text.slice(start, end)`
|
|
773
|
+
* to be the section verbatim, so it can splice a replacement back in without
|
|
774
|
+
* guessing at the whitespace on either side.
|
|
775
|
+
*
|
|
776
|
+
* @param {string} text - The changelog's full contents.
|
|
777
|
+
* @returns {{start: number, end: number}|null} `null` when no `## ` heading
|
|
778
|
+
* is present. `text.slice(start, end)` is the section, byte-exact,
|
|
779
|
+
* including whatever separates it from the next `## ` heading or the end
|
|
780
|
+
* of the file.
|
|
781
|
+
*/
|
|
782
|
+
export function releaseSectionRange(text) {
|
|
783
|
+
const matches = [...text.matchAll(/^## /gm)];
|
|
784
|
+
if (matches.length === 0) return null;
|
|
785
|
+
const start = matches[0].index;
|
|
786
|
+
const end = matches.length > 1 ? matches[1].index : text.length;
|
|
787
|
+
return { start, end };
|
|
788
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "22.4.
|
|
3
|
+
"version": "22.4.3",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"types": "./types/templates.d.mts",
|
|
92
92
|
"import": "./templates.mjs"
|
|
93
93
|
},
|
|
94
|
+
"./changelog": "./changelog.cjs",
|
|
94
95
|
"./package.json": "./package.json"
|
|
95
96
|
},
|
|
96
97
|
"bin": {
|
|
@@ -106,6 +107,7 @@
|
|
|
106
107
|
"packagebuild-metadata.jsonl",
|
|
107
108
|
"bin",
|
|
108
109
|
"bundle.mjs",
|
|
110
|
+
"changelog.cjs",
|
|
109
111
|
"ci",
|
|
110
112
|
"config.mjs",
|
|
111
113
|
"container.mjs",
|
|
@@ -177,8 +179,7 @@
|
|
|
177
179
|
"lint:content-format:schema": "node bin/content-build.mjs content-format schema --schema sohl=tests/fixtures/content-format/schema-sohl.json --schema hm3=tests/fixtures/content-format/schema-hm3.json",
|
|
178
180
|
"lint:content-format:fields": "node bin/content-build.mjs content-format fields --fields sohl && node bin/content-build.mjs content-format fields --fields hm3",
|
|
179
181
|
"changeset": "changeset",
|
|
180
|
-
"changeset:
|
|
181
|
-
"changeset:version": "changeset version && npm install --package-lock-only",
|
|
182
|
+
"changeset:version": "changeset version && node bin/package-build.mjs changelog group && npm install --package-lock-only",
|
|
182
183
|
"prepare": "git config core.hooksPath .githooks || true"
|
|
183
184
|
},
|
|
184
185
|
"engines": {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `changelog group`: fold the newest release's changeset blocks together by
|
|
3
|
+
* their bold label, order them, and file an undeclared one last.
|
|
4
|
+
*
|
|
5
|
+
* Every earlier release section is untouched, byte for byte — only the
|
|
6
|
+
* first `## <version>` section's `### <Bump> Changes` bodies are rewritten,
|
|
7
|
+
* each independently (a label groups within its own bump level, never
|
|
8
|
+
* across one). Running this on its own output is a no-op: a release already
|
|
9
|
+
* in label order, with each label merged to one block, groups to itself.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} text - The changelog's full contents.
|
|
12
|
+
* @param {object} [opts]
|
|
13
|
+
* @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
|
|
14
|
+
* display order. `null`/absent orders every group by first appearance
|
|
15
|
+
* instead, lead paragraph first, and files nothing as unknown.
|
|
16
|
+
* @returns {{text: string, findings: Array<{line: number,
|
|
17
|
+
* severity: "warning", message: string}>}}
|
|
18
|
+
*/
|
|
19
|
+
export function groupChangelogText(text: string, { labels }?: {
|
|
20
|
+
labels?: readonly string[] | null | undefined;
|
|
21
|
+
}): {
|
|
22
|
+
text: string;
|
|
23
|
+
findings: Array<{
|
|
24
|
+
line: number;
|
|
25
|
+
severity: "warning";
|
|
26
|
+
message: string;
|
|
27
|
+
}>;
|
|
28
|
+
};
|