@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.
- package/CHANGELOG-content-build.md +965 -0
- package/CHANGELOG.md +58 -0
- package/CONTENT.md +824 -0
- package/MIGRATING.md +127 -0
- package/README.md +29 -17
- package/bin/content-build.mjs +1082 -0
- package/bin/package-build.mjs +2 -2
- package/bin/report.mjs +2 -2
- package/config.mjs +21 -18
- package/content-config.mjs +1424 -0
- package/coverage.mjs +1 -1
- package/e2e.mjs +1 -1
- package/engine/abbreviations.mjs +0 -0
- package/engine/base-compiler.mjs +608 -0
- package/engine/code-fences.mjs +311 -0
- package/engine/compendiums.mjs +353 -0
- package/engine/content-address.mjs +141 -0
- package/engine/content-links.mjs +438 -0
- package/engine/content-lint.mjs +204 -0
- package/engine/content-package.mjs +67 -0
- package/engine/content-slug.mjs +169 -0
- package/engine/content-tables.mjs +1216 -0
- package/engine/content-tree.mjs +56 -0
- package/engine/diagnostics.mjs +270 -0
- package/engine/field-reference.mjs +242 -0
- package/engine/field-spec.mjs +218 -0
- package/engine/foreign-catalog.mjs +450 -0
- package/engine/foreign-manifests.mjs +129 -0
- package/engine/frontmatter-lint.mjs +343 -0
- package/engine/frontmatter.mjs +258 -0
- package/engine/generate.mjs +305 -0
- package/engine/helpers.mjs +809 -0
- package/engine/ids.mjs +192 -0
- package/engine/index.mjs +142 -0
- package/engine/item-docs.mjs +153 -0
- package/engine/item-registry.mjs +129 -0
- package/engine/journals.mjs +374 -0
- package/engine/kb-manifest.mjs +473 -0
- package/engine/macros.mjs +333 -0
- package/engine/manifest-emit.mjs +339 -0
- package/engine/map-notes.mjs +1302 -0
- package/engine/pack-config.mjs +479 -0
- package/engine/pack-router.mjs +254 -0
- package/engine/prose-config.mjs +238 -0
- package/engine/prose-lint.mjs +294 -0
- package/engine/region-events.mjs +79 -0
- package/engine/scene-levels.mjs +420 -0
- package/engine/scenes.mjs +528 -0
- package/engine/site-build.mjs +778 -0
- package/engine/site-index.mjs +332 -0
- package/engine/web-wikilinks.mjs +330 -0
- package/engine/wikilink-syntax.mjs +105 -0
- package/engine/wikilinks.mjs +645 -0
- package/index.mjs +42 -25
- package/lang.mjs +1 -1
- package/manifest.mjs +79 -5
- package/markdownlint-config.mjs +45 -0
- package/package.json +78 -35
- package/prettier-config.mjs +34 -0
- package/sohl/actors.mjs +581 -0
- package/sohl/affiliation-standings.mjs +44 -0
- package/sohl/being-info.mjs +173 -0
- package/sohl/default-item-art.mjs +82 -0
- package/sohl/index.mjs +55 -0
- package/sohl/item-builders.mjs +114 -0
- package/sohl/item-fields.mjs +867 -0
- package/sohl/items.mjs +197 -0
- package/sohl/kb-passes.mjs +207 -0
- package/sohl/note-schemas.mjs +322 -0
- package/templates.mjs +1 -1
- package/types/config.d.mts +1 -1
- package/types/content-config.d.mts +571 -0
- package/types/engine/abbreviations.d.mts +44 -0
- package/types/engine/base-compiler.d.mts +320 -0
- package/types/engine/code-fences.d.mts +102 -0
- package/types/engine/compendiums.d.mts +77 -0
- package/types/engine/content-address.d.mts +61 -0
- package/types/engine/content-links.d.mts +87 -0
- package/types/engine/content-lint.d.mts +47 -0
- package/types/engine/content-package.d.mts +36 -0
- package/types/engine/content-slug.d.mts +59 -0
- package/types/engine/content-tables.d.mts +131 -0
- package/types/engine/content-tree.d.mts +12 -0
- package/types/engine/diagnostics.d.mts +152 -0
- package/types/engine/field-reference.d.mts +20 -0
- package/types/engine/field-spec.d.mts +181 -0
- package/types/engine/foreign-catalog.d.mts +103 -0
- package/types/engine/foreign-manifests.d.mts +43 -0
- package/types/engine/frontmatter-lint.d.mts +74 -0
- package/types/engine/frontmatter.d.mts +99 -0
- package/types/engine/generate.d.mts +56 -0
- package/types/engine/helpers.d.mts +348 -0
- package/types/engine/ids.d.mts +126 -0
- package/types/engine/index.d.mts +33 -0
- package/types/engine/item-docs.d.mts +73 -0
- package/types/engine/item-registry.d.mts +57 -0
- package/types/engine/journals.d.mts +121 -0
- package/types/engine/kb-manifest.d.mts +214 -0
- package/types/engine/macros.d.mts +201 -0
- package/types/engine/manifest-emit.d.mts +137 -0
- package/types/engine/map-notes.d.mts +283 -0
- package/types/engine/pack-config.d.mts +77 -0
- package/types/engine/pack-router.d.mts +53 -0
- package/types/engine/prose-config.d.mts +121 -0
- package/types/engine/prose-lint.d.mts +59 -0
- package/types/engine/region-events.d.mts +43 -0
- package/types/engine/scene-levels.d.mts +36 -0
- package/types/engine/scenes.d.mts +48 -0
- package/types/engine/site-build.d.mts +214 -0
- package/types/engine/site-index.d.mts +141 -0
- package/types/engine/web-wikilinks.d.mts +67 -0
- package/types/engine/wikilink-syntax.d.mts +101 -0
- package/types/engine/wikilinks.d.mts +172 -0
- package/types/index.d.mts +3 -0
- package/types/manifest.d.mts +52 -4
- package/types/markdownlint-config.d.mts +8 -0
- package/types/prettier-config.d.mts +1 -0
- package/types/sohl/actors.d.mts +65 -0
- package/types/sohl/affiliation-standings.d.mts +22 -0
- package/types/sohl/being-info.d.mts +78 -0
- package/types/sohl/default-item-art.d.mts +28 -0
- package/types/sohl/index.d.mts +7 -0
- package/types/sohl/item-builders.d.mts +11 -0
- package/types/sohl/item-fields.d.mts +20 -0
- package/types/sohl/items.d.mts +19 -0
- package/types/sohl/kb-passes.d.mts +71 -0
- 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
|
+
}
|