@heroiclands/package-build 4.0.0 → 6.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.
@@ -23,10 +23,15 @@
23
23
  * Each `*` compiler walks the whole content tree and selects its own entries by
24
24
  * the note's `type` — every note in the tree belongs to this repository's
25
25
  * `contentPackage` (#56) — so routing is directory-agnostic: a file lands in a
26
- * pack because of its `type`, not its location. Which packs exist, in what order, and which folder
27
- * hierarchy each one loads are all declared in `package-build.config.yaml`;
28
- * folder files live under the content root and are referenced from entry
29
- * frontmatter via `sohl.folder: <id>`.
26
+ * pack because of its `type`, not its location. Which packs exist and which
27
+ * folder hierarchy each one loads are declared in
28
+ * `package-build.config.yaml`; folder files live under the content root and are
29
+ * referenced from entry frontmatter via `sohl.folder: <id>`.
30
+ *
31
+ * **The order the passes run in is derived, not declared** — see
32
+ * {@link orderPassesByDependency}. The declared list is the manifest's `packs`
33
+ * array as well, so it is ordered for a reader; a pass that reads another's
34
+ * output states that on its compiler and is scheduled after it (#73).
30
35
  *
31
36
  * This replaces the retired `packs:export` (vault → committed `_source/`); the
32
37
  * HeroicLands vault is no longer a build input for SoHL content.
@@ -50,6 +55,7 @@ import {
50
55
  writeFolderDocs,
51
56
  } from "./helpers.mjs";
52
57
  import { countContentNotes } from "./content-tree.mjs";
58
+ import { emitDiagnostic } from "./diagnostics.mjs";
53
59
  import { loadPackConfig } from "./pack-config.mjs";
54
60
  import { routerFor } from "./pack-router.mjs";
55
61
 
@@ -89,8 +95,9 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
89
95
  * and an actor's embedded items may be sourced from any of them. Finding one
90
96
  * pack and stopping is how embedded-item resolution would silently miss every
91
97
  * item that landed in another. Returned in configured order, which is also the
92
- * order they compile in, so a pack later in the list cannot be read before it
93
- * is written.
98
+ * order they compile in {@link orderPassesByDependency} keeps the declared
99
+ * order among packs of one type — and every one of them is written before the
100
+ * actors pass that reads them.
94
101
  *
95
102
  * @param {object} [config] - The resolved build configuration. Defaults to this
96
103
  * repository's.
@@ -104,6 +111,126 @@ export function itemPackJsonDirs(config = loadPackConfig()) {
104
111
  .map((pack) => packJsonDir(pack.name, config));
105
112
  }
106
113
 
114
+ /**
115
+ * The document types whose compiled output a pass of this type reads.
116
+ *
117
+ * Asked of the compiler rather than held in a table here, so the dependency
118
+ * lives in the class that does the reading and a consumer's own compiler can
119
+ * declare its own. A type no compiler is registered for waits on nothing —
120
+ * {@link generatePack} reports it as an unknown type, which is the better
121
+ * message.
122
+ *
123
+ * @param {string} type - A pack's document type.
124
+ * @returns {readonly string[]} The types it reads the output of.
125
+ */
126
+ function readsOutputOf(type) {
127
+ return COMPILERS[type]?.readsPackOutputOf ?? [];
128
+ }
129
+
130
+ /**
131
+ * The passes to run, ordered so that each one follows the output it reads.
132
+ *
133
+ * **Declaration order is presentation, not compile order (#73).** The same
134
+ * `packs:` list is the manifest's `packs` array, which a consumer orders for a
135
+ * reader browsing compendiums; the actors pass, meanwhile, resolves each
136
+ * being's embedded items against the item passes' *output*. Making one list
137
+ * satisfy both meant an Actor pack declared first compiled only where a
138
+ * previous run had already left `build/packs-json` populated — green on a warm
139
+ * tree, exit 1 on every fresh checkout and every CI runner, and `build/` is
140
+ * gitignored so that is the state CI always starts from.
141
+ *
142
+ * **The reordering is the smallest one that works.** Each step takes the
143
+ * *earliest declared* pass whose dependencies are all already emitted, so a
144
+ * list that was already in a workable order comes back untouched, and one that
145
+ * was not moves exactly the passes that had to move. A dependency is satisfied
146
+ * only when **every** pack of that type has run: a being addresses an item by
147
+ * `(type, shortcode)` without knowing which Item pack ships it, so waiting for
148
+ * one of several would resolve some beings and silently fail others.
149
+ *
150
+ * A dependency on a type this configuration declares no pack of is not waited
151
+ * for. A package may ship an Actor pack and no Item pack; the pass that needs
152
+ * one refuses on its own, with a message about items rather than about order.
153
+ *
154
+ * @param {readonly object[]} packs - The passes to be run, as declared.
155
+ * @returns {object[]} A new list, in compile order. The input is untouched.
156
+ * @throws {Error} If the passes read each other's output in a cycle, which no
157
+ * order can satisfy. Only reachable from a mis-declared compiler, so it names
158
+ * the passes rather than blaming the pack list.
159
+ */
160
+ export function orderPassesByDependency(packs) {
161
+ const declared = new Set(packs.map((pack) => pack.type));
162
+ const remaining = [...packs];
163
+ const emitted = new Set();
164
+ /** @type {object[]} */
165
+ const ordered = [];
166
+
167
+ /** Whether every pack this pass reads the output of has already run. */
168
+ const ready = (pack) =>
169
+ readsOutputOf(pack.type).every(
170
+ (dependency) =>
171
+ !declared.has(dependency) ||
172
+ packs.every(
173
+ (other) =>
174
+ other.type !== dependency || emitted.has(other.name),
175
+ ),
176
+ );
177
+
178
+ while (remaining.length) {
179
+ const next = remaining.findIndex(ready);
180
+ if (next === -1) {
181
+ throw new Error(
182
+ `package-build: the configured passes read each other's ` +
183
+ `output in a cycle (` +
184
+ `${remaining.map((pack) => `${pack.name} (${pack.type})`).join(", ")}` +
185
+ `); no compile order can satisfy that.`,
186
+ );
187
+ }
188
+ const [pack] = remaining.splice(next, 1);
189
+ emitted.add(pack.name);
190
+ ordered.push(pack);
191
+ }
192
+ return ordered;
193
+ }
194
+
195
+ /**
196
+ * The dependencies this run cannot satisfy by ordering, because the pass that
197
+ * would produce them is not in it.
198
+ *
199
+ * Ordering answers the whole-package build; a run restricted to one pack
200
+ * (`content-build package compile <name>`) cannot conjure the passes it left
201
+ * out. Where their output is already on disk from an earlier run that is fine
202
+ * — it is how compiling one pack at a time is meant to work — so this reports
203
+ * only what is genuinely absent, and names the pack that would write it rather
204
+ * than the directory that is missing.
205
+ *
206
+ * @param {readonly object[]} running - The passes this run will execute.
207
+ * @param {object} config - The resolved build configuration.
208
+ * @returns {string[]} One message per unsatisfiable dependency.
209
+ */
210
+ export function unsatisfiedPassDependencies(running, config) {
211
+ const included = new Set(running.map((pack) => pack.name));
212
+ /** @type {string[]} */
213
+ const messages = [];
214
+ for (const pack of running) {
215
+ for (const dependency of readsOutputOf(pack.type)) {
216
+ for (const producer of config.packs) {
217
+ if (producer.type !== dependency) continue;
218
+ if (included.has(producer.name)) continue;
219
+ const dir = packJsonDir(producer.name, config);
220
+ if (fs.existsSync(dir)) continue;
221
+ messages.push(
222
+ `pack "${pack.name}" (${pack.type}) reads the compiled ` +
223
+ `output of the ${producer.type} pack ` +
224
+ `"${producer.name}", which this run does not compile ` +
225
+ `and which ${dir} does not hold — compile the whole ` +
226
+ `package, or compile "${producer.name}" first`,
227
+ );
228
+ }
229
+ }
230
+ }
231
+ return messages;
232
+ }
233
+
107
234
  /**
108
235
  * Generate the per-entry JSON for one pack into `build/packs-json/<name>/`.
109
236
  *
@@ -280,9 +407,31 @@ export async function generatePacksJson({
280
407
  if (!firstOfType.has(pack.type)) firstOfType.set(pack.type, pack.name);
281
408
  }
282
409
 
410
+ // Compile order is derived from what each pass reads, not from the order
411
+ // `packs:` declares — that list is also the manifest's, which a consumer
412
+ // orders for a reader (#73).
413
+ const ordered = orderPassesByDependency(packs);
414
+ if (ordered.some((pack, index) => pack !== packs[index])) {
415
+ log.info(
416
+ `Pass order: ${ordered.map((pack) => pack.name).join(", ")} — a ` +
417
+ `pass that reads another's output compiles after it, whatever ` +
418
+ `order \`packs:\` declares.`,
419
+ );
420
+ }
421
+
422
+ // What ordering cannot reach: a run restricted to one pack, whose
423
+ // dependencies are simply not in it and not on disk either.
424
+ const unsatisfied = unsatisfiedPassDependencies(ordered, config);
425
+ if (unsatisfied.length) {
426
+ for (const message of unsatisfied) {
427
+ emitDiagnostic({ severity: "error", message });
428
+ }
429
+ return unsatisfied.length;
430
+ }
431
+
283
432
  let totalErrors = 0;
284
433
  const passes = [];
285
- for (const pack of packs) {
434
+ for (const pack of ordered) {
286
435
  const { errors, compiled } = await generatePack(
287
436
  pack,
288
437
  config,
@@ -0,0 +1,259 @@
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 package homepage — a note that compiles to a **page** rather than to a
16
+ * compendium document (#51).
17
+ *
18
+ * Every HeroicLands package is reachable at `https://www.heroiclands.org/<contentPackage>/`,
19
+ * and what a reader finds there is one markdown file in the content tree,
20
+ * written by a person: what the module is, which system it needs, how to install
21
+ * it, where its source lives. Nothing about it is derived.
22
+ *
23
+ * **Authored, not assembled.** An earlier sketch generated the page in tiers —
24
+ * identity and licence from the manifest, install URL from the release address,
25
+ * "requires" links from `relationships`, a card per configured section. It would
26
+ * have worked and needed almost no authoring, and it produces a page nobody
27
+ * chose the contents of. The things that matter most on these pages cannot be
28
+ * derived: that Kethira requires buying the book from Keléstia, what Thalorna's
29
+ * setting *is*, which of twenty sections a reader should start with. So the only
30
+ * thing defaulted here is the title, from `packageBuild.manifest.title`, so that
31
+ * the package's name is not written twice.
32
+ *
33
+ * **Dispatched by `type`, not by filename.** A fixed `homepage.md` the walker
34
+ * special-cased would be the anomaly: notes are routed by frontmatter, not by
35
+ * location, and `NOTE_SCHEMAS` already routes `doc`, `macro`, `being` and the
36
+ * map types. `homepage` is one more entry whose compile step emits a page. It is
37
+ * deliberately not `README.md`: `landing: readme` already means "a `README.md`
38
+ * is its section's landing page", and `sohl-thalorna/assets/content/README.md`
39
+ * is a developer explainer about the source tree — adopting that name would make
40
+ * Thalorna's public front page its build documentation.
41
+ *
42
+ * **Engine, not `sohl/`.** The `engine/` ÷ `sohl/` line separates *note-format*
43
+ * knowledge from *game-system* knowledge, and a homepage is note format: it
44
+ * carries no `system` block, mirrors no item builder, and would mean the same
45
+ * thing for a game system that is not SoHL. Reachability is the symptom that
46
+ * makes it obvious — `HarnMaster-3-FoundryVTT` declares no `itemBuilders`, so a
47
+ * type living in the SoHL registry would be unavailable to HM3 and to every HM3
48
+ * module, which is most of the packages that need a homepage and nothing else.
49
+ *
50
+ * **Its address is the package's, not the note's.** A homepage publishes at
51
+ * `/<contentPackage>/` because that is where the package is, so `name.full`,
52
+ * `shortcode` and `id` decide nothing on it (#53 refuses them outright; this
53
+ * module simply never reads them). It compiles into no document, so it carries
54
+ * no compendium UUID and appears in no pack and in no link-manifest entry.
55
+ *
56
+ * @module
57
+ */
58
+
59
+ import { matchAllOutsideCode } from "./code-fences.mjs";
60
+
61
+ /**
62
+ * The note type that compiles to the package homepage.
63
+ *
64
+ * @type {string}
65
+ */
66
+ export const HOMEPAGE_TYPE = "homepage";
67
+
68
+ /**
69
+ * What a homepage note may write under `sohl:` — nothing.
70
+ *
71
+ * Empty on purpose, and declared rather than omitted: a type with no vocabulary
72
+ * and a type that is unknown are different findings, and only the second is an
73
+ * authoring error. The whole envelope is the two top-level keys `type` and an
74
+ * optional `title`; there is no game-system data on a page that compiles to no
75
+ * document.
76
+ *
77
+ * @type {readonly import("./field-spec.mjs").FieldSpec[]}
78
+ */
79
+ export const HOMEPAGE_FIELDS = Object.freeze([]);
80
+
81
+ /**
82
+ * Where a homepage is written, relative to the package's site root.
83
+ *
84
+ * Hugo's section landing, because the page *is* the package's landing: the
85
+ * package root is a section and this is its index.
86
+ *
87
+ * @type {string}
88
+ */
89
+ export const HOMEPAGE_DESTINATION = "_index.md";
90
+
91
+ /**
92
+ * Whether a note's frontmatter declares the homepage type.
93
+ *
94
+ * @param {object|null|undefined} fm - Parsed frontmatter.
95
+ * @returns {boolean} Whether it is a homepage note.
96
+ */
97
+ export function isHomepage(fm) {
98
+ return Boolean(fm) && fm.type === HOMEPAGE_TYPE;
99
+ }
100
+
101
+ /**
102
+ * The title a homepage publishes under.
103
+ *
104
+ * The one defaulted value on the page, and it defaults to the package's own
105
+ * `packageBuild.manifest.title` — the name Foundry already shows for the
106
+ * package — so a homepage that adds nothing to it need not restate it. An
107
+ * authored `title` wins, because a front page is allowed to greet a reader
108
+ * differently from a package browser.
109
+ *
110
+ * Falls back to `contentPackage` last, so a package that has no manifest of its
111
+ * own still yields a titled page rather than a blank heading.
112
+ *
113
+ * @param {object|null|undefined} fm - The note's frontmatter.
114
+ * @param {object} config - The resolved configuration.
115
+ * @returns {string} The title.
116
+ */
117
+ export function homepageTitle(fm, config) {
118
+ const authored = fm?.title;
119
+ if (typeof authored === "string" && authored.trim()) return authored;
120
+ const manifest = /** @type {Record<string, unknown>|undefined} */ (
121
+ config?.packageBuild?.manifest
122
+ );
123
+ const title = manifest?.title;
124
+ return typeof title === "string" && title.trim() ?
125
+ title
126
+ : config.contentPackage;
127
+ }
128
+
129
+ /**
130
+ * The frontmatter a homepage publishes with.
131
+ *
132
+ * The note's own, plus the two derived values every emitted page carries: the
133
+ * resolved `title`, and the package the build derived — no note declares one
134
+ * (`package:` is retired, #56) and the theme's breadcrumb partial reads
135
+ * `.Params.package`.
136
+ *
137
+ * An authored `aliases` is dropped for the same reason it is on every other
138
+ * page: Obsidian reads it as names a reader might call the note, Hugo reads it
139
+ * as URL redirects, and passing it through would publish a redirect stub at
140
+ * each one.
141
+ *
142
+ * @param {object} fm - The note's frontmatter.
143
+ * @param {object} options - Options.
144
+ * @param {string} options.contentPackage - The package this build publishes.
145
+ * @param {string} options.title - The resolved title.
146
+ * @returns {object} The frontmatter to write.
147
+ */
148
+ export function homepageFrontmatter(fm, { contentPackage, title }) {
149
+ const data = { ...fm, package: contentPackage, title };
150
+ delete data.aliases;
151
+ return data;
152
+ }
153
+
154
+ /**
155
+ * An inline markdown link — `[text](target)`, but not an image.
156
+ *
157
+ * Reference-style links are deliberately not matched: a landing's prose fields
158
+ * are single YAML scalars with nowhere to put a link definition, so a `[x][y]`
159
+ * in one could never resolve and is not an address anybody wrote.
160
+ *
161
+ * @type {RegExp}
162
+ */
163
+ const MARKDOWN_LINK = /(?<!!)\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
164
+
165
+ /**
166
+ * The two frontmatter keys that hold an address, and what each one means.
167
+ *
168
+ * They are **not** interchangeable, and a check that treated them as one would
169
+ * be wrong about both. The theme resolves a `url` against the site with
170
+ * `relURL`, so a package writes `kb/rules/` and is served `/sohl/kb/rules/`
171
+ * without ever naming its own prefix. An `href` is an address that is *already*
172
+ * resolved and is used verbatim — which is what `cards.source: sections` fills
173
+ * in, since a section's permalink already carries the prefix.
174
+ *
175
+ * So a leading `/` is a defect in a `url` (it is prefixed a second time) and
176
+ * correct in an `href`.
177
+ *
178
+ * @type {ReadonlySet<string>}
179
+ */
180
+ export const HOMEPAGE_ADDRESS_KEYS = Object.freeze(new Set(["url", "href"]));
181
+
182
+ /**
183
+ * Collect the markdown links in one prose value.
184
+ *
185
+ * @param {string} text - The value.
186
+ * @param {string} field - Where it came from.
187
+ * @param {string} kind - The address kind to record.
188
+ * @param {object[]} out - Accumulator.
189
+ * @param {boolean} [skipCode] - Whether to ignore links inside code.
190
+ */
191
+ function collectProse(text, field, kind, out, skipCode = false) {
192
+ const pattern = new RegExp(MARKDOWN_LINK.source, "g");
193
+ const matches =
194
+ skipCode ?
195
+ matchAllOutsideCode(text, pattern)
196
+ : [...text.matchAll(pattern)];
197
+ for (const m of matches) out.push({ field, url: m[1], kind });
198
+ }
199
+
200
+ /**
201
+ * Every address a homepage carries, wherever it is written.
202
+ *
203
+ * **Both halves of the page are in scope, and that is the finding rather than
204
+ * the assumption.** Of the six homepages authored today, four carry every link
205
+ * in the body as ordinary markdown and two carry them in `landing:` — and the
206
+ * one whose dead links prompted the check has an *empty body*, so a body-only
207
+ * reading would have found nothing at all on it. A dead link in a card is
208
+ * exactly as broken as one in a paragraph.
209
+ *
210
+ * Three shapes are gathered, and the caller needs to tell them apart because
211
+ * the rules differ:
212
+ *
213
+ * - **`url`** — package-relative, resolved against the site by the theme.
214
+ * - **`href`** — already resolved, used verbatim.
215
+ * - **prose and body markdown links** — emitted as written and resolved by the
216
+ * browser against the landing's own address, which *is* the package root, so
217
+ * a relative one means the same thing a `url` does.
218
+ *
219
+ * `banner:` is not an address: it is an image path resolved through the CDN
220
+ * base, and `banner: none` is a sentinel rather than a target. Top-level
221
+ * `title` and `description` are not walked either — they are set as text, never
222
+ * rendered as markdown.
223
+ *
224
+ * @param {object|null|undefined} fm - The note's frontmatter.
225
+ * @param {string} [body] - The note's markdown body.
226
+ * @returns {Array<{field: string, url: string, kind: string}>} Every address,
227
+ * frontmatter first and then the body, each with the dotted path it was
228
+ * written at.
229
+ */
230
+ export function homepageAddresses(fm, body = "") {
231
+ const out = [];
232
+
233
+ const walk = (value, field) => {
234
+ if (typeof value === "string") {
235
+ collectProse(value, field, "prose", out);
236
+ return;
237
+ }
238
+ if (Array.isArray(value)) {
239
+ value.forEach((v, i) => walk(v, `${field}[${i}]`));
240
+ return;
241
+ }
242
+ if (!value || typeof value !== "object") return;
243
+ for (const [key, v] of Object.entries(value)) {
244
+ const child = `${field}.${key}`;
245
+ // An address field holds an address, not prose: reading it for
246
+ // markdown links as well would report the same target twice
247
+ // whenever one happened to look like a link.
248
+ if (HOMEPAGE_ADDRESS_KEYS.has(key) && typeof v === "string") {
249
+ out.push({ field: child, url: v, kind: key });
250
+ continue;
251
+ }
252
+ walk(v, child);
253
+ }
254
+ };
255
+
256
+ walk(fm?.landing, "landing");
257
+ collectProse(String(body ?? ""), "body", "body", out, true);
258
+ return out;
259
+ }
package/engine/index.mjs CHANGED
@@ -60,6 +60,12 @@ export * as notePackage from "./note-package.mjs";
60
60
  /** Frontmatter fields a note may no longer declare, and the refusal of them. */
61
61
  export * as retiredFields from "./retired-fields.mjs";
62
62
 
63
+ /** The package homepage: the note type that compiles to a page, not a document. */
64
+ export * as homepage from "./homepage.mjs";
65
+
66
+ /** The note types the engine itself declares, whatever a consumer registers. */
67
+ export * as noteSchemas from "./note-schemas.mjs";
68
+
63
69
  /** The shipped Foundry manifest: locating it, reading it, guarding its id. */
64
70
 
65
71
  /** The URL a content note is published at — the one web-address rule. */
@@ -59,6 +59,7 @@ import { assertNoDraftField } from "./retired-fields.mjs";
59
59
  import { journalPageId, splitPages } from "./journals.mjs";
60
60
  import { routerFor } from "./pack-router.mjs";
61
61
  import { loadPackConfig } from "./pack-config.mjs";
62
+ import { publishesContentPages } from "../content-config.mjs";
62
63
 
63
64
  /**
64
65
  * The reserved anchor name for a journal's **first** page.
@@ -271,7 +272,7 @@ export function manifestContext(config = loadPackConfig()) {
271
272
  foundryPackageId: config.foundryPackage,
272
273
  packRouter: routerFor(config),
273
274
  scheme: config.publish.address,
274
- web: config.publish.site,
275
+ web: publishesContentPages(config),
275
276
  // The walk's own configuration, threaded through rather than left to
276
277
  // its default, so a caller that passes a config drives every read.
277
278
  skipDirectories: config.skipDirectories,
@@ -0,0 +1,44 @@
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 note types the **engine** declares — the ones whose vocabulary is a fact
16
+ * about the note format rather than about any game system (#51).
17
+ *
18
+ * `sohl/note-schemas.mjs` is the other half, and the line between them is the
19
+ * `engine/` ÷ `sohl/` line everywhere else in this package: note-format
20
+ * knowledge here, game-system knowledge there. It is not a permission boundary
21
+ * between consumers — every content project authors the full vocabulary — but it
22
+ * is a reachability one in exactly one direction. A package that declares no
23
+ * `itemBuilders` (`HarnMaster-3-FoundryVTT`, and every HM3 module) uses only the
24
+ * packaging half of the toolchain, so a type declared in the SoHL registry would
25
+ * be unavailable to it. These are the types every package has, whatever it
26
+ * ships.
27
+ *
28
+ * One entry today. A consumer merges it under its own registry —
29
+ * `{ ...ENGINE_NOTE_SCHEMAS, ...NOTE_SCHEMAS }` — so a game system may extend
30
+ * these but the engine's declaration stands wherever no registry is configured.
31
+ *
32
+ * @module
33
+ */
34
+
35
+ import { HOMEPAGE_FIELDS, HOMEPAGE_TYPE } from "./homepage.mjs";
36
+
37
+ /**
38
+ * Every engine-level content type, and what a note of that type may write.
39
+ *
40
+ * @type {Readonly<Record<string, readonly import("./field-spec.mjs").FieldSpec[]>>}
41
+ */
42
+ export const ENGINE_NOTE_SCHEMAS = Object.freeze({
43
+ [HOMEPAGE_TYPE]: HOMEPAGE_FIELDS,
44
+ });
@@ -453,6 +453,9 @@ function loadCodeConfig(configPath) {
453
453
  /** The loaded configuration, memoised — the file is read at most once. */
454
454
  let loaded;
455
455
 
456
+ /** The file {@link loadPackConfig} read, alongside the memoised result. */
457
+ let loadedFrom;
458
+
456
459
  /**
457
460
  * The consuming repository's resolved, frozen configuration.
458
461
  *
@@ -492,5 +495,23 @@ export function loadPackConfig() {
492
495
  YAML.parse(fs.readFileSync(configPath, "utf8")),
493
496
  configPath,
494
497
  );
498
+ loadedFrom = configPath;
495
499
  return loaded;
496
500
  }
501
+
502
+ /**
503
+ * The file {@link loadPackConfig} resolved the configuration from.
504
+ *
505
+ * A diagnostic about a *configured* value has to name the file it was declared
506
+ * in, and re-deriving that path at the point of the finding would be a second
507
+ * resolution free to disagree with the first — the `PACKAGE_BUILD_CONFIG`
508
+ * override, the upward walk and the one-file-per-directory rule all have to
509
+ * come out the same way. This reports the path actually read.
510
+ *
511
+ * @returns {string} Its absolute path.
512
+ * @throws {Error} As {@link loadPackConfig}, when there is no configuration.
513
+ */
514
+ export function packConfigPath() {
515
+ loadPackConfig();
516
+ return /** @type {string} */ (loadedFrom);
517
+ }