@heroiclands/package-build 20.3.0 → 20.4.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 (178) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/CONTENT.md +111 -92
  3. package/README.md +2 -9
  4. package/bin/content-build.mjs +70 -61
  5. package/bin/package-build.mjs +2 -2
  6. package/config.mjs +6 -8
  7. package/content-config.mjs +187 -64
  8. package/docs/content-format.md +57 -68
  9. package/e2e.mjs +4 -4
  10. package/engine/actor-compiler.mjs +36 -36
  11. package/engine/address-charset.mjs +6 -6
  12. package/engine/address-diff.mjs +12 -11
  13. package/engine/anchored-sections.mjs +1 -1
  14. package/engine/anchors.mjs +1 -1
  15. package/engine/base-compiler.mjs +36 -38
  16. package/engine/bundle-notes.mjs +4 -4
  17. package/engine/bundles.mjs +8 -8
  18. package/engine/code-fences.mjs +3 -3
  19. package/engine/compendiums.mjs +8 -8
  20. package/engine/compile-corpus.mjs +4 -5
  21. package/engine/content-address.mjs +15 -15
  22. package/engine/content-charset.mjs +1 -1
  23. package/engine/content-format-check.mjs +24 -25
  24. package/engine/content-format.mjs +8 -8
  25. package/engine/content-html.mjs +202 -0
  26. package/engine/content-icons.mjs +225 -98
  27. package/engine/content-index.mjs +17 -17
  28. package/engine/content-links.mjs +30 -30
  29. package/engine/content-lint.mjs +20 -24
  30. package/engine/content-package.mjs +7 -9
  31. package/engine/content-slug.mjs +2 -2
  32. package/engine/content-tables.mjs +7 -8
  33. package/engine/content-tree.mjs +1 -1
  34. package/engine/derived-fields.mjs +174 -0
  35. package/engine/diagnostics.mjs +3 -4
  36. package/engine/document-subtypes.mjs +6 -7
  37. package/engine/field-reference.mjs +8 -8
  38. package/engine/field-spec.mjs +29 -30
  39. package/engine/folder-notes.mjs +14 -14
  40. package/engine/foreign-catalog.mjs +8 -9
  41. package/engine/foundry-entries.mjs +19 -20
  42. package/engine/frontmatter-lint.mjs +64 -69
  43. package/engine/frontmatter.mjs +11 -11
  44. package/engine/generate.mjs +39 -39
  45. package/engine/helpers.mjs +63 -54
  46. package/engine/homepage.mjs +20 -22
  47. package/engine/ids.mjs +15 -15
  48. package/engine/index-records.mjs +4 -4
  49. package/engine/index.mjs +14 -8
  50. package/engine/item-compiler.mjs +54 -29
  51. package/engine/item-docs.mjs +11 -13
  52. package/engine/item-registry.mjs +13 -13
  53. package/engine/journals.mjs +15 -15
  54. package/engine/macros.mjs +3 -3
  55. package/engine/map-notes.mjs +6 -6
  56. package/engine/metadata-index.mjs +5 -5
  57. package/engine/note-claims.mjs +36 -34
  58. package/engine/note-ids.mjs +5 -5
  59. package/engine/note-package.mjs +4 -4
  60. package/engine/note-renames.mjs +3 -3
  61. package/engine/note-schemas.mjs +3 -3
  62. package/engine/note-vocabulary.mjs +27 -26
  63. package/engine/pack-config.mjs +13 -13
  64. package/engine/pack-router.mjs +0 -0
  65. package/engine/prose-config.mjs +9 -9
  66. package/engine/prose-lint.mjs +12 -13
  67. package/engine/region-events.mjs +2 -2
  68. package/engine/retired-fields.mjs +24 -22
  69. package/engine/runtime-only-fields.mjs +1 -1
  70. package/engine/scene-levels.mjs +1 -1
  71. package/engine/scenes.mjs +19 -20
  72. package/engine/schema-check.mjs +20 -20
  73. package/engine/schema-extract.mjs +1 -1
  74. package/engine/site-build.mjs +45 -47
  75. package/engine/site-index.mjs +17 -18
  76. package/engine/sql-tables.mjs +21 -13
  77. package/engine/subtype-registry.mjs +5 -4
  78. package/engine/system-block.mjs +18 -19
  79. package/engine/systems.mjs +2 -2
  80. package/engine/web-wikilinks.mjs +35 -35
  81. package/engine/wikilink-syntax.mjs +16 -16
  82. package/engine/wikilinks.mjs +39 -39
  83. package/hm3/actors.mjs +22 -22
  84. package/hm3/document-subtypes.mjs +5 -5
  85. package/hm3/index.mjs +2 -2
  86. package/hm3/item-builders.mjs +1 -1
  87. package/hm3/item-fields.mjs +3 -3
  88. package/hm3/items.mjs +20 -8
  89. package/hm3/template-priority.mjs +2 -2
  90. package/lang.mjs +3 -3
  91. package/manifest.mjs +17 -20
  92. package/package.json +1 -2
  93. package/release.mjs +3 -3
  94. package/sohl/actors.mjs +28 -28
  95. package/sohl/affiliation-standings.mjs +2 -2
  96. package/sohl/being-info.mjs +5 -5
  97. package/sohl/default-item-art.mjs +5 -5
  98. package/sohl/document-subtypes.mjs +5 -5
  99. package/sohl/index.mjs +3 -3
  100. package/sohl/item-builders.mjs +7 -7
  101. package/sohl/item-fields.mjs +8 -9
  102. package/sohl/items.mjs +20 -6
  103. package/sohl/kb-passes.mjs +5 -5
  104. package/sohl/note-schemas.mjs +7 -7
  105. package/sohl/skill-base.mjs +1 -1
  106. package/types/content-config.d.mts +25 -24
  107. package/types/e2e.d.mts +3 -3
  108. package/types/engine/actor-compiler.d.mts +14 -13
  109. package/types/engine/address-charset.d.mts +6 -6
  110. package/types/engine/address-diff.d.mts +3 -3
  111. package/types/engine/base-compiler.d.mts +17 -19
  112. package/types/engine/bundle-notes.d.mts +3 -3
  113. package/types/engine/bundles.d.mts +1 -1
  114. package/types/engine/code-fences.d.mts +3 -3
  115. package/types/engine/compendiums.d.mts +3 -3
  116. package/types/engine/compile-corpus.d.mts +1 -1
  117. package/types/engine/content-address.d.mts +11 -11
  118. package/types/engine/content-format-check.d.mts +6 -6
  119. package/types/engine/content-format.d.mts +2 -2
  120. package/types/engine/content-html.d.mts +78 -0
  121. package/types/engine/content-icons.d.mts +150 -62
  122. package/types/engine/content-index.d.mts +2 -2
  123. package/types/engine/content-links.d.mts +8 -8
  124. package/types/engine/content-lint.d.mts +2 -2
  125. package/types/engine/content-package.d.mts +6 -8
  126. package/types/engine/derived-fields.d.mts +101 -0
  127. package/types/engine/diagnostics.d.mts +2 -2
  128. package/types/engine/document-subtypes.d.mts +3 -3
  129. package/types/engine/field-spec.d.mts +37 -39
  130. package/types/engine/folder-notes.d.mts +4 -5
  131. package/types/engine/foreign-catalog.d.mts +3 -3
  132. package/types/engine/foundry-entries.d.mts +6 -7
  133. package/types/engine/frontmatter-lint.d.mts +7 -7
  134. package/types/engine/frontmatter.d.mts +7 -7
  135. package/types/engine/generate.d.mts +7 -7
  136. package/types/engine/helpers.d.mts +25 -27
  137. package/types/engine/homepage.d.mts +16 -18
  138. package/types/engine/ids.d.mts +13 -13
  139. package/types/engine/index-records.d.mts +3 -3
  140. package/types/engine/index.d.mts +2 -0
  141. package/types/engine/item-compiler.d.mts +21 -5
  142. package/types/engine/item-docs.d.mts +2 -2
  143. package/types/engine/item-registry.d.mts +6 -6
  144. package/types/engine/journals.d.mts +3 -3
  145. package/types/engine/map-notes.d.mts +2 -2
  146. package/types/engine/metadata-index.d.mts +4 -4
  147. package/types/engine/note-claims.d.mts +16 -15
  148. package/types/engine/note-ids.d.mts +4 -4
  149. package/types/engine/note-package.d.mts +1 -1
  150. package/types/engine/note-renames.d.mts +3 -3
  151. package/types/engine/note-vocabulary.d.mts +9 -8
  152. package/types/engine/pack-config.d.mts +3 -3
  153. package/types/engine/pack-router.d.mts +1 -1
  154. package/types/engine/prose-config.d.mts +9 -9
  155. package/types/engine/prose-lint.d.mts +3 -4
  156. package/types/engine/region-events.d.mts +2 -2
  157. package/types/engine/retired-fields.d.mts +10 -9
  158. package/types/engine/schema-check.d.mts +11 -11
  159. package/types/engine/site-build.d.mts +18 -19
  160. package/types/engine/site-index.d.mts +3 -4
  161. package/types/engine/sql-tables.d.mts +11 -5
  162. package/types/engine/subtype-registry.d.mts +3 -3
  163. package/types/engine/system-block.d.mts +3 -3
  164. package/types/engine/web-wikilinks.d.mts +7 -7
  165. package/types/engine/wikilink-syntax.d.mts +17 -17
  166. package/types/engine/wikilinks.d.mts +13 -12
  167. package/types/hm3/actors.d.mts +1 -1
  168. package/types/hm3/document-subtypes.d.mts +1 -1
  169. package/types/hm3/items.d.mts +1 -2
  170. package/types/hm3/template-priority.d.mts +1 -1
  171. package/types/manifest.d.mts +8 -8
  172. package/types/sohl/actors.d.mts +2 -2
  173. package/types/sohl/affiliation-standings.d.mts +2 -2
  174. package/types/sohl/being-info.d.mts +2 -2
  175. package/types/sohl/document-subtypes.d.mts +1 -1
  176. package/types/sohl/items.d.mts +1 -2
  177. package/types/sohl/note-schemas.d.mts +1 -1
  178. package/MIGRATING.md +0 -608
