@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.
- package/CHANGELOG.md +32 -0
- package/CONTENT.md +53 -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 +277 -0
- package/engine/changelog-lint.mjs +188 -9
- package/engine/homepage.mjs +9 -1
- package/engine/related-pages.mjs +161 -0
- package/engine/site-build.mjs +107 -9
- package/engine/site-index.mjs +5 -1
- package/engine/web-wikilinks.mjs +13 -2
- package/package.json +4 -3
- package/types/engine/changelog-group.d.mts +28 -0
- package/types/engine/changelog-lint.d.mts +109 -2
- package/types/engine/homepage.d.mts +8 -1
- package/types/engine/related-pages.d.mts +70 -0
- package/types/engine/site-build.d.mts +36 -3
- package/types/engine/site-index.d.mts +5 -1
- package/types/engine/web-wikilinks.d.mts +11 -2
package/docs/project-setup.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
134
|
-
are separate scripts rather than one because a failing chain stops at its
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
`
|
|
238
|
-
|
|
239
|
-
|
|
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) => ({
|
|
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
|
+
}
|
package/engine/homepage.mjs
CHANGED
|
@@ -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
|
|