@heroiclands/package-build 25.1.2 → 25.2.0

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/assets/styles/content-base.css +0 -9
  3. package/bin/content-commands.mjs +22 -16
  4. package/bin/package-build.mjs +24 -25
  5. package/content-config.mjs +7 -34
  6. package/docs/api.md +38 -21
  7. package/docs/authoring/first-note.md +3 -3
  8. package/docs/authoring/frontmatter.md +30 -2
  9. package/docs/authoring/links-and-markup.md +92 -31
  10. package/docs/commands.md +103 -59
  11. package/docs/configuration.md +10 -45
  12. package/docs/content-format.md +7 -5
  13. package/docs/diagnostics.md +75 -0
  14. package/docs/encoding-core.md +11 -0
  15. package/docs/guides/book.md +3 -3
  16. package/docs/guides/module-project.md +1 -1
  17. package/docs/guides/reference-pages.md +7 -3
  18. package/docs/project-setup.md +3 -0
  19. package/docs/reference/format-details.md +329 -187
  20. package/docs/reference/note-types.md +274 -274
  21. package/engine/changelog-group.mjs +84 -173
  22. package/engine/changelog-labels.mjs +73 -0
  23. package/engine/changelog-lint.mjs +168 -44
  24. package/engine/content-expand.mjs +58 -38
  25. package/engine/content-format.yaml +0 -2
  26. package/engine/content-index.mjs +49 -23
  27. package/engine/content-links.mjs +4 -0
  28. package/engine/content-lint.mjs +2 -3
  29. package/engine/content-render.mjs +6 -2
  30. package/engine/culture-parents.mjs +1 -1
  31. package/engine/culture-reach.mjs +10 -1
  32. package/engine/diagnostics.mjs +3 -2
  33. package/engine/dictionary-rows.mjs +3 -1
  34. package/engine/encoding-lexicon.mjs +12 -2
  35. package/engine/first-use.mjs +301 -87
  36. package/engine/frontmatter-lint.mjs +40 -14
  37. package/engine/glossary-entries.mjs +5 -5
  38. package/engine/glossary-language.mjs +21 -44
  39. package/engine/homepage.mjs +0 -4
  40. package/engine/index-records.mjs +3 -1
  41. package/engine/infobox.mjs +6 -3
  42. package/engine/language-names.mjs +70 -10
  43. package/engine/language-rows.mjs +6 -7
  44. package/engine/lexical-sections.mjs +3 -1
  45. package/engine/literature-notes.mjs +4 -76
  46. package/engine/metadata-index.mjs +15 -8
  47. package/engine/name-forms.mjs +99 -0
  48. package/engine/name-refs.mjs +143 -0
  49. package/engine/name-rows.mjs +11 -1
  50. package/engine/names.mjs +27 -22
  51. package/engine/note-addresses.mjs +0 -3
  52. package/engine/note-frontmatter.mjs +3 -5
  53. package/engine/note-language.mjs +348 -0
  54. package/engine/note-vocabulary.mjs +35 -13
  55. package/engine/pdf-index.mjs +3 -2
  56. package/engine/pdf-render.mjs +2 -4
  57. package/engine/settlement-tables.mjs +4 -3
  58. package/engine/site-build.mjs +2 -7
  59. package/engine/site-search.mjs +3 -5
  60. package/engine/site-theme.mjs +1 -1
  61. package/engine/sql-tables.mjs +29 -14
  62. package/engine/wikilink-syntax.mjs +2 -0
  63. package/engine/word-citations.mjs +3 -3
  64. package/engine/word-rows.mjs +3 -1
  65. package/engine/word-terms.mjs +2 -2
  66. package/package.json +1 -1
  67. package/site-theme/static/css/style.css +0 -4
  68. package/types/engine/changelog-group.d.mts +6 -10
  69. package/types/engine/changelog-labels.d.mts +10 -0
  70. package/types/engine/changelog-lint.d.mts +11 -6
  71. package/types/engine/content-index.d.mts +16 -16
  72. package/types/engine/diagnostics.d.mts +1 -1
  73. package/types/engine/first-use.d.mts +97 -1
  74. package/types/engine/frontmatter-lint.d.mts +9 -2
  75. package/types/engine/glossary-language.d.mts +13 -6
  76. package/types/engine/homepage.d.mts +0 -3
  77. package/types/engine/language-names.d.mts +3 -0
  78. package/types/engine/literature-notes.d.mts +2 -27
  79. package/types/engine/name-forms.d.mts +61 -0
  80. package/types/engine/name-refs.d.mts +51 -0
  81. package/types/engine/name-rows.d.mts +4 -0
  82. package/types/engine/names.d.mts +12 -92
  83. package/types/engine/note-frontmatter.d.mts +1 -4
  84. package/types/engine/note-language.d.mts +47 -0
  85. package/types/engine/settlement-tables.d.mts +1 -1
  86. package/types/engine/site-build.d.mts +2 -6