@@ -52,22 +52,25 @@
52
52
  * Both forms end here, so both are validated and frozen identically.
53
53
  *
54
54
  * **`rootDir` anchors every path**, so the build reads the same files whatever
55
- * directory it was launched from (#1508).
55
+ * directory it was launched from.
56
56
  *
57
57
  * The Foundry floor is declared here as top-level `compatibility`, and the
58
58
  * shipped manifest is generated *from* this file. That reverses an older rule —
59
59
  * configuration named where the manifest was and read the floor back out of it —
60
60
  * which was right while the manifest was hand-authored and became a round trip
61
- * through a generated artifact once it was not (#50, package-build#9).
61
+ * through a generated artifact once it was not.
62
62
  *
63
63
  * @module
64
64
  */
65
65
 
66
+ import fs from "node:fs";
66
67
  import path from "node:path";
68
+ import YAML from "yaml";
67
69
 
68
70
  // Leaves with no local imports of their own, so naming them here cannot close
69
71
  // a cycle around a consumer's config file (see `engine/pack-config.mjs`).
70
72
  import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./engine/address-charset.mjs";
73
+ import { EMPTY_ICON_REGISTRY, checkIconRegistry } from "./engine/content-icons.mjs";
71
74
  import { MAP_TYPES, PACK_BY_TYPE } from "./engine/ids.mjs";
72
75
  import { ACTOR_TYPES } from "./engine/subtype-registry.mjs";
73
76
  import { NOTE_VOCABULARY } from "./engine/note-vocabulary.mjs";
@@ -92,7 +95,7 @@ export const DEFAULT_PATHS = /** @type {const} */ ({
92
95
  // Where `content-index` writes this package's note index. Under `build/`
93
96
  // because it is derived and disposable — regenerating it costs a
94
97
  // frontmatter parse — and emphatically not under `stage`, which is mirrored
95
- // into a Foundry data root (#224).
98
+ // into a Foundry data root.
96
99
  contentIndex: "build/content-index",
97
100
  packJson: "build/packs-json",
98
101
  stage: "build/stage/packs",
@@ -100,7 +103,7 @@ export const DEFAULT_PATHS = /** @type {const} */ ({
100
103
  // Where a dependency declaring `itemCatalog: true` is unpacked. Under
101
104
  // `build/` because it is derived, disposable, and version-keyed.
102
105
  foreignCache: "build/cache/foreign",
103
- // Where a dependency's published content index is fetched to (#239). A
106
+ // Where a dependency's published content index is fetched to. A
104
107
  // sibling of the item catalogue rather than a subdirectory of it: the two
105
108
  // are fetched for different dependency sets — a catalogue only where
106
109
  // `itemCatalog: true` is declared, an index for *every* declared
@@ -112,7 +115,7 @@ export const DEFAULT_PATHS = /** @type {const} */ ({
112
115
  /**
113
116
  * The Foundry document types a compendium pack may hold. This is the set the
114
117
  * toolchain is able to compile a pack of; a document type Foundry supports but
115
- * this toolchain does not compile is deliberately absent (see #1501 — playlists
118
+ * this toolchain does not compile is deliberately absent (playlists
116
119
  * and roll tables are out of scope).
117
120
  *
118
121
  * @satisfies {readonly PackDocumentType[]}
@@ -139,15 +142,11 @@ export const PACK_DOCUMENT_TYPES = /** @type {const} */ ([
139
142
  * key is **refused**, at the line it was written on, with a message that says
140
143
  * the mechanism is gone rather than naming a value to correct.
141
144
  *
142
- * **What `landing` did (#204).** It named which note addressed a whole section
145
+ * **`landing` is one such key.** It named which note addressed a whole section
143
146
  * rather than a page within one — a *landing page*, which therefore had no slug
144
- * of its own. #203 retired the second of its two rules and #204 retired the
145
- * concept both rules chose between: a section is a Hugo content directory that
146
- * the note format does not carry, a page's address names no directory, and so
147
- * no note lands anything. The key outlived its mechanism by one release only
148
- * because both publishing consumers still declared the then-true
149
- * `landing: readme`, and neither breaking them over a correct statement nor
150
- * accepting the key in silence was acceptable. Neither declares it now.
147
+ * of its own. There are no sections to address: a section is a Hugo content
148
+ * directory the note format does not carry, a page's address names no
149
+ * directory, and so no note lands anything.
151
150
  *
152
151
  * @type {Readonly<Record<string, string>>}
153
152
  */
@@ -168,7 +167,7 @@ export const RETIRED_ADDRESS_KEYS = Object.freeze({
168
167
  * `thalorna`, whose site is nothing but its content. It is not the package's
169
168
  * own mount point: where the package itself is served is the consuming build's
170
169
  * knowledge, held in `PACKAGE_BASE` (`engine/content-address.mjs`) and prefixed at
171
- * resolve time, so it is never recorded here (#1465).
170
+ * resolve time, so it is never recorded here.
172
171
  *
173
172
  * It is the whole scheme: `landing`, the key that named which note addressed a
174
173
  * whole section, is retired with the sections themselves — see
@@ -183,7 +182,7 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
183
182
  *
184
183
  * Every HeroicLands package publishes something: a top-level, human-authored
185
184
  * homepage at `https://www.heroiclands.org/<contentPackage>/` saying what the
186
- * module is, which system it needs and how to install it (#50). So there is no
185
+ * module is, which system it needs and how to install it. So there is no
187
186
  * value here meaning *no web presence at all* — homepage-only is the **floor**,
188
187
  * and the default.
189
188
  *
@@ -251,7 +250,7 @@ export function publishesContentPages(config) {
251
250
  * type** receives its document. The two are orthogonal, and both are needed
252
251
  * once a repository groups same-type documents editorially — which it may have
253
252
  * to, since a compendium UUID carries its pack name and collapsing such a
254
- * layout breaks every stored reference (#1566).
253
+ * layout breaks every stored reference.
255
254
  *
256
255
  * @typedef {object} PackSpec
257
256
  * @property {string} name Pack name — the manifest `name`, and the
@@ -317,7 +316,7 @@ export function publishesContentPages(config) {
317
316
  * Inbound, and fetched rather than
318
317
  * committed.
319
318
  * @property {string} [metadataCache] Where a dependency's published content
320
- * index is fetched to (#239). Inbound,
319
+ * index is fetched to. Inbound,
321
320
  * for *every* declared dependency, not
322
321
  * only those supplying a catalogue.
323
322
  */
@@ -473,13 +472,13 @@ export function publishesContentPages(config) {
473
472
  * `fields` is what makes the type documentable: a builder function says
474
473
  * nothing about the vocabulary it consumes, so a consumer that declares its
475
474
  * fields can generate its own authoring reference and check its own notes,
476
- * while one that does not is simply undocumented rather than broken (#22).
475
+ * while one that does not is simply undocumented rather than broken.
477
476
  *
478
477
  * @typedef {((fm: object) => object)|{system: (fm: object) => object, img?: string, fields?: readonly object[]}} ItemBuilderEntry
479
478
  */
480
479
 
481
480
  /**
482
- * One **registry** of a declared set, and the system it belongs to (#58).
481
+ * One **registry** of a declared set, and the system it belongs to.
483
482
  *
484
483
  * A repository shipping content for two systems declares one of these per
485
484
  * system: the accepted type vocabulary is their union, and a type both declare
@@ -518,14 +517,13 @@ export function publishesContentPages(config) {
518
517
  * two systems declares a **list** of
519
518
  * `{ system, builders }` registries
520
519
  * instead, and the accepted type
521
- * vocabulary is their union (#58).
520
+ * vocabulary is their union.
522
521
  * @property {PackSpec[]} packs Packs to compile. More than one entry
523
522
  * may share a `type`: a note then names
524
523
  * the pack it belongs in with its
525
524
  * `pack:` frontmatter, and one pack of
526
525
  * the type is marked `default: true` to
527
- * receive the notes that name none
528
- * (#1566).
526
+ * receive the notes that name none.
529
527
  * @property {PathsInput} [paths] Layout overrides. See {@link DEFAULT_PATHS}.
530
528
  * @property {string[]} [skipDirectories] Directory names the content walk ignores
531
529
  * wherever they appear (e.g. Obsidian's
@@ -571,12 +569,11 @@ export function publishesContentPages(config) {
571
569
  * the frontmatter fields each entry
572
570
  * declared. Sparse, like `itemArt` — a type
573
571
  * absent here compiles normally and is
574
- * simply undocumented (#22).
572
+ * simply undocumented.
575
573
  * @property {Readonly<Record<string, Readonly<Record<string, Function>>>>} itemBuildersBySystem
576
574
  * Derived: the same builders, kept per
577
575
  * declaring system. `{}` for the single
578
- * registry form, which names no system
579
- * (#58).
576
+ * registry form, which names no system.
580
577
  * @property {Readonly<Record<string, Readonly<Record<string, string>>>>} itemArtBySystem
581
578
  * Derived: the default art, per system.
582
579
  * @property {Readonly<Record<string, Readonly<Record<string, readonly object[]>>>>} itemFieldsBySystem
@@ -589,13 +586,17 @@ export function publishesContentPages(config) {
589
586
  * {@link ContentBuildConfigInput.itemBuilders},
590
587
  * unioned across every declared registry, so
591
588
  * the accepted item types and the builder
592
- * tables are one list (#1504).
589
+ * tables are one list.
593
590
  * @property {ReadonlySet<string>} docEntryTypes Derived: every type whose prose
594
591
  * compiles into a JournalEntry of its own —
595
592
  * the item types, plus `macro`, plus the map
596
593
  * types. The one set the compilers and the
597
594
  * link-manifest emitter both read.
598
595
  * @property {readonly string[]} skipDirectories
596
+ * @property {import("./engine/content-icons.mjs").IconRegistry} icons The
597
+ * fonts this package ships and the names it
598
+ * draws from them; empty when it declares
599
+ * none.
599
600
  * @property {readonly Readonly<ResolvedPackSpec>[]} packs
600
601
  * @property {readonly string[]} packDirectories Derived: every pack directory
601
602
  * the build produces, in compile order —
@@ -618,6 +619,7 @@ const CONFIG_KEYS = [
618
619
  "itemBuilders",
619
620
  "paths",
620
621
  "skipDirectories",
622
+ "icons",
621
623
  "packs",
622
624
  "docs",
623
625
  "site",
@@ -668,7 +670,7 @@ const STATS_KEYS = ["lastModifiedBy"];
668
670
  * How the loader hands {@link defineConfig} the system version it resolved.
669
671
  *
670
672
  * A **Symbol**, deliberately. `stats.systemVersion` is refused from an authored
671
- * configuration (#48), but the value still has to reach here from the loader —
673
+ * configuration, but the value still has to reach here from the loader —
672
674
  * which is the half that may do I/O, and which reads a system package's version
673
675
  * out of the adjacent `package.json`. A string key would be a second spelling of
674
676
  * the refused one, forgeable from YAML and reachable by `rejectUnknownKeys`; a
@@ -692,7 +694,7 @@ function isPlainObject(value) {
692
694
  * The dotted path is carried on the error as `field` as well as spelled into
693
695
  * the message, because the message alone is a good description and a bad
694
696
  * locator: the loader that read the file can resolve that path to a line and
695
- * column, and does (`locateConfigError` in `engine/pack-config.mjs`, #95).
697
+ * column, and does (`locateConfigError` in `engine/pack-config.mjs`).
696
698
  * Attaching it here rather than formatting here is what keeps this module
697
699
  * free of I/O — it is the leaf an `.mjs` configuration imports, so it may not
698
700
  * reach for the file it is validating.
@@ -739,7 +741,7 @@ function requireNonEmptyString(value, field) {
739
741
  * It is the first segment of every canonical address this repository publishes
740
742
  * (`package-system-type-shortcode`, so `sohl-none-doc-gear`), and an address is
741
743
  * read by counting hyphen-separated segments. So the value carries two
742
- * obligations that the rest of the configuration does not, and #59 asks for
744
+ * obligations that the rest of the configuration does not, and asks for
743
745
  * both to be **enforced rather than assumed** — the alternative is a package
744
746
  * whose addresses are simply unreadable, reported nowhere and discovered as
745
747
  * links that resolve to nothing.
@@ -754,9 +756,9 @@ function requireNonEmptyString(value, field) {
754
756
  * by asking whether the name is a known package, and a name in both
755
757
  * vocabularies makes one target readable two ways with no defensible pick.
756
758
  * Keeping the two disjoint is what lets a name be taken at face value; that
757
- * the package and the type are no longer *adjacent* segments (#59 put the
758
- * system between them) changes nothing, because the hazard was never
759
- * adjacency — it is that a short form omits the slots in between.
759
+ * the package and the type are not *adjacent* segments, the system sitting
760
+ * between them, changes nothing: the hazard is not adjacency, it is that a
761
+ * short form omits the slots in between.
760
762
  * One such collision is structural and cannot be fixed — `sohl` is both a
761
763
  * content package and a system id, because Foundry requires a system
762
764
  * package's id to *be* its system id, and `sohl-sohl-skill-clmb` is the
@@ -850,7 +852,7 @@ function optionalString(value, field) {
850
852
  function normalizePack(value, where, nested = false) {
851
853
  if (!isPlainObject(value)) fail(where, "must be an object");
852
854
  const pack = /** @type {Record<string, unknown>} */ (value);
853
- // Retired with the YAML it named (#260). Refused explicitly rather than
855
+ // Retired with the YAML it named. Refused explicitly rather than
854
856
  // left to the unknown-key check, because the useful thing to say is not
855
857
  // "no such key" but where the folders went: they are notes, and a pack
856
858
  // materialises the ones its documents reference.
@@ -967,6 +969,128 @@ function normalizePack(value, where, nested = false) {
967
969
  * @param {string} rootDir
968
970
  * @returns {Readonly<ResolvedPaths>}
969
971
  */
972
+ /**
973
+ * A package's icon registry — the fonts it ships and the names it draws from
974
+ * them.
975
+ *
976
+ * **Nothing is supplied by default.** A registry entry is a promise that a
977
+ * glyph will render, and only the package shipping the font can keep it: the
978
+ * Game-Icons webfont is built by a consumer from its own templates, and Font
979
+ * Awesome reaches neither the knowledgebase nor a printed page unless somebody
980
+ * puts it there. A toolchain that shipped a starter table would be promising on
981
+ * a consumer's behalf, and a name like `victory-star-tester` is one game
982
+ * system's vocabulary besides.
983
+ *
984
+ * So a package declares both halves, and a package that declares neither names
985
+ * no icons at all.
986
+ *
987
+ * **Two spellings, one shape.** The value is either the registry itself:
988
+ *
989
+ * ```yaml
990
+ * icons:
991
+ * families:
992
+ * fontawesome: { class: fa, styles: [solid, regular, brands], describe: Font Awesome Free }
993
+ * icons:
994
+ * being: { style: solid, icon: user, label: being }
995
+ * ```
996
+ *
997
+ * or a **path to a file holding it**, relative to this configuration:
998
+ *
999
+ * ```yaml
1000
+ * icons: assets/icon-registry.yaml
1001
+ * ```
1002
+ *
1003
+ * The file form is the one a real package wants. A registry is derived from
1004
+ * what the interface actually draws, so it is generated rather than hand-kept —
1005
+ * and a generated document inlined into a hand-edited configuration is a merge
1006
+ * conflict on every regeneration. Kept beside it, the generator owns one file
1007
+ * and the configuration owns the other.
1008
+ *
1009
+ * Validated with {@link module:engine/content-icons.checkIconRegistry}, whose
1010
+ * findings are warnings everywhere else and a **refusal** here: elsewhere the
1011
+ * question is whether one note is wrong, and here it is whether the table every
1012
+ * note is read against is.
1013
+ *
1014
+ * @param {unknown} value - The authored `icons:` value.
1015
+ * @param {string} rootDir - The configuration's own directory, which a relative
1016
+ * path is resolved against.
1017
+ * @returns {import("./engine/content-icons.mjs").IconRegistry} The frozen
1018
+ * registry.
1019
+ */
1020
+ function normalizeIcons(value, rootDir) {
1021
+ if (value === undefined) return EMPTY_ICON_REGISTRY;
1022
+
1023
+ let declared = value;
1024
+ let where = "icons";
1025
+ if (typeof value === "string") {
1026
+ if (!value.trim()) fail("icons", "is empty — name a file, or write the registry inline");
1027
+ const file = path.resolve(rootDir, value);
1028
+ let text;
1029
+ try {
1030
+ text = fs.readFileSync(file, "utf8");
1031
+ } catch {
1032
+ fail("icons", `names ${value}, which cannot be read from ${rootDir}`);
1033
+ }
1034
+ try {
1035
+ declared = YAML.parse(text);
1036
+ } catch (err) {
1037
+ fail("icons", `names ${value}, which is not readable YAML: ${err.message}`);
1038
+ }
1039
+ // A finding says which *file* is wrong, not which key of this one.
1040
+ where = value;
1041
+ if (declared === null || declared === undefined) {
1042
+ fail("icons", `names ${value}, which is empty`);
1043
+ }
1044
+ }
1045
+
1046
+ if (!isPlainObject(declared)) {
1047
+ fail(
1048
+ "icons",
1049
+ "must be a registry — `families` and `icons` — or a path to a file holding one",
1050
+ );
1051
+ }
1052
+
1053
+ const families = declared.families ?? {};
1054
+ const icons = declared.icons ?? {};
1055
+ if (!isPlainObject(families)) fail(`${where}.families`, "must be a mapping of name to family");
1056
+ if (!isPlainObject(icons)) fail(`${where}.icons`, "must be a mapping of name to icon entry");
1057
+
1058
+ for (const name of Object.keys(icons)) {
1059
+ // The name a note writes between the colons. Checked here rather than
1060
+ // left to the note, because an entry nothing can name is a silent
1061
+ // no-op: every use of it reports "no such icon" and the table says
1062
+ // otherwise.
1063
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
1064
+ fail(
1065
+ `${where}.icons.${name}`,
1066
+ "is not a name a note can write — `:icon-…:` takes lowercase " +
1067
+ "letters, digits and hyphens, the charset an address segment uses",
1068
+ );
1069
+ }
1070
+ }
1071
+
1072
+ const defaultFamily = declared.defaultFamily;
1073
+ if (defaultFamily !== undefined) {
1074
+ if (typeof defaultFamily !== "string" || !(defaultFamily in families)) {
1075
+ fail(
1076
+ `${where}.defaultFamily`,
1077
+ `names \`${defaultFamily}\`, which is not one of the declared families`,
1078
+ );
1079
+ }
1080
+ }
1081
+
1082
+ const findings = checkIconRegistry({ families, icons, defaultFamily }, where);
1083
+ if (findings.length) {
1084
+ fail("icons", findings.map((finding) => finding.message).join("; "));
1085
+ }
1086
+
1087
+ return Object.freeze({
1088
+ families: Object.freeze(families),
1089
+ defaultFamily,
1090
+ icons: Object.freeze(icons),
1091
+ });
1092
+ }
1093
+
970
1094
  function normalizePaths(value, rootDir) {
971
1095
  if (value !== undefined && !isPlainObject(value)) {
972
1096
  fail("paths", "must be an object");
@@ -1002,7 +1126,7 @@ function normalizeStats(value, derived) {
1002
1126
  const input = /** @type {Record<string, unknown>} */ (value);
1003
1127
 
1004
1128
  // **`systemId` and `systemVersion` are derived, and authoring a derived
1005
- // value is an error rather than an override (#48).** `systems:` is the
1129
+ // value is an error rather than an override.** `systems:` is the
1006
1130
  // single source: it says which systems this package stamps against, and
1007
1131
  // `requiresSystem` — or a lone declared system — says which one the
1008
1132
  // package-wide block takes. A system package answers for itself.
@@ -1115,7 +1239,7 @@ function normalizeDocs(value) {
1115
1239
  * One section's landing metadata — what a section says about itself on the
1116
1240
  * `_index.md` this build generates for it.
1117
1241
  *
1118
- * A generated landing is the *only* place a section can speak, and since #204 it
1242
+ * A generated landing is the *only* place a section can speak, and it
1119
1243
  * is the only place a section **exists**: a content page is addressed
1120
1244
  * `(type, shortcode)` and written flat under the mount, so no page creates a
1121
1245
  * directory and nothing else makes `<prefix><section>/` answer. This is
@@ -1127,18 +1251,18 @@ function normalizeDocs(value) {
1127
1251
  * written fourteen to twenty times per build against a contract every package
1128
1252
  * and every section shares. Unbounded there, a mistyped `descrption:` publishes
1129
1253
  * into front matter, is read by nobody, and says nothing to anyone — which is
1130
- * the failure #91 was filed about, moved one step downstream where no build can
1254
+ * the same failure, moved one step downstream where no build can
1131
1255
  * see it. So the keys are named here, and the writers emit what this produced
1132
- * rather than transcribing a second list of their own (#91).
1256
+ * rather than transcribing a second list of their own.
1133
1257
  *
1134
1258
  * `banner` and `description` are optional — the hero images are external assets
1135
1259
  * and not every section has one, and a section may reasonably have nothing to
1136
1260
  * add to its title. Each is left off entirely rather than written as
1137
1261
  * `undefined`, which is not a value YAML can carry.
1138
1262
  *
1139
- * **`listType` / `listSubType` say what the section lists**
1140
- * (heroiclands-hugo-theme#50). Since #204 a section's directory holds nothing
1141
- * but the `_index.md` written here, so a layout reading Hugo's `.Pages` finds
1263
+ * **`listType` / `listSubType` say what the section lists.** A section's
1264
+ * directory holds nothing but the `_index.md` written here, so a layout
1265
+ * reading Hugo's `.Pages` finds
1142
1266
  * no members and renders an empty landing. The membership survives in this map
1143
1267
  * and nowhere a theme can reach it, so the landing states it and a layout
1144
1268
  * substitutes the equivalent `site.RegularPages` query — the same one `sohl`'s
@@ -1155,7 +1279,7 @@ function normalizeDocs(value) {
1155
1279
  * Both are checked as **address segments**, which is the trap this came from:
1156
1280
  * a section is named for the URL a consumer chose and a subType is an address
1157
1281
  * segment, and the two need not agree — `/sohl/kb/user-guide/` is the section,
1158
- * `userguide` the subType (#207). Copying the section's name into the
1282
+ * `userguide` the subType. Copying the section's name into the
1159
1283
  * declaration would select no page at all, and an empty landing reported by
1160
1284
  * nobody is the failure being fixed. A `listSubType` with no `listType` is
1161
1285
  * refused for the same reason: a subType is only distinguishing *within* a
@@ -1349,10 +1473,10 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1349
1473
  * @returns {Readonly<Relationships>} It, frozen; `{}` when absent.
1350
1474
  */
1351
1475
  /**
1352
- * The systems this package can stamp content against — declaration only (#48).
1476
+ * The systems this package can stamp content against — declaration only.
1353
1477
  *
1354
1478
  * **Declaring is not requiring, and that separation is the whole point.** The
1355
- * only place to state a system version used to be `relationships.systems`, and
1479
+ * only other place to state a system version is `relationships.systems`, and
1356
1480
  * that list is a *restriction*: Foundry's `supportsSystem` drops a module from
1357
1481
  * any world whose system it does not name. So a module shipping content for two
1358
1482
  * systems — `harn-ensemble` ships an HM3 pack, a SoHL pack and a system-neutral
@@ -1371,8 +1495,7 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1371
1495
  * @returns {Readonly<Record<string, Readonly<object>>>} Frozen; `{}` when absent.
1372
1496
  */
1373
1497
  /**
1374
- * The **package-wide** system, or `null` where the configuration names none
1375
- * (#48).
1498
+ * The **package-wide** system, or `null` where the configuration names none.
1376
1499
  *
1377
1500
  * A *system* package is its own system, which is true by construction and needs
1378
1501
  * no declaration. A *module* takes the one it requires, or the one system it
@@ -1387,7 +1510,7 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1387
1510
  * exists to remove. Several entries have no single answer and get none.
1388
1511
  *
1389
1512
  * **Written once and read twice**, which is why it is a function rather than the
1390
- * expression it used to be: the value stamped into `stats.systemId` and the
1513
+ * expression: the value stamped into `stats.systemId` and the
1391
1514
  * value a pack's `system:` is validated against are the same fact, and two
1392
1515
  * spellings of it would be free to disagree about exactly the case that has no
1393
1516
  * answer.
@@ -1458,7 +1581,7 @@ function normalizeSystems(value) {
1458
1581
  }
1459
1582
 
1460
1583
  /**
1461
- * The one system this package refuses to load without, or `null` (#48).
1584
+ * The one system this package refuses to load without, or `null`.
1462
1585
  *
1463
1586
  * The gate half of the split. Naming a system here emits
1464
1587
  * `relationships.systems` for it, which is what Foundry's `supportsSystem`
@@ -1557,7 +1680,7 @@ function normalizePackageBuild(value) {
1557
1680
  * The registry is *code* a consumer supplies — the only place the configuration
1558
1681
  * carries any — because the type list and the builder table have to be the same
1559
1682
  * list. They were two, and `trait` sat in the whitelist for a release with no
1560
- * builder behind it (#1504).
1683
+ * builder behind it.
1561
1684
  *
1562
1685
  * **An entry may be written two ways**, and the difference is only whether the
1563
1686
  * type brings default art:
@@ -1637,12 +1760,11 @@ function normalizeOneRegistry(value, at) {
1637
1760
  }
1638
1761
 
1639
1762
  /**
1640
- * The declared item-builder registries, and the vocabulary their union gives
1641
- * (#58).
1763
+ * The declared item-builder registries, and the vocabulary their union gives.
1642
1764
  *
1643
1765
  * **One registry is a ceiling, not a default.** The accepted type list is the
1644
1766
  * registry's keys, which is what makes a type impossible to accept without a
1645
- * builder behind it (#1504) — and, with one registry, impossible to accept a
1767
+ * builder behind it — and, with one registry, impossible to accept a
1646
1768
  * type a *second* system declares. A tree feeding two systems has both:
1647
1769
  * `spell`, `invocation` and `psionic` are HM3's, `mysticalability` and
1648
1770
  * `projectile` are SoHL's, and `skill` is both systems' under one name and
@@ -1768,7 +1890,7 @@ function normalizeItemBuilders(value) {
1768
1890
  }
1769
1891
 
1770
1892
  /**
1771
- * The publishing mode, refusing the boolean this setting used to be.
1893
+ * The publishing mode, refusing a boolean.
1772
1894
  *
1773
1895
  * A boolean is refused rather than mapped onto the nearest mode, because the
1774
1896
  * reading `false` invited — *this package has no web presence* — is exactly the
@@ -1827,7 +1949,7 @@ function normalizePublish(value) {
1827
1949
  // A retired key is refused by name, ahead of the vocabulary check: reported
1828
1950
  // as merely unrecognized it would read as a misspelling of the one key that
1829
1951
  // survives, and the author would correct the spelling rather than learn
1830
- // that the mechanism is gone (#215).
1952
+ // that the mechanism is gone.
1831
1953
  for (const key of Object.keys(address)) {
1832
1954
  if (Object.hasOwn(RETIRED_ADDRESS_KEYS, key)) {
1833
1955
  fail(`publish.address.${key}`, RETIRED_ADDRESS_KEYS[key]);
@@ -1847,7 +1969,7 @@ function normalizePublish(value) {
1847
1969
  }
1848
1970
  if (prefix.startsWith("/")) {
1849
1971
  // A leading slash would make the recorded address package-absolute,
1850
- // which is exactly the site-absolute shape #1465 removed.
1972
+ // which is exactly the site-absolute shape this avoids.
1851
1973
  fail("publish.address.prefix", "must not begin with a slash");
1852
1974
  }
1853
1975
 
@@ -1900,7 +2022,7 @@ export function defineConfig(config) {
1900
2022
  const packs = input.packs.map((pack, index) => normalizePack(pack, `packs[${index}]`));
1901
2023
 
1902
2024
  // One list, so the compile order and the directory list cannot disagree —
1903
- // they used to be `PACK_CONFIGS` and `SOURCE_PACKS`, maintained apart (#1508).
2025
+ // as `PACK_CONFIGS` and `SOURCE_PACKS` they would be maintained apart.
1904
2026
  const packDirectories = packs.flatMap((pack) => [
1905
2027
  pack.name,
1906
2028
  ...pack.companions.map((companion) => companion.name),
@@ -1913,7 +2035,7 @@ export function defineConfig(config) {
1913
2035
  seen.add(name);
1914
2036
  }
1915
2037
 
1916
- // ── systems: declaring, and requiring, are separate decisions (#48) ──────
2038
+ // ── systems: declaring, and requiring, are separate decisions ──────
1917
2039
  const systems = normalizeSystems(input.systems);
1918
2040
  const requiresSystem = normalizeRequiresSystem(input.requiresSystem);
1919
2041
  const declaredSystems = new Set(Object.keys(systems));
@@ -1934,7 +2056,7 @@ export function defineConfig(config) {
1934
2056
  // A name that resolves to nothing is a build error rather than a
1935
2057
  // fall-through, in the spirit the rest of this file already follows: a pack
1936
2058
  // stamping a system nobody declared would stamp `undefined`, which is the
1937
- // plausible lie #43 was about.
2059
+ // plausible lie.
1938
2060
  if (requiresSystem !== null && !declaredSystems.has(requiresSystem)) {
1939
2061
  fail(
1940
2062
  "requiresSystem",
@@ -1951,14 +2073,14 @@ export function defineConfig(config) {
1951
2073
  // verified version `statsForPack` reads, or this package's own
1952
2074
  // package-wide system, whose stats answer for every pack of it.
1953
2075
  //
1954
- // This used to be skipped entirely when `systems:` was empty or absent
2076
+ // Skipping this entirely when `systems:` is empty or absent
1955
2077
  // — `declaredSystems.size &&` guarded it — which left the case the
1956
2078
  // comment above was written about wide open. `harn-ensemble` declares
1957
2079
  // `system: sohl` and `system: hm3` on its packs, no `systems:` block,
1958
2080
  // and no package-wide system, so every pack fell through to a
1959
2081
  // package-wide stat that is null: 2,513 compiled actors stamped
1960
2082
  // `_stats.systemId: null` in a pack that says `system: sohl` on the
1961
- // line above. That is the plausible lie #43 was about, reached by the
2083
+ // line above. That is the plausible lie, reached by the
1962
2084
  // one path this check did not cover, and the `requiresSystem` check ten
1963
2085
  // lines up already refuses its own version of it in as many words.
1964
2086
  if (!declaredSystems.has(pack.system) && pack.system !== packageWide) {
@@ -1994,7 +2116,7 @@ export function defineConfig(config) {
1994
2116
 
1995
2117
  // Several packs of one document type are allowed — editorial grouping of
1996
2118
  // same-type documents is ordinary Foundry practice, and collapsing such a
1997
- // layout breaks every stored compendium UUID (#1566). What is not allowed
2119
+ // layout breaks every stored compendium UUID. What is not allowed
1998
2120
  // is two candidates for the same undeclared note.
1999
2121
  const defaultsByType = new Map();
2000
2122
  for (const pack of packs) {
@@ -2029,12 +2151,12 @@ export function defineConfig(config) {
2029
2151
  itemFieldsBySystem,
2030
2152
  itemTypesBySeveralSystems,
2031
2153
  } = normalizeItemBuilders(input.itemBuilders);
2032
- // The union across every declared registry (#58) — the flat table already
2154
+ // The union across every declared registry — the flat table already
2033
2155
  // holds every key any of them declares, so this stays "the registry's keys"
2034
- // rather than becoming a second list to keep in step (#1504).
2156
+ // rather than becoming a second list to keep in step.
2035
2157
  const itemTypes = Object.freeze(new Set(Object.keys(itemBuilders)));
2036
2158
  // Every note that compiles into a *system-bearing* document publishes its
2037
- // prose as a documentation JournalEntry, and that includes actors (#337).
2159
+ // prose as a documentation JournalEntry, and that includes actors.
2038
2160
  // A being was the one such note with no `none` address — its only address
2039
2161
  // named the Actor — so nothing a prose link wrote could land on its page.
2040
2162
  // `doc` stays out for the reason that actually applies to it: its single
@@ -2052,7 +2174,7 @@ export function defineConfig(config) {
2052
2174
  // one place `systems/sohl` (or `modules/sohl-thalorna`) is spelled.
2053
2175
  assetRoot: `${packageKind}/${foundryPackage}/assets`,
2054
2176
  paths: normalizePaths(input.paths, rootDir),
2055
- // The package-wide system, derived (#48). A **system** package is its
2177
+ // The package-wide system, derived. A **system** package is its
2056
2178
  // own system, which is true by construction and needs no declaration. A
2057
2179
  // **module** takes the one it requires, or the one system it declares
2058
2180
  // when there is exactly one; with several and no gate there is no
@@ -2097,6 +2219,7 @@ export function defineConfig(config) {
2097
2219
  itemTypes,
2098
2220
  docEntryTypes,
2099
2221
  skipDirectories: Object.freeze(skipDirectories),
2222
+ icons: normalizeIcons(input.icons, rootDir),
2100
2223
  packs: Object.freeze(packs),
2101
2224
  packDirectories: Object.freeze(packDirectories),
2102
2225
  docs: normalizeDocs(input.docs),