@heroiclands/package-build 25.1.3 → 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 (83) hide show
  1. package/CHANGELOG.md +29 -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/guides/book.md +3 -3
  15. package/docs/guides/module-project.md +1 -1
  16. package/docs/guides/reference-pages.md +7 -3
  17. package/docs/project-setup.md +3 -0
  18. package/docs/reference/format-details.md +299 -185
  19. package/docs/reference/note-types.md +16 -17
  20. package/engine/changelog-group.mjs +84 -173
  21. package/engine/changelog-labels.mjs +73 -0
  22. package/engine/changelog-lint.mjs +168 -44
  23. package/engine/content-expand.mjs +58 -38
  24. package/engine/content-format.yaml +0 -2
  25. package/engine/content-index.mjs +49 -23
  26. package/engine/content-links.mjs +4 -0
  27. package/engine/content-lint.mjs +2 -3
  28. package/engine/content-render.mjs +6 -2
  29. package/engine/culture-parents.mjs +1 -1
  30. package/engine/culture-reach.mjs +10 -1
  31. package/engine/diagnostics.mjs +3 -2
  32. package/engine/dictionary-rows.mjs +3 -1
  33. package/engine/first-use.mjs +301 -87
  34. package/engine/frontmatter-lint.mjs +40 -14
  35. package/engine/glossary-entries.mjs +5 -5
  36. package/engine/glossary-language.mjs +21 -44
  37. package/engine/homepage.mjs +0 -4
  38. package/engine/index-records.mjs +3 -1
  39. package/engine/infobox.mjs +6 -3
  40. package/engine/language-names.mjs +3 -2
  41. package/engine/language-rows.mjs +6 -7
  42. package/engine/lexical-sections.mjs +3 -1
  43. package/engine/literature-notes.mjs +4 -76
  44. package/engine/metadata-index.mjs +15 -8
  45. package/engine/name-forms.mjs +99 -0
  46. package/engine/name-refs.mjs +143 -0
  47. package/engine/name-rows.mjs +11 -1
  48. package/engine/names.mjs +27 -22
  49. package/engine/note-addresses.mjs +0 -3
  50. package/engine/note-frontmatter.mjs +3 -5
  51. package/engine/note-language.mjs +348 -0
  52. package/engine/note-vocabulary.mjs +13 -11
  53. package/engine/pdf-index.mjs +3 -2
  54. package/engine/pdf-render.mjs +2 -4
  55. package/engine/settlement-tables.mjs +4 -3
  56. package/engine/site-build.mjs +2 -7
  57. package/engine/site-search.mjs +3 -5
  58. package/engine/site-theme.mjs +1 -1
  59. package/engine/sql-tables.mjs +29 -14
  60. package/engine/wikilink-syntax.mjs +2 -0
  61. package/engine/word-citations.mjs +3 -3
  62. package/engine/word-rows.mjs +3 -1
  63. package/engine/word-terms.mjs +2 -2
  64. package/package.json +1 -1
  65. package/site-theme/static/css/style.css +0 -4
  66. package/types/engine/changelog-group.d.mts +6 -10
  67. package/types/engine/changelog-labels.d.mts +10 -0
  68. package/types/engine/changelog-lint.d.mts +11 -6
  69. package/types/engine/content-index.d.mts +16 -16
  70. package/types/engine/diagnostics.d.mts +1 -1
  71. package/types/engine/first-use.d.mts +97 -1
  72. package/types/engine/frontmatter-lint.d.mts +9 -2
  73. package/types/engine/glossary-language.d.mts +13 -6
  74. package/types/engine/homepage.d.mts +0 -3
  75. package/types/engine/literature-notes.d.mts +2 -27
  76. package/types/engine/name-forms.d.mts +61 -0
  77. package/types/engine/name-refs.d.mts +51 -0
  78. package/types/engine/name-rows.d.mts +4 -0
  79. package/types/engine/names.d.mts +12 -92
  80. package/types/engine/note-frontmatter.d.mts +1 -4
  81. package/types/engine/note-language.d.mts +47 -0
  82. package/types/engine/settlement-tables.d.mts +1 -1
  83. package/types/engine/site-build.d.mts +2 -6
@@ -15,18 +15,18 @@ The [first-note guide](../authoring/first-note.md) teaches the shape of a note.
15
15
 
