@heroiclands/package-build 0.6.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +95 -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 -34
  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,258 @@
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
+ * **Frontmatter readers** — the pure functions that read a content note's
16
+ * `sohl:` block and normalize what they find.
17
+ *
18
+ * Split out of `helpers.mjs` as a **leaf module**: its only import is a frozen
19
+ * list of constants, so the
20
+ * item-type registry (`item-builders.mjs`) can build on it without dragging in
21
+ * `helpers.mjs`, which reaches wikilinks — and through them back to
22
+ * `item-docs.mjs`, the very module that derives `itemTypes()` from the registry
23
+ * (#1504). Keeping these readers dependency-free is what makes that one-way.
24
+ *
25
+ * `helpers.mjs` re-exports everything here, so existing importers are
26
+ * unaffected: there is still one name for each reader.
27
+ */
28
+
29
+ // The affiliation standings an authored `relation` map may use. Read from the
30
+ // build package rather than restated here, so the pipeline and the runtime
31
+ // enum cannot drift apart (#1510) — a value absent from the list is a build
32
+ // error, never a silent ship.
33
+ import { AFFILIATION_STANDINGS } from "../sohl/affiliation-standings.mjs";
34
+
35
+ /**
36
+ * Resolves a dotted frontmatter key (e.g., "name.full") into the nested
37
+ * value. Returns `defaultValue` if any path segment is missing.
38
+ */
39
+ export function getFrontmatter(fm, key, defaultValue = undefined) {
40
+ if (fm == null || typeof fm !== "object") return defaultValue;
41
+ if (key in fm) return fm[key];
42
+ const parts = key.split(".");
43
+ let current = fm;
44
+ for (const part of parts) {
45
+ if (current == null || typeof current !== "object") return defaultValue;
46
+ current = current[part];
47
+ }
48
+ return current !== undefined ? current : defaultValue;
49
+ }
50
+
51
+ /**
52
+ * Reads a key from `fm.sohl` (the vault's nested system-fields block).
53
+ * Supports dotted notation, e.g. sohlField(fm, "charges.value", 0).
54
+ * Falls back to top-level `fm[key]` if `sohl` doesn't carry the key.
55
+ */
56
+ export function sohlField(fm, key, defaultValue = undefined) {
57
+ if (fm == null || typeof fm !== "object") return defaultValue;
58
+ const sohl = fm.sohl;
59
+ if (sohl && typeof sohl === "object") {
60
+ if (key in sohl) return sohl[key] ?? defaultValue;
61
+ const fromNested = getFrontmatter(sohl, key, undefined);
62
+ if (fromNested !== undefined) return fromNested;
63
+ }
64
+ return getFrontmatter(fm, key, defaultValue);
65
+ }
66
+
67
+ /**
68
+ * Read a frontmatter property that is authored as a **map**, returning its
69
+ * entries — or `null` when the note authors none.
70
+ *
71
+ * Obsidian's property editor serializes an **emptied map as an empty list**, so
72
+ * a note whose map property was ever touched and cleared in the editor arrives
73
+ * as `[]`, not `{}`. The two spellings mean the same thing — this note authors
74
+ * no entries — and a build that accepted only one of them failed on notes the
75
+ * editor itself had produced (#8; 44 affiliation notes in `sohl-thalorna`).
76
+ * Normalizing the notes would fix only today's tree: the next editor touch puts
77
+ * the empty list back.
78
+ *
79
+ * A **populated** array is still malformed, and stays the caller's error to
80
+ * raise: a list of entries is not a map, and quietly dropping them is the
81
+ * silent data loss these readers exist to prevent.
82
+ *
83
+ * @param {object} fm - The item frontmatter.
84
+ * @param {string} key - The property name, read via {@link sohlField}.
85
+ * @returns {[string, unknown][] | null} The property's entries — empty when the
86
+ * note authors none — or `null` when the value is not a map.
87
+ */
88
+ function readMapEntries(fm, key) {
89
+ const raw = sohlField(fm, key, undefined);
90
+ if (raw == null) return [];
91
+ if (Array.isArray(raw)) return raw.length === 0 ? [] : null;
92
+ if (typeof raw !== "object") return null;
93
+ return Object.entries(raw);
94
+ }
95
+
96
+ /**
97
+ * Resolve the `charges` block shared by Mystery and Mystical Ability items.
98
+ *
99
+ * Charge usage is carried by the **maximum** alone (#1129): a `null` max means
100
+ * the item does not use charges at all, `0` means it is counted but uncapped,
101
+ * and a positive number is a real cap. `value` is the current count, with
102
+ * `null` meaning "infinite remaining". Both persist as nullable NumberFields,
103
+ * so absent frontmatter must resolve to `null` — coercing it to `0` would ship
104
+ * every item as an uncapped charge-user.
105
+ *
106
+ * A legacy `usesCharges` flag in authored frontmatter is ignored: it was inert
107
+ * and has been dropped from the schema.
108
+ *
109
+ * @param {object} fm - The item frontmatter.
110
+ * @returns {{value: number|null, max: number|null}} The persisted charges block.
111
+ */
112
+ export function resolveCharges(fm) {
113
+ const toCount = (raw) => {
114
+ if (raw == null || raw === "") return null;
115
+ const num = Number(raw);
116
+ return Number.isFinite(num) ? Math.trunc(num) : null;
117
+ };
118
+ const max = toCount(sohlField(fm, "charges.max", null));
119
+ // A blank maximum means "does not use charges" — a stray current count
120
+ // cannot outlive it, since the logic layer disables both modifiers.
121
+ return {
122
+ value:
123
+ max === null ? null : toCount(sohlField(fm, "charges.value", null)),
124
+ max,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Resolve an item's `skillAptitudes` map — selector → mastery-level modifier,
130
+ * where a selector is a skill shortcode or `subType:<value>`.
131
+ *
132
+ * Authored values must be whole numbers: the persisted field is an integer
133
+ * `NumberField`, and a fractional or non-numeric entry would be silently
134
+ * coerced at load, shipping an aptitude nobody authored. A malformed entry is a
135
+ * build error rather than a rounded surprise. A `0` is legitimate and must be
136
+ * kept — an element a sign leaves untouched still beats one another sign
137
+ * hinders, so it carries real weight when maps merge.
138
+ *
139
+ * An absent property, an empty map, and the empty **list** Obsidian's property
140
+ * editor writes for a cleared map all mean the same thing — see
141
+ * {@link readMapEntries}.
142
+ *
143
+ * @param {object} fm - The item frontmatter.
144
+ * @param {string} [ctx] - Label for the error (defaults to "item").
145
+ * @returns {Record<string, number>} The persisted aptitude map (empty when
146
+ * the item authors none).
147
+ * @throws {Error} When the value is not a map, or a value is not an integer.
148
+ */
149
+ export function resolveSkillAptitudes(fm, ctx = "item") {
150
+ const entries = readMapEntries(fm, "skillAptitudes");
151
+ if (entries === null) {
152
+ throw new Error(
153
+ `${ctx}: skillAptitudes must be a map of selector → number`,
154
+ );
155
+ }
156
+ const out = {};
157
+ for (const [selector, value] of entries) {
158
+ const num = Number(value);
159
+ if (!Number.isInteger(num)) {
160
+ throw new Error(
161
+ `${ctx}: skillAptitudes["${selector}"] must be a whole number, got "${value}"`,
162
+ );
163
+ }
164
+ out[selector] = num;
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * Resolve an affiliation's `relation` map — the shortcode of another
171
+ * affiliation → this one's standing toward it (#1404).
172
+ *
173
+ * An unrecognized standing would fail the schema's `choices` validation at load
174
+ * and be dropped silently, shipping an affiliation whose authored hostility had
175
+ * quietly become neutrality — so it is a build error instead.
176
+ *
177
+ * An absent property, an empty map, and the empty **list** Obsidian's property
178
+ * editor writes for a cleared map all mean the same thing — neutral toward
179
+ * everyone. See {@link readMapEntries}.
180
+ *
181
+ * @param {object} fm - The item frontmatter.
182
+ * @param {string} [ctx] - Label for the error (defaults to "item").
183
+ * @returns {Record<string, string>} The persisted relation map (empty when the
184
+ * affiliation authors none — neutral toward everyone).
185
+ * @throws {Error} When the map is malformed or names an unknown standing.
186
+ */
187
+ export function resolveRelation(fm, ctx = "item") {
188
+ const entries = readMapEntries(fm, "relation");
189
+ if (entries === null) {
190
+ throw new Error(
191
+ `${ctx}: relation must be a map of shortcode → standing`,
192
+ );
193
+ }
194
+ const out = {};
195
+ for (const [code, value] of entries) {
196
+ const standing = String(value);
197
+ if (!AFFILIATION_STANDINGS.includes(standing)) {
198
+ throw new Error(
199
+ `${ctx}: relation["${code}"] must be one of ${AFFILIATION_STANDINGS.join(", ")}, got "${value}"`,
200
+ );
201
+ }
202
+ out[code] = standing;
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /**
208
+ * Read the mandatory `subType` from an item's frontmatter, throwing when it is
209
+ * absent or blank.
210
+ *
211
+ * Every subType-bearing item type declares `subType` as `required` with **no**
212
+ * default in its DataModel — a subtype must always be specified, and it is an
213
+ * error to omit it. The builder therefore substitutes no fallback: a content
214
+ * file missing `subType` is a build error, surfaced here rather than shipped as
215
+ * an invalid (typeless-fallback) item.
216
+ *
217
+ * @param {object} fm - The item frontmatter.
218
+ * @param {string} [ctx] - Optional label for the error (defaults to the item's
219
+ * title/name, else "item").
220
+ * @returns {string} The declared subType.
221
+ * @throws {Error} When `subType` is missing or blank.
222
+ */
223
+ export function requireSubType(fm, ctx) {
224
+ const subType = sohlField(fm, "subType", undefined);
225
+ if (subType == null || subType === "") {
226
+ const label = ctx || fm?.title || fm?.name || "item";
227
+ throw new Error(
228
+ `${label}: missing required 'subType' — every subType-bearing item must declare its kind (the builder substitutes no default).`,
229
+ );
230
+ }
231
+ return String(subType);
232
+ }
233
+
234
+ /**
235
+ * Parses the valueDesc / threshold array format. Accepts either:
236
+ * - Array of "Label:MaxValue" strings, e.g. ["Ugly:4", "Plain:12"]
237
+ * - Array of objects, e.g. [{ label, maxValue }]
238
+ * Returns a normalized array of `{ label, maxValue: number }`.
239
+ */
240
+ export function parseValueDesc(raw) {
241
+ if (!raw || !Array.isArray(raw)) return [];
242
+ return raw.map((entry) => {
243
+ if (typeof entry === "string") {
244
+ const [label, maxStr] = entry.split(":");
245
+ return {
246
+ label: (label ?? "").trim(),
247
+ maxValue: parseInt(maxStr, 10) || 0,
248
+ };
249
+ }
250
+ if (typeof entry === "object" && entry?.label !== undefined) {
251
+ return {
252
+ label: String(entry.label),
253
+ maxValue: Number(entry.maxValue) || 0,
254
+ };
255
+ }
256
+ return { label: String(entry), maxValue: 0 };
257
+ });
258
+ }
@@ -0,0 +1,305 @@
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
+ * Pack JSON generation — in-repo Markdown → per-entry JSON (build-only).
16
+ *
17
+ * Reads the authoritative content tree at the configured content root and
18
+ * compiles each pack's entries to per-entry JSON under its build directory
19
+ * (`build/packs-json/<pack>/` in this repository). The JSON is
20
+ * a disposable build intermediate consumed by `build:compiledb` (which turns it
21
+ * into the shipped LevelDB packs) — it is never committed.
22
+ *
23
+ * Each `*` compiler walks the whole content tree and selects its own entries by
24
+ * frontmatter (a `package:` matching the configured `contentPackage` + `type`),
25
+ * so routing is directory-agnostic: a file lands in a pack because of its
26
+ * `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>`.
30
+ *
31
+ * This replaces the retired `packs:export` (vault → committed `_source/`); the
32
+ * HeroicLands vault is no longer a build input for SoHL content.
33
+ */
34
+
35
+ import fs from "fs";
36
+ import path from "path";
37
+ import log from "loglevel";
38
+
39
+ import { foreignItemCatalogDirs } from "./foreign-catalog.mjs";
40
+
41
+ import { Items } from "../sohl/items.mjs";
42
+ import { Journals } from "./journals.mjs";
43
+ import { Actors } from "../sohl/actors.mjs";
44
+ import { Macros } from "./macros.mjs";
45
+ import { Scenes } from "./scenes.mjs";
46
+ import {
47
+ buildStats,
48
+ loadFolders,
49
+ buildFolderResolver,
50
+ writeFolderDocs,
51
+ } from "./helpers.mjs";
52
+ import { countContentNotes } from "./content-tree.mjs";
53
+ import { loadPackConfig } from "./pack-config.mjs";
54
+ import { routerFor } from "./pack-router.mjs";
55
+
56
+ /**
57
+ * The compiler class for each Foundry document type a pack may hold.
58
+ *
59
+ * The pack list is data (`package-build.config.yaml`), so the one thing it
60
+ * cannot carry is the code that compiles it — a document type maps to its
61
+ * compiler here. Unknown types fail the build rather than defaulting, so a pack
62
+ * declaring a type nothing can compile is loud at the first pass instead of
63
+ * shipping empty.
64
+ */
65
+ const COMPILERS = {
66
+ Item: Items,
67
+ JournalEntry: Journals,
68
+ Actor: Actors,
69
+ Macro: Macros,
70
+ Scene: Scenes,
71
+ };
72
+
73
+ /**
74
+ * Root of the build-only JSON tree for one pack.
75
+ *
76
+ * @param {string} name - The pack name.
77
+ * @param {object} [config] - The resolved build configuration. Defaults to this
78
+ * repository's.
79
+ * @returns {string} The pack's JSON directory.
80
+ */
81
+ export const packJsonDir = (name, config = loadPackConfig()) =>
82
+ path.join(config.paths.packJson, name);
83
+
84
+ /**
85
+ * The generated JSON of **every** configured Item pack — what the actors pass
86
+ * reads its predefined items from.
87
+ *
88
+ * All of them, not the first: a repository may ship several Item packs (#1566),
89
+ * and an actor's embedded items may be sourced from any of them. Finding one
90
+ * pack and stopping is how embedded-item resolution would silently miss every
91
+ * 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.
94
+ *
95
+ * @param {object} [config] - The resolved build configuration. Defaults to this
96
+ * repository's.
97
+ * @returns {string[]} Each Item pack's JSON directory. Empty when the
98
+ * repository ships no items at all — the actors pass, which is the only
99
+ * caller that needs one, refuses that itself.
100
+ */
101
+ export function itemPackJsonDirs(config = loadPackConfig()) {
102
+ return config.packs
103
+ .filter((pack) => pack.type === "Item")
104
+ .map((pack) => packJsonDir(pack.name, config));
105
+ }
106
+
107
+ /**
108
+ * Generate the per-entry JSON for one pack into `build/packs-json/<name>/`.
109
+ *
110
+ * @param {object} pack - One entry of the configured pack list.
111
+ * @param {object} config - The resolved build configuration.
112
+ * @param {object} router - The pack router, which decides which pack of this
113
+ * pass's document type each claimed note belongs in.
114
+ * @param {boolean} routingReporter - Whether this pass reports a note of its
115
+ * document type that routes nowhere. True for the first configured pack of
116
+ * the type, so one unroutable note yields one error rather than one per
117
+ * pack.
118
+ * @returns {Promise<{errors: number, compiled: number}>} The compiler's error
119
+ * count (0 on success) and the number of entries it wrote.
120
+ */
121
+ async function generatePack(
122
+ { name, type, folders, companions },
123
+ config,
124
+ router,
125
+ routingReporter,
126
+ ) {
127
+ const contentBase = config.paths.content;
128
+ const dest = packJsonDir(name, config);
129
+
130
+ const packClass = COMPILERS[type];
131
+ if (!packClass) {
132
+ log.error(
133
+ `Pack ${name}: no compiler for document type "${type}" — the ` +
134
+ `configured pack list names a type this toolchain cannot compile.`,
135
+ );
136
+ return { errors: 1, compiled: 0 };
137
+ }
138
+
139
+ log.info(`Pack ${name}: ${contentBase} → ${dest}`);
140
+
141
+ let folderList;
142
+ let resolver;
143
+ try {
144
+ folderList =
145
+ folders ? loadFolders(path.join(contentBase, folders)) : [];
146
+ ({ resolver } = buildFolderResolver(folderList));
147
+ } catch (err) {
148
+ log.error(`${name} ${folders} validation failed: ${err.message}`);
149
+ return { errors: 1, compiled: 0 };
150
+ }
151
+
152
+ // Wipe and recreate so removed content notes leave no stale JSON.
153
+ fs.rmSync(dest, { recursive: true, force: true });
154
+ fs.mkdirSync(dest, { recursive: true });
155
+
156
+ // A companion pack is written by the same pass — the scenes pass also emits
157
+ // the adventures that bundle them — so it is wiped on the same schedule.
158
+ const companionDests = {};
159
+ for (const companion of companions) {
160
+ const companionDest = packJsonDir(companion.name, config);
161
+ fs.rmSync(companionDest, { recursive: true, force: true });
162
+ fs.mkdirSync(companionDest, { recursive: true });
163
+ companionDests[companion.name] = companionDest;
164
+ }
165
+
166
+ writeFolderDocs(folderList, buildStats(undefined, config), dest, type);
167
+
168
+ const pack = new packClass({
169
+ contentBase,
170
+ dest,
171
+ companionDests,
172
+ // The actors pass resolves each being's embedded items against the items
173
+ // passes' output. That used to be an unwritten sibling-directory contract
174
+ // (`path.resolve(dest, "..", "items")`); the configured pack list names
175
+ // the Item packs, so the dependency is stated rather than assumed
176
+ // (#1508) — and it is every Item pack, since a repository may ship more
177
+ // than one (#1566).
178
+ itemsSourceDirs: itemPackJsonDirs(config),
179
+ // The catalogue of a package this repository depends on but does
180
+ // not contain, for a repository that authors beings without
181
+ // holding the items they are assembled from. Cache-only: a cold
182
+ // cache throws naming `content-build deps fetch` rather than
183
+ // downloading inside a compile.
184
+ foreignSourceDirs: foreignItemCatalogDirs(config),
185
+ folderResolver: resolver,
186
+ packName: name,
187
+ docType: type,
188
+ router,
189
+ routingReporter,
190
+ });
191
+ await pack.compile();
192
+ return { errors: pack.errorCount, compiled: pack.compiledCount };
193
+ }
194
+
195
+ /**
196
+ * The passes that compiled nothing when they were expected to compile
197
+ * something — a build failure, not a quiet no-op.
198
+ *
199
+ * A pack compiler selects its entries by the configured content package, so a
200
+ * single wrong package id rejects every note in a perfectly good tree and every
201
+ * pack ships blank while the build exits 0 (#1502). The empty-tree guard in
202
+ * {@link generatePacksJson} cannot see that: the tree is full, it is the
203
+ * *output* that is empty.
204
+ *
205
+ * @param {Array<{name: string, compiled: number, mayBeEmpty?: boolean}>} passes -
206
+ * One entry per generated pack.
207
+ * @returns {string[]} One message per pass that must not have been empty.
208
+ */
209
+ export function emptyPassErrors(passes) {
210
+ return passes
211
+ .filter((pass) => !pass.mayBeEmpty && pass.compiled === 0)
212
+ .map(
213
+ (pass) =>
214
+ `Pack "${pass.name}" compiled 0 entries from a non-empty ` +
215
+ `content tree. Every note was rejected — check that the notes ` +
216
+ `declare the package this build compiles (\`contentPackage\` in ` +
217
+ `package-build.config.yaml), or declare the pack ` +
218
+ `\`mayBeEmpty\` if it genuinely ships nothing.`,
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Generate the build-only JSON for every pack (or one, when `only` is given).
224
+ *
225
+ * @param {object} [opts]
226
+ * @param {string} [opts.only] - Restrict to a single pack name.
227
+ * @param {object} [opts.config] - The resolved build configuration. Defaults to
228
+ * this repository's. Supplying one is how a caller compiles a *different*
229
+ * package's tree — and how the guard-order test below induces id drift, now
230
+ * that the manifest is located by configuration rather than by the working
231
+ * directory.
232
+ * @returns {Promise<number>} Total error count across the generated packs.
233
+ * @throws {Error} If the configured Foundry package id has drifted from the
234
+ * shipped manifest's `id` (see `package-manifest.mjs`).
235
+ */
236
+ export async function generatePacksJson({
237
+ only,
238
+ config = loadPackConfig(),
239
+ } = {}) {
240
+ // Before anything is generated: every UUID written below is addressed to
241
+ // the configured `foundryPackage`, so a value that has drifted from the shipped
242
+ // manifest's `id` produces a whole pack of links that resolve nowhere.
243
+ // Throws rather than counting an error — there is nothing worth compiling.
244
+ //
245
+ const contentBase = config.paths.content;
246
+ if (!fs.existsSync(contentBase)) {
247
+ log.error(`Content tree not found at ${contentBase}.`);
248
+ return 1;
249
+ }
250
+ // A tree that is present but empty compiles zero documents *without an
251
+ // error*, and ships blank compendiums. Refuse instead: this only happens
252
+ // when the generated tree was never exported, or exported from the wrong
253
+ // place, and neither is something to build on.
254
+ const noteCount = countContentNotes(contentBase);
255
+ if (noteCount === 0) {
256
+ log.error(
257
+ `Content tree at ${contentBase} holds no notes, so every pack would ` +
258
+ `compile empty. The configured content root is ` +
259
+ `this repository's own source — check out the tree.`,
260
+ );
261
+ return 1;
262
+ }
263
+ log.info(`Content tree: ${noteCount} note(s) at ${contentBase}`);
264
+ fs.mkdirSync(config.paths.packJson, { recursive: true });
265
+
266
+ // A companion pack has no pass of its own — naming it selects the pass that
267
+ // writes it, so `compile adventures` is not a silent no-op.
268
+ const packs = config.packs.filter(
269
+ (pack) =>
270
+ !only ||
271
+ pack.name === only ||
272
+ pack.companions.some((companion) => companion.name === only),
273
+ );
274
+ // One router per configuration, so every pass agrees about where a note
275
+ // goes, and the first pack of each document type owns the error message for
276
+ // a note of that type that goes nowhere.
277
+ const router = routerFor(config);
278
+ const firstOfType = new Map();
279
+ for (const pack of config.packs) {
280
+ if (!firstOfType.has(pack.type)) firstOfType.set(pack.type, pack.name);
281
+ }
282
+
283
+ let totalErrors = 0;
284
+ const passes = [];
285
+ for (const pack of packs) {
286
+ const { errors, compiled } = await generatePack(
287
+ pack,
288
+ config,
289
+ router,
290
+ firstOfType.get(pack.type) === pack.name,
291
+ );
292
+ totalErrors += errors;
293
+ passes.push({
294
+ name: pack.name,
295
+ compiled,
296
+ mayBeEmpty: pack.mayBeEmpty,
297
+ });
298
+ }
299
+
300
+ for (const message of emptyPassErrors(passes)) {
301
+ log.error(message);
302
+ totalErrors++;
303
+ }
304
+ return totalErrors;
305
+ }