@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.
Files changed (152) hide show
  1. package/CHANGELOG.md +1711 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +225 -127
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +55 -83
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/manifest.mjs +43 -2
  74. package/package.json +18 -4
  75. package/release.mjs +62 -7
  76. package/sohl/actors.mjs +33 -487
  77. package/sohl/being-info.mjs +16 -7
  78. package/sohl/default-item-art.mjs +14 -3
  79. package/sohl/document-subtypes.mjs +16 -10
  80. package/sohl/item-builders.mjs +14 -5
  81. package/sohl/item-fields.mjs +68 -7
  82. package/sohl/items.mjs +44 -258
  83. package/sohl/note-schemas.mjs +2 -2
  84. package/types/content-config.d.mts +21 -42
  85. package/types/engine/actor-compiler.d.mts +204 -0
  86. package/types/engine/address-charset.d.mts +11 -8
  87. package/types/engine/address-diff.d.mts +53 -5
  88. package/types/engine/anchored-sections.d.mts +21 -0
  89. package/types/engine/anchors.d.mts +20 -0
  90. package/types/engine/base-compiler.d.mts +17 -17
  91. package/types/engine/bundle-notes.d.mts +173 -0
  92. package/types/engine/bundles.d.mts +60 -0
  93. package/types/engine/code-fences.d.mts +43 -0
  94. package/types/engine/compile-corpus.d.mts +32 -0
  95. package/types/engine/content-address.d.mts +205 -5
  96. package/types/engine/content-format-check.d.mts +6 -2
  97. package/types/engine/content-format.d.mts +57 -1
  98. package/types/engine/content-index.d.mts +54 -49
  99. package/types/engine/content-links.d.mts +52 -7
  100. package/types/engine/content-lint.d.mts +10 -1
  101. package/types/engine/content-package.d.mts +2 -1
  102. package/types/engine/content-tables.d.mts +21 -39
  103. package/types/engine/document-subtypes.d.mts +37 -3
  104. package/types/engine/field-spec.d.mts +76 -5
  105. package/types/engine/folder-notes.d.mts +159 -0
  106. package/types/engine/foreign-catalog.d.mts +53 -0
  107. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +2 -43
  108. package/types/engine/frontmatter-lint.d.mts +10 -2
  109. package/types/engine/frontmatter.d.mts +64 -0
  110. package/types/engine/generate.d.mts +38 -0
  111. package/types/engine/helpers.d.mts +94 -30
  112. package/types/engine/ids.d.mts +96 -0
  113. package/types/engine/index-records.d.mts +68 -0
  114. package/types/engine/index.d.mts +9 -3
  115. package/types/engine/item-compiler.d.mts +131 -0
  116. package/types/engine/journals.d.mts +47 -9
  117. package/types/engine/metadata-index.d.mts +226 -0
  118. package/types/engine/note-claims.d.mts +57 -11
  119. package/types/engine/note-ids.d.mts +38 -0
  120. package/types/engine/note-renames.d.mts +102 -0
  121. package/types/engine/note-vocabulary.d.mts +44 -8
  122. package/types/engine/retired-fields.d.mts +75 -0
  123. package/types/engine/scenes.d.mts +3 -2
  124. package/types/engine/schema-check.d.mts +25 -4
  125. package/types/engine/site-build.d.mts +4 -4
  126. package/types/engine/site-index.d.mts +1 -1
  127. package/types/engine/sql-tables.d.mts +185 -0
  128. package/types/engine/subtype-registry.d.mts +49 -0
  129. package/types/engine/system-block.d.mts +40 -1
  130. package/types/engine/systems.d.mts +106 -0
  131. package/types/engine/web-wikilinks.d.mts +4 -2
  132. package/types/engine/wikilink-syntax.d.mts +10 -3
  133. package/types/engine/wikilinks.d.mts +41 -13
  134. package/types/engine/yaml-lint.d.mts +107 -0
  135. package/types/hm3/actors.d.mts +48 -0
  136. package/types/hm3/default-item-art.d.mts +42 -0
  137. package/types/hm3/document-subtypes.d.mts +24 -0
  138. package/types/hm3/index.d.mts +7 -0
  139. package/types/hm3/item-builders.d.mts +11 -0
  140. package/types/hm3/item-fields.d.mts +12 -0
  141. package/types/hm3/items.d.mts +23 -0
  142. package/types/hm3/template-priority.d.mts +21 -0
  143. package/types/manifest.d.mts +22 -1
  144. package/types/release.d.mts +9 -4
  145. package/types/sohl/actors.d.mts +7 -74
  146. package/types/sohl/being-info.d.mts +8 -27
  147. package/types/sohl/default-item-art.d.mts +5 -3
  148. package/types/sohl/items.d.mts +17 -32
  149. package/engine/foreign-manifests.mjs +0 -126
  150. package/engine/kb-manifest.mjs +0 -490
  151. package/types/engine/foreign-manifests.d.mts +0 -43
  152. package/types/engine/kb-manifest.d.mts +0 -241
@@ -66,23 +66,26 @@ import path from "path";
66
66
  import log from "loglevel";
67
67
 
68
68
  import {
69
- walkMarkdownTree,
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
- * @throws {Error} When this pack's system is absent from the note. The
362
- * error carries a `position` where the note's own file can be read.
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 = buildContentLinkIndex(this.contentBase, this.router);
430
- this.contentDocs = collectContentDocs(this.contentBase);
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
- const findings = checkAuthoredSystemData(fm, { block, documentType, subType });
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`, `archetype` — and neither the field-declaration check
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(system, { fm, block, documentType, subType, type, fields, config }) {
579
- const findings = checkEmittedSystemData(system, {
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
- for (const { frontmatter: fm, body, absPath, bodyLine, bodyColumn } of walkMarkdownTree(
758
- this.contentBase,
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(`${Label} missing id: ${absPath}`);
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
+ }