16
16
  These are the only keys a note writes at its top level, in the order the formatter puts them. A key absent from this table is a lint error at its own line: the region is closed, so the key reaches no document and no page. Facts about the subject belong under `data`, and values for one game system belong inside that system's block.
17
17
 
18
- | Key | Meaning |
19
- | ------------- | ----------------------------------------------------------------------------------------- |
20
- | `shortcode` | The note's own address segment, unique within its type. |
21
- | `name` | The display names — a required `full`, and any `aliases` and `sort`. |
22
- | `type` | What the note is about, which decides its vocabulary and its document. |
23
- | `subType` | The type's own genre, where it declares one. |
24
- | `description` | The short page summary. |
25
- | `tags` | Draft state, GM routing, and the descriptive labels a page list reads. |
26
- | `data` | The facts about the subject itself, shared by every system. |
27
- | `dnd5e` | What the `dnd5e` system makes of the subject — its document's mechanics, routing and art. |
28
- | `hm3` | What the `hm3` system makes of the subject — its document's mechanics, routing and art. |
29
- | `sohl` | What the `sohl` system makes of the subject — its document's mechanics, routing and art. |
18
+ | Key | Meaning |
19
+ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
20
+ | `shortcode` | The note's own address segment, unique within its type. |
21
+ | `name` | The display names — a required `full`, the English name; the other names, listed under the code of their language; and any `sort`. |
22
+ | `type` | What the note is about, which decides its vocabulary and its document. |
23
+ | `subType` | The type's own genre, where it declares one. |
24
+ | `description` | The short page summary. |
25
+ | `tags` | Draft state, GM routing, and the descriptive labels a page list reads. |
26
+ | `data` | The facts about the subject itself, shared by every system. |
27
+ | `dnd5e` | What the `dnd5e` system makes of the subject — its document's mechanics, routing and art. |
28
+ | `hm3` | What the `hm3` system makes of the subject — its document's mechanics, routing and art. |
29
+ | `sohl` | What the `sohl` system makes of the subject — its document's mechanics, routing and art. |
30
30
 
31
31
  ## Shared `data` fields
32
32
 
@@ -34,6 +34,7 @@ These fields are accepted by every note type. A field's value can still be irrel
34
34
 
35
35
  | Field | Shape | Meaning |
36
36
  | ----------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
37
+ | `data.lang` | a language code `languages:` declares, or `en` | The language the note is in. Absent, the content index derives it: the note's culture's language, then its `data.parents`' nearest first, then `en`. |
37
38
  | `data.seeAlso` | list of `{ address, gloss? }` | Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books. |
38
39
  | `data.calendar` | an Address | The calendar this note's dates print in, as a calendar lore note. Absent, the note takes its culture's calendar, then — for a place — its parents', then the configured `defaultCalendar`. |
39
40
  | `data.id` | string | A fixed document identity when the derived identity is unsuitable. |
@@ -139,7 +140,6 @@ These fields are accepted by every note type. A field's value can still be irrel
139
140
  | `data.lore` | list of Addresses | Lore concerning it — the peoples it draws on, the god a faith venerates, its law, its calendar. |
140
141
  | `data.parents` | list of Addresses | Affiliations it is subordinate to. |
141
142
  | `data.culture` | an Address | The people this affiliation belongs to, as a culture lore note. |
