@heroiclands/package-build 8.1.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +748 -0
- package/CONTENT.md +273 -13
- package/bin/content-build.mjs +479 -123
- package/bin/package-build.mjs +27 -69
- package/bin/report.mjs +1 -2
- package/bundle.mjs +2 -10
- package/config.mjs +31 -106
- package/container.mjs +13 -57
- package/content-config.mjs +300 -188
- package/coverage.mjs +14 -55
- package/deploy.mjs +4 -13
- package/docs/content-format.md +1418 -0
- package/e2e.mjs +16 -55
- package/engine/address-charset.mjs +62 -0
- package/engine/address-diff.mjs +1 -4
- package/engine/alias-index.mjs +153 -0
- package/engine/base-compiler.mjs +203 -31
- package/engine/code-fences.mjs +4 -13
- package/engine/compendiums.mjs +13 -37
- package/engine/content-address.mjs +6 -10
- package/engine/content-format-check.mjs +570 -0
- package/engine/content-format.mjs +253 -0
- package/engine/content-links.mjs +144 -99
- package/engine/content-lint.mjs +12 -16
- package/engine/content-slug.mjs +2 -6
- package/engine/content-tables.mjs +26 -79
- package/engine/diagnostics.mjs +37 -15
- package/engine/document-subtypes.mjs +440 -0
- package/engine/field-reference.mjs +6 -20
- package/engine/field-spec.mjs +49 -45
- package/engine/foreign-catalog.mjs +7 -22
- package/engine/foreign-manifests.mjs +1 -4
- package/engine/frontmatter-lint.mjs +347 -43
- package/engine/frontmatter.mjs +3 -8
- package/engine/generate.mjs +36 -20
- package/engine/helpers.mjs +54 -81
- package/engine/homepage.mjs +4 -15
- package/engine/ids.mjs +21 -12
- package/engine/index.mjs +15 -0
- package/engine/item-registry.mjs +72 -9
- package/engine/journals.mjs +4 -14
- package/engine/kb-manifest.mjs +41 -24
- package/engine/macros.mjs +2 -10
- package/engine/manifest-emit.mjs +6 -17
- package/engine/map-notes.mjs +53 -87
- package/engine/note-claims.mjs +383 -0
- package/engine/note-package.mjs +1 -4
- package/engine/note-vocabulary.mjs +678 -0
- package/engine/pack-config.mjs +56 -60
- package/engine/pack-router.mjs +18 -8
- package/engine/prose-config.mjs +20 -4
- package/engine/prose-lint.mjs +61 -17
- package/engine/region-events.mjs +1 -3
- package/engine/retired-fields.mjs +117 -3
- package/engine/scene-levels.mjs +8 -22
- package/engine/scenes.mjs +31 -47
- package/engine/schema-check.mjs +348 -7
- package/engine/schema-extract.mjs +11 -39
- package/engine/site-build.mjs +13 -38
- package/engine/site-index.mjs +40 -35
- package/engine/system-block.mjs +513 -0
- package/engine/web-wikilinks.mjs +115 -92
- package/engine/wikilink-syntax.mjs +30 -0
- package/engine/wikilinks.mjs +147 -183
- package/index.mjs +1 -5
- package/lang.mjs +1 -3
- package/manifest.mjs +10 -37
- package/markdownlint-config.mjs +1 -5
- package/package.json +6 -2
- package/sohl/actors.mjs +251 -68
- package/sohl/being-info.mjs +3 -6
- package/sohl/document-subtypes.mjs +82 -0
- package/sohl/index.mjs +4 -6
- package/sohl/item-builders.mjs +1 -3
- package/sohl/item-fields.mjs +16 -34
- package/sohl/items.mjs +111 -17
- package/sohl/kb-passes.mjs +29 -39
- package/sohl/note-schemas.mjs +11 -7
- package/sohl/skill-base.mjs +7 -23
- package/stage.mjs +3 -13
- package/templates.mjs +4 -15
- package/types/bundle.d.mts +1 -1
- package/types/container.d.mts +2 -2
- package/types/content-config.d.mts +48 -4
- package/types/coverage.d.mts +1 -1
- package/types/e2e.d.mts +4 -4
- package/types/engine/address-charset.d.mts +45 -0
- package/types/engine/alias-index.d.mts +122 -0
- package/types/engine/base-compiler.d.mts +132 -4
- package/types/engine/content-address.d.mts +2 -2
- package/types/engine/content-format-check.d.mts +163 -0
- package/types/engine/content-format.d.mts +101 -0
- package/types/engine/content-links.d.mts +16 -1
- package/types/engine/content-lint.d.mts +6 -0
- package/types/engine/diagnostics.d.mts +29 -0
- package/types/engine/document-subtypes.d.mts +233 -0
- package/types/engine/field-spec.d.mts +76 -23
- package/types/engine/frontmatter-lint.d.mts +47 -2
- package/types/engine/generate.d.mts +15 -2
- package/types/engine/helpers.d.mts +22 -14
- package/types/engine/ids.d.mts +10 -0
- package/types/engine/index.d.mts +5 -0
- package/types/engine/item-registry.d.mts +21 -2
- package/types/engine/kb-manifest.d.mts +35 -8
- package/types/engine/map-notes.d.mts +21 -11
- package/types/engine/note-claims.d.mts +113 -0
- package/types/engine/note-vocabulary.d.mts +251 -0
- package/types/engine/pack-config.d.mts +4 -3
- package/types/engine/pack-router.d.mts +4 -4
- package/types/engine/prose-lint.d.mts +6 -2
- package/types/engine/retired-fields.d.mts +73 -2
- package/types/engine/schema-check.d.mts +182 -0
- package/types/engine/schema-extract.d.mts +1 -1
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/system-block.d.mts +281 -0
- package/types/engine/web-wikilinks.d.mts +23 -12
- package/types/engine/wikilink-syntax.d.mts +29 -0
- package/types/manifest.d.mts +1 -1
- package/types/sohl/actors.d.mts +62 -6
- package/types/sohl/document-subtypes.d.mts +14 -0
- package/types/sohl/index.d.mts +1 -0
- package/types/sohl/items.d.mts +21 -0
package/engine/scene-levels.mjs
CHANGED
|
@@ -87,9 +87,7 @@ const LEVEL_SPLITTING_CLI = "3.0.3";
|
|
|
87
87
|
*/
|
|
88
88
|
export function compendiumCliVersion() {
|
|
89
89
|
try {
|
|
90
|
-
return createRequire(import.meta.url)(
|
|
91
|
-
"@foundryvtt/foundryvtt-cli/package.json",
|
|
92
|
-
).version;
|
|
90
|
+
return createRequire(import.meta.url)("@foundryvtt/foundryvtt-cli/package.json").version;
|
|
93
91
|
} catch {
|
|
94
92
|
return undefined;
|
|
95
93
|
}
|
|
@@ -128,8 +126,7 @@ function levelIdOf(entry) {
|
|
|
128
126
|
* @returns {string} A phrase naming the entry, never `[object Object]`.
|
|
129
127
|
*/
|
|
130
128
|
function describeEntry(entry, index) {
|
|
131
|
-
if (isInlineLevel(entry))
|
|
132
|
-
return `\`levels[${index}]\` is ${describeShape(entry)}`;
|
|
129
|
+
if (isInlineLevel(entry)) return `\`levels[${index}]\` is ${describeShape(entry)}`;
|
|
133
130
|
return `\`levels[${index}]\` is ${JSON.stringify(entry) ?? String(entry)}`;
|
|
134
131
|
}
|
|
135
132
|
|
|
@@ -234,15 +231,11 @@ function describeRemedy(cliVersion) {
|
|
|
234
231
|
* @returns {string} One report covering every affected scene.
|
|
235
232
|
*/
|
|
236
233
|
function describeWholesaleLoss(declaring, cliVersion) {
|
|
237
|
-
const inline = declaring.some(([, scene]) =>
|
|
238
|
-
declaredLevels(scene).some(isInlineLevel),
|
|
239
|
-
);
|
|
234
|
+
const inline = declaring.some(([, scene]) => declaredLevels(scene).some(isInlineLevel));
|
|
240
235
|
const list = declaring
|
|
241
236
|
.map(([sceneId, scene]) => {
|
|
242
237
|
const entries = declaredLevels(scene)
|
|
243
|
-
.map((entry) =>
|
|
244
|
-
isInlineLevel(entry) ? describeShape(entry) : `"${entry}"`,
|
|
245
|
-
)
|
|
238
|
+
.map((entry) => (isInlineLevel(entry) ? describeShape(entry) : `"${entry}"`))
|
|
246
239
|
.join(", ");
|
|
247
240
|
return `${nameScene(sceneId, scene)} → ${entries}`;
|
|
248
241
|
})
|
|
@@ -329,9 +322,7 @@ export function checkSceneLevels(records, { cliVersion } = {}) {
|
|
|
329
322
|
// No scene in the pack has a Level record: one fact about the compile,
|
|
330
323
|
// which saying per scene would bury. Scenes declaring nothing are not
|
|
331
324
|
// covered by it, so they are still judged individually below.
|
|
332
|
-
const declaring = scenes.filter(
|
|
333
|
-
([, scene]) => declaredLevels(scene).length,
|
|
334
|
-
);
|
|
325
|
+
const declaring = scenes.filter(([, scene]) => declaredLevels(scene).length);
|
|
335
326
|
const wholesale = levelKeys.size === 0 && declaring.length > 0;
|
|
336
327
|
if (wholesale) problems.push(describeWholesaleLoss(declaring, cliVersion));
|
|
337
328
|
|
|
@@ -357,9 +348,7 @@ export function checkSceneLevels(records, { cliVersion } = {}) {
|
|
|
357
348
|
`${LEVEL_SPLITTING_CLI} writes.`,
|
|
358
349
|
);
|
|
359
350
|
} else if (!id) {
|
|
360
|
-
broken.push(
|
|
361
|
-
`${where}: ${describeEntry(entry, index)}, not a Level id.`,
|
|
362
|
-
);
|
|
351
|
+
broken.push(`${where}: ${describeEntry(entry, index)}, not a Level id.`);
|
|
363
352
|
} else if (!levelKeys.has(`${sceneId}.${id}`)) {
|
|
364
353
|
broken.push(
|
|
365
354
|
`${where} lists level "${id}", but no record exists at ` +
|
|
@@ -372,16 +361,13 @@ export function checkSceneLevels(records, { cliVersion } = {}) {
|
|
|
372
361
|
// A broken entry is already reported above; only the declaration
|
|
373
362
|
// itself is judged here, so nothing is reported twice.
|
|
374
363
|
if (broken.length) problems.push(...broken);
|
|
375
|
-
else
|
|
376
|
-
problems.push(...checkDeclaredLevels(scene ?? {}, declared, where));
|
|
364
|
+
else problems.push(...checkDeclaredLevels(scene ?? {}, declared, where));
|
|
377
365
|
}
|
|
378
366
|
|
|
379
367
|
for (const adventure of adventures) {
|
|
380
368
|
const inline = Array.isArray(adventure?.scenes) ? adventure.scenes : [];
|
|
381
369
|
for (const scene of inline) {
|
|
382
|
-
const levelIds = declaredLevels(scene).map(
|
|
383
|
-
(level) => levelIdOf(level) ?? level,
|
|
384
|
-
);
|
|
370
|
+
const levelIds = declaredLevels(scene).map((level) => levelIdOf(level) ?? level);
|
|
385
371
|
const where =
|
|
386
372
|
`Adventure "${adventure?.name ?? adventure?._id}" scene ` +
|
|
387
373
|
`"${scene?.name ?? scene?._id}"`;
|
package/engine/scenes.mjs
CHANGED
|
@@ -51,25 +51,21 @@ import fs from "fs";
|
|
|
51
51
|
import path from "path";
|
|
52
52
|
import log from "loglevel";
|
|
53
53
|
|
|
54
|
-
import {
|
|
55
|
-
walkMarkdownTree,
|
|
56
|
-
sohlField,
|
|
57
|
-
resolveName,
|
|
58
|
-
slugify,
|
|
59
|
-
defaultStats,
|
|
60
|
-
} from "./helpers.mjs";
|
|
54
|
+
import { walkMarkdownTree, sohlField, resolveName, slugify, defaultStats } from "./helpers.mjs";
|
|
61
55
|
import { BasePackCompiler } from "./base-compiler.mjs";
|
|
62
56
|
import { buildJournalEntry, splitPages, journalPageId } from "./journals.mjs";
|
|
63
57
|
import { compendiumUuid, makeId, packForType } from "./ids.mjs";
|
|
64
58
|
import { packRouter } from "./pack-router.mjs";
|
|
65
59
|
import { foundryPackageId } from "./content-package.mjs";
|
|
66
60
|
import { itemDocEntryId } from "./item-docs.mjs";
|
|
61
|
+
import { behaviorDocId, buildScene, isMapType, regionDocId } from "./map-notes.mjs";
|
|
67
62
|
import {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
RETIRED_FIELD_ALIASES,
|
|
64
|
+
declaresRetiredAlias,
|
|
65
|
+
locateFrontmatterKey,
|
|
66
|
+
readAliasedField,
|
|
67
|
+
retiredAliasMessage,
|
|
68
|
+
} from "./retired-fields.mjs";
|
|
73
69
|
|
|
74
70
|
/**
|
|
75
71
|
* Every SoHL action name this build knows about, for the `action:` warning on a
|
|
@@ -158,9 +154,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
158
154
|
}) {
|
|
159
155
|
super({ contentBase, dest, folderResolver });
|
|
160
156
|
if (!companionDests.adventures) {
|
|
161
|
-
throw new Error(
|
|
162
|
-
"Scenes compiler requires an `adventures` companion destination",
|
|
163
|
-
);
|
|
157
|
+
throw new Error("Scenes compiler requires an `adventures` companion destination");
|
|
164
158
|
}
|
|
165
159
|
Object.defineProperty(this, "adventureDir", {
|
|
166
160
|
value: companionDests.adventures,
|
|
@@ -188,30 +182,21 @@ export class Scenes extends BasePackCompiler {
|
|
|
188
182
|
#collect() {
|
|
189
183
|
const maps = [];
|
|
190
184
|
const effectsByAddress = new Map();
|
|
191
|
-
for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(
|
|
192
|
-
this.contentBase,
|
|
193
|
-
)) {
|
|
185
|
+
for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(this.contentBase)) {
|
|
194
186
|
// No retired-field test: this pass's own walk — the shared compile
|
|
195
187
|
// loop — is where a note still declaring `package:` (#56) or
|
|
196
188
|
// `draft:` (#69) is reported, once. Repeating either check here
|
|
197
189
|
// would double the diagnostic or throw past it. A refused note is
|
|
198
190
|
// indexed and then never compiled, so it reaches no document.
|
|
199
191
|
if (!fm || !fm.id) continue;
|
|
200
|
-
if (
|
|
201
|
-
fm.shortcode &&
|
|
202
|
-
Array.isArray(fm.effects) &&
|
|
203
|
-
fm.effects.length
|
|
204
|
-
) {
|
|
192
|
+
if (fm.shortcode && Array.isArray(fm.effects) && fm.effects.length) {
|
|
205
193
|
effectsByAddress.set(`${fm.type}-${fm.shortcode}`, {
|
|
206
194
|
id: fm.id,
|
|
207
195
|
type: fm.type,
|
|
208
196
|
// Where the owning item landed, so a region behaviour's
|
|
209
197
|
// effect reference addresses the right pack when a
|
|
210
198
|
// repository ships several of one type (#1566).
|
|
211
|
-
pack: packRouter().resolveOrNull(
|
|
212
|
-
fm,
|
|
213
|
-
packForType(fm.type).docType,
|
|
214
|
-
),
|
|
199
|
+
pack: packRouter().resolveOrNull(fm, packForType(fm.type).docType),
|
|
215
200
|
effects: fm.effects,
|
|
216
201
|
});
|
|
217
202
|
}
|
|
@@ -239,17 +224,13 @@ export class Scenes extends BasePackCompiler {
|
|
|
239
224
|
throw new Error(`Map note missing shortcode: ${absPath}`);
|
|
240
225
|
}
|
|
241
226
|
if (index.has(fm.shortcode)) {
|
|
242
|
-
throw new Error(
|
|
243
|
-
`Two map notes share the shortcode "${fm.shortcode}"`,
|
|
244
|
-
);
|
|
227
|
+
throw new Error(`Two map notes share the shortcode "${fm.shortcode}"`);
|
|
245
228
|
}
|
|
246
229
|
const regions = new Map();
|
|
247
230
|
for (const [key, spec] of Object.entries(fm.sohl?.regions ?? {})) {
|
|
248
231
|
const id = regionDocId(fm.id, key, spec?._id);
|
|
249
232
|
const behaviors = new Map();
|
|
250
|
-
for (const [bKey, bSpec] of Object.entries(
|
|
251
|
-
spec?.behaviors ?? {},
|
|
252
|
-
)) {
|
|
233
|
+
for (const [bKey, bSpec] of Object.entries(spec?.behaviors ?? {})) {
|
|
253
234
|
behaviors.set(bKey, behaviorDocId(id, bKey, bSpec?._id));
|
|
254
235
|
}
|
|
255
236
|
regions.set(key, { id, behaviors });
|
|
@@ -283,9 +264,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
283
264
|
const mapKey = addr.map ?? selfShortcode;
|
|
284
265
|
const target = index.get(mapKey);
|
|
285
266
|
if (!target) {
|
|
286
|
-
throw new Error(
|
|
287
|
-
`${label}: no map note has the shortcode "${mapKey}"`,
|
|
288
|
-
);
|
|
267
|
+
throw new Error(`${label}: no map note has the shortcode "${mapKey}"`);
|
|
289
268
|
}
|
|
290
269
|
const region = target.regions.get(addr.region);
|
|
291
270
|
if (!region) {
|
|
@@ -305,9 +284,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
305
284
|
resolveBehaviorRef: (addr, label) => {
|
|
306
285
|
const { target, region } = lookupRegion(addr, label);
|
|
307
286
|
if (!addr.behavior) {
|
|
308
|
-
throw new Error(
|
|
309
|
-
`${label}: a behaviour reference is {map, region, behavior}`,
|
|
310
|
-
);
|
|
287
|
+
throw new Error(`${label}: a behaviour reference is {map, region, behavior}`);
|
|
311
288
|
}
|
|
312
289
|
const behaviorId = region.behaviors.get(addr.behavior);
|
|
313
290
|
if (!behaviorId) {
|
|
@@ -317,8 +294,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
317
294
|
);
|
|
318
295
|
}
|
|
319
296
|
return (
|
|
320
|
-
`Scene.${target.sceneId}.Region.${region.id}` +
|
|
321
|
-
`.RegionBehavior.${behaviorId}`
|
|
297
|
+
`Scene.${target.sceneId}.Region.${region.id}` + `.RegionBehavior.${behaviorId}`
|
|
322
298
|
);
|
|
323
299
|
},
|
|
324
300
|
resolveEffectRef: (addr, label) => {
|
|
@@ -409,6 +385,17 @@ export class Scenes extends BasePackCompiler {
|
|
|
409
385
|
// pass has to read the other's output.
|
|
410
386
|
const entryId = hasBody ? itemDocEntryId(fm.id) : undefined;
|
|
411
387
|
const folder = this.folderResolver(sohlField(fm, "folder", null));
|
|
388
|
+
// The retired spelling of the background art, reported where an author
|
|
389
|
+
// meets it soonest — every consumer runs the compile, and not every
|
|
390
|
+
// one runs the lint (#142). Located by reading the note back, which is
|
|
391
|
+
// what the other retired-field reports do: this is the one path that
|
|
392
|
+
// needs the position, so it is paid for only here.
|
|
393
|
+
if (declaresRetiredAlias(fm, "img")) {
|
|
394
|
+
this.noteWarn(
|
|
395
|
+
retiredAliasMessage(RETIRED_FIELD_ALIASES.img, "img"),
|
|
396
|
+
locateFrontmatterKey(this.currentNote?.absPath, RETIRED_FIELD_ALIASES.img),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
412
399
|
const warnings = [];
|
|
413
400
|
const scene = buildScene(fm, {
|
|
414
401
|
packageId: foundryPackageId(),
|
|
@@ -420,8 +407,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
420
407
|
// default JournalEntry pack, not in whichever Scene pack the map
|
|
421
408
|
// itself was routed to (#1566).
|
|
422
409
|
journalPack: packRouter().defaultOf("JournalEntry"),
|
|
423
|
-
pageIds:
|
|
424
|
-
hasBody ? this.#pageIds(markdown, entryId, name) : new Map(),
|
|
410
|
+
pageIds: hasBody ? this.#pageIds(markdown, entryId, name) : new Map(),
|
|
425
411
|
knownActions: this.knownActions,
|
|
426
412
|
warnings,
|
|
427
413
|
...this.#resolvers(this.index, this.effectsByAddress, fm.shortcode),
|
|
@@ -455,7 +441,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
455
441
|
this.places.set(placeKey, {
|
|
456
442
|
key: placeKey,
|
|
457
443
|
name: sohlField(fm, "placeName", null) || name,
|
|
458
|
-
img:
|
|
444
|
+
img: readAliasedField(fm, "img") ?? null,
|
|
459
445
|
pinned: false,
|
|
460
446
|
scenes: [],
|
|
461
447
|
journal: [],
|
|
@@ -489,9 +475,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
489
475
|
|
|
490
476
|
/** @inheritdoc */
|
|
491
477
|
reportCompiled(stats) {
|
|
492
|
-
log.info(
|
|
493
|
-
`Compiled ${stats.compiled} scene(s) and ${this.adventureCount} adventure(s)`,
|
|
494
|
-
);
|
|
478
|
+
log.info(`Compiled ${stats.compiled} scene(s) and ${this.adventureCount} adventure(s)`);
|
|
495
479
|
}
|
|
496
480
|
|
|
497
481
|
/**
|
package/engine/schema-check.mjs
CHANGED
|
@@ -32,10 +32,20 @@
|
|
|
32
32
|
* documents' `system` keys against `defineSchema()` **by hand**, which is how
|
|
33
33
|
* the next one would have to be found too.
|
|
34
34
|
*
|
|
35
|
-
* **The
|
|
35
|
+
* **The declared half of the emission is statically known.** A builder *is* its
|
|
36
36
|
* field list — {@link module:engine/field-spec} makes `buildFromFields` the only
|
|
37
|
-
* statement of the mapping — so every `system` path a
|
|
38
|
-
* `field.to
|
|
37
|
+
* statement of the mapping — so every `system` path a *declared field* emits is
|
|
38
|
+
* `field.to`. {@link compareFields} needs no compilation to read that.
|
|
39
|
+
*
|
|
40
|
+
* **The rest of the emission is only observable.** A compiler writes keys of
|
|
41
|
+
* its own alongside the declared fields — `shortcode`, `actionDefs`, `notes`,
|
|
42
|
+
* `docHtml`, and since #126 `archetype` — and those appear in neither set
|
|
43
|
+
* {@link compareFields} compares, so nothing compared them at all (#155). They
|
|
44
|
+
* cannot be listed here without the list going stale the next time a compiler
|
|
45
|
+
* grows a key, so they are read off the `system` object the compiler produced:
|
|
46
|
+
* {@link compareEmittedSystem} takes the assembled block and asks what the
|
|
47
|
+
* schema does not declare. That is the same question, against the same schema,
|
|
48
|
+
* with the emitted set *observed* rather than derived.
|
|
39
49
|
*
|
|
40
50
|
* **The declared half is the consumer's, and arrives as data.** `defineSchema()`
|
|
41
51
|
* lives in the target system's `src/`, so the system publishes its field sets as
|
|
@@ -56,6 +66,8 @@ import fs from "node:fs";
|
|
|
56
66
|
import path from "node:path";
|
|
57
67
|
|
|
58
68
|
import { cachedSchemaPath, SCHEMA_ARTIFACT_FILE } from "./foreign-catalog.mjs";
|
|
69
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
70
|
+
import { systemData, systemDataPaths, undeclaredPaths } from "./system-block.mjs";
|
|
59
71
|
|
|
60
72
|
/**
|
|
61
73
|
* The artifact version this module reads.
|
|
@@ -239,6 +251,186 @@ export function compareFields({
|
|
|
239
251
|
return { undeclared, unemitted, skipped };
|
|
240
252
|
}
|
|
241
253
|
|
|
254
|
+
/**
|
|
255
|
+
* One `system` key a compiled document carries that its subtype does not
|
|
256
|
+
* declare.
|
|
257
|
+
*
|
|
258
|
+
* @typedef {object} EmissionFinding
|
|
259
|
+
* @property {string} type - The content type whose note produced the document.
|
|
260
|
+
* @property {string} subtype - The document subtype it compiled into.
|
|
261
|
+
* @property {string} documentType - `Item`, `Actor`, …
|
|
262
|
+
* @property {string} field - The undeclared path, dotted.
|
|
263
|
+
* @property {string} systemVersion - The version checked against.
|
|
264
|
+
* @property {"builder"|"compiler"} origin - What wrote it. See
|
|
265
|
+
* {@link emittedUndeclaredMessage}.
|
|
266
|
+
*/
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether a value is a mapping this walk descends into. Arrays are leaves: a
|
|
270
|
+
* schema declares an `ArrayField` as one path and says nothing about its
|
|
271
|
+
* indices.
|
|
272
|
+
*
|
|
273
|
+
* @param {unknown} value - The emitted value.
|
|
274
|
+
* @returns {boolean} Whether to walk into it.
|
|
275
|
+
*/
|
|
276
|
+
function isMapping(value) {
|
|
277
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Every declared path that has at least one declared path beneath it.
|
|
282
|
+
*
|
|
283
|
+
* The artifact does not describe every subtree it declares. A discriminated
|
|
284
|
+
* `TypedSchemaField` — SoHL's `strikeMode` — is published as the single path
|
|
285
|
+
* `strikeMode`, because its sub-schema is chosen by a discriminator at runtime
|
|
286
|
+
* and there is no one field list to publish; the compiled document stores it
|
|
287
|
+
* flat as `{ type, name, … }` all the same. Walking into it against a schema
|
|
288
|
+
* that says nothing about its contents reported all ten of a combat technique's
|
|
289
|
+
* stored keys as undeclared — ten findings, every one wrong, about a document
|
|
290
|
+
* that is correct.
|
|
291
|
+
*
|
|
292
|
+
* So a container is descended into only where the artifact actually enumerates
|
|
293
|
+
* something beneath it. That is the same stance {@link declaredFields} takes on
|
|
294
|
+
* a subtype the artifact does not name: what is not described is not checked,
|
|
295
|
+
* rather than guessed at.
|
|
296
|
+
*
|
|
297
|
+
* @param {ReadonlySet<string>} declared - Every path the subtype declares.
|
|
298
|
+
* @returns {Set<string>} The paths whose subtree the artifact describes.
|
|
299
|
+
*/
|
|
300
|
+
function enumeratedContainers(declared) {
|
|
301
|
+
const out = new Set();
|
|
302
|
+
for (const declaredPath of declared) {
|
|
303
|
+
const parts = declaredPath.split(".");
|
|
304
|
+
for (let i = 1; i < parts.length; i++) out.add(parts.slice(0, i).join("."));
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The paths in a compiled `system` block that the subtype does not declare.
|
|
311
|
+
*
|
|
312
|
+
* {@link module:engine/system-block.undeclaredPaths} answers this for *authored*
|
|
313
|
+
* data, where every path came from a human and every container is one a human
|
|
314
|
+
* typed. An emitted block is different in one way that matters: it contains
|
|
315
|
+
* whole subtrees the artifact declares as a single path and describes no
|
|
316
|
+
* further — see {@link enumeratedContainers} — so the walk stops there instead
|
|
317
|
+
* of reporting their contents.
|
|
318
|
+
*
|
|
319
|
+
* Reported at the shallowest undeclared path, as its sibling is: everything
|
|
320
|
+
* beneath an undeclared container is undeclared by construction, and listing it
|
|
321
|
+
* all buries the one key that is the problem.
|
|
322
|
+
*
|
|
323
|
+
* @param {Record<string, unknown>} data - The emitted `system` block.
|
|
324
|
+
* @param {ReadonlySet<string>} declared - Every path the subtype declares.
|
|
325
|
+
* @param {ReadonlySet<string>} enumerated - From {@link enumeratedContainers}.
|
|
326
|
+
* @param {string} [prefix] - Internal: the path so far.
|
|
327
|
+
* @returns {string[]} The undeclared paths, shallowest-first.
|
|
328
|
+
*/
|
|
329
|
+
function undeclaredEmittedPaths(data, declared, enumerated, prefix = "") {
|
|
330
|
+
const out = [];
|
|
331
|
+
if (!isMapping(data)) return out;
|
|
332
|
+
for (const [key, value] of Object.entries(data)) {
|
|
333
|
+
const emittedPath = prefix ? `${prefix}.${key}` : key;
|
|
334
|
+
if (!declared.has(emittedPath)) {
|
|
335
|
+
out.push(emittedPath);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!isMapping(value) || !Object.keys(value).length) continue;
|
|
339
|
+
if (!enumerated.has(emittedPath)) continue;
|
|
340
|
+
out.push(
|
|
341
|
+
...undeclaredEmittedPaths(
|
|
342
|
+
/** @type {Record<string, unknown>} */ (value),
|
|
343
|
+
declared,
|
|
344
|
+
enumerated,
|
|
345
|
+
emittedPath,
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* What a **compiled document** carries in `system`, against what the receiving
|
|
354
|
+
* subtype declares (#155).
|
|
355
|
+
*
|
|
356
|
+
* The third of the three checks, and the only one whose emitted set is
|
|
357
|
+
* *observed*. {@link compareFields} reads the `itemBuilders` declarations and
|
|
358
|
+
* {@link checkAuthoredSystemData} reads a note's `<system>.system`; between them
|
|
359
|
+
* they miss every key a compiler writes on its own initiative, which is not a
|
|
360
|
+
* residue — it is `shortcode`, `actionDefs`, `notes`, `docHtml` and `archetype`.
|
|
361
|
+
*
|
|
362
|
+
* **The keys come from the object the compiler built, after a JSON round trip.**
|
|
363
|
+
* That is exactly what the pack file receives, so a key whose value is
|
|
364
|
+
* `undefined` — dropped by `JSON.stringify`, never written, nothing for Foundry
|
|
365
|
+
* to discard — is correctly not a finding. Reading the assembled block is also
|
|
366
|
+
* the only derivation that cannot go stale: a compiler that grows a key is
|
|
367
|
+
* checked on the next build without anyone remembering to add it to a list.
|
|
368
|
+
*
|
|
369
|
+
* **Authored paths are left alone.** A note's own `<system>.system` is reported
|
|
370
|
+
* by {@link checkAuthoredSystemData}, which can point at the line the author
|
|
371
|
+
* wrote; reporting it again here would be the same defect twice, once without a
|
|
372
|
+
* position.
|
|
373
|
+
*
|
|
374
|
+
* **A subtree the artifact does not describe is not checked**, rather than
|
|
375
|
+
* reported wholesale — see {@link enumeratedContainers} for the
|
|
376
|
+
* `TypedSchemaField` case that makes the distinction load-bearing.
|
|
377
|
+
*
|
|
378
|
+
* @param {object} opts
|
|
379
|
+
* @param {object} opts.system - The `system` block the compiler assembled.
|
|
380
|
+
* @param {SchemaArtifact} opts.artifact - The receiving system's schemas.
|
|
381
|
+
* @param {string} opts.documentType - `Item`, `Actor`, …
|
|
382
|
+
* @param {string} opts.subtype - The document subtype being emitted.
|
|
383
|
+
* @param {string} opts.type - The content type whose note produced it, for the
|
|
384
|
+
* message.
|
|
385
|
+
* @param {readonly {to?: string}[]} [opts.fields] - The type's field
|
|
386
|
+
* declaration, which decides a finding's {@link EmissionFinding.origin}.
|
|
387
|
+
* @param {ReadonlySet<string>} [opts.authored] - Paths the note authored, left
|
|
388
|
+
* to the note-side check.
|
|
389
|
+
* @returns {EmissionFinding[]} One per undeclared path, shallowest-first.
|
|
390
|
+
*/
|
|
391
|
+
export function compareEmittedSystem({
|
|
392
|
+
system,
|
|
393
|
+
artifact,
|
|
394
|
+
documentType,
|
|
395
|
+
subtype,
|
|
396
|
+
type,
|
|
397
|
+
fields,
|
|
398
|
+
authored = new Set(),
|
|
399
|
+
}) {
|
|
400
|
+
if (artifact?.version !== SCHEMA_ARTIFACT_VERSION) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`package-build: schema artifact version ${artifact?.version ?? "(absent)"}, ` +
|
|
403
|
+
`expected ${SCHEMA_ARTIFACT_VERSION}. A schema read under the wrong ` +
|
|
404
|
+
`shape would report confidently in both directions, so the check ` +
|
|
405
|
+
`stops rather than resolving anyway.`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const declared = declaredFields(artifact, documentType, subtype);
|
|
410
|
+
// Not a finding, for the same reason `compareFields` skips one: a subtype
|
|
411
|
+
// the artifact says nothing about is a routing question (#79), not a field
|
|
412
|
+
// one, and guessing at it would report every key on the document.
|
|
413
|
+
if (!declared) return [];
|
|
414
|
+
|
|
415
|
+
// What the pack file receives. `undefined` is gone after this, which is the
|
|
416
|
+
// point: a key that never reaches the JSON is not an emitted key.
|
|
417
|
+
const emitted = JSON.parse(JSON.stringify(system ?? {}));
|
|
418
|
+
const byField = emittedFields(fields ?? []);
|
|
419
|
+
|
|
420
|
+
return undeclaredEmittedPaths(emitted, declared.all, enumeratedContainers(declared.all))
|
|
421
|
+
.filter((field) => !authored.has(field))
|
|
422
|
+
.map((field) => ({
|
|
423
|
+
type,
|
|
424
|
+
subtype,
|
|
425
|
+
documentType,
|
|
426
|
+
field,
|
|
427
|
+
systemVersion: artifact.systemVersion,
|
|
428
|
+
origin: /** @type {"builder"|"compiler"} */ (
|
|
429
|
+
byField.has(field) ? "builder" : "compiler"
|
|
430
|
+
),
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
|
|
242
434
|
/**
|
|
243
435
|
* The published schema this build should check itself against, or `null`.
|
|
244
436
|
*
|
|
@@ -277,10 +469,7 @@ export function resolveSchemaArtifact(config) {
|
|
|
277
469
|
});
|
|
278
470
|
|
|
279
471
|
// The system checking itself, against the schema its own build published.
|
|
280
|
-
if (
|
|
281
|
-
config.packageKind === "systems" &&
|
|
282
|
-
config.foundryPackage === systemId
|
|
283
|
-
) {
|
|
472
|
+
if (config.packageKind === "systems" && config.foundryPackage === systemId) {
|
|
284
473
|
const own = path.join(config.rootDir, SCHEMA_ARTIFACT_FILE);
|
|
285
474
|
return fs.existsSync(own) ? read(own) : null;
|
|
286
475
|
}
|
|
@@ -330,3 +519,155 @@ export function unemittedMessage(finding) {
|
|
|
330
519
|
`field's initial value rather than an authored one`
|
|
331
520
|
);
|
|
332
521
|
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* What an author is told about an emitted key the target system does not
|
|
525
|
+
* define — in the terms of whoever can actually fix it.
|
|
526
|
+
*
|
|
527
|
+
* The failure is identical in both cases and the remedies are not, which is
|
|
528
|
+
* why the two are told apart at all:
|
|
529
|
+
*
|
|
530
|
+
* | origin | who writes it | what fixes it |
|
|
531
|
+
* | --- | --- | --- |
|
|
532
|
+
* | `builder` | a `fields:` entry in this repository's `itemBuilders` | change the field's `to`, or get the system to declare it |
|
|
533
|
+
* | `compiler` | this package, on every document of the subtype | **nothing here** — the system must declare it, or this package must be pinned to a build that does not write it |
|
|
534
|
+
*
|
|
535
|
+
* A `compiler` finding is the one worth spelling out, because the obvious first
|
|
536
|
+
* move — go and look for the field in `itemBuilders` — leads nowhere: there is
|
|
537
|
+
* no declaration to correct. It means the build is running ahead of the system
|
|
538
|
+
* it compiles for, and the version named in the message is what says so.
|
|
539
|
+
*
|
|
540
|
+
* @param {EmissionFinding} finding - One entry from
|
|
541
|
+
* {@link compareEmittedSystem}.
|
|
542
|
+
* @returns {string} The message.
|
|
543
|
+
*/
|
|
544
|
+
export function emittedUndeclaredMessage(finding) {
|
|
545
|
+
if (finding.origin === "builder") return undeclaredMessage(finding);
|
|
546
|
+
return (
|
|
547
|
+
`the compiler writes \`system.${finding.field}\` into every ` +
|
|
548
|
+
`${finding.documentType} of subtype "${finding.subtype}", and no field ` +
|
|
549
|
+
`declaration names it — ${finding.documentType} subtype ` +
|
|
550
|
+
`"${finding.subtype}" does not define it at ${finding.systemVersion}, and ` +
|
|
551
|
+
`Foundry discards an unknown \`system\` key when the document is ` +
|
|
552
|
+
`constructed, without a warning, so the value is lost at load while the ` +
|
|
553
|
+
`build reports success. No \`itemBuilders\` change fixes this: declare ` +
|
|
554
|
+
`the field in the receiving system, or hold this package at a build that ` +
|
|
555
|
+
`does not write it`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** One resolved schema artifact per resolved configuration. */
|
|
560
|
+
const artifacts = new WeakMap();
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* {@link resolveSchemaArtifact}, read once per configuration.
|
|
564
|
+
*
|
|
565
|
+
* The per-note check below runs thousands of times in a build and the artifact
|
|
566
|
+
* never changes inside one, so reading and parsing it per note would be a
|
|
567
|
+
* megabyte of JSON per hundred documents for an answer that is already known.
|
|
568
|
+
*
|
|
569
|
+
* @param {object} config - The resolved build configuration.
|
|
570
|
+
* @returns {{artifact: SchemaArtifact, source: string}|null} The schema.
|
|
571
|
+
*/
|
|
572
|
+
function schemaFor(config) {
|
|
573
|
+
if (!artifacts.has(config)) artifacts.set(config, resolveSchemaArtifact(config));
|
|
574
|
+
return artifacts.get(config);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* What a note authors under `<system>.system`, against what the receiving
|
|
579
|
+
* subtype declares (#58).
|
|
580
|
+
*
|
|
581
|
+
* The **note-side** half of the check `compareFields` performs on the
|
|
582
|
+
* declarations. A field list is checked once for the whole build because it is
|
|
583
|
+
* the same for every document; an authored `system` block is a property of one
|
|
584
|
+
* note, so it is checked where that note is compiled and reported against that
|
|
585
|
+
* note's file.
|
|
586
|
+
*
|
|
587
|
+
* It is the same failure either way, and the reason both halves exist: Foundry
|
|
588
|
+
* discards an unknown `system` key at construction and says nothing, so a
|
|
589
|
+
* mistyped path is lost at load while the build reports success.
|
|
590
|
+
*
|
|
591
|
+
* **Silent where there is nothing to check against.** A module pinning a system
|
|
592
|
+
* version released before the artifact existed, or a subtype the artifact does
|
|
593
|
+
* not name, produces no findings — the same stance `compareFields` takes, where
|
|
594
|
+
* an unknown subtype is a routing question rather than a field one. The
|
|
595
|
+
* whole-build check in `content-build lint` is where a missing artifact is said
|
|
596
|
+
* out loud, once, instead of per note.
|
|
597
|
+
*
|
|
598
|
+
* @param {object} fm - The note's frontmatter.
|
|
599
|
+
* @param {object} opts
|
|
600
|
+
* @param {string} opts.block - The system block to read, e.g. `"sohl"`.
|
|
601
|
+
* @param {string} opts.documentType - `Item`, `Actor`, …
|
|
602
|
+
* @param {string} opts.subType - The document subtype the note compiles into.
|
|
603
|
+
* @param {object} [opts.config] - The resolved build configuration.
|
|
604
|
+
* @returns {{path: string, message: string}[]} One finding per undeclared path,
|
|
605
|
+
* shallowest-first.
|
|
606
|
+
*/
|
|
607
|
+
export function checkAuthoredSystemData(
|
|
608
|
+
fm,
|
|
609
|
+
{ block, documentType, subType, config = loadPackConfig() },
|
|
610
|
+
) {
|
|
611
|
+
const data = systemData(fm, block);
|
|
612
|
+
if (!Object.keys(data).length) return [];
|
|
613
|
+
|
|
614
|
+
const schema = schemaFor(config);
|
|
615
|
+
if (!schema) return [];
|
|
616
|
+
const declared = declaredFields(schema.artifact, documentType, subType);
|
|
617
|
+
if (!declared) return [];
|
|
618
|
+
|
|
619
|
+
return undeclaredPaths(data, declared.all).map((path) => ({
|
|
620
|
+
path,
|
|
621
|
+
message:
|
|
622
|
+
`\`${block}.system.${path}\` is not a field ${documentType} subtype ` +
|
|
623
|
+
`"${subType}" declares at ${schema.artifact.systemVersion} — Foundry ` +
|
|
624
|
+
`discards an unknown \`system\` key when the document is constructed, ` +
|
|
625
|
+
`without a warning, so the value is lost at load while the build ` +
|
|
626
|
+
`reports success`,
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The `system` block a compiler just assembled, against what the receiving
|
|
632
|
+
* subtype declares (#155).
|
|
633
|
+
*
|
|
634
|
+
* The build-time face of {@link compareEmittedSystem}: it resolves the schema
|
|
635
|
+
* the way every other check here does — the system's own committed artifact, or
|
|
636
|
+
* the cached one from the release a module pins — and attaches the message a
|
|
637
|
+
* reader sees.
|
|
638
|
+
*
|
|
639
|
+
* **Silent where there is nothing to check against**, exactly as its two
|
|
640
|
+
* siblings are: a module pinning a system version released before the artifact
|
|
641
|
+
* existed, or a subtype the artifact does not name, produces no findings.
|
|
642
|
+
* `content-build lint` is where a missing artifact is said out loud, once.
|
|
643
|
+
*
|
|
644
|
+
* @param {object} system - The `system` block the compiler produced.
|
|
645
|
+
* @param {object} opts
|
|
646
|
+
* @param {object} opts.fm - The note's frontmatter, for the authored paths this
|
|
647
|
+
* check leaves to {@link checkAuthoredSystemData}.
|
|
648
|
+
* @param {string} opts.block - The system block to read, e.g. `"sohl"`.
|
|
649
|
+
* @param {string} opts.documentType - `Item`, `Actor`, …
|
|
650
|
+
* @param {string} opts.subType - The document subtype the note compiles into.
|
|
651
|
+
* @param {string} opts.type - The note's content type, for the message.
|
|
652
|
+
* @param {readonly {to?: string}[]} [opts.fields] - The type's field
|
|
653
|
+
* declaration, which decides each finding's origin.
|
|
654
|
+
* @param {object} [opts.config] - The resolved build configuration.
|
|
655
|
+
* @returns {(EmissionFinding & {message: string})[]} One per undeclared path.
|
|
656
|
+
*/
|
|
657
|
+
export function checkEmittedSystemData(
|
|
658
|
+
system,
|
|
659
|
+
{ fm, block, documentType, subType, type, fields, config = loadPackConfig() },
|
|
660
|
+
) {
|
|
661
|
+
const schema = schemaFor(config);
|
|
662
|
+
if (!schema) return [];
|
|
663
|
+
|
|
664
|
+
return compareEmittedSystem({
|
|
665
|
+
system,
|
|
666
|
+
artifact: schema.artifact,
|
|
667
|
+
documentType,
|
|
668
|
+
subtype: subType,
|
|
669
|
+
type,
|
|
670
|
+
fields,
|
|
671
|
+
authored: new Set(systemDataPaths(systemData(fm, block))),
|
|
672
|
+
}).map((finding) => ({ ...finding, message: emittedUndeclaredMessage(finding) }));
|
|
673
|
+
}
|