@heroiclands/package-build 0.6.1 → 3.0.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 (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +58 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -35
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,1424 @@
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 per-repository configuration contract for `@heroiclands/package-build`.
16
+ *
17
+ * Every consuming repository declares one `package-build.config.yaml` at its
18
+ * root:
19
+ *
20
+ * ```yaml
21
+ * contentPackage: sohl
22
+ * packageKind: systems
23
+ * compatibility: { minimum: "14.359", verified: "14.364" }
24
+ * stats:
25
+ * systemId: sohl
26
+ * lastModifiedBy: sohlbuilder00000
27
+ * itemBuilders: sohl
28
+ * skipDirectories: [Templates]
29
+ * packs:
30
+ * - { name: items, type: Item, folders: item-folders.yaml }
31
+ * - { name: journals, type: JournalEntry, label: Journals }
32
+ * packageBuild:
33
+ * assets:
34
+ * - { from: assets/icons, to: assets/icons }
35
+ * publish:
36
+ * site: true
37
+ * manifests: { publish: true, consume: true }
38
+ * ```
39
+ *
40
+ * `defineConfig` is the whole of the contract: it validates the object, fills
41
+ * the optional halves with their defaults, and returns a deeply frozen copy.
42
+ * It performs no I/O and knows nothing about any particular package's content —
43
+ * a consumer's config is data, and the compilers read it.
44
+ *
45
+ * **This module validates; it does not load.** `engine/pack-config.mjs` is what
46
+ * finds a repository's configuration and reads it, and it is where the three
47
+ * fields absent from the YAML above are derived: `rootDir` (the directory the
48
+ * file sits in), `stats.systemVersion` (the adjacent `package.json`), and the
49
+ * `itemBuilders` table the name `sohl` stands for. All three are I/O or code,
50
+ * and this module is deliberately neither — which is also why a consumer whose
51
+ * item-builder registry is its own writes `package-build.config.mjs`, calling
52
+ * `defineConfig` below directly with a `rootDir` of `import.meta.dirname`.
53
+ * Both forms end here, so both are validated and frozen identically.
54
+ *
55
+ * **`rootDir` anchors every path**, so the build reads the same files whatever
56
+ * directory it was launched from (#1508).
57
+ *
58
+ * The Foundry floor is declared here as top-level `compatibility`, and the
59
+ * shipped manifest is generated *from* this file. That reverses an older rule —
60
+ * configuration named where the manifest was and read the floor back out of it —
61
+ * which was right while the manifest was hand-authored and became a round trip
62
+ * through a generated artifact once it was not (#50, package-build#9).
63
+ *
64
+ * @module
65
+ */
66
+
67
+ import path from "node:path";
68
+
69
+ // A leaf with no local imports of its own, so naming it here cannot close a
70
+ // cycle around a consumer's config file (see `engine/pack-config.mjs`).
71
+ import { MAP_TYPES } from "./engine/ids.mjs";
72
+
73
+ /**
74
+ * The two kinds of Foundry package a content module can be built into. The
75
+ * value is also the directory Foundry installs the package under, which is why
76
+ * it is plural.
77
+ *
78
+ * @satisfies {readonly PackageKind[]}
79
+ */
80
+ export const PACKAGE_KINDS = /** @type {const} */ (["systems", "modules"]);
81
+
82
+ /**
83
+ * The directories the build reads from and writes to, relative to `rootDir`,
84
+ * with the layout a HeroicLands content repository conventionally uses. A
85
+ * consumer overrides only the ones it moves.
86
+ *
87
+ */
88
+ export const DEFAULT_PATHS = /** @type {const} */ ({
89
+ content: "assets/content",
90
+ manifests: "assets/manifests",
91
+ manifestOut: "build/manifests",
92
+ packJson: "build/packs-json",
93
+ stage: "build/stage/packs",
94
+ unpack: "build/tmp/packs",
95
+ // Where a dependency declaring `itemCatalog: true` is unpacked. Under
96
+ // `build/` because it is derived, disposable, and version-keyed.
97
+ foreignCache: "build/cache/foreign",
98
+ });
99
+
100
+ /**
101
+ * The Foundry document types a compendium pack may hold. This is the set the
102
+ * toolchain is able to compile a pack of; a document type Foundry supports but
103
+ * this toolchain does not compile is deliberately absent (see #1501 — playlists
104
+ * and roll tables are out of scope).
105
+ *
106
+ * @satisfies {readonly PackDocumentType[]}
107
+ */
108
+ export const PACK_DOCUMENT_TYPES = /** @type {const} */ ([
109
+ "Actor",
110
+ "Adventure",
111
+ "Item",
112
+ "JournalEntry",
113
+ "Macro",
114
+ "Scene",
115
+ ]);
116
+
117
+ /**
118
+ * The landing-page rules a repository may route by.
119
+ *
120
+ * A *landing page* is a note that addresses a whole section rather than a page
121
+ * within one, so it has no slug of its own. Which note that is differs between
122
+ * repositories, and both live rules are represented here because switching
123
+ * either one on for the other repository moves addresses that are already
124
+ * published:
125
+ *
126
+ * - `readme` — a `README.md` **is** its section's landing page. This is `sohl`'s
127
+ * rule, and a `doc` note routes by its `category` like any other, so `sohl`'s
128
+ * eleven `category: collection` notes publish under a literal `collection/`
129
+ * section (`kb/collection/skills/`).
130
+ * - `collection` — a `doc` note whose `category` is `collection` addresses the
131
+ * section it introduces, named by its authored `section`. This is `thalorna`'s
132
+ * rule, under which the same note publishes at `creature/`.
133
+ *
134
+ * The two are not disjoint and cannot simply both apply: each tree holds notes
135
+ * the other rule would move.
136
+ *
137
+ * @type {readonly string[]}
138
+ */
139
+ export const LANDING_RULES = Object.freeze(["readme", "collection"]);
140
+
141
+ /**
142
+ * A repository's address scheme, with the defaults an unconfigured one gets.
143
+ *
144
+ * `prefix` is where the content tree mounts *inside the package* — `"kb/"` for
145
+ * `sohl`, whose knowledgebase is one surface among several, and empty for
146
+ * `thalorna`, whose site is nothing but its content. It is not the package's
147
+ * own mount point: where the package itself is served is the consuming build's
148
+ * knowledge, held in `PACKAGE_BASE` (`engine/kb-manifest.mjs`) and prefixed at
149
+ * resolve time, so it is never recorded here (#1465).
150
+ */
151
+ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
152
+ prefix: "",
153
+ landing: "readme",
154
+ });
155
+
156
+ /**
157
+ * @typedef {"systems" | "modules"} PackageKind
158
+ */
159
+
160
+ /**
161
+ * @typedef {"Actor" | "Adventure" | "Item" | "JournalEntry" | "Macro" | "Scene"} PackDocumentType
162
+ */
163
+
164
+ /**
165
+ * One compendium pack the build compiles, named exactly as it is declared in
166
+ * the package manifest's `packs` array.
167
+ *
168
+ * Several packs may share a `type`. The `type` selects the **compiler** that
169
+ * fills the pack; a note's `pack:` frontmatter selects **which pack of that
170
+ * type** receives its document. The two are orthogonal, and both are needed
171
+ * once a repository groups same-type documents editorially — which it may have
172
+ * to, since a compendium UUID carries its pack name and collapsing such a
173
+ * layout breaks every stored reference (#1566).
174
+ *
175
+ * @typedef {object} PackSpec
176
+ * @property {string} name Pack name — the manifest `name`, and the
177
+ * directory under `packs/`.
178
+ * @property {PackDocumentType} type Foundry document type the pack holds.
179
+ * @property {string} [label] Human-readable label. Defaults to `name`.
180
+ * @property {boolean} [private] Whether the pack is GM-only. Default `false`.
181
+ * @property {string|null} [folders] The pack's folder-hierarchy file, relative
182
+ * to `paths.content`. Default `null` — no
183
+ * folder documents are emitted.
184
+ * @property {PackSpec[]} [companions] Packs written by this pack's own compiler
185
+ * pass rather than a pass of their own (the
186
+ * scenes pass also emits the adventures
187
+ * bundling them). Default `[]`.
188
+ * @property {boolean} [mayBeEmpty] Whether a pass compiling zero entries is
189
+ * legitimate rather than a build failure.
190
+ * Default `false`.
191
+ * @property {boolean} [default] Whether this is the pack of its `type`
192
+ * that receives notes declaring no `pack:`
193
+ * of their own. Default `false`. A type
194
+ * with exactly one pack is its default
195
+ * implicitly; a type with several and no
196
+ * `default: true` requires every note of
197
+ * that type to declare one. Not permitted
198
+ * on a companion — no note is routed into
199
+ * one. See `engine/pack-router.mjs`.
200
+ */
201
+
202
+ /**
203
+ * The normalized form of a {@link PackSpec}: every optional half filled in.
204
+ *
205
+ * @typedef {object} ResolvedPackSpec
206
+ * @property {string} name
207
+ * @property {PackDocumentType} type
208
+ * @property {string} label
209
+ * @property {boolean} private
210
+ * @property {string|null} folders
211
+ * @property {readonly Readonly<ResolvedPackSpec>[]} companions
212
+ * @property {boolean} mayBeEmpty
213
+ * @property {boolean} default
214
+ */
215
+
216
+ /**
217
+ * The directories a consumer may relocate, each relative to `rootDir`.
218
+ *
219
+ * @typedef {object} PathsInput
220
+ * @property {string} [content] Content tree root.
221
+ * @property {string} [manifests] Vendored cross-package link manifests,
222
+ * read by `links`. Inbound.
223
+ * @property {string} [manifestOut] Where `manifest` writes this package's
224
+ * own link manifest. Outbound, and a
225
+ * build artifact — the published copy is
226
+ * the one a consumer vendors into its
227
+ * `manifests` directory.
228
+ * @property {string} [packJson] Build-only per-entry JSON intermediate.
229
+ * @property {string} [stage] Compiled LevelDB packs.
230
+ * @property {string} [unpack] Where `unpack` extracts JSON back to.
231
+ */
232
+
233
+ /**
234
+ * {@link PathsInput}, resolved to absolute paths against `rootDir`.
235
+ *
236
+ * @typedef {object} ResolvedPaths
237
+ * @property {string} content
238
+ * @property {string} manifests
239
+ * @property {string} manifestOut
240
+ * @property {string} packJson
241
+ * @property {string} stage
242
+ * @property {string} unpack
243
+ */
244
+
245
+ /**
246
+ * The identity every compiled document's `_stats` block carries.
247
+ *
248
+ * `coreVersion` is **not** here: it is the top-level `compatibility.minimum`,
249
+ * so the floor is declared in one place and stamped from it.
250
+ *
251
+ * @typedef {object} StatsSpec
252
+ * @property {string} systemId The game system the documents are for —
253
+ * `"sohl"` even for a module, which ships
254
+ * content *for* the system rather than being it.
255
+ * @property {string} systemVersion The system version the packs were built against.
256
+ * @property {string} lastModifiedBy The 16-character id stamped as the author.
257
+ */
258
+
259
+ /**
260
+ * The section of the configuration belonging to `@heroiclands/package-build`.
261
+ *
262
+ * **Opaque here, on purpose.** One repository describes itself in one file, so
263
+ * the two shared build packages share it — but they split by *input*, and
264
+ * neither should learn the other's schema. This validator checks only that the
265
+ * section is a mapping and hands it back frozen; package-build validates what
266
+ * is inside it, exactly as this module validates the keys around it.
267
+ *
268
+ * That is also why it is a section rather than a scatter of top-level keys: one
269
+ * reserved name keeps {@link ContentBuildConfig}'s unknown-key guard intact for
270
+ * everything else, which is the guard that catches a typo'd `packs` before it
271
+ * becomes an empty compendium.
272
+ *
273
+ * The values package-build needs that are *not* in here — `packageKind`,
274
+ * `foundryPackage` — it reads from the top level, where they already are. They
275
+ * were duplicated in each consumer's deploy script until this existed, which is
276
+ * two places for one fact.
277
+ *
278
+ * @typedef {Record<string, unknown>} PackageBuildSection
279
+ */
280
+
281
+ /**
282
+ * The two manifest switches. A package may publish a link manifest, consume
283
+ * other packages' manifests, both, or neither — the four combinations are all
284
+ * real (see #1385/#1446: `kethira` consumes but never publishes).
285
+ *
286
+ * @typedef {object} ManifestSwitches
287
+ * @property {boolean} publish Emit this package's link manifest.
288
+ * @property {boolean} consume Resolve cross-package links through vendored manifests.
289
+ */
290
+
291
+ /**
292
+ * @typedef {object} PublishSwitches
293
+ * @property {boolean} site Render this package's knowledgebase/site pages.
294
+ * @property {ManifestSwitches} manifests
295
+ */
296
+
297
+ /**
298
+ * @typedef {object} ManifestSwitchesInput
299
+ * @property {boolean} [publish]
300
+ * @property {boolean} [consume]
301
+ */
302
+
303
+ /**
304
+ * The **Foundry core** version range this package supports.
305
+ *
306
+ * `minimum` is stamped into every compiled document as `_stats.coreVersion`, so
307
+ * a document never claims to predate the migrations that would rewrite it.
308
+ *
309
+ * `verified` names the newest build the full suite has **actually passed** —
310
+ * never an aspiration. Moving this out of the hand-authored manifest and into a
311
+ * configuration file does not soften that; if anything it makes the claim
312
+ * easier to edit casually, so it is written down here beside the key rather
313
+ * than left behind in the template.
314
+ *
315
+ * Not to be confused with `relationships.systems[].compatibility`, which is the
316
+ * **game system's** version range. Same key, different subject.
317
+ *
318
+ * @typedef {object} CompatibilitySpec
319
+ * @property {string} minimum Oldest Foundry core this package supports.
320
+ * @property {string} [verified] Newest Foundry core the suite has passed on.
321
+ */
322
+
323
+ /**
324
+ * What this package declares about other packages, in Foundry's own shape.
325
+ *
326
+ * Passed through to the shipped manifest, and read here for one derivation: a
327
+ * module's `_stats.systemVersion` comes from the `verified` field of the system
328
+ * it declares a relationship with, because a module's own `package.json`
329
+ * version is the *module's* and stamping it would claim a system version that
330
+ * never existed.
331
+ *
332
+ * @typedef {object} Relationships
333
+ * @property {RelationshipSpec[]} [systems] Game systems this package targets.
334
+ * @property {RelationshipSpec[]} [requires] Packages this one needs.
335
+ * @property {RelationshipSpec[]} [recommends] Packages it works well with.
336
+ * @property {RelationshipSpec[]} [conflicts] Packages it cannot run beside.
337
+ */
338
+
339
+ /**
340
+ * One declared relationship.
341
+ *
342
+ * @typedef {object} RelationshipSpec
343
+ * @property {string} id The other package's id.
344
+ * @property {string} [type] `system`, `module`, or `world`.
345
+ * @property {string} [manifest] Where its manifest is published.
346
+ * @property {CompatibilitySpec} [compatibility] The version range of *that*
347
+ * package this one targets — for a system
348
+ * relationship, `verified` is what
349
+ * `_stats.systemVersion` is stamped from.
350
+ */
351
+
352
+ /**
353
+ * How a generated documentation page is framed in the repository publishing it.
354
+ *
355
+ * The tables come from the `itemBuilders` registry and are the same wherever
356
+ * they are rendered. Everything around them is the consumer's: the heading, the
357
+ * "See also" line its section's pages carry, the orientation a reader needs
358
+ * before the tables start, and where the page is filed. Those were the reason
359
+ * every consumer wrapped the renderer in a script of its own.
360
+ *
361
+ * @typedef {object} DocPageSpec
362
+ * @property {string} [title] The page's H1.
363
+ * @property {string} [out] Where to write it, relative to `rootDir`.
364
+ * Without it the page goes to stdout.
365
+ * @property {string[]} [preamble] Lines between the generated banner and the
366
+ * first table. Markdown, emitted verbatim.
367
+ */
368
+
369
+ /**
370
+ * The documentation pages this repository generates.
371
+ *
372
+ * @typedef {object} DocsSpec
373
+ * @property {DocPageSpec} [itemFields] The item-frontmatter reference,
374
+ * rendered by `content-build docs
375
+ * item-fields`.
376
+ */
377
+
378
+ /**
379
+ * @typedef {object} PublishSwitchesInput
380
+ * @property {boolean} [site]
381
+ * @property {ManifestSwitchesInput} [manifests]
382
+ */
383
+
384
+ /**
385
+ * One entry of a consumer's `itemBuilders` registry.
386
+ *
387
+ * Either a bare builder function, or that builder paired with the type's
388
+ * default art and the frontmatter fields it declares. See
389
+ * {@link normalizeItemBuilders} for why the paired form exists.
390
+ *
391
+ * `fields` is what makes the type documentable: a builder function says
392
+ * nothing about the vocabulary it consumes, so a consumer that declares its
393
+ * fields can generate its own authoring reference and check its own notes,
394
+ * while one that does not is simply undocumented rather than broken (#22).
395
+ *
396
+ * @typedef {((fm: object) => object)|{system: (fm: object) => object, img?: string, fields?: readonly object[]}} ItemBuilderEntry
397
+ */
398
+
399
+ /**
400
+ * The configuration a consumer writes.
401
+ *
402
+ * @typedef {object} ContentBuildConfigInput
403
+ * @property {string} rootDir Absolute path of the consuming
404
+ * repository — every configured path is
405
+ * resolved against it, so the build never
406
+ * depends on the working directory.
407
+ * @property {string} contentPackage Content package name — the value each
408
+ * content note carries in its `package:`
409
+ * frontmatter.
410
+ * @property {string} foundryPackage Foundry package id, as it appears in
411
+ * `system.json` / `module.json`.
412
+ * @property {PackageKind} packageKind Whether the package is a system or a module.
413
+ * @property {StatsSpec} stats Identity stamped into every document's `_stats`.
414
+ * @property {Record<string, ItemBuilderEntry>} [itemBuilders] The consumer's
415
+ * item-type registry: each content `type`
416
+ * that compiles into an Item, paired with
417
+ * the builder producing its `system` block
418
+ * — and, optionally, the default art a
419
+ * note of that type gets when it sets no
420
+ * `img:` of its own. Default `{}` — a
421
+ * content module that ships no items
422
+ * declares none.
423
+ * @property {PackSpec[]} packs Packs to compile. More than one entry
424
+ * may share a `type`: a note then names
425
+ * the pack it belongs in with its
426
+ * `pack:` frontmatter, and one pack of
427
+ * the type is marked `default: true` to
428
+ * receive the notes that name none
429
+ * (#1566).
430
+ * @property {PathsInput} [paths] Layout overrides. See {@link DEFAULT_PATHS}.
431
+ * @property {string[]} [skipDirectories] Directory names the content walk ignores
432
+ * wherever they appear (e.g. Obsidian's
433
+ * `Templates`). Default `[]`.
434
+ * @property {PackageBuildSection} [packageBuild] Reserved for
435
+ * `@heroiclands/package-build`, which
436
+ * validates it. Not read here.
437
+ * @property {DocsSpec} [docs] How this repository frames the
438
+ * documentation pages it generates.
439
+ * @property {CompatibilitySpec} [compatibility] The Foundry core range this
440
+ * package supports. Required for any
441
+ * repository that ships one — reading
442
+ * the floor throws without it — and
443
+ * absent for a content-only consumer,
444
+ * which has none to invent.
445
+ * @property {Relationships} [relationships] What this package declares about
446
+ * others, in Foundry's own shape.
447
+ * @property {PublishSwitchesInput} [publish] Publishing switches. Each defaults to off.
448
+ */
449
+
450
+ /**
451
+ * The normalized, frozen configuration the toolchain reads.
452
+ *
453
+ * @typedef {object} ContentBuildConfig
454
+ * @property {string} rootDir
455
+ * @property {string} contentPackage
456
+ * @property {string} foundryPackage
457
+ * @property {PackageKind} packageKind
458
+ * @property {string} assetRoot Derived: the served Foundry asset root,
459
+ * `<packageKind>/<foundryPackage>/assets`.
460
+ * @property {Readonly<ResolvedPaths>} paths
461
+ * @property {Readonly<StatsSpec>} stats
462
+ * @property {Readonly<Record<string, Function>>} itemBuilders Derived: the
463
+ * `system` builder of each entry, whichever
464
+ * of the two spellings declared it.
465
+ * @property {Readonly<Record<string, string>>} itemArt Derived: the default art
466
+ * of each entry that paired one. Sparse — a
467
+ * type absent here has no default, and a note
468
+ * of it must carry `img:` (#7).
469
+ * @property {Readonly<Record<string, readonly object[]>>} itemFields Derived:
470
+ * the frontmatter fields each entry
471
+ * declared. Sparse, like `itemArt` — a type
472
+ * absent here compiles normally and is
473
+ * simply undocumented (#22).
474
+ * @property {ReadonlySet<string>} itemTypes Derived: the keys of
475
+ * {@link ContentBuildConfigInput.itemBuilders},
476
+ * so the accepted item types and the builder
477
+ * table are one list (#1504).
478
+ * @property {ReadonlySet<string>} docEntryTypes Derived: every type whose prose
479
+ * compiles into a JournalEntry of its own —
480
+ * the item types, plus `macro`, plus the map
481
+ * types. The one set the compilers and the
482
+ * link-manifest emitter both read.
483
+ * @property {readonly string[]} skipDirectories
484
+ * @property {readonly Readonly<ResolvedPackSpec>[]} packs
485
+ * @property {readonly string[]} packDirectories Derived: every pack directory
486
+ * the build produces, in compile order —
487
+ * each pack followed by its companions.
488
+ * @property {Readonly<PackageBuildSection>} packageBuild Passed through
489
+ * frozen, uninterpreted. `{}` when absent.
490
+ * @property {Readonly<DocsSpec>} docs Frozen; `{}` when absent.
491
+ * @property {Readonly<CompatibilitySpec>|null} compatibility The Foundry core
492
+ * range, or `null` when none is declared.
493
+ * @property {Readonly<Relationships>} relationships Frozen; `{}` when absent.
494
+ * @property {Readonly<PublishSwitches>} publish
495
+ */
496
+
497
+ const CONFIG_KEYS = [
498
+ "rootDir",
499
+ "contentPackage",
500
+ "foundryPackage",
501
+ "packageKind",
502
+ "stats",
503
+ "itemBuilders",
504
+ "paths",
505
+ "skipDirectories",
506
+ "packs",
507
+ "docs",
508
+ "site",
509
+ "compatibility",
510
+ "relationships",
511
+ "packageBuild",
512
+ "publish",
513
+ ];
514
+ const COMPATIBILITY_KEYS = ["minimum", "verified"];
515
+ const DOCS_KEYS = ["itemFields"];
516
+ const SITE_KEYS = [
517
+ "out",
518
+ "base",
519
+ "packages",
520
+ "sections",
521
+ "readmeSections",
522
+ "landing",
523
+ "trees",
524
+ "pass",
525
+ "passOptions",
526
+ "backfillSections",
527
+ ];
528
+ const SITE_TREE_KEYS = ["from", "section"];
529
+ const SECTION_META_KEYS = ["title", "banner"];
530
+ const DOC_PAGE_KEYS = ["title", "out", "preamble"];
531
+ const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
532
+ const RELATIONSHIP_KEYS = [
533
+ "id",
534
+ "type",
535
+ "manifest",
536
+ "compatibility",
537
+ "itemCatalog",
538
+ ];
539
+ const ITEM_BUILDER_KEYS = ["system", "img", "fields"];
540
+ const PACK_KEYS = [
541
+ "name",
542
+ "type",
543
+ "label",
544
+ "private",
545
+ "folders",
546
+ "companions",
547
+ "mayBeEmpty",
548
+ "default",
549
+ ];
550
+ const PATH_KEYS = Object.keys(DEFAULT_PATHS);
551
+ const STATS_KEYS = ["systemId", "systemVersion", "lastModifiedBy"];
552
+ const PUBLISH_KEYS = ["site", "manifests", "address"];
553
+ const MANIFEST_KEYS = ["publish", "consume"];
554
+ const ADDRESS_KEYS = ["prefix", "landing"];
555
+
556
+ /** @param {unknown} value */
557
+ function isPlainObject(value) {
558
+ return typeof value === "object" && value !== null && !Array.isArray(value);
559
+ }
560
+
561
+ /**
562
+ * @param {string} field
563
+ * @param {string} problem
564
+ * @returns {never}
565
+ */
566
+ function fail(field, problem) {
567
+ throw new TypeError(`package-build config: \`${field}\` ${problem}.`);
568
+ }
569
+
570
+ /**
571
+ * @param {object} object
572
+ * @param {readonly string[]} allowed
573
+ * @param {string} where
574
+ */
575
+ function rejectUnknownKeys(object, allowed, where) {
576
+ for (const key of Object.keys(object)) {
577
+ if (!allowed.includes(key)) {
578
+ fail(
579
+ `${where}${key}`,
580
+ `is not a recognized option (expected one of: ${allowed.join(", ")})`,
581
+ );
582
+ }
583
+ }
584
+ }
585
+
586
+ /**
587
+ * @param {unknown} value
588
+ * @param {string} field
589
+ * @returns {string}
590
+ */
591
+ function requireNonEmptyString(value, field) {
592
+ if (typeof value !== "string" || value.trim() === "") {
593
+ fail(field, "must be a non-empty string");
594
+ }
595
+ return /** @type {string} */ (value);
596
+ }
597
+
598
+ /**
599
+ * @param {unknown} value
600
+ * @param {string} field
601
+ * @param {boolean} fallback
602
+ * @returns {boolean}
603
+ */
604
+ function optionalBoolean(value, field, fallback) {
605
+ if (value === undefined) return fallback;
606
+ if (typeof value !== "boolean") fail(field, "must be a boolean");
607
+ return /** @type {boolean} */ (value);
608
+ }
609
+
610
+ /**
611
+ * A string-valued field with a fallback, rejecting any other type.
612
+ *
613
+ * Separate from {@link requireNonEmptyString} because an empty string is a
614
+ * meaningful value here: an address prefix of `""` is the statement "the
615
+ * content tree mounts at the package root", which is `thalorna`'s layout.
616
+ *
617
+ * @param {unknown} value - The supplied value.
618
+ * @param {string} field - The field's dotted path, for the error message.
619
+ * @returns {string} The value.
620
+ */
621
+ function optionalString(value, field) {
622
+ if (typeof value !== "string") fail(field, "must be a string");
623
+ return value;
624
+ }
625
+
626
+ /**
627
+ * @param {unknown} value
628
+ * @param {string} where Field path used in error messages.
629
+ * @param {boolean} [nested] Whether this is a companion, which may not nest
630
+ * companions of its own.
631
+ * @returns {Readonly<ResolvedPackSpec>}
632
+ */
633
+ function normalizePack(value, where, nested = false) {
634
+ if (!isPlainObject(value)) fail(where, "must be an object");
635
+ const pack = /** @type {Record<string, unknown>} */ (value);
636
+ rejectUnknownKeys(pack, PACK_KEYS, `${where}.`);
637
+
638
+ const name = requireNonEmptyString(pack.name, `${where}.name`);
639
+ const type = pack.type;
640
+ if (
641
+ typeof type !== "string" ||
642
+ !(/** @type {readonly string[]} */ (PACK_DOCUMENT_TYPES).includes(type))
643
+ ) {
644
+ fail(
645
+ `${where}.type`,
646
+ `must be one of: ${PACK_DOCUMENT_TYPES.join(", ")}`,
647
+ );
648
+ }
649
+
650
+ if (pack.folders !== undefined && pack.folders !== null) {
651
+ requireNonEmptyString(pack.folders, `${where}.folders`);
652
+ }
653
+
654
+ const companionsInput = pack.companions;
655
+ if (companionsInput !== undefined && !Array.isArray(companionsInput)) {
656
+ fail(`${where}.companions`, "must be an array");
657
+ }
658
+ if (nested && pack.default !== undefined) {
659
+ fail(
660
+ `${where}.default`,
661
+ "may not be declared on a companion: a companion is written by " +
662
+ "another pack's pass, so no note is ever routed into one",
663
+ );
664
+ }
665
+ if (nested && Array.isArray(companionsInput) && companionsInput.length) {
666
+ fail(
667
+ `${where}.companions`,
668
+ "may not nest: a companion is written by another pack's pass, and " +
669
+ "that pass is the only level of indirection the build has",
670
+ );
671
+ }
672
+ const companions = (companionsInput ?? []).map((companion, index) =>
673
+ normalizePack(companion, `${where}.companions[${index}]`, true),
674
+ );
675
+
676
+ /** @type {ResolvedPackSpec} */
677
+ const normalized = {
678
+ name,
679
+ type: /** @type {PackDocumentType} */ (type),
680
+ label:
681
+ pack.label === undefined ?
682
+ name
683
+ : requireNonEmptyString(pack.label, `${where}.label`),
684
+ private: optionalBoolean(pack.private, `${where}.private`, false),
685
+ folders:
686
+ pack.folders === undefined || pack.folders === null ?
687
+ null
688
+ : /** @type {string} */ (pack.folders),
689
+ companions: Object.freeze(companions),
690
+ mayBeEmpty: optionalBoolean(
691
+ pack.mayBeEmpty,
692
+ `${where}.mayBeEmpty`,
693
+ false,
694
+ ),
695
+ // Which pack of a type receives a note that declares none. Validated
696
+ // across the whole list in `defineConfig` — at most one per type.
697
+ default: optionalBoolean(pack.default, `${where}.default`, false),
698
+ };
699
+ return Object.freeze(normalized);
700
+ }
701
+
702
+ /**
703
+ * Resolve the layout a consumer supplies against its `rootDir`, filling every
704
+ * unnamed directory from {@link DEFAULT_PATHS}.
705
+ *
706
+ * Configured paths are **relative by contract**: an absolute one would escape
707
+ * the repository the config anchors, which is never what a consumer means and
708
+ * is what made these paths working-directory-dependent in the first place.
709
+ *
710
+ * @param {unknown} value
711
+ * @param {string} rootDir
712
+ * @returns {Readonly<ResolvedPaths>}
713
+ */
714
+ function normalizePaths(value, rootDir) {
715
+ if (value !== undefined && !isPlainObject(value)) {
716
+ fail("paths", "must be an object");
717
+ }
718
+ const input = /** @type {Record<string, unknown>} */ (value ?? {});
719
+ rejectUnknownKeys(input, PATH_KEYS, "paths.");
720
+
721
+ /** @type {Record<string, string>} */
722
+ const resolved = {};
723
+ for (const key of PATH_KEYS) {
724
+ const raw =
725
+ input[key] === undefined ?
726
+ /** @type {Record<string, string>} */ (DEFAULT_PATHS)[key]
727
+ : requireNonEmptyString(input[key], `paths.${key}`);
728
+ if (path.isAbsolute(raw)) {
729
+ fail(
730
+ `paths.${key}`,
731
+ "must be relative to rootDir, so a consumer's layout travels " +
732
+ "with its repository",
733
+ );
734
+ }
735
+ resolved[key] = path.resolve(rootDir, raw);
736
+ }
737
+ return Object.freeze(/** @type {ResolvedPaths} */ (resolved));
738
+ }
739
+
740
+ /**
741
+ * @param {unknown} value
742
+ * @returns {Readonly<StatsSpec>}
743
+ */
744
+ function normalizeStats(value) {
745
+ if (!isPlainObject(value)) fail("stats", "must be an object");
746
+ const input = /** @type {Record<string, unknown>} */ (value);
747
+ rejectUnknownKeys(input, STATS_KEYS, "stats.");
748
+
749
+ return Object.freeze({
750
+ systemId: requireNonEmptyString(input.systemId, "stats.systemId"),
751
+ systemVersion: requireNonEmptyString(
752
+ input.systemVersion,
753
+ "stats.systemVersion",
754
+ ),
755
+ lastModifiedBy: requireNonEmptyString(
756
+ input.lastModifiedBy,
757
+ "stats.lastModifiedBy",
758
+ ),
759
+ });
760
+ }
761
+
762
+ /**
763
+ * Freeze a value and everything reachable from it.
764
+ *
765
+ * The reserved section is handed back frozen like every other part of the
766
+ * configuration, so package-build reads the same immutable object the rest of
767
+ * the toolchain does — but its *shape* is package-build's business, so this
768
+ * walks whatever is there rather than checking it against a key list.
769
+ *
770
+ * @param {unknown} value - Any value.
771
+ * @returns {unknown} The same value, deeply frozen.
772
+ */
773
+ function deepFreeze(value) {
774
+ if (value === null || typeof value !== "object") return value;
775
+ for (const inner of Object.values(value)) deepFreeze(inner);
776
+ return Object.freeze(value);
777
+ }
778
+
779
+ /**
780
+ * Validate one generated page's framing.
781
+ *
782
+ * @param {unknown} value - The page spec, or `undefined`.
783
+ * @param {string} where - Dotted path, for the error.
784
+ * @returns {Readonly<DocPageSpec>} It, frozen; `{}` when absent.
785
+ */
786
+ function normalizeDocPage(value, where) {
787
+ if (value === undefined) return Object.freeze({});
788
+ if (!isPlainObject(value)) fail(where, "must be a mapping");
789
+ const input = /** @type {Record<string, unknown>} */ (value);
790
+ rejectUnknownKeys(input, DOC_PAGE_KEYS, `${where}.`);
791
+
792
+ const out = {};
793
+ for (const key of ["title", "out"]) {
794
+ if (input[key] !== undefined) {
795
+ out[key] = requireNonEmptyString(input[key], `${where}.${key}`);
796
+ }
797
+ }
798
+ if (input.preamble !== undefined) {
799
+ if (!Array.isArray(input.preamble)) {
800
+ fail(
801
+ `${where}.preamble`,
802
+ "must be a list of lines — a blank entry is a blank line, " +
803
+ "which is how paragraphs are separated in markdown",
804
+ );
805
+ }
806
+ // A blank line is meaningful here, so this checks the type without
807
+ // requiring content.
808
+ out.preamble = Object.freeze(
809
+ input.preamble.map((line, index) => {
810
+ if (typeof line !== "string") {
811
+ fail(`${where}.preamble[${index}]`, "must be a string");
812
+ }
813
+ return line;
814
+ }),
815
+ );
816
+ }
817
+ return Object.freeze(out);
818
+ }
819
+
820
+ /**
821
+ * Validate the `docs` section.
822
+ *
823
+ * @param {unknown} value - The section, or `undefined`.
824
+ * @returns {Readonly<DocsSpec>} It, frozen; `{}` when absent.
825
+ */
826
+ function normalizeDocs(value) {
827
+ if (value === undefined) return Object.freeze({});
828
+ if (!isPlainObject(value)) fail("docs", "must be a mapping");
829
+ const input = /** @type {Record<string, unknown>} */ (value);
830
+ rejectUnknownKeys(input, DOCS_KEYS, "docs.");
831
+ return Object.freeze({
832
+ itemFields: normalizeDocPage(input.itemFields, "docs.itemFields"),
833
+ });
834
+ }
835
+
836
+ /**
837
+ * One section's landing metadata — the title and hero a generated `_index.md`
838
+ * carries.
839
+ *
840
+ * `banner` is optional because the hero images are external assets and not
841
+ * every section has one. It is left off entirely rather than written as
842
+ * `undefined`, which is not a value YAML can carry.
843
+ *
844
+ * @param {unknown} value - The declared entry.
845
+ * @param {string} where - Dotted path, for the error.
846
+ * @returns {Readonly<{title: string, banner?: string}>}
847
+ */
848
+ function normalizeSectionMeta(value, where) {
849
+ if (!isPlainObject(value)) fail(where, "must be a mapping");
850
+ const input = /** @type {Record<string, unknown>} */ (value);
851
+ rejectUnknownKeys(input, SECTION_META_KEYS, `${where}.`);
852
+ const out = { title: requireNonEmptyString(input.title, `${where}.title`) };
853
+ if (input.banner !== undefined) {
854
+ out.banner = requireNonEmptyString(input.banner, `${where}.banner`);
855
+ }
856
+ return Object.freeze(out);
857
+ }
858
+
859
+ /**
860
+ * A map of section name → landing metadata.
861
+ *
862
+ * @param {unknown} value - The declared mapping.
863
+ * @param {string} where - Dotted path, for the error.
864
+ * @returns {Readonly<Record<string, object>>}
865
+ */
866
+ function normalizeSectionMap(value, where) {
867
+ if (value === undefined) return Object.freeze({});
868
+ if (!isPlainObject(value)) fail(where, "must be a mapping");
869
+ const out = {};
870
+ for (const [name, meta] of Object.entries(
871
+ /** @type {Record<string, unknown>} */ (value),
872
+ )) {
873
+ out[name] = normalizeSectionMeta(meta, `${where}.${name}`);
874
+ }
875
+ return Object.freeze(out);
876
+ }
877
+
878
+ /**
879
+ * The `site` section — how this repository frames the website it publishes.
880
+ *
881
+ * Everything here is *framing*: where the Hugo tree is written, what a section
882
+ * is called, which extra trees are published beside the content, and which
883
+ * named pass bundle supplies the repository's own body rewrites. How a page gets
884
+ * its **address** is deliberately not here — that is `publish.address`, shared
885
+ * with the link manifest so the two cannot disagree about where a page is.
886
+ *
887
+ * @param {unknown} value - The `site` block, or `undefined`.
888
+ * @returns {Readonly<object>} It, frozen, with every default filled.
889
+ */
890
+ function normalizeSite(value) {
891
+ const empty = Object.freeze({
892
+ out: "",
893
+ base: "",
894
+ packages: Object.freeze([]),
895
+ sections: Object.freeze({}),
896
+ readmeSections: Object.freeze({}),
897
+ landing: null,
898
+ trees: Object.freeze([]),
899
+ pass: "",
900
+ passOptions: Object.freeze({}),
901
+ backfillSections: false,
902
+ });
903
+ if (value === undefined) return empty;
904
+ if (!isPlainObject(value)) fail("site", "must be a mapping");
905
+ const input = /** @type {Record<string, unknown>} */ (value);
906
+ rejectUnknownKeys(input, SITE_KEYS, "site.");
907
+
908
+ const trees = [];
909
+ if (input.trees !== undefined) {
910
+ if (!Array.isArray(input.trees)) fail("site.trees", "must be a list");
911
+ input.trees.forEach((entry, i) => {
912
+ const where = `site.trees[${i}]`;
913
+ if (!isPlainObject(entry)) fail(where, "must be a mapping");
914
+ const tree = /** @type {Record<string, unknown>} */ (entry);
915
+ rejectUnknownKeys(tree, SITE_TREE_KEYS, `${where}.`);
916
+ trees.push(
917
+ Object.freeze({
918
+ from: requireNonEmptyString(tree.from, `${where}.from`),
919
+ section: requireNonEmptyString(
920
+ tree.section,
921
+ `${where}.section`,
922
+ ),
923
+ // The tree's own path, POSIX-separated — what a
924
+ // repository-relative link inside it is resolved against.
925
+ rel: String(tree.from).split(path.sep).join("/"),
926
+ }),
927
+ );
928
+ });
929
+ }
930
+
931
+ let packages = [];
932
+ if (input.packages !== undefined) {
933
+ if (!Array.isArray(input.packages)) {
934
+ fail("site.packages", "must be a list");
935
+ }
936
+ packages = input.packages.map((p, i) =>
937
+ requireNonEmptyString(p, `site.packages[${i}]`),
938
+ );
939
+ }
940
+
941
+ let landing = null;
942
+ if (input.landing !== undefined) {
943
+ if (!isPlainObject(input.landing)) {
944
+ fail("site.landing", "must be a mapping");
945
+ }
946
+ // Passed through rather than validated field by field: it is Hugo
947
+ // frontmatter, whose vocabulary is the theme's and not this package's.
948
+ landing = Object.freeze({ ...input.landing });
949
+ }
950
+
951
+ return Object.freeze({
952
+ out:
953
+ input.out === undefined ?
954
+ ""
955
+ : requireNonEmptyString(input.out, "site.out"),
956
+ base:
957
+ input.base === undefined ?
958
+ ""
959
+ : requireNonEmptyString(input.base, "site.base"),
960
+ packages: Object.freeze(packages),
961
+ sections: normalizeSectionMap(input.sections, "site.sections"),
962
+ readmeSections: normalizeSectionMap(
963
+ input.readmeSections,
964
+ "site.readmeSections",
965
+ ),
966
+ landing,
967
+ trees: Object.freeze(trees),
968
+ pass:
969
+ input.pass === undefined ?
970
+ ""
971
+ : requireNonEmptyString(input.pass, "site.pass"),
972
+ passOptions:
973
+ input.passOptions === undefined ?
974
+ Object.freeze({})
975
+ : Object.freeze({ ...input.passOptions }),
976
+ backfillSections: optionalBoolean(
977
+ input.backfillSections,
978
+ "site.backfillSections",
979
+ false,
980
+ ),
981
+ });
982
+ }
983
+
984
+ /**
985
+ * Validate a Foundry version range.
986
+ *
987
+ * `minimum` is required of the package's own range, because it is stamped into
988
+ * every compiled document and a guessed floor is invisible until something
989
+ * migrates on it. Inside a *relationship* neither field is required: what is
990
+ * load-bearing there is `verified`, and a relationship may reasonably name a
991
+ * package without pinning a floor at all.
992
+ *
993
+ * @param {unknown} value - The declared range, or `undefined`.
994
+ * @param {string} where - Dotted path, for the error.
995
+ * @param {boolean} [requireMinimum] - Whether `minimum` must be present.
996
+ * @returns {Readonly<CompatibilitySpec>|null} It, frozen; `null` when absent.
997
+ */
998
+ function normalizeCompatibility(value, where, requireMinimum = true) {
999
+ if (value === undefined) return null;
1000
+ if (!isPlainObject(value)) fail(where, "must be a mapping");
1001
+ const input = /** @type {Record<string, unknown>} */ (value);
1002
+ rejectUnknownKeys(input, COMPATIBILITY_KEYS, `${where}.`);
1003
+ const out = {};
1004
+ if (requireMinimum || input.minimum !== undefined) {
1005
+ out.minimum = requireNonEmptyString(input.minimum, `${where}.minimum`);
1006
+ }
1007
+ if (input.verified !== undefined) {
1008
+ out.verified = requireNonEmptyString(
1009
+ input.verified,
1010
+ `${where}.verified`,
1011
+ );
1012
+ }
1013
+ return Object.freeze(out);
1014
+ }
1015
+
1016
+ /**
1017
+ * Validate the declared relationships.
1018
+ *
1019
+ * Only as far as this package needs to read them: enough that a system
1020
+ * relationship can be found and its `verified` version trusted. The rest is
1021
+ * passed through for the manifest generator to emit.
1022
+ *
1023
+ * @param {unknown} value - The `relationships` block, or `undefined`.
1024
+ * @returns {Readonly<Relationships>} It, frozen; `{}` when absent.
1025
+ */
1026
+ function normalizeRelationships(value) {
1027
+ if (value === undefined) return Object.freeze({});
1028
+ if (!isPlainObject(value)) fail("relationships", "must be a mapping");
1029
+ const input = /** @type {Record<string, unknown>} */ (value);
1030
+ rejectUnknownKeys(input, RELATIONSHIP_KINDS, "relationships.");
1031
+
1032
+ const out = {};
1033
+ for (const kind of RELATIONSHIP_KINDS) {
1034
+ if (input[kind] === undefined) continue;
1035
+ if (!Array.isArray(input[kind])) {
1036
+ fail(`relationships.${kind}`, "must be a list");
1037
+ }
1038
+ out[kind] = Object.freeze(
1039
+ input[kind].map((entry, index) => {
1040
+ const at = `relationships.${kind}[${index}]`;
1041
+ if (!isPlainObject(entry)) fail(at, "must be a mapping");
1042
+ const rel = /** @type {Record<string, unknown>} */ (entry);
1043
+ rejectUnknownKeys(rel, RELATIONSHIP_KEYS, `${at}.`);
1044
+ const spec = {
1045
+ id: requireNonEmptyString(rel.id, `${at}.id`),
1046
+ };
1047
+ for (const key of ["type", "manifest"]) {
1048
+ if (rel[key] !== undefined) {
1049
+ spec[key] = requireNonEmptyString(
1050
+ rel[key],
1051
+ `${at}.${key}`,
1052
+ );
1053
+ }
1054
+ }
1055
+ const compat = normalizeCompatibility(
1056
+ rel.compatibility,
1057
+ `${at}.compatibility`,
1058
+ false,
1059
+ );
1060
+ if (compat) spec.compatibility = compat;
1061
+ // Opt-in: extract this package's Item packs so the actors pass
1062
+ // can resolve embedded items this repository does not hold.
1063
+ // Off by default, because depending on a package is not the
1064
+ // same as needing its item catalogue at build time.
1065
+ if (rel.itemCatalog !== undefined) {
1066
+ if (typeof rel.itemCatalog !== "boolean") {
1067
+ fail(`${at}.itemCatalog`, "must be true or false");
1068
+ }
1069
+ if (rel.itemCatalog && spec.manifest === undefined) {
1070
+ fail(
1071
+ `${at}.itemCatalog`,
1072
+ "needs a `manifest` naming the package to fetch",
1073
+ );
1074
+ }
1075
+ spec.itemCatalog = rel.itemCatalog;
1076
+ }
1077
+ return Object.freeze(spec);
1078
+ }),
1079
+ );
1080
+ }
1081
+ return Object.freeze(out);
1082
+ }
1083
+
1084
+ /**
1085
+ * Validate the reserved `packageBuild` section — that it is a mapping, and no
1086
+ * more than that.
1087
+ *
1088
+ * @param {unknown} value - The section, or `undefined`.
1089
+ * @returns {Readonly<PackageBuildSection>} It, frozen; `{}` when absent.
1090
+ */
1091
+ function normalizePackageBuild(value) {
1092
+ if (value === undefined) return Object.freeze({});
1093
+ if (!isPlainObject(value)) {
1094
+ fail(
1095
+ "packageBuild",
1096
+ "must be a mapping — it is the section @heroiclands/package-build " +
1097
+ "reads, and that package validates what is inside it",
1098
+ );
1099
+ }
1100
+ return /** @type {Readonly<PackageBuildSection>} */ (
1101
+ deepFreeze(structuredClone(value))
1102
+ );
1103
+ }
1104
+
1105
+ /**
1106
+ * Validate a consumer's item-type registry, splitting it into the two tables
1107
+ * the rest of the toolchain reads.
1108
+ *
1109
+ * The registry is *code* a consumer supplies — the only place the configuration
1110
+ * carries any — because the type list and the builder table have to be the same
1111
+ * list. They were two, and `trait` sat in the whitelist for a release with no
1112
+ * builder behind it (#1504).
1113
+ *
1114
+ * **An entry may be written two ways**, and the difference is only whether the
1115
+ * type brings default art:
1116
+ *
1117
+ * - `type: fn` — a bare builder. Every note of the type must carry its own
1118
+ * `img:`.
1119
+ * - `type: { system: fn, img }` — the same builder, paired with the image a
1120
+ * note of the type gets when it sets no `img:` of its own.
1121
+ *
1122
+ * The paired form exists because the type whitelist and the default art used to
1123
+ * travel by different routes: `itemTypes` was derived from these keys, while
1124
+ * art was looked up in `sohl/default-item-art.mjs` — a table a consumer cannot
1125
+ * add to. A consumer's own item type was therefore configurable while its
1126
+ * default art was not, so its notes all had to carry an explicit `img:` (#7).
1127
+ * Art now travels with the builder it belongs to, which is the one place a type
1128
+ * is already declared.
1129
+ *
1130
+ * @param {unknown} value
1131
+ * @returns {{itemBuilders: Readonly<Record<string, Function>>,
1132
+ * itemArt: Readonly<Record<string, string>>}}
1133
+ * The `system` builder for each type, and the default art for those types
1134
+ * that paired one. The art table is deliberately *sparse*: a bare-function
1135
+ * entry contributes no key, which is what distinguishes "no default art" from
1136
+ * an empty one.
1137
+ */
1138
+ function normalizeItemBuilders(value) {
1139
+ if (value === undefined) {
1140
+ return { itemBuilders: Object.freeze({}), itemArt: Object.freeze({}) };
1141
+ }
1142
+ if (!isPlainObject(value)) fail("itemBuilders", "must be an object");
1143
+ const input = /** @type {Record<string, unknown>} */ (value);
1144
+
1145
+ /** @type {Record<string, Function>} */
1146
+ const itemBuilders = {};
1147
+ /** @type {Record<string, string>} */
1148
+ const itemArt = {};
1149
+ /** @type {Record<string, readonly object[]>} */
1150
+ const itemFields = {};
1151
+
1152
+ for (const [type, entry] of Object.entries(input)) {
1153
+ if (typeof entry === "function") {
1154
+ itemBuilders[type] = entry;
1155
+ continue;
1156
+ }
1157
+ if (!isPlainObject(entry)) {
1158
+ fail(
1159
+ `itemBuilders.${type}`,
1160
+ "must be a builder function, or an object with a `system` builder",
1161
+ );
1162
+ }
1163
+ const paired = /** @type {Record<string, unknown>} */ (entry);
1164
+ rejectUnknownKeys(paired, ITEM_BUILDER_KEYS, `itemBuilders.${type}.`);
1165
+ if (typeof paired.system !== "function") {
1166
+ fail(`itemBuilders.${type}.system`, "must be a function");
1167
+ }
1168
+ itemBuilders[type] = /** @type {Function} */ (paired.system);
1169
+ if (paired.img !== undefined) {
1170
+ itemArt[type] = requireNonEmptyString(
1171
+ paired.img,
1172
+ `itemBuilders.${type}.img`,
1173
+ );
1174
+ }
1175
+ if (paired.fields !== undefined) {
1176
+ if (!Array.isArray(paired.fields)) {
1177
+ fail(`itemBuilders.${type}.fields`, "must be an array");
1178
+ }
1179
+ for (const [index, field] of paired.fields.entries()) {
1180
+ if (!isPlainObject(field)) {
1181
+ fail(
1182
+ `itemBuilders.${type}.fields[${index}]`,
1183
+ "must be a field declaration object",
1184
+ );
1185
+ }
1186
+ requireNonEmptyString(
1187
+ /** @type {Record<string, unknown>} */ (field).to,
1188
+ `itemBuilders.${type}.fields[${index}].to`,
1189
+ );
1190
+ }
1191
+ itemFields[type] = Object.freeze([...paired.fields]);
1192
+ }
1193
+ }
1194
+
1195
+ return {
1196
+ itemBuilders: Object.freeze(itemBuilders),
1197
+ itemArt: Object.freeze(itemArt),
1198
+ itemFields: Object.freeze(itemFields),
1199
+ };
1200
+ }
1201
+
1202
+ /**
1203
+ * @param {unknown} value
1204
+ * @returns {Readonly<PublishSwitches>}
1205
+ */
1206
+ function normalizePublish(value) {
1207
+ if (value === undefined) {
1208
+ return Object.freeze({
1209
+ site: false,
1210
+ manifests: Object.freeze({ publish: false, consume: false }),
1211
+ address: Object.freeze({ ...DEFAULT_ADDRESS_SCHEME }),
1212
+ });
1213
+ }
1214
+ if (!isPlainObject(value)) fail("publish", "must be an object");
1215
+ const publish = /** @type {Record<string, unknown>} */ (value);
1216
+ rejectUnknownKeys(publish, PUBLISH_KEYS, "publish.");
1217
+
1218
+ const manifestsInput = publish.manifests;
1219
+ if (manifestsInput !== undefined && !isPlainObject(manifestsInput)) {
1220
+ fail("publish.manifests", "must be an object");
1221
+ }
1222
+ const manifests = /** @type {Record<string, unknown>} */ (
1223
+ manifestsInput ?? {}
1224
+ );
1225
+ rejectUnknownKeys(manifests, MANIFEST_KEYS, "publish.manifests.");
1226
+
1227
+ const addressInput = publish.address;
1228
+ if (addressInput !== undefined && !isPlainObject(addressInput)) {
1229
+ fail("publish.address", "must be an object");
1230
+ }
1231
+ const address = /** @type {Record<string, unknown>} */ (addressInput ?? {});
1232
+ rejectUnknownKeys(address, ADDRESS_KEYS, "publish.address.");
1233
+
1234
+ const prefix =
1235
+ address.prefix === undefined ?
1236
+ DEFAULT_ADDRESS_SCHEME.prefix
1237
+ : optionalString(address.prefix, "publish.address.prefix");
1238
+ // A prefix is concatenated, not joined, so a missing slash would silently
1239
+ // fuse it to the first section (`kbaffliction/`) — an address that builds,
1240
+ // resolves nowhere, and reads as a content error rather than a config one.
1241
+ if (prefix && !prefix.endsWith("/")) {
1242
+ fail("publish.address.prefix", "must end in a slash when it is set");
1243
+ }
1244
+ if (prefix.startsWith("/")) {
1245
+ // A leading slash would make the recorded address package-absolute,
1246
+ // which is exactly the site-absolute shape #1465 removed.
1247
+ fail("publish.address.prefix", "must not begin with a slash");
1248
+ }
1249
+
1250
+ const landing =
1251
+ address.landing === undefined ?
1252
+ DEFAULT_ADDRESS_SCHEME.landing
1253
+ : optionalString(address.landing, "publish.address.landing");
1254
+ if (!LANDING_RULES.includes(landing)) {
1255
+ fail(
1256
+ "publish.address.landing",
1257
+ `must be one of ${LANDING_RULES.join(", ")}`,
1258
+ );
1259
+ }
1260
+
1261
+ return Object.freeze({
1262
+ site: optionalBoolean(publish.site, "publish.site", false),
1263
+ address: Object.freeze({ prefix, landing }),
1264
+ manifests: Object.freeze({
1265
+ publish: optionalBoolean(
1266
+ manifests.publish,
1267
+ "publish.manifests.publish",
1268
+ false,
1269
+ ),
1270
+ consume: optionalBoolean(
1271
+ manifests.consume,
1272
+ "publish.manifests.consume",
1273
+ false,
1274
+ ),
1275
+ }),
1276
+ });
1277
+ }
1278
+
1279
+ /**
1280
+ * Validate and normalize a content configuration.
1281
+ *
1282
+ * Every configuration reaches this function — a YAML one through the loader in
1283
+ * `engine/pack-config.mjs`, an `.mjs` one by calling it itself — so that a
1284
+ * malformed configuration fails at load with a message naming the offending
1285
+ * field, rather than surfacing much later as an empty pack or a missing asset.
1286
+ * The returned object is a deeply frozen **copy**: mutating the input
1287
+ * afterwards cannot reach the configuration the build reads.
1288
+ *
1289
+ * @param {ContentBuildConfigInput} config The configuration to validate.
1290
+ * @returns {ContentBuildConfig} The frozen, defaulted configuration.
1291
+ * @throws {TypeError} If any field is missing, mistyped, or unrecognized.
1292
+ */
1293
+ export function defineConfig(config) {
1294
+ if (!isPlainObject(config)) {
1295
+ throw new TypeError(
1296
+ "package-build config: expected a configuration object.",
1297
+ );
1298
+ }
1299
+ const input = /** @type {Record<string, unknown>} */ (
1300
+ /** @type {unknown} */ (config)
1301
+ );
1302
+ rejectUnknownKeys(input, CONFIG_KEYS, "");
1303
+
1304
+ const rootDir = requireNonEmptyString(input.rootDir, "rootDir");
1305
+ if (!path.isAbsolute(rootDir)) {
1306
+ fail(
1307
+ "rootDir",
1308
+ "must be an absolute path — it is what makes the build independent " +
1309
+ "of the directory it was launched from (pass `import.meta.dirname`)",
1310
+ );
1311
+ }
1312
+
1313
+ const packageKind = input.packageKind;
1314
+ if (
1315
+ typeof packageKind !== "string" ||
1316
+ !(
1317
+ /** @type {readonly string[]} */ (PACKAGE_KINDS).includes(
1318
+ packageKind,
1319
+ )
1320
+ )
1321
+ ) {
1322
+ fail("packageKind", `must be one of: ${PACKAGE_KINDS.join(", ")}`);
1323
+ }
1324
+
1325
+ if (!Array.isArray(input.packs)) fail("packs", "must be an array");
1326
+ if (input.packs.length === 0)
1327
+ fail("packs", "must declare at least one pack");
1328
+ const packs = input.packs.map((pack, index) =>
1329
+ normalizePack(pack, `packs[${index}]`),
1330
+ );
1331
+
1332
+ // One list, so the compile order and the directory list cannot disagree —
1333
+ // they used to be `PACK_CONFIGS` and `SOURCE_PACKS`, maintained apart (#1508).
1334
+ const packDirectories = packs.flatMap((pack) => [
1335
+ pack.name,
1336
+ ...pack.companions.map((companion) => companion.name),
1337
+ ]);
1338
+ const seen = new Set();
1339
+ for (const name of packDirectories) {
1340
+ if (seen.has(name)) {
1341
+ fail("packs", `declares the pack \`${name}\` more than once`);
1342
+ }
1343
+ seen.add(name);
1344
+ }
1345
+
1346
+ // Several packs of one document type are allowed — editorial grouping of
1347
+ // same-type documents is ordinary Foundry practice, and collapsing such a
1348
+ // layout breaks every stored compendium UUID (#1566). What is not allowed
1349
+ // is two candidates for the same undeclared note.
1350
+ const defaultsByType = new Map();
1351
+ for (const pack of packs) {
1352
+ if (!pack.default) continue;
1353
+ const already = defaultsByType.get(pack.type);
1354
+ if (already) {
1355
+ fail(
1356
+ "packs",
1357
+ `marks both \`${already}\` and \`${pack.name}\` as the ` +
1358
+ `default ${pack.type} pack; a note declaring no \`pack:\` ` +
1359
+ `must have one destination`,
1360
+ );
1361
+ }
1362
+ defaultsByType.set(pack.type, pack.name);
1363
+ }
1364
+
1365
+ if (
1366
+ input.skipDirectories !== undefined &&
1367
+ !Array.isArray(input.skipDirectories)
1368
+ ) {
1369
+ fail("skipDirectories", "must be an array");
1370
+ }
1371
+ const skipDirectories = (input.skipDirectories ?? []).map((name, index) =>
1372
+ requireNonEmptyString(name, `skipDirectories[${index}]`),
1373
+ );
1374
+
1375
+ const foundryPackage = requireNonEmptyString(
1376
+ input.foundryPackage,
1377
+ "foundryPackage",
1378
+ );
1379
+
1380
+ const { itemBuilders, itemArt, itemFields } = normalizeItemBuilders(
1381
+ input.itemBuilders,
1382
+ );
1383
+ const itemTypes = Object.freeze(new Set(Object.keys(itemBuilders)));
1384
+
1385
+ return Object.freeze({
1386
+ rootDir,
1387
+ contentPackage: requireNonEmptyString(
1388
+ input.contentPackage,
1389
+ "contentPackage",
1390
+ ),
1391
+ foundryPackage,
1392
+ packageKind: /** @type {PackageKind} */ (packageKind),
1393
+ // Foundry serves a package's files from `<kind>/<id>/`, so this is the
1394
+ // one place `systems/sohl` (or `modules/sohl-thalorna`) is spelled.
1395
+ assetRoot: `${packageKind}/${foundryPackage}/assets`,
1396
+ paths: normalizePaths(input.paths, rootDir),
1397
+ stats: normalizeStats(input.stats),
1398
+ itemBuilders,
1399
+ itemArt,
1400
+ itemFields,
1401
+ // Resolved once, here, and read everywhere through
1402
+ // `loadPackConfig()`. The doc-entry *concept* is the engine's —
1403
+ // a note that carries documentation is not a SoHL idea — but the
1404
+ // membership is the consumer's, and there is exactly one resolved set at
1405
+ // runtime. Two would drift, which is the whole reason the composition
1406
+ // was written down in one place to begin with.
1407
+ itemTypes,
1408
+ docEntryTypes: Object.freeze(
1409
+ new Set([...itemTypes, "macro", ...MAP_TYPES]),
1410
+ ),
1411
+ skipDirectories: Object.freeze(skipDirectories),
1412
+ packs: Object.freeze(packs),
1413
+ packDirectories: Object.freeze(packDirectories),
1414
+ docs: normalizeDocs(input.docs),
1415
+ site: normalizeSite(input.site),
1416
+ compatibility: normalizeCompatibility(
1417
+ input.compatibility,
1418
+ "compatibility",
1419
+ ),
1420
+ relationships: normalizeRelationships(input.relationships),
1421
+ packageBuild: normalizePackageBuild(input.packageBuild),
1422
+ publish: normalizePublish(input.publish),
1423
+ });
1424
+ }