@heroiclands/package-build 17.2.0 → 18.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 +1711 -0
- package/CONTENT.md +288 -72
- package/README.md +1 -1
- package/bin/content-build.mjs +218 -137
- package/bin/package-build.mjs +43 -0
- package/content-config.mjs +62 -67
- package/docs/content-format.md +768 -58
- package/engine/actor-compiler.mjs +586 -0
- package/engine/address-charset.mjs +11 -8
- package/engine/address-diff.mjs +266 -22
- package/engine/anchored-sections.mjs +83 -0
- package/engine/anchors.mjs +83 -0
- package/engine/base-compiler.mjs +148 -18
- package/engine/bundle-notes.mjs +276 -0
- package/engine/bundles.mjs +307 -0
- package/engine/code-fences.mjs +103 -0
- package/engine/compile-corpus.mjs +89 -0
- package/engine/content-address.mjs +267 -5
- package/engine/content-format-check.mjs +54 -11
- package/engine/content-format.mjs +37 -5
- package/engine/content-index.mjs +225 -127
- package/engine/content-links.mjs +185 -77
- package/engine/content-lint.mjs +232 -14
- package/engine/content-package.mjs +2 -1
- package/engine/content-tables.mjs +99 -3
- package/engine/document-subtypes.mjs +49 -6
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +56 -6
- package/engine/folder-notes.mjs +470 -0
- package/engine/foreign-catalog.mjs +195 -4
- package/engine/{manifest-emit.mjs → foundry-entries.mjs} +55 -83
- package/engine/frontmatter-lint.mjs +245 -19
- package/engine/frontmatter.mjs +88 -8
- package/engine/generate.mjs +304 -13
- package/engine/helpers.mjs +293 -64
- package/engine/ids.mjs +137 -2
- package/engine/index-records.mjs +126 -0
- package/engine/index.mjs +23 -5
- package/engine/item-compiler.mjs +349 -0
- package/engine/item-docs.mjs +7 -1
- package/engine/item-registry.mjs +6 -0
- package/engine/journals.mjs +93 -27
- package/engine/macros.mjs +4 -2
- package/engine/metadata-index.mjs +495 -0
- package/engine/note-claims.mjs +146 -25
- package/engine/note-ids.mjs +112 -0
- package/engine/note-renames.mjs +134 -0
- package/engine/note-schemas.mjs +25 -0
- package/engine/note-vocabulary.mjs +110 -27
- package/engine/pack-config.mjs +4 -0
- package/engine/pack-router.mjs +23 -0
- package/engine/retired-fields.mjs +137 -3
- package/engine/scenes.mjs +66 -34
- package/engine/schema-check.mjs +58 -17
- package/engine/site-build.mjs +71 -12
- package/engine/site-index.mjs +20 -7
- package/engine/sql-tables.mjs +485 -0
- package/engine/subtype-registry.mjs +102 -0
- package/engine/system-block.mjs +96 -12
- package/engine/systems.mjs +201 -0
- package/engine/web-wikilinks.mjs +50 -20
- package/engine/wikilink-syntax.mjs +17 -8
- package/engine/wikilinks.mjs +124 -51
- package/engine/yaml-lint.mjs +282 -0
- package/hm3/actors.mjs +352 -0
- package/hm3/default-item-art.mjs +75 -0
- package/hm3/document-subtypes.mjs +134 -0
- package/hm3/index.mjs +56 -0
- package/hm3/item-builders.mjs +84 -0
- package/hm3/item-fields.mjs +180 -0
- package/hm3/items.mjs +96 -0
- package/hm3/template-priority.mjs +85 -0
- package/manifest.mjs +43 -2
- package/package.json +18 -4
- package/release.mjs +62 -7
- package/sohl/actors.mjs +33 -487
- package/sohl/being-info.mjs +16 -7
- package/sohl/default-item-art.mjs +14 -3
- package/sohl/document-subtypes.mjs +16 -10
- package/sohl/item-builders.mjs +14 -5
- package/sohl/item-fields.mjs +68 -7
- package/sohl/items.mjs +44 -258
- package/sohl/note-schemas.mjs +2 -2
- package/types/content-config.d.mts +21 -42
- package/types/engine/actor-compiler.d.mts +204 -0
- package/types/engine/address-charset.d.mts +11 -8
- package/types/engine/address-diff.d.mts +53 -5
- package/types/engine/anchored-sections.d.mts +21 -0
- package/types/engine/anchors.d.mts +20 -0
- package/types/engine/base-compiler.d.mts +17 -17
- package/types/engine/bundle-notes.d.mts +173 -0
- package/types/engine/bundles.d.mts +60 -0
- package/types/engine/code-fences.d.mts +43 -0
- package/types/engine/compile-corpus.d.mts +32 -0
- package/types/engine/content-address.d.mts +205 -5
- package/types/engine/content-format-check.d.mts +6 -2
- package/types/engine/content-format.d.mts +57 -1
- package/types/engine/content-index.d.mts +54 -49
- package/types/engine/content-links.d.mts +52 -7
- package/types/engine/content-lint.d.mts +10 -1
- package/types/engine/content-package.d.mts +2 -1
- package/types/engine/content-tables.d.mts +21 -39
- package/types/engine/document-subtypes.d.mts +37 -3
- package/types/engine/field-spec.d.mts +76 -5
- package/types/engine/folder-notes.d.mts +159 -0
- package/types/engine/foreign-catalog.d.mts +53 -0
- package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +2 -43
- package/types/engine/frontmatter-lint.d.mts +10 -2
- package/types/engine/frontmatter.d.mts +64 -0
- package/types/engine/generate.d.mts +38 -0
- package/types/engine/helpers.d.mts +94 -30
- package/types/engine/ids.d.mts +96 -0
- package/types/engine/index-records.d.mts +68 -0
- package/types/engine/index.d.mts +9 -3
- package/types/engine/item-compiler.d.mts +131 -0
- package/types/engine/journals.d.mts +47 -9
- package/types/engine/metadata-index.d.mts +226 -0
- package/types/engine/note-claims.d.mts +57 -11
- package/types/engine/note-ids.d.mts +38 -0
- package/types/engine/note-renames.d.mts +102 -0
- package/types/engine/note-vocabulary.d.mts +44 -8
- package/types/engine/retired-fields.d.mts +75 -0
- package/types/engine/scenes.d.mts +3 -2
- package/types/engine/schema-check.d.mts +25 -4
- package/types/engine/site-build.d.mts +4 -4
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +185 -0
- package/types/engine/subtype-registry.d.mts +49 -0
- package/types/engine/system-block.d.mts +40 -1
- package/types/engine/systems.d.mts +106 -0
- package/types/engine/web-wikilinks.d.mts +4 -2
- package/types/engine/wikilink-syntax.d.mts +10 -3
- package/types/engine/wikilinks.d.mts +41 -13
- package/types/engine/yaml-lint.d.mts +107 -0
- package/types/hm3/actors.d.mts +48 -0
- package/types/hm3/default-item-art.d.mts +42 -0
- package/types/hm3/document-subtypes.d.mts +24 -0
- package/types/hm3/index.d.mts +7 -0
- package/types/hm3/item-builders.d.mts +11 -0
- package/types/hm3/item-fields.d.mts +12 -0
- package/types/hm3/items.d.mts +23 -0
- package/types/hm3/template-priority.d.mts +21 -0
- package/types/manifest.d.mts +22 -1
- package/types/release.d.mts +9 -4
- package/types/sohl/actors.d.mts +7 -74
- package/types/sohl/being-info.d.mts +8 -27
- package/types/sohl/default-item-art.d.mts +5 -3
- package/types/sohl/items.d.mts +17 -32
- package/engine/foreign-manifests.mjs +0 -126
- package/engine/kb-manifest.mjs +0 -490
- package/types/engine/foreign-manifests.d.mts +0 -43
- package/types/engine/kb-manifest.d.mts +0 -241
package/engine/base-compiler.mjs
CHANGED
|
@@ -66,23 +66,26 @@ import path from "path";
|
|
|
66
66
|
import log from "loglevel";
|
|
67
67
|
|
|
68
68
|
import {
|
|
69
|
-
|
|
69
|
+
parseMarkdownFile,
|
|
70
70
|
makeFilename,
|
|
71
71
|
resolveName,
|
|
72
|
-
buildContentLinkIndex,
|
|
73
72
|
convertNoteWikilinks,
|
|
74
|
-
collectContentDocs,
|
|
75
73
|
expandNoteTables,
|
|
76
74
|
statsForPack,
|
|
77
75
|
} from "./helpers.mjs";
|
|
76
|
+
// The record accessors only — see `engine/index-records.mjs` for why they live
|
|
77
|
+
// apart from the index that builds them (#243).
|
|
78
|
+
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
78
79
|
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
79
80
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
80
81
|
import {
|
|
81
82
|
assertNoAliasesField,
|
|
82
83
|
assertNoDraftField,
|
|
83
84
|
assertNoSectionField,
|
|
85
|
+
assertNoTraitsField,
|
|
84
86
|
} from "./retired-fields.mjs";
|
|
85
87
|
import { assertTypeNotRetired, packForType } from "./ids.mjs";
|
|
88
|
+
import { resolveNoteId } from "./note-ids.mjs";
|
|
86
89
|
import { carriesSystemBlock } from "./system-block.mjs";
|
|
87
90
|
import { checkAuthoredSystemData, checkEmittedSystemData } from "./schema-check.mjs";
|
|
88
91
|
import { locateFrontmatterKey } from "./retired-fields.mjs";
|
|
@@ -265,6 +268,8 @@ export class BasePackCompiler {
|
|
|
265
268
|
* @param {object} options
|
|
266
269
|
* @param {string} options.contentBase - Root of the content tree.
|
|
267
270
|
* @param {string} options.dest - Where this pass writes its JSON.
|
|
271
|
+
* @param {readonly string[]} options.skipDirectories - Directories the walk
|
|
272
|
+
* never descends into. Required: see {@link assertStatedScope}.
|
|
268
273
|
* @param {(path: string|null) => string|null} [options.folderResolver] -
|
|
269
274
|
* Resolves a `sohl.folder` id against this pack's folder hierarchy.
|
|
270
275
|
* @param {string} [options.packName] - The pack this pass writes.
|
|
@@ -277,12 +282,14 @@ export class BasePackCompiler {
|
|
|
277
282
|
constructor({
|
|
278
283
|
contentBase,
|
|
279
284
|
dest,
|
|
285
|
+
skipDirectories,
|
|
280
286
|
folderResolver = () => null,
|
|
281
287
|
packName,
|
|
282
288
|
packSystem = null,
|
|
283
289
|
docType,
|
|
284
290
|
router,
|
|
285
291
|
routingReporter = false,
|
|
292
|
+
corpus,
|
|
286
293
|
} = {}) {
|
|
287
294
|
if (!contentBase) {
|
|
288
295
|
throw new Error(`${this.constructor.name} compiler requires \`contentBase\``);
|
|
@@ -290,6 +297,13 @@ export class BasePackCompiler {
|
|
|
290
297
|
if (!fs.existsSync(contentBase)) {
|
|
291
298
|
throw new Error(`Content tree not found at ${contentBase}`);
|
|
292
299
|
}
|
|
300
|
+
if (skipDirectories === undefined) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`${this.constructor.name} compiler requires \`skipDirectories\`: ` +
|
|
303
|
+
`the walk's scope is stated by whoever builds the pass, not ` +
|
|
304
|
+
`resolved from the working directory`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
293
307
|
Object.defineProperty(this, "contentBase", {
|
|
294
308
|
value: contentBase,
|
|
295
309
|
writable: false,
|
|
@@ -302,11 +316,24 @@ export class BasePackCompiler {
|
|
|
302
316
|
value: folderResolver,
|
|
303
317
|
writable: false,
|
|
304
318
|
});
|
|
319
|
+
// The walk's scope, stated by whoever built this pass rather than
|
|
320
|
+
// resolved from the working directory (#243). Every walk this compiler
|
|
321
|
+
// makes — its own, the table corpus, the link index, the SQL tables —
|
|
322
|
+
// uses this one answer.
|
|
323
|
+
Object.defineProperty(this, "skipDirectories", {
|
|
324
|
+
value: skipDirectories,
|
|
325
|
+
writable: false,
|
|
326
|
+
});
|
|
305
327
|
this.packName = packName;
|
|
306
328
|
this.packSystem = packSystem;
|
|
307
329
|
this.docType = docType;
|
|
308
330
|
this.router = router;
|
|
309
331
|
this.routingReporter = routingReporter;
|
|
332
|
+
// The corpus this compile is running over, derived once by
|
|
333
|
+
// `generatePacksJson` and shared by every pass (#243). A pass that is
|
|
334
|
+
// handed none derives its own in `prepare`, which is what a consumer
|
|
335
|
+
// constructing one compiler directly does.
|
|
336
|
+
this.corpus = corpus;
|
|
310
337
|
}
|
|
311
338
|
|
|
312
339
|
/**
|
|
@@ -357,13 +384,27 @@ export class BasePackCompiler {
|
|
|
357
384
|
* instances of.
|
|
358
385
|
*
|
|
359
386
|
* @param {object} fm - The note's frontmatter.
|
|
360
|
-
* @returns {boolean} True when the note may be compiled here
|
|
361
|
-
*
|
|
362
|
-
*
|
|
387
|
+
* @returns {boolean} True when the note may be compiled here; `false` when
|
|
388
|
+
* it belongs to another system's pass of the same document type, which is
|
|
389
|
+
* skipped as quietly as any other note this pack does not own.
|
|
390
|
+
* @throws {Error} When this pack's system is absent from the note and no
|
|
391
|
+
* other configured system claims it. The error carries a `position` where
|
|
392
|
+
* the note's own file can be read.
|
|
363
393
|
*/
|
|
364
394
|
eligibleFor(fm) {
|
|
365
395
|
if (!this.constructor.requiresSystemBlock || !this.packSystem) return true;
|
|
366
396
|
if (carriesSystemBlock(fm, this.packSystem)) return true;
|
|
397
|
+
// Another system's pack of this document type will claim it (#139). A
|
|
398
|
+
// note carrying only `hm3:` routes here because this pack is the
|
|
399
|
+
// *default* of its document type, and defaults are declared per type
|
|
400
|
+
// rather than per system — but it is not an incomplete note, it is
|
|
401
|
+
// another pass's. The single-system case is untouched: with no second
|
|
402
|
+
// system declared there is nothing for this to find, and the error
|
|
403
|
+
// below still fires.
|
|
404
|
+
const claimant = (this.router?.systemsOfType?.(this.docType) ?? []).find(
|
|
405
|
+
(system) => system !== this.packSystem && carriesSystemBlock(fm, system),
|
|
406
|
+
);
|
|
407
|
+
if (claimant) return false;
|
|
367
408
|
const label = fm?.name?.full ?? fm?.shortcode ?? fm?.id ?? "this note";
|
|
368
409
|
throw new Error(
|
|
369
410
|
`${label} carries no \`${this.packSystem}:\` block, so it has no ` +
|
|
@@ -425,9 +466,35 @@ export class BasePackCompiler {
|
|
|
425
466
|
* @returns {Promise<void>}
|
|
426
467
|
*/
|
|
427
468
|
async prepare() {
|
|
469
|
+
// The corpus, and the three whole-tree derivations built over it. Every
|
|
470
|
+
// one of them is a pure function of (tree, scope, router), which do not
|
|
471
|
+
// vary between the passes of a single compile — so `generatePacksJson`
|
|
472
|
+
// derives them once and hands them to each pass (#243).
|
|
473
|
+
//
|
|
474
|
+
// The measurement that motivated it: compiling `sohl` read every note
|
|
475
|
+
// **20 times**, four per pass — this link index, the table corpus, the
|
|
476
|
+
// `sql` scan, and the pass's own walk — across five passes.
|
|
477
|
+
// Imported here rather than at module scope: deriving the corpus
|
|
478
|
+
// reaches the pack router and the manifest emitter, which reach this
|
|
479
|
+
// module, so a static import would close a cycle. `generate.mjs`
|
|
480
|
+
// normally supplies the corpus and this path never runs.
|
|
481
|
+
const { buildCompileCorpus } = await import("./compile-corpus.mjs");
|
|
482
|
+
if (!this.corpus) {
|
|
483
|
+
this.corpus = await buildCompileCorpus({
|
|
484
|
+
contentBase: this.contentBase,
|
|
485
|
+
skipDirectories: this.skipDirectories,
|
|
486
|
+
router: this.router,
|
|
487
|
+
});
|
|
488
|
+
// Derived here, so reported here. A corpus handed in was derived by
|
|
489
|
+
// `generatePacksJson`, which has already reported its problems
|
|
490
|
+
// once — and reporting them again in each of five passes would say
|
|
491
|
+
// the same thing six times.
|
|
492
|
+
this.reportsCorpusProblems = true;
|
|
493
|
+
}
|
|
428
494
|
if (this.constructor.convertsWikilinks) {
|
|
429
|
-
this.linkIndex =
|
|
430
|
-
this.contentDocs =
|
|
495
|
+
this.linkIndex = this.corpus.linkIndex;
|
|
496
|
+
this.contentDocs = this.corpus.contentDocs;
|
|
497
|
+
this.sqlTables = this.corpus.sqlTables;
|
|
431
498
|
}
|
|
432
499
|
this.unresolvedLinks = 0;
|
|
433
500
|
}
|
|
@@ -452,6 +519,7 @@ export class BasePackCompiler {
|
|
|
452
519
|
name,
|
|
453
520
|
fm,
|
|
454
521
|
bodyLine,
|
|
522
|
+
sqlTables: absPath ? this.sqlTables?.get(absPath) : undefined,
|
|
455
523
|
});
|
|
456
524
|
const { markdown, unresolved } = convertNoteWikilinks(tabulated, {
|
|
457
525
|
type: fm.type,
|
|
@@ -534,7 +602,15 @@ export class BasePackCompiler {
|
|
|
534
602
|
*/
|
|
535
603
|
reportUndeclaredSystemData(fm, block, documentType, subType) {
|
|
536
604
|
const absPath = this.currentNote?.absPath;
|
|
537
|
-
|
|
605
|
+
// Whose schema, where a build has more than one system: this pack's
|
|
606
|
+
// (#139). `undefined` — a pack that declares no system — keeps the
|
|
607
|
+
// package-wide answer this always used.
|
|
608
|
+
const findings = checkAuthoredSystemData(fm, {
|
|
609
|
+
block,
|
|
610
|
+
documentType,
|
|
611
|
+
subType,
|
|
612
|
+
system: this.packSystem ?? undefined,
|
|
613
|
+
});
|
|
538
614
|
for (const finding of findings) {
|
|
539
615
|
this.errorCount++;
|
|
540
616
|
const leaf = finding.path.split(".").pop();
|
|
@@ -550,7 +626,7 @@ export class BasePackCompiler {
|
|
|
550
626
|
* The sibling of {@link BasePackCompiler#reportUndeclaredSystemData}, and
|
|
551
627
|
* the half that sees what no declaration states. A compiler writes keys of
|
|
552
628
|
* its own alongside the declared fields — `shortcode`, `actionDefs`,
|
|
553
|
-
* `notes`, `docHtml`, `
|
|
629
|
+
* `notes`, `docHtml`, `templatePriority` — and neither the field-declaration check
|
|
554
630
|
* nor the authored-`system` check can see them, so until this nothing
|
|
555
631
|
* compared them at all. Foundry's discard is the same silent one either
|
|
556
632
|
* way.
|
|
@@ -575,14 +651,16 @@ export class BasePackCompiler {
|
|
|
575
651
|
* @param {object} [opts.config] - The resolved build configuration.
|
|
576
652
|
* @returns {number} How many findings were new to this pass.
|
|
577
653
|
*/
|
|
578
|
-
reportEmittedSystemData(
|
|
579
|
-
const findings = checkEmittedSystemData(
|
|
654
|
+
reportEmittedSystemData(emitted, { fm, block, documentType, subType, type, fields, config }) {
|
|
655
|
+
const findings = checkEmittedSystemData(emitted, {
|
|
580
656
|
fm,
|
|
581
657
|
block,
|
|
582
658
|
documentType,
|
|
583
659
|
subType,
|
|
584
660
|
type,
|
|
585
661
|
fields,
|
|
662
|
+
// See the sibling above: this pack's system, where there is one.
|
|
663
|
+
system: this.packSystem ?? undefined,
|
|
586
664
|
...(config ? { config } : {}),
|
|
587
665
|
});
|
|
588
666
|
let added = 0;
|
|
@@ -754,9 +832,40 @@ export class BasePackCompiler {
|
|
|
754
832
|
const label = this.constructor.label;
|
|
755
833
|
const Label = label.charAt(0).toUpperCase() + label.slice(1);
|
|
756
834
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
835
|
+
// A note the index could not record is a note this pass declines —
|
|
836
|
+
// counted and reported exactly as the loop below does for the same
|
|
837
|
+
// refusal, because it *is* the same refusal: the index defers to
|
|
838
|
+
// `assertNoDeclaredPackage` for a retired `package:`, which is the
|
|
839
|
+
// check this loop makes a few lines further down. All that changed is
|
|
840
|
+
// which pass sees the note first (#243).
|
|
841
|
+
if (this.reportsCorpusProblems) {
|
|
842
|
+
for (const problem of this.corpus.problems ?? []) {
|
|
843
|
+
stats.declined++;
|
|
844
|
+
this.errorCount++;
|
|
845
|
+
// Emitted directly rather than through `noteError`, which reads
|
|
846
|
+
// the file from `currentNote` — the loop has not started, so
|
|
847
|
+
// there is no current note and the problem carries its own.
|
|
848
|
+
emitDiagnostic({
|
|
849
|
+
file: problem.file,
|
|
850
|
+
line: problem.line,
|
|
851
|
+
column: problem.column,
|
|
852
|
+
severity: "error",
|
|
853
|
+
message: problem.message,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// The corpus, from the index this compile derived once — not a walk of
|
|
859
|
+
// this pass's own (#243). Each note is then read for its **prose**: the
|
|
860
|
+
// index carries what is *about* a note and deliberately not its text,
|
|
861
|
+
// nor the `bodyLine`/`bodyColumn` a diagnostic needs, and this pass has
|
|
862
|
+
// to have the body anyway. So the read is one this pass was already
|
|
863
|
+
// making; what it no longer does is decide for itself which files to
|
|
864
|
+
// make it over.
|
|
865
|
+
for (const record of this.corpus.records) {
|
|
866
|
+
if (!isNoteRecord(record)) continue;
|
|
867
|
+
const absPath = noteFile(this.contentBase, record);
|
|
868
|
+
const { frontmatter: fm, body, bodyLine, bodyColumn } = parseMarkdownFile(absPath);
|
|
760
869
|
// Which note this pass is on, so anything it calls can report a
|
|
761
870
|
// position without every method having to be handed one (#17).
|
|
762
871
|
this.currentNote = { absPath, bodyLine, bodyColumn };
|
|
@@ -789,6 +898,7 @@ export class BasePackCompiler {
|
|
|
789
898
|
assertNoDraftField(fm, { absPath });
|
|
790
899
|
assertNoAliasesField(fm, { absPath });
|
|
791
900
|
assertNoSectionField(fm, { absPath });
|
|
901
|
+
assertNoTraitsField(fm, { absPath });
|
|
792
902
|
} catch (err) {
|
|
793
903
|
stats.declined++;
|
|
794
904
|
this.errorCount++;
|
|
@@ -805,12 +915,29 @@ export class BasePackCompiler {
|
|
|
805
915
|
stats.skippedOther++;
|
|
806
916
|
continue;
|
|
807
917
|
}
|
|
918
|
+
// The id this note's document is filed under: its authored `id`
|
|
919
|
+
// if it pins one, otherwise the id derived from its canonical
|
|
920
|
+
// address (#270). Resolved for every note this pass claims, and
|
|
921
|
+
// through the one function every other corpus reader calls — the
|
|
922
|
+
// wikilink index, the content index and the Foundry-address pass
|
|
923
|
+
// must all compute the id this pass compiles under, and none of
|
|
924
|
+
// them can see this answer.
|
|
925
|
+
resolveNoteId(fm);
|
|
808
926
|
if (!fm.id) {
|
|
927
|
+
// What is left is a note with **no address** — no `type`, or no
|
|
928
|
+
// `shortcode`. It is not addressable, so there is nothing to
|
|
929
|
+
// derive from and nothing for a link to point at; the message
|
|
930
|
+
// names the reason rather than the missing field, because the
|
|
931
|
+
// field is no longer something an author writes.
|
|
809
932
|
if (this.constructor.requiresId) {
|
|
810
|
-
throw new Error(
|
|
933
|
+
throw new Error(
|
|
934
|
+
`${Label} note has no address, so it has no document id: ` +
|
|
935
|
+
`${absPath} — a note is addressed as ` +
|
|
936
|
+
`"<type>-<shortcode>" and must declare both`,
|
|
937
|
+
);
|
|
811
938
|
}
|
|
812
939
|
stats.skippedNoId++;
|
|
813
|
-
this.noteWarn(`${label} note has no id, skipping`);
|
|
940
|
+
this.noteWarn(`${label} note has no address to derive an id from, skipping`);
|
|
814
941
|
continue;
|
|
815
942
|
}
|
|
816
943
|
// Which pack of this type takes it. Applied after the id check —
|
|
@@ -834,7 +961,10 @@ export class BasePackCompiler {
|
|
|
834
961
|
// pass's business — and before `skipNote`, so a pass's own
|
|
835
962
|
// rejection rules never run on a note it may not compile.
|
|
836
963
|
try {
|
|
837
|
-
this.eligibleFor(fm)
|
|
964
|
+
if (!this.eligibleFor(fm)) {
|
|
965
|
+
stats.skippedOther++;
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
838
968
|
} catch (err) {
|
|
839
969
|
stats.declined++;
|
|
840
970
|
this.errorCount++;
|
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
* Bundles, as notes — the Foundry `Adventure` a `type: bundle` note compiles
|
|
16
|
+
* into.
|
|
17
|
+
*
|
|
18
|
+
* An `Adventure` is badly named, and the name misled the first design: it is
|
|
19
|
+
* not a story. `Adventure.contentFields` maps each `SetField` on the schema to
|
|
20
|
+
* a document class, and importing one partitions its members by whether the
|
|
21
|
+
* world's collection already holds that `_id`, then creates or updates each.
|
|
22
|
+
* Afterwards the documents live independently and the Adventure has no further
|
|
23
|
+
* role. **It is an installer** — a set of document *copies* packaged for
|
|
24
|
+
* one-shot import (#259).
|
|
25
|
+
*
|
|
26
|
+
* That is the whole difference from a folder (#256), which is a live grouping
|
|
27
|
+
* **by reference** that persists in the pack. A bundle carries copies and
|
|
28
|
+
* exists to be imported once, so the two are not variations on one idea.
|
|
29
|
+
*
|
|
30
|
+
* The note type is `bundle` rather than `adventure` because the format prefers
|
|
31
|
+
* the domain word wherever Foundry's misleads — a `Scene` is authored as a
|
|
32
|
+
* `map` — and `collection` was refused because `DocumentCollection` is a real
|
|
33
|
+
* Foundry class meaning very nearly the opposite. See `docs/content-format.md`
|
|
34
|
+
* § `type: bundle`.
|
|
35
|
+
*
|
|
36
|
+
* This module is the framework-free half: what a bundle note *says*, and how a
|
|
37
|
+
* set of already-compiled documents becomes an Adventure. The pass that walks
|
|
38
|
+
* the tree and resolves what one note says about another is
|
|
39
|
+
* {@link module:engine/bundles}.
|
|
40
|
+
*
|
|
41
|
+
* @module
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The note type a bundle is authored as.
|
|
46
|
+
*
|
|
47
|
+
* @type {string}
|
|
48
|
+
*/
|
|
49
|
+
export const BUNDLE_TYPE = "bundle";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Foundry's `Adventure.contentFields`, keyed by the document class each holds.
|
|
53
|
+
*
|
|
54
|
+
* Restated here rather than derived, because this build never loads Foundry:
|
|
55
|
+
* the schema is read from `common/documents/adventure.mjs`, where every
|
|
56
|
+
* `SetField` of an `EmbeddedDataField` is a content field and its name is the
|
|
57
|
+
* key an importer partitions on. The inverse direction is the one a compiler
|
|
58
|
+
* wants — it has a document type in hand and needs the field to file it under.
|
|
59
|
+
*
|
|
60
|
+
* The full set is listed, not only the five this build compiles, because the
|
|
61
|
+
* map is a statement about Foundry rather than about this toolchain: a consumer
|
|
62
|
+
* registering a compiler for `RollTable` needs no edit here.
|
|
63
|
+
*
|
|
64
|
+
* @type {Readonly<Record<string, string>>}
|
|
65
|
+
*/
|
|
66
|
+
export const ADVENTURE_CONTENT_FIELD = Object.freeze({
|
|
67
|
+
Actor: "actors",
|
|
68
|
+
Combat: "combats",
|
|
69
|
+
Item: "items",
|
|
70
|
+
JournalEntry: "journal",
|
|
71
|
+
Scene: "scenes",
|
|
72
|
+
RollTable: "tables",
|
|
73
|
+
Macro: "macros",
|
|
74
|
+
Cards: "cards",
|
|
75
|
+
Playlist: "playlists",
|
|
76
|
+
Folder: "folders",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The `data:` key a bundle lists its members under.
|
|
81
|
+
*
|
|
82
|
+
* @type {string}
|
|
83
|
+
*/
|
|
84
|
+
export const CONTENTS_FIELD = "contents";
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Strip the LevelDB keys from a document tree.
|
|
88
|
+
*
|
|
89
|
+
* An Adventure's members are **inline source data** in a `SetField`, not
|
|
90
|
+
* sublevel documents, so they carry no `_key`: the compendium CLI's hierarchy
|
|
91
|
+
* does not recurse into an adventure, and Foundry's schema has no such field to
|
|
92
|
+
* hold one. A member that kept its key would ship a property the data model
|
|
93
|
+
* refuses.
|
|
94
|
+
*
|
|
95
|
+
* Shared with the scenes pass, which bundles the Adventures that make a pinned
|
|
96
|
+
* map's ids resolve — one rule about what an Adventure member may carry, stated
|
|
97
|
+
* once.
|
|
98
|
+
*
|
|
99
|
+
* @param {*} value - A document, array, or scalar.
|
|
100
|
+
* @returns {*} The same shape with every `_key` removed.
|
|
101
|
+
*/
|
|
102
|
+
export function stripAdventureKeys(value) {
|
|
103
|
+
if (Array.isArray(value)) return value.map(stripAdventureKeys);
|
|
104
|
+
if (value && typeof value === "object") {
|
|
105
|
+
return Object.fromEntries(
|
|
106
|
+
Object.entries(value)
|
|
107
|
+
.filter(([k]) => k !== "_key")
|
|
108
|
+
.map(([k, v]) => [k, stripAdventureKeys(v)]),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* An authored address with any wikilink brackets and label stripped.
|
|
116
|
+
*
|
|
117
|
+
* The specification types `contents` as a `WikiLink[]`, and a frontmatter link
|
|
118
|
+
* is written as a bare address — but `[[address]]` is what an author reaches
|
|
119
|
+
* for, and it is what a folder note's `parent` already accepts. Accepting both
|
|
120
|
+
* costs one regex and removes a failure whose message would have to explain the
|
|
121
|
+
* difference.
|
|
122
|
+
*
|
|
123
|
+
* @param {unknown} value - As authored.
|
|
124
|
+
* @returns {string|null} The bare address, or `null` for a blank entry.
|
|
125
|
+
*/
|
|
126
|
+
export function bareAddress(value) {
|
|
127
|
+
if (value == null) return null;
|
|
128
|
+
const text = String(value).trim();
|
|
129
|
+
if (!text) return null;
|
|
130
|
+
const unwrapped = text.replace(/^\[\[(.*)\]\]$/s, "$1");
|
|
131
|
+
// A label is presentation; the address is everything before the pipe.
|
|
132
|
+
const [target] = unwrapped.split("|");
|
|
133
|
+
return target.trim() || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The addresses a bundle note names, in the order it names them.
|
|
138
|
+
*
|
|
139
|
+
* `contents` is a `data:` property, which is where the specification's
|
|
140
|
+
* `### type: bundle` table puts it — the closed container, so a misspelled
|
|
141
|
+
* `content` is a finding rather than a silently empty Adventure. It is accepted
|
|
142
|
+
* at the top level too, exactly as a folder note's `parent` is: an author
|
|
143
|
+
* following #259's own example rather than the specification should get a
|
|
144
|
+
* bundle, not a silent default.
|
|
145
|
+
*
|
|
146
|
+
* **Order is the author's**, and it is kept: an Adventure's `SetField` attaches
|
|
147
|
+
* no meaning to member order, but the emitted JSON is compared between runs, so
|
|
148
|
+
* a stable order is what keeps the same tree compiling to the same bytes.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} fm - Parsed frontmatter.
|
|
151
|
+
* @returns {string[]} The bare addresses. Empty when the note states none.
|
|
152
|
+
* @throws {Error} When `contents` is neither absent nor a list — a scalar is a
|
|
153
|
+
* bundle of one written wrongly, and reading it as such would accept a shape
|
|
154
|
+
* the specification does not admit.
|
|
155
|
+
*/
|
|
156
|
+
export function bundleContents(fm) {
|
|
157
|
+
const data = fm?.data && typeof fm.data === "object" ? fm.data : {};
|
|
158
|
+
const authored = data[CONTENTS_FIELD] ?? fm?.[CONTENTS_FIELD];
|
|
159
|
+
if (authored == null) return [];
|
|
160
|
+
if (!Array.isArray(authored)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`\`contents\` is a list of addresses, and this note writes a ` +
|
|
163
|
+
`${typeof authored} — write \`contents:\` as a YAML list, one ` +
|
|
164
|
+
`address per entry, even where the bundle holds only one`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const addresses = [];
|
|
168
|
+
for (const entry of authored) {
|
|
169
|
+
const address = bareAddress(entry);
|
|
170
|
+
if (address) addresses.push(address);
|
|
171
|
+
}
|
|
172
|
+
return addresses;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What a pass should do about a member its sources do not hold.
|
|
177
|
+
*
|
|
178
|
+
* The whole of the system rule, in one predicate, because it is a rule and not
|
|
179
|
+
* an implementation detail. An `Adventure` has no `system` field, so a bundle
|
|
180
|
+
* spanning two systems cannot be one document that knows it spans them: it is
|
|
181
|
+
* one Adventure per system, and **the pack each is written to is what carries
|
|
182
|
+
* the system**.
|
|
183
|
+
*
|
|
184
|
+
* The constraint is not computed from the member's *type* — that would be wrong
|
|
185
|
+
* for the types both systems map, which is most of them: a `miscgear` is a SoHL
|
|
186
|
+
* item **and** an HM3 item, so "which system does this type belong to" has no
|
|
187
|
+
* single answer. It is read instead from what the pack can actually see. A pack
|
|
188
|
+
* declaring `system: hm3` reads the HM3 packs and the system-neutral ones, so a
|
|
189
|
+
* note that publishes no HM3 document is simply not in its sources — which is
|
|
190
|
+
* the same fact, established where it is already true.
|
|
191
|
+
*
|
|
192
|
+
* - **The pack declares a system.** A member it cannot see is another system's,
|
|
193
|
+
* and is left **out** — reported, never silently, because an installer that
|
|
194
|
+
* quietly ships half its contents is worse than one that fails.
|
|
195
|
+
* - **The pack declares none.** Nothing is scoped away, so there is no other
|
|
196
|
+
* system for the member to have gone to: its absence is a **failure**.
|
|
197
|
+
*
|
|
198
|
+
* @param {string|null|undefined} packSystem - The pack's declared `system:`.
|
|
199
|
+
* @returns {"omit"|"fail"} What to do about a member the sources lack.
|
|
200
|
+
*/
|
|
201
|
+
export function missingMemberVerdict(packSystem) {
|
|
202
|
+
return packSystem ? "omit" : "fail";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Assemble one `Adventure` from a set of already-compiled documents.
|
|
207
|
+
*
|
|
208
|
+
* The members are **copies**, and they arrive compiled: this takes the JSON a
|
|
209
|
+
* previous pass wrote and files each document under the content field its class
|
|
210
|
+
* maps to. Nothing is derived from the content tree here, which is what keeps
|
|
211
|
+
* this half framework-free and testable without one.
|
|
212
|
+
*
|
|
213
|
+
* @param {object} params
|
|
214
|
+
* @param {string} params.id - The Adventure's `_id`.
|
|
215
|
+
* @param {string} params.name - Its name.
|
|
216
|
+
* @param {string|null} [params.img] - Its artwork, or `null`.
|
|
217
|
+
* @param {string} [params.description] - The note's prose, already rendered.
|
|
218
|
+
* @param {string} [params.caption] - The short caption Foundry shows on the
|
|
219
|
+
* import card. Blank unless a caller has one.
|
|
220
|
+
* @param {string|null} [params.folder] - The folder id it is filed under.
|
|
221
|
+
* @param {object} [params.flags] - Document flags.
|
|
222
|
+
* @param {object} params.stats - The `_stats` block to stamp.
|
|
223
|
+
* @param {ReadonlyArray<{docType: string, document: object}>} params.contents -
|
|
224
|
+
* The compiled documents it holds, each with the Foundry class it is.
|
|
225
|
+
* @returns {object} The Adventure document, keyed for the pack.
|
|
226
|
+
* @throws {Error} When a member's document class is not one an Adventure can
|
|
227
|
+
* hold — a defect in the caller rather than in the note.
|
|
228
|
+
*/
|
|
229
|
+
export function buildAdventure({
|
|
230
|
+
id,
|
|
231
|
+
name,
|
|
232
|
+
img = null,
|
|
233
|
+
description = "",
|
|
234
|
+
caption = "",
|
|
235
|
+
folder = null,
|
|
236
|
+
flags,
|
|
237
|
+
stats,
|
|
238
|
+
contents = [],
|
|
239
|
+
}) {
|
|
240
|
+
// Every content field, empty, so the emitted document states the whole
|
|
241
|
+
// schema rather than only the parts this bundle happened to fill. Foundry
|
|
242
|
+
// defaults an absent `SetField` anyway; writing them all makes two
|
|
243
|
+
// Adventures diffable against each other.
|
|
244
|
+
/** @type {Record<string, object[]>} */
|
|
245
|
+
const held = {};
|
|
246
|
+
for (const field of Object.values(ADVENTURE_CONTENT_FIELD)) held[field] = [];
|
|
247
|
+
|
|
248
|
+
for (const { docType, document } of contents) {
|
|
249
|
+
const field = ADVENTURE_CONTENT_FIELD[docType];
|
|
250
|
+
if (!field) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`an Adventure cannot hold ${docType} documents — Foundry's ` +
|
|
253
|
+
`\`Adventure.contentFields\` names ` +
|
|
254
|
+
`${Object.keys(ADVENTURE_CONTENT_FIELD).join(", ")}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
held[field].push(stripAdventureKeys(document));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
name,
|
|
262
|
+
img,
|
|
263
|
+
caption,
|
|
264
|
+
description,
|
|
265
|
+
...held,
|
|
266
|
+
folder,
|
|
267
|
+
sort: 0,
|
|
268
|
+
flags: flags || {},
|
|
269
|
+
_id: id,
|
|
270
|
+
_stats: stats,
|
|
271
|
+
// The Foundry *collection* name, not the pack's: a pack directory may
|
|
272
|
+
// be called anything (#1566), and the key names the collection the
|
|
273
|
+
// record belongs to inside it.
|
|
274
|
+
_key: `!adventures!${id}`,
|
|
275
|
+
};
|
|
276
|
+
}
|