@heroiclands/package-build 0.6.1 → 3.0.1

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 +68 -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,479 @@
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 resolved package-build configuration the pack pipeline reads.
16
+ *
17
+ * One module, one import: everything the compilers used to hard-code — the
18
+ * content package, the Foundry package and its kind, every path, the `_stats`
19
+ * identity, the item-type membership, and the pack list — arrives from the
20
+ * consuming repository's `package-build.config.yaml` (#1508).
21
+ *
22
+ * **The configuration is data, and a repository writes it as data.** Every
23
+ * value in it is a literal; the three consumers' configs held no logic between
24
+ * them, only the boilerplate a code file needs in order to *say* a literal — a
25
+ * `rootDir` computed from `import.meta.url`, a version read out of
26
+ * `package.json`, an imported registry constant. Each of those is something
27
+ * this loader can derive from where the file sits, so the file itself is YAML
28
+ * and the derivation happens once here rather than being copy-pasted into every
29
+ * repository. YAML rather than JSON because these configurations carry their
30
+ * reasoning in comments, and that reasoning is most of their value.
31
+ *
32
+ * **`.mjs` remains, as the escape hatch it always was.** A consumer whose
33
+ * `itemBuilders` registry is its own code — not one of the built-in registries
34
+ * named below — cannot express it in data, and writes
35
+ * `package-build.config.mjs` calling `defineConfig` directly. Both forms end at
36
+ * the same {@link defineConfig}, so they are validated and frozen identically;
37
+ * only the derivations differ, and they differ because a code file can do its
38
+ * own I/O while `defineConfig` deliberately does none.
39
+ *
40
+ * **Resolved on first read, never at import (#2).** {@link loadPackConfig}
41
+ * is a function rather than a module-level constant, so importing this module —
42
+ * or the `engine` barrel, or a leaf module that happens to sit downstream of it
43
+ * — costs nothing and requires nothing. A repository with no configuration can
44
+ * still ask the CLI its version, and a consumer can still import a pure helper
45
+ * (`engine/content-slug`, `engine/wikilinks`) without standing up a whole pack
46
+ * build. The absence is still loud, just at the moment a configured value is
47
+ * actually needed: every accessor in the engine funnels through here, so
48
+ * anything that reads configuration throws with the message below.
49
+ *
50
+ * **Located by walking up from this module, not from the working directory.**
51
+ * The config file sits at the root of the repository that installed the
52
+ * toolchain, so climbing out of
53
+ * `node_modules/@heroiclands/package-build/engine/` lands on it either way.
54
+ * Resolving it against `process.cwd()` instead would make the build read a
55
+ * different tree depending on where it was launched from, which is the very
56
+ * property #1508 removed. `PACKAGE_BUILD_CONFIG` names the file explicitly when
57
+ * a consumer keeps it somewhere else.
58
+ *
59
+ * **Loaded synchronously.** A YAML config is parsed synchronously as a matter
60
+ * of course; an `.mjs` one is loaded with `require` rather than `await import`,
61
+ * so that reading configuration is an ordinary expression at any call site
62
+ * instead of making every module downstream of it an async one. Node has
63
+ * supported `require()` of an ES module since v22.12 and this package requires
64
+ * v24, so the only shape it cannot load is a config whose own module graph uses
65
+ * top-level `await` — which is reported as such rather than as an opaque loader
66
+ * error.
67
+ *
68
+ * **An `.mjs` config must import `defineConfig` from
69
+ * `@heroiclands/package-build/content-config`, never from the package root barrel.**
70
+ * The barrel pulls in the compilers, the compilers read this module, and this
71
+ * module loads the config file — so a config that reaches for the barrel closes
72
+ * a cycle around its own evaluation. The leaf entry point performs no I/O and
73
+ * imports nothing but `node:path`, so it cannot.
74
+ *
75
+ * @module
76
+ */
77
+
78
+ import fs from "node:fs";
79
+ import path from "node:path";
80
+ import { createRequire } from "node:module";
81
+ import YAML from "yaml";
82
+
83
+ import { defineConfig } from "../content-config.mjs";
84
+
85
+ /** The stem every consuming repository declares its build under. */
86
+ export const CONFIG_BASENAME = "package-build.config";
87
+
88
+ /**
89
+ * The file names a configuration may be written as, in resolution order.
90
+ *
91
+ * Order settles nothing in practice — two of these in one directory is an
92
+ * error, not a precedence question (see {@link findConfigFile}) — but it fixes
93
+ * the order the names are *reported* in, which is the order a reader should
94
+ * reach for them: YAML first, `.mjs` last.
95
+ *
96
+ * The `content-build.config.*` stem this package read before the two toolchains
97
+ * merged is **not** resolved. 3.0.0 renames the file rather than accepting both:
98
+ * a deprecation window here would mean a repository could sit indefinitely on a
99
+ * name for a package that no longer exists, and the upgrade already requires
100
+ * touching the consumer's manifest and imports.
101
+ *
102
+ * @type {readonly string[]}
103
+ */
104
+ export const CONFIG_FILENAMES = Object.freeze([
105
+ `${CONFIG_BASENAME}.yaml`,
106
+ `${CONFIG_BASENAME}.yml`,
107
+ `${CONFIG_BASENAME}.mjs`,
108
+ ]);
109
+
110
+ /**
111
+ * The nearest configuration file at or above a directory.
112
+ *
113
+ * **Two of them in one directory is an error.** Picking one by precedence would
114
+ * mean a repository mid-conversion silently builds from the file its author is
115
+ * no longer editing, and the build would look entirely healthy while doing it.
116
+ * The walk continues past a directory holding none, so a repository may still
117
+ * sit inside one that has its own.
118
+ *
119
+ * @param {string} from - The directory to start from.
120
+ * @returns {string|undefined} Its absolute path, or `undefined` if the walk
121
+ * reaches the filesystem root without finding one.
122
+ * @throws {Error} When one directory holds more than one of
123
+ * {@link CONFIG_FILENAMES}.
124
+ */
125
+ export function findConfigFile(from) {
126
+ let dir = path.resolve(from);
127
+ for (;;) {
128
+ const found = CONFIG_FILENAMES.map((name) =>
129
+ path.join(dir, name),
130
+ ).filter((candidate) => fs.existsSync(candidate));
131
+ if (found.length > 1) {
132
+ throw new Error(
133
+ `package-build: ${dir} holds more than one configuration ` +
134
+ `(${found.map((f) => path.basename(f)).join(", ")}). A ` +
135
+ `repository declares its build in exactly one file — ` +
136
+ `delete the one you are no longer editing.`,
137
+ );
138
+ }
139
+ if (found.length === 1) return found[0];
140
+ const parent = path.dirname(dir);
141
+ if (parent === dir) return undefined;
142
+ dir = parent;
143
+ }
144
+ }
145
+
146
+ const require = createRequire(import.meta.url);
147
+
148
+ /**
149
+ * The item-type registries a data configuration can name.
150
+ *
151
+ * `itemBuilders` is the one part of the contract that is *code* — a table of
152
+ * functions building each type's `system` block — so a YAML configuration names
153
+ * a registry instead of supplying one. The tables themselves are ordinary
154
+ * exports; this is only the map from the name a config may write to the module
155
+ * holding it.
156
+ *
157
+ * Required lazily, so that importing this module does not drag the `sohl` half
158
+ * of the package in behind it — the property #2 exists to protect. It is also
159
+ * why the registry's own module graph must not read configuration: requiring it
160
+ * here happens *during* the resolution such a read would be asking for. Nothing
161
+ * in that graph does.
162
+ *
163
+ * @type {Readonly<Record<string, () => Record<string, unknown>>>}
164
+ */
165
+ const ITEM_BUILDER_REGISTRIES = Object.freeze({
166
+ sohl: () =>
167
+ /** @type {{ ITEM_BUILDERS: Record<string, unknown> }} */ (
168
+ require("../sohl/item-builders.mjs")
169
+ ).ITEM_BUILDERS,
170
+ });
171
+
172
+ /**
173
+ * The version of the system a repository ships content for, read from the
174
+ * `package.json` beside its configuration.
175
+ *
176
+ * `stats.systemVersion` is stamped into every compiled document, and a
177
+ * transcribed copy of it froze at `0.6.0` for four releases before anyone
178
+ * noticed (#1548). `package.json` is the file Changesets bumps, so reading it
179
+ * is what keeps the stamp equal to the version that did the compiling.
180
+ *
181
+ * The read happens *here*, in the loader, rather than in `defineConfig`:
182
+ * validating a configuration is pure by design, and this is I/O. An `.mjs`
183
+ * config does the same read itself, which it can, because it is code.
184
+ *
185
+ * @param {string} rootDir - The directory the configuration sits in.
186
+ * @returns {string} The `version` field of the adjacent `package.json`.
187
+ * @throws {Error} When there is no adjacent `package.json`, or it declares no
188
+ * version. Guessing one would produce documents claiming a version nothing
189
+ * ever shipped.
190
+ */
191
+ function readPackageJson(rootDir) {
192
+ const manifestPath = path.join(rootDir, "package.json");
193
+ try {
194
+ return {
195
+ manifestPath,
196
+ pkg: JSON.parse(fs.readFileSync(manifestPath, "utf8")),
197
+ };
198
+ } catch (err) {
199
+ throw new Error(
200
+ `package-build: ${manifestPath} could not be read, and the ` +
201
+ `configuration derives both the Foundry package id and the ` +
202
+ `system version from it.`,
203
+ { cause: err },
204
+ );
205
+ }
206
+ }
207
+
208
+ /**
209
+ * The Foundry package id, from the `name` of the adjacent `package.json`.
210
+ *
211
+ * Every project in this organisation carries a unique name by requirement, and
212
+ * each `package.json` `name` maps directly onto its Foundry package id — all
213
+ * three consumers matched exactly when this was still transcribed. So the value
214
+ * is used **verbatim**: no normalisation, no legality check.
215
+ *
216
+ * That is a decision rather than an omission. A scoped npm name (`@scope/pkg`)
217
+ * is not a legal Foundry id, but these packages are private and therefore never
218
+ * scoped, so the case does not arise; guarding it would be inventing a rule the
219
+ * project does not have.
220
+ *
221
+ * @param {string} rootDir - The directory the configuration sits in.
222
+ * @returns {string} The package id.
223
+ * @throws {Error} When `package.json` declares no name.
224
+ */
225
+ function foundryPackageId(rootDir) {
226
+ const { manifestPath, pkg } = readPackageJson(rootDir);
227
+ if (typeof pkg.name !== "string" || pkg.name.length === 0) {
228
+ throw new Error(
229
+ `package-build: ${manifestPath} declares no \`name\`, which is ` +
230
+ `what the Foundry package id is derived from.`,
231
+ );
232
+ }
233
+ return pkg.name;
234
+ }
235
+
236
+ /**
237
+ * The version of the game system a repository ships content *for*.
238
+ *
239
+ * The two package kinds derive it from different places, and the distinction is
240
+ * the whole point:
241
+ *
242
+ * - A **system** ships itself, so its own `package.json` version *is* the
243
+ * system version. That is the read #1548 introduced after a transcribed copy
244
+ * froze at `0.6.0` for four releases.
245
+ * - A **module** ships content *for* someone else's system. Its own version is
246
+ * the module's — `sohl-thalorna` sits at `0.0.1` — so deriving from it would
247
+ * stamp a SoHL version that has never existed, which is worse than a frozen
248
+ * one that at least was once true. The honest source is the system
249
+ * relationship the module already declares, and specifically `verified`:
250
+ * `_stats.systemVersion` records what the packs were built against, not the
251
+ * floor they tolerate.
252
+ *
253
+ * @param {string} rootDir - The directory the configuration sits in.
254
+ * @param {Record<string, unknown>} input - The configuration being resolved.
255
+ * @returns {string} The system version to stamp.
256
+ * @throws {Error} When a module declares no usable system relationship. A wrong
257
+ * `_stats.systemVersion` is invisible until something migrates on it, so this
258
+ * fails rather than guessing.
259
+ */
260
+ function shippedSystemVersion(rootDir, input) {
261
+ if (input.packageKind === "systems") {
262
+ const { manifestPath, pkg } = readPackageJson(rootDir);
263
+ if (typeof pkg.version !== "string" || pkg.version.length === 0) {
264
+ throw new Error(
265
+ `package-build: ${manifestPath} declares no \`version\`, ` +
266
+ `which is what a system's stats.systemVersion is derived ` +
267
+ `from.`,
268
+ );
269
+ }
270
+ return pkg.version;
271
+ }
272
+
273
+ const systemId = /** @type {Record<string, unknown>} */ (input.stats ?? {})
274
+ .systemId;
275
+ const systems =
276
+ /** @type {{id?: string, compatibility?: {verified?: string}}[]} */ (
277
+ /** @type {Record<string, unknown>} */ (input.relationships ?? {})
278
+ .systems
279
+ ) ?? [];
280
+ const relationship =
281
+ systems.find((entry) => entry?.id === systemId) ?? systems[0];
282
+ const verified = relationship?.compatibility?.verified;
283
+
284
+ if (typeof verified !== "string" || verified.length === 0) {
285
+ throw new Error(
286
+ `package-build: a module's stats.systemVersion is derived from ` +
287
+ `the system it declares a relationship with, and this ` +
288
+ `configuration declares none usable. Add ` +
289
+ `\`relationships.systems\` naming ` +
290
+ `${systemId ? `\`${systemId}\`` : "the system"} with a ` +
291
+ `\`compatibility.verified\` version. It is not taken from ` +
292
+ `this package's own \`package.json\` version — that is the ` +
293
+ `module's version, and stamping it would claim a system ` +
294
+ `version that never existed.`,
295
+ );
296
+ }
297
+ return verified;
298
+ }
299
+
300
+ /**
301
+ * Turn a parsed YAML configuration into the frozen one the engine reads.
302
+ *
303
+ * Three fields exist in a code configuration only because a code file has to
304
+ * *compute* what a data file can simply be told, and each is derived here from
305
+ * where the file sits:
306
+ *
307
+ * - **`rootDir`** is the configuration's own directory, always. A data file
308
+ * cannot write `import.meta.dirname`, and any absolute path it wrote instead
309
+ * would be one machine's — so authoring it is rejected rather than honoured.
310
+ * - **`itemBuilders`** is a *name* (`sohl`), resolved against the built-in
311
+ * registries. A registry of a consumer's own is code, and code goes in an
312
+ * `.mjs` configuration.
313
+ * - **`stats.systemVersion`** is derived from the adjacent `package.json` when
314
+ * the configuration does not state it. Stating it is still allowed: a
315
+ * repository shipping content *for* another package (a module declaring
316
+ * `systemId: sohl`) may need a version that is not its own.
317
+ *
318
+ * Everything else passes through untouched, to be validated by the one
319
+ * validator both configuration forms end at.
320
+ *
321
+ * @param {unknown} data - The parsed configuration document.
322
+ * @param {string} configPath - Absolute path of the file it was parsed from.
323
+ * @returns {import("../config.mjs").ContentBuildConfig} The frozen configuration.
324
+ * @throws {Error} When the document is not a mapping, declares `rootDir`, or
325
+ * names an item-builder registry this package does not ship.
326
+ */
327
+ export function configFromData(data, configPath) {
328
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
329
+ throw new Error(
330
+ `package-build: ${configPath} does not parse to a mapping. A ` +
331
+ `configuration is a block of top-level keys — see the README.`,
332
+ );
333
+ }
334
+ const input = /** @type {Record<string, unknown>} */ ({ ...data });
335
+ const rootDir = path.dirname(configPath);
336
+
337
+ if (input.rootDir !== undefined) {
338
+ throw new Error(
339
+ `package-build: ${configPath} declares \`rootDir\`, which a data ` +
340
+ `configuration may not: it is always the directory the file ` +
341
+ `sits in. An absolute path written here would be one ` +
342
+ `machine's; remove the key.`,
343
+ );
344
+ }
345
+ input.rootDir = rootDir;
346
+
347
+ // Transcribed from `package.json`, and therefore free to disagree with it.
348
+ // Every consumer's copy matched exactly, which is what a transcription
349
+ // looks like right up until it does not (#1548 froze one at `0.6.0` for
350
+ // four releases while nothing said so).
351
+ if (input.foundryPackage !== undefined) {
352
+ throw new Error(
353
+ `package-build: ${configPath} declares \`foundryPackage\`, which ` +
354
+ `a data configuration may not: it is the \`name\` of the ` +
355
+ `\`package.json\` beside it. Remove the key.`,
356
+ );
357
+ }
358
+ input.foundryPackage = foundryPackageId(rootDir);
359
+
360
+ if (input.itemBuilders !== undefined) {
361
+ const named = input.itemBuilders;
362
+ const known = Object.keys(ITEM_BUILDER_REGISTRIES).join(", ");
363
+ if (typeof named !== "string") {
364
+ throw new Error(
365
+ `package-build: ${configPath} must name its \`itemBuilders\` ` +
366
+ `registry as a string — the registry is code, and data ` +
367
+ `cannot carry it. Known registries: ${known}; a registry ` +
368
+ `of your own goes in ${CONFIG_BASENAME}.mjs.`,
369
+ );
370
+ }
371
+ const load = ITEM_BUILDER_REGISTRIES[named];
372
+ if (!load) {
373
+ throw new Error(
374
+ `package-build: ${configPath} names the \`itemBuilders\` ` +
375
+ `registry "${named}", which this package does not ship. ` +
376
+ `Known registries: ${known}. To supply your own, declare ` +
377
+ `it in ${CONFIG_BASENAME}.mjs.`,
378
+ );
379
+ }
380
+ input.itemBuilders = load();
381
+ }
382
+
383
+ const stats = input.stats;
384
+ if (stats !== null && typeof stats === "object" && !Array.isArray(stats)) {
385
+ const declared = /** @type {Record<string, unknown>} */ (stats);
386
+ if (declared.systemVersion !== undefined) {
387
+ throw new Error(
388
+ `package-build: ${configPath} declares ` +
389
+ `\`stats.systemVersion\`, which a data configuration may ` +
390
+ `not: a system derives it from the \`version\` of the ` +
391
+ `\`package.json\` beside it, and a module from the ` +
392
+ `\`compatibility.verified\` of the system it declares a ` +
393
+ `relationship with. Remove the key.`,
394
+ );
395
+ }
396
+ input.stats = {
397
+ ...declared,
398
+ systemVersion: shippedSystemVersion(rootDir, input),
399
+ };
400
+ }
401
+
402
+ return defineConfig(/** @type {never} */ (input));
403
+ }
404
+
405
+ /**
406
+ * Load an `.mjs` configuration — one that called `defineConfig` itself.
407
+ *
408
+ * @param {string} configPath - Absolute path of the file.
409
+ * @returns {import("../config.mjs").ContentBuildConfig} What it exported.
410
+ * @throws {Error} When its module graph uses top-level `await`, which a
411
+ * synchronously-read configuration cannot.
412
+ */
413
+ function loadCodeConfig(configPath) {
414
+ let module;
415
+ try {
416
+ module = require(configPath);
417
+ } catch (err) {
418
+ if (
419
+ /** @type {{ code?: string }} */ (err)?.code ===
420
+ "ERR_REQUIRE_ASYNC_MODULE"
421
+ ) {
422
+ throw new Error(
423
+ `package-build: ${configPath} (or something it imports) uses ` +
424
+ `top-level await, which the configuration cannot: it is ` +
425
+ `read synchronously so that reading a configured value ` +
426
+ `stays an ordinary expression. Move the awaited work into ` +
427
+ `the build that consumes the configuration.`,
428
+ { cause: err },
429
+ );
430
+ }
431
+ throw err;
432
+ }
433
+ return module.default ?? module;
434
+ }
435
+
436
+ /** The loaded configuration, memoised — the file is read at most once. */
437
+ let loaded;
438
+
439
+ /**
440
+ * The consuming repository's resolved, frozen configuration.
441
+ *
442
+ * Every engine module that needs a configured value calls this, rather than
443
+ * hoisting one at import: that is what keeps the library importable without a
444
+ * configuration (#2). The result is memoised, so calling it in a default
445
+ * parameter — the usual spelling here — costs one property read per call.
446
+ *
447
+ * @returns {import("../config.mjs").ContentBuildConfig} The frozen configuration.
448
+ * @throws {Error} When no configuration file can be found, or the one named
449
+ * cannot be loaded. Absence is a defect, not a fallback: without it the
450
+ * compilers know neither what to compile nor where to put it.
451
+ */
452
+ export function loadPackConfig() {
453
+ if (loaded) return loaded;
454
+
455
+ const explicit = process.env.PACKAGE_BUILD_CONFIG;
456
+ const configPath =
457
+ explicit ? path.resolve(explicit) : findConfigFile(import.meta.dirname);
458
+
459
+ if (!configPath || !fs.existsSync(configPath)) {
460
+ throw new Error(
461
+ explicit ?
462
+ `package-build: PACKAGE_BUILD_CONFIG names ${configPath}, ` +
463
+ `which does not exist.`
464
+ : `package-build: no ${CONFIG_FILENAMES.join(" or ")} found at ` +
465
+ `or above ${import.meta.dirname}. A consuming repository ` +
466
+ `declares its build in one file at its root; set ` +
467
+ `PACKAGE_BUILD_CONFIG to name it elsewhere.`,
468
+ );
469
+ }
470
+
471
+ loaded =
472
+ configPath.endsWith(".mjs") ?
473
+ loadCodeConfig(configPath)
474
+ : configFromData(
475
+ YAML.parse(fs.readFileSync(configPath, "utf8")),
476
+ configPath,
477
+ );
478
+ return loaded;
479
+ }