@@ -12,29 +12,28 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Fold a release's changeset blocks together by their bold label.
15
+ * Turn Changesets' output for the newest release into its final shape.
16
16
  *
17
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.
18
+ * changeset's summary into `CHANGELOG.md` verbatim, under a
19
+ * `### Major Changes`, `### Minor Changes` or `### Patch Changes` heading,
20
+ * one block per changeset in whatever order Changesets read the files. A
21
+ * release is read by subject, not by bump, so `groupChangelogText` removes
22
+ * every one of those headings, merges the blocks that share a label into one,
23
+ * and orders the labels as the changelog `labels` declares them. A label the list
24
+ * does not declare follows the declared ones, in order of first appearance,
25
+ * and `Miscellaneous`, which every repository has without declaring it, is
26
+ * always last.
27
+ *
28
+ * It reuses {@link module:engine/changelog-lint.topLevelBlocks}, the block
29
+ * model `changelog check` reads a block's label from, rather than parsing the
30
+ * markdown a second way.
33
31
  *
34
32
  * @module
35
33
  */
36
34
 
37
35
  import {
36
+ MISCELLANEOUS,
38
37
  codeLineSet,
39
38
  lineColOf,
40
39
  releaseSectionRange,
@@ -43,43 +42,13 @@ import {
43
42
  } from "./changelog-lint.mjs";
44
43
 
45
44
  /** A `### <Bump> Changes` heading — the generated scaffold, never authored. */
46
- const CHANGES_HEADING_RE = /^### (?:Major|Minor|Patch) Changes$/gm;
47
-
48
- /**
49
- * A label, its complete opening paragraph and its remaining items.
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, introduction: string, rest: string}} The
54
- * introduction retains hard wrapping and stops before a bullet or code.
55
- */
56
- function splitLabelBlock(blockText) {
57
- const lines = blockText.replace(/\s+$/, "").split("\n");
58
- const labelLine = /^\*\*[^*]+\*\*/.exec(lines[0])[0];
59
- const inline = lines[0].slice(labelLine.length).trimStart();
60
- const introduction = inline ? [inline] : [];
61
- const codeLines = codeLineSet(blockText);
62
- let cursor = 1;
63
- while (
64
- cursor < lines.length &&
65
- lines[cursor].trim() !== "" &&
66
- !/^-\s/.test(lines[cursor]) &&
67
- !codeLines.has(cursor + 1)
68
- )
69
- introduction.push(lines[cursor++]);
70
- return {
71
- labelLine,
72
- introduction: introduction.join("\n"),
73
- rest: lines.slice(cursor).join("\n").replace(/^\n+/, ""),
74
- };
75
- }
45
+ const CHANGES_HEADING_RE = /^### (?:Major|Minor|Patch) Changes\s*$/;
76
46
 
77
47
  /**
78
48
  * A block's top-level bullets and the prose or code between them, in source
79
49
  * order. A bullet retains its indented continuation and nested items.
80
50
  *
81
- * @param {string} text - A block's content, label line already stripped (or
82
- * a lead block's whole text).
51
+ * @param {string} text - A block's content, label line already stripped.
83
52
  * @returns {string[]}
84
53
  */
85
54
  function itemsOf(text) {
@@ -111,7 +80,7 @@ function itemsOf(text) {
111
80
 
112
81
  /**
113
82
  * The first occurrence of each item, exact-duplicate text dropped, order
114
- * preserved — the "an exact-duplicate bullet once" rule.
83
+ * preserved.
115
84
  *
116
85
  * @param {string[]} items
117
86
  * @returns {string[]}
@@ -129,66 +98,43 @@ function dedupeItems(items) {
129
98
  }
130
99
 
131
100
  /**
132
- * Join a merged group's items the way its own shape calls for: bullets
133
- * adjacent, one to a line; a paragraph or a mix of paragraphs separated by a
134
- * blank line, the same spacing a changeset's own multi-paragraph summary
135
- * already uses.
136
- *
137
- * @param {string[]} items
138
- * @returns {string}
139
- */
140
- function joinItems(items) {
141
- if (items.length === 0) return "";
142
- const allBullets = items.every((item) => /^-\s/.test(item));
143
- return items.join(allBullets ? "\n" : "\n\n");
144
- }
145
-
146
- /**
147
- * One label's blocks (or every unlabelled one), folded into the single
148
- * block `group` writes for it.
149
- *
150
- * A single block with no duplicate items retains its own spacing. Merged
151
- * opening paragraphs form one hard-wrapped introduction before the items,
152
- * so paragraph boundaries cannot turn labelled text into unlabelled lead.
101
+ * One label's blocks, folded into the single block `group` writes for it:
102
+ * the label line, a blank line, then every bullet in file order. Bullets are
103
+ * adjacent; any other item is separated by a blank line.
153
104
  *
154
105
  * @param {Array<{label: string|null, text: string}>} blocks - Every block
155
- * sharing one label, in file order. All carry the same `label`.
106
+ * sharing one label, in file order.
156
107
  * @returns {string}
157
108
  */
158
109
  function mergeGroup(blocks) {
159
- if (blocks[0].label === null) {
160
- return joinItems(dedupeItems(blocks.flatMap((block) => itemsOf(block.text))));
161
- }
162
- const parts = blocks.map((block) => splitLabelBlock(block.text));
163
- const introductions = dedupeItems(parts.map((part) => part.introduction).filter(Boolean));
164
- const originalItems = parts.flatMap((part) => itemsOf(part.rest));
165
- const items = dedupeItems(originalItems);
166
- if (blocks.length === 1 && items.length === originalItems.length)
167
- return blocks[0].text.replace(/\s+$/, "");
168
- const heading =
169
- parts[0].labelLine + (introductions.length ? ` ${introductions.join("\n")}` : "");
170
- return heading + (items.length ? `\n\n${joinItems(items)}` : "");
110
+ const labelled = blocks[0].label !== null;
111
+ const items = dedupeItems(
112
+ blocks.flatMap((block) => {
113
+ if (!labelled) return itemsOf(block.text);
114
+ const [, ...rest] = block.text.replace(/\s+$/, "").split("\n");
115
+ return itemsOf(rest.join("\n"));
116
+ }),
117
+ );
118
+ const joined = items.join(items.every((item) => /^-\s/.test(item)) ? "\n" : "\n\n");
119
+ if (!labelled) return joined;
120
+ const labelLine = blocks[0].text.split("\n")[0].trimEnd();
121
+ return joined ? `${labelLine}\n\n${joined}` : labelLine;
171
122
  }
172
123
 
173
124
  /**
174
- * Where a group sorts, relative to the others in its section.
175
- *
176
- * The unlabelled lead group always sorts first, whether or not
177
- * `changelog.labels` is declared — it is the summary a changeset writes with
178
- * no category, and reads like the section's own opening line. A declared
179
- * vocabulary then orders everything else by its position in that list, with
180
- * an undeclared label sorted after every declared one; with no vocabulary
181
- * declared at all, every labelled group keeps the order its label first
182
- * appeared in.
183
- *
184
- * @param {string|null} key - A group's label, or `null` for the lead group.
185
- * @param {string[]} appearanceOrder - Every key, in the order its first
186
- * block appeared in the section.
187
- * @param {readonly string[]|null} labels - `changelog.labels`, or `null`.
125
+ * Where a group sorts in its release: an unlabelled block first, then the
126
+ * declared labels in their declared order, then any other label in order of
127
+ * first appearance, and `Miscellaneous` last. With no vocabulary declared,
128
+ * every label but `Miscellaneous` keeps its order of first appearance.
129
+ *
130
+ * @param {string|null} key - A group's label, or `null` for an unlabelled one.
131
+ * @param {string[]} appearanceOrder - Every key, in order of first appearance.
132
+ * @param {readonly string[]|null} labels - the changelog `labels`, or `null`.
188
133
  * @returns {[number, number]} `[tier, rank]`, compared tier first.
189
134
  */
190
135
  function groupRank(key, appearanceOrder, labels) {
191
136
  if (key === null) return [-1, 0];
137
+ if (key === MISCELLANEOUS) return [2, 0];
192
138
  if (labels) {
193
139
  const configured = labels.indexOf(key);
194
140
  return configured === -1 ? [1, appearanceOrder.indexOf(key)] : [0, configured];
@@ -197,26 +143,40 @@ function groupRank(key, appearanceOrder, labels) {
197
143
  }
198
144
 
199
145
  /**
200
- * Fold one `### <Bump> Changes` section's blocks together by label.
146
+ * `changelog group`: give the newest release its final shape.
147
+ *
148
+ * Every earlier release section is untouched, byte for byte. Running this on
149
+ * its own output is a no-op.
201
150
  *
202
- * @param {string} bodyText - Everything after the heading, up to the next
203
- * one or the end of the release section.
204
- * @param {readonly string[]|null} labels - `changelog.labels`, in display
205
- * order, or `null` when the repository declares none.
206
- * @returns {{text: string, unknown: Array<{label: string, startLine: number}>}}
207
- * The regrouped body (no leading or trailing blank lines), and every label
208
- * it wrote that `labels` does not declare, each at its merged block's
209
- * first-occurring line within `bodyText` — empty when `labels` is `null`,
210
- * since nothing is "unknown" against no vocabulary.
151
+ * @param {string} text - The changelog's full contents.
152
+ * @param {object} [opts]
153
+ * @param {readonly string[]|null} [opts.labels] - the changelog `labels`, in
154
+ * display order. `null`/absent orders by first appearance and reports
155
+ * nothing as undeclared.
156
+ * @returns {{text: string, findings: Array<{line: number,
157
+ * severity: "warning", message: string}>}}
211
158
  */
212
- function groupSection(bodyText, labels) {
213
- const codeLines = codeLineSet(bodyText);
214
- const blocks = topLevelBlocks(bodyText, codeLines);
215
- if (blocks.length === 0) return { text: "", unknown: [] };
159
+ export function groupChangelogText(text, { labels = null } = {}) {
160
+ const range = releaseSectionRange(text);
161
+ if (!range) return { text, findings: [] };
216
162
 
217
- /** @type {string[]} */
163
+ const section = text.slice(range.start, range.end);
164
+ const headingEnd = section.indexOf("\n");
165
+ if (headingEnd === -1) return { text, findings: [] };
166
+ const heading = section.slice(0, headingEnd);
167
+
168
+ // The scaffold headings become blank lines, so every line keeps its number.
169
+ const codeLines = codeLineSet(section.slice(headingEnd + 1));
170
+ const body = section
171
+ .slice(headingEnd + 1)
172
+ .split("\n")
173
+ .map((line, i) => (!codeLines.has(i + 1) && CHANGES_HEADING_RE.test(line) ? "" : line))
174
+ .join("\n");
175
+
176
+ const blocks = topLevelBlocks(body, codeLines);
177
+ /** @type {Array<string|null>} */
218
178
  const appearanceOrder = [];
219
- /** @type {Map<string|null, Array<{label: string|null, text: string}>>} */
179
+ /** @type {Map<string|null, Array<{startLine: number, label: string|null, text: string}>>} */
220
180
  const groups = new Map();
221
181
  for (const block of blocks) {
222
182
  if (!groups.has(block.label)) {
@@ -225,77 +185,28 @@ function groupSection(bodyText, labels) {
225
185
  }
226
186
  groups.get(block.label).push(block);
227
187
  }
228
-
229
188
  const sortedKeys = [...appearanceOrder].sort((a, b) => {
230
189
  const [aTier, aRank] = groupRank(a, appearanceOrder, labels);
231
190
  const [bTier, bRank] = groupRank(b, appearanceOrder, labels);
232
191
  return aTier - bTier || aRank - bRank;
233
192
  });
234
193
 
235
- const unknown =
236
- labels === null ?
237
- []
238
- : sortedKeys
239
- .filter((key) => key !== null && !labels.includes(key))
240
- .map((key) => ({ label: key, startLine: groups.get(key)[0].startLine }));
241
-
242
- const text = sortedKeys.map((key) => mergeGroup(groups.get(key))).join("\n\n");
243
- return { text, unknown };
244
- }
245
-
246
- /**
247
- * `changelog group`: fold the newest release's changeset blocks together by
248
- * their bold label, order them, and file an undeclared one last.
249
- *
250
- * Every earlier release section is untouched, byte for byte — only the
251
- * first `## <version>` section's `### <Bump> Changes` bodies are rewritten,
252
- * each independently (a label groups within its own bump level, never
253
- * across one). Running this on its own output is a no-op: a release already
254
- * in label order, with each label merged to one block, groups to itself.
255
- *
256
- * @param {string} text - The changelog's full contents.
257
- * @param {object} [opts]
258
- * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
259
- * display order. `null`/absent orders every group by first appearance
260
- * instead, lead paragraph first, and files nothing as unknown.
261
- * @returns {{text: string, findings: Array<{line: number,
262
- * severity: "warning", message: string}>}}
263
- */
264
- export function groupChangelogText(text, { labels = null } = {}) {
265
- const range = releaseSectionRange(text);
266
- if (!range) return { text, findings: [] };
267
-
268
- const section = text.slice(range.start, range.end);
269
- const codeLines = codeLineSet(section);
270
- const headings = [...section.matchAll(CHANGES_HEADING_RE)].filter(
271
- (heading) => !codeLines.has(lineColOf(section, heading.index).line),
272
- );
273
- if (headings.length === 0) return { text, findings: [] };
274
-
275
- let rebuilt = section.slice(0, headings[0].index);
276
- /** @type {Array<{line: number, severity: "warning", message: string}>} */
194
+ const { line: bodyFirstLine } = lineColOf(text, range.start + headingEnd + 1);
277
195
  const findings = [];
278
-
279
- for (let i = 0; i < headings.length; i++) {
280
- const heading = headings[i];
281
- const bodyStart = heading.index + heading[0].length;
282
- const bodyEnd = i + 1 < headings.length ? headings[i + 1].index : section.length;
283
- const { text: grouped, unknown } = groupSection(section.slice(bodyStart, bodyEnd), labels);
284
-
285
- const isVeryLast = i === headings.length - 1 && range.end === text.length;
286
- rebuilt += heading[0] + (grouped ? `\n\n${grouped}` : "") + (isVeryLast ? "\n" : "\n\n");
287
-
288
- const { line: bodyFirstLine } = lineColOf(text, range.start + bodyStart);
289
- for (const { label, startLine } of unknown) {
196
+ if (labels)
197
+ for (const key of sortedKeys) {
198
+ if (key === null || key === MISCELLANEOUS || labels.includes(key)) continue;
290
199
  findings.push({
291
- line: bodyFirstLine + (startLine - 1),
292
- severity: "warning",
200
+ line: bodyFirstLine + (groups.get(key)[0].startLine - 1),
201
+ severity: /** @type {const} */ ("warning"),
293
202
  message:
294
- `changelog-group/unknown-label "${label}" is not declared in ` +
295
- "`changelog.labels` — filed last, in order of first appearance",
203
+ `changelog-group/unknown-label "${key}" is not declared in ` +
204
+ "the changelog `labels` — filed after the declared labels, in order of first appearance",
296
205
  });
297
206
  }
298
- }
299
207
 
208
+ const grouped = sortedKeys.map((key) => mergeGroup(groups.get(key))).join("\n\n");
209
+ const rebuilt =
210
+ heading + (grouped ? `\n\n${grouped}` : "") + (range.end === text.length ? "\n" : "\n\n");
300
211
  return { text: text.slice(0, range.start) + rebuilt + text.slice(range.end), findings };
301
212
  }
@@ -0,0 +1,73 @@
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
+ * The labels a repository's changesets group under.
16
+ *
17
+ * They are the options of the changelog entry in `.changeset/config.json`,
18
+ * beside the generator they configure:
19
+ *
20
+ * ```json
21
+ * "changelog": ["@heroiclands/package-build/changelog", { "labels": ["Verse", "Rendering"] }]
22
+ * ```
23
+ *
24
+ * `changelog check` accepts a label only if it is listed there or is
25
+ * `Miscellaneous`, which every repository has and never declares.
26
+ *
27
+ * @module
28
+ */
29
+
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+
33
+ import { MISCELLANEOUS } from "./changelog-lint.mjs";
34
+
35
+ /**
36
+ * Read `labels` from the changelog options in `.changeset/config.json`.
37
+ *
38
+ * @param {string} [cwd] - The repository root.
39
+ * @returns {readonly string[]|null} The labels in display order, or `null`
40
+ * when the file, the options or `labels` is absent.
41
+ * @throws {Error} When `config.json` is not JSON, or `labels` is not an array
42
+ * of distinct non-empty strings or names `Miscellaneous`.
43
+ */
44
+ export function readChangelogLabels(cwd = process.cwd()) {
45
+ const file = path.join(cwd, ".changeset", "config.json");
46
+ if (!fs.existsSync(file)) return null;
47
+ const where = path.relative(cwd, file);
48
+ let config;
49
+ try {
50
+ config = JSON.parse(fs.readFileSync(file, "utf8"));
51
+ } catch (error) {
52
+ throw new Error(`${where}: is not valid JSON (${error.message})`);
53
+ }
54
+ const entry = config?.changelog;
55
+ const options = Array.isArray(entry) ? entry[1] : undefined;
56
+ if (options === undefined || options === null) return null;
57
+ if (typeof options !== "object" || Array.isArray(options))
58
+ throw new Error(`${where}: the changelog options must be an object`);
59
+ if (options.labels === undefined) return null;
60
+ const { labels } = options;
61
+ const bad = (problem) => new Error(`${where}: changelog \`labels\` ${problem}`);
62
+ if (!Array.isArray(labels)) throw bad("must be an array of strings");
63
+ const seen = new Set();
64
+ for (const label of labels) {
65
+ if (typeof label !== "string" || label.trim() === "")
66
+ throw bad("must hold only non-empty strings");
67
+ if (label === MISCELLANEOUS)
68
+ throw bad(`declares \`${MISCELLANEOUS}\`, which is always available and always last`);
69
+ if (seen.has(label)) throw bad(`declares \`${label}\` more than once`);
70
+ seen.add(label);
71
+ }
72
+ return Object.freeze([...labels]);
73
+ }