142
- | `data.language` | an Address | The trade, temple or order tongue the affiliation keeps, as a language skill note. A glossary entry that states no `language` is in it. The affiliation chooses its own common vocabulary and context meanings. |
143
143
  | `data.glossary` | list of `{ name, name_sort?, common?, definition?, pronunciation?, language? }` | Its own glossary entries: common vocabulary, dictionary words with a contextual meaning, and phrases, compounds or terms of art. A named dictionary word supplies its senses and class unless `definition` supplies the sense. Common marks belong to this owner. Every entry resolves to a language. Empty when unstated. See [glossary terms](format-details.md#glossary-terms). |
144
144
  | `data.relations` | a map keyed by Address | Standing with other affiliations — aligned, unaligned, rival, nemesis. |
145
145
 
@@ -200,7 +200,7 @@ These fields are accepted by every note type. A field's value can still be irrel
200
200
  | `data.glossary[].common` | `true` or `false` | no | Core vocabulary for this glossary's owner; `false` when unstated. |
201
201
  | `data.glossary[].definition` | a string | no | The sense text, overriding the word's or governance sources' senses. Unstated, a named dictionary word supplies all its senses and class; a named rank or office supplies its descriptions. |
202
202
  | `data.glossary[].pronunciation` | a respelling such as `sol-MEH-reh` | no | A respelling, held to the same rule as a word's; the dictionary word's when unstated. |
203
- | `data.glossary[].language` | a language skill Address | no | The language the term is in. Unstated, it is the affiliation's language, its parents', or its culture's, or the culture's own. |
203
+ | `data.glossary[].language` | a language skill Address | no | The language the term is in. Unstated, it is the note of the language the note is in, its `data.lang` as the content index derives it. |
204
204
 
205
205
  **System mappings**
206
206
 
@@ -791,11 +791,10 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
791
791
  | `data.namedDays` | list of `{ name, abbreviation?, day }` | Names assigned to particular days of the year. |
792
792
  | `data.eras` | list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, label? }` | The year-counts kept in this calendar. A marker names one era and uses these months. |
793
793
  | `data.formats` | map of named Calendaria format strings | Named patterns for reading and writing this calendar's dates. |
794
- | `data.parents` | list of Addresses | Culture only: parent culture lore notes, searched in listed depth-first order for omitted language and appearance profile fields. |
794
+ | `data.parents` | list of Addresses | Culture only: parent culture lore notes, searched in listed depth-first order for an omitted appearance profile, and nearest first for an omitted `data.lang`. |
795
795
  | `data.culture` | an Address | The people this lore belongs to — for a work of literature, the people whose work it is — as a culture lore note. A culture note is its own culture, and states the field only as itself. |
796
796
  | `data.form` | string | The kind of work in its people's own terms — an epic, a saga, a praise-song, an elegy. Free text. |
797
797
  | `data.subjects` | list of Addresses | The beings, places, gods and other notes the work concerns, and each event it concerns, named as `place-ironfells#sack`. |
798
- | `data.language` | an Address | The tongue the note is in, as a language skill note: the tongue a work is composed in, a culture's people speak, or an affiliation keeps as its trade, temple or order tongue. `package-build npc new` draws a culture's names from its language's `data.names`. A `literature` or `culture` lore note, or an affiliation, only. |
799
798
  | `data.events` | list of event entries | What happened to or at this subject — each entry one dated, attributed event: a founding, a war, a fall, or an occasion that recurs. |
800
799
  | `data.appearanceProfile` | an appearance profile | What this culture's people look like, for `package-build npc new`: `frame`, `eye_color`, `hair_color`, `skin_color` and `complexion` each map words of that field's vocabulary to positive weights, and `height` maps a gender to a `{min, max}` range, `female` and `male` required. A culture note only. See [appearance profiles](format-details.md#appearance-profiles). |
801
800
  | `data.glossary` | list of `{ name, name_sort?, common?, definition?, pronunciation?, language? }` | `culture` only: Its own glossary entries: common vocabulary, dictionary words with a contextual meaning, and phrases, compounds or terms of art. A named dictionary word supplies its senses and class unless `definition` supplies the sense. Common marks belong to this owner. Every entry resolves to a language. Empty when unstated. See [glossary terms](format-details.md#glossary-terms). |
@@ -875,7 +874,7 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
875
874
  | `data.glossary[].common` | `true` or `false` | no | Core vocabulary for this glossary's owner; `false` when unstated. |
876
875
  | `data.glossary[].definition` | a string | no | The sense text, overriding the word's or governance sources' senses. Unstated, a named dictionary word supplies all its senses and class; a named rank or office supplies its descriptions. |
877
876
  | `data.glossary[].pronunciation` | a respelling such as `sol-MEH-reh` | no | A respelling, held to the same rule as a word's; the dictionary word's when unstated. |
878
- | `data.glossary[].language` | a language skill Address | no | The language the term is in. Unstated, it is the affiliation's language, its parents', or its culture's, or the culture's own. |
877
+ | `data.glossary[].language` | a language skill Address | no | The language the term is in. Unstated, it is the note of the language the note is in, its `data.lang` as the content index derives it. |
879
878
 
880
879
  ### map
881
880
 
@@ -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
+ }