@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.
- package/CHANGELOG-content-build.md +965 -0
- package/CHANGELOG.md +68 -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,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
|
+
}
|