@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.
Files changed (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
@@ -48,16 +48,12 @@ import { Journals } from "./journals.mjs";
48
48
  import { Actors } from "../sohl/actors.mjs";
49
49
  import { Macros } from "./macros.mjs";
50
50
  import { Scenes } from "./scenes.mjs";
51
- import {
52
- statsForPack,
53
- loadFolders,
54
- buildFolderResolver,
55
- writeFolderDocs,
56
- } from "./helpers.mjs";
51
+ import { statsForPack, loadFolders, buildFolderResolver, writeFolderDocs } from "./helpers.mjs";
57
52
  import { countContentNotes } from "./content-tree.mjs";
58
53
  import { emitDiagnostic } from "./diagnostics.mjs";
59
54
  import { loadPackConfig } from "./pack-config.mjs";
60
55
  import { routerFor } from "./pack-router.mjs";
56
+ import { unclaimedNoteFindings } from "./note-claims.mjs";
61
57
 
62
58
  /**
63
59
  * The compiler class for each Foundry document type a pack may hold.
@@ -99,16 +95,30 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
99
95
  * order among packs of one type — and every one of them is written before the
100
96
  * actors pass that reads them.
101
97
  *
98
+ * **Scoped to one system when the caller has one (#58).** A being addresses an
99
+ * item by `(type, shortcode)`, and that address is unique within one system and
100
+ * not across two: `skill:sword` is an HM3 skill *and* a SoHL skill, with
101
+ * different data models behind them. The reference itself is unambiguous — it
102
+ * sits inside a system block, so position says which it means — but the
103
+ * resolver has to know which catalogue it is searching, or it resolves the pair
104
+ * by whichever pack was read first. So an Actor pass reads the Item packs of
105
+ * **its own** system plus the system-neutral ones, which belong to every
106
+ * system. Asking for no system reads them all, which is every single-system
107
+ * build and the behaviour this always had.
108
+ *
102
109
  * @param {object} [config] - The resolved build configuration. Defaults to this
103
110
  * repository's.
111
+ * @param {string|null} [system] - The system whose catalogue is wanted. Omitted
112
+ * or `null`, every Item pack is read.
104
113
  * @returns {string[]} Each Item pack's JSON directory. Empty when the
105
114
  * repository ships no items at all, which is a legitimate package: the actors
106
115
  * pass accepts an empty list and reports an item it cannot resolve per
107
116
  * `(type, shortcode)` instead, naming the being (#49).
108
117
  */
109
- export function itemPackJsonDirs(config = loadPackConfig()) {
118
+ export function itemPackJsonDirs(config = loadPackConfig(), system = null) {
110
119
  return config.packs
111
120
  .filter((pack) => pack.type === "Item")
121
+ .filter((pack) => system == null || !pack.system || pack.system === system)
112
122
  .map((pack) => packJsonDir(pack.name, config));
113
123
  }
114
124
 
@@ -170,10 +180,7 @@ export function orderPassesByDependency(packs) {
170
180
  readsOutputOf(pack.type).every(
171
181
  (dependency) =>
172
182
  !declared.has(dependency) ||
173
- packs.every(
174
- (other) =>
175
- other.type !== dependency || emitted.has(other.name),
176
- ),
183
+ packs.every((other) => other.type !== dependency || emitted.has(other.name)),
177
184
  );
178
185
 
179
186
  while (remaining.length) {
@@ -269,8 +276,7 @@ async function generatePack(
269
276
  let folderList;
270
277
  let resolver;
271
278
  try {
272
- folderList =
273
- folders ? loadFolders(path.join(contentBase, folders)) : [];
279
+ folderList = folders ? loadFolders(path.join(contentBase, folders)) : [];
274
280
  ({ resolver } = buildFolderResolver(folderList));
275
281
  } catch (err) {
276
282
  log.error(`${name} ${folders} validation failed: ${err.message}`);
@@ -305,7 +311,9 @@ async function generatePack(
305
311
  // the Item packs, so the dependency is stated rather than assumed
306
312
  // (#1508) — and it is every Item pack, since a repository may ship more
307
313
  // than one (#1566).
308
- itemsSourceDirs: itemPackJsonDirs(config),
314
+ // Scoped to this pack's system, so a being resolves `(type, shortcode)`
315
+ // against its own system's catalogue and the neutral one (#58).
316
+ itemsSourceDirs: itemPackJsonDirs(config, system ?? null),
309
317
  // The catalogue of a package this repository depends on but does
310
318
  // not contain, for a repository that authors beings without
311
319
  // holding the items they are assembled from. Cache-only: a cold
@@ -365,10 +373,7 @@ export function emptyPassErrors(passes) {
365
373
  * @throws {Error} If the configured Foundry package id has drifted from the
366
374
  * shipped manifest's `id` (see `package-manifest.mjs`).
367
375
  */
368
- export async function generatePacksJson({
369
- only,
370
- config = loadPackConfig(),
371
- } = {}) {
376
+ export async function generatePacksJson({ only, config = loadPackConfig() } = {}) {
372
377
  // Before anything is generated: every UUID written below is addressed to
373
378
  // the configured `foundryPackage`, so a value that has drifted from the shipped
374
379
  // manifest's `id` produces a whole pack of links that resolve nowhere.
@@ -393,6 +398,17 @@ export async function generatePacksJson({
393
398
  return 1;
394
399
  }
395
400
  log.info(`Content tree: ${noteCount} note(s) at ${contentBase}`);
401
+
402
+ // A note whose `type:` no configured pack claims compiles into nothing, and
403
+ // used to say nothing (#146) — no pass got far enough to reject it, so the
404
+ // silence had no owner. Asked once, of the whole configuration, because
405
+ // that is the only place it can be answered: a per-pass check would report
406
+ // every type a system deliberately does not map, which is exactly the
407
+ // silence #79 requires. Independent of `only`, since it is a fact about the
408
+ // configured pack list rather than about which passes this run executes.
409
+ const unclaimed = unclaimedNoteFindings(config);
410
+ for (const finding of unclaimed) emitDiagnostic(finding);
411
+
396
412
  fs.mkdirSync(config.paths.packJson, { recursive: true });
397
413
 
398
414
  // A companion pack has no pass of its own — naming it selects the pass that
@@ -431,10 +447,10 @@ export async function generatePacksJson({
431
447
  for (const message of unsatisfied) {
432
448
  emitDiagnostic({ severity: "error", message });
433
449
  }
434
- return unsatisfied.length;
450
+ return unsatisfied.length + unclaimed.length;
435
451
  }
436
452
 
437
- let totalErrors = 0;
453
+ let totalErrors = unclaimed.length;
438
454
  const passes = [];
439
455
  for (const pack of ordered) {
440
456
  const { errors, compiled } = await generatePack(
@@ -39,6 +39,8 @@ import { contentPackage, foundryPackageId } from "./content-package.mjs";
39
39
  import { searchableFrontmatter } from "./note-package.mjs";
40
40
  import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
41
41
  import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
42
+ // The alias sources every index shares (#131).
43
+ import { aliasesOf } from "./alias-index.mjs";
42
44
  import { expandContentTables } from "./content-tables.mjs";
43
45
  import { emitDiagnostic, positionInBody } from "./diagnostics.mjs";
44
46
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
@@ -89,8 +91,7 @@ export function parseMarkdownFile(filePath) {
89
91
  // be reported as a file position (#17). The frontmatter's lines and the
90
92
  // blank lines `trim()` removes both sit in between, and the trim can take
91
93
  // indentation off the first line as well — hence a column, not just a line.
92
- const bodyStart =
93
- content.length - raw.length + (raw.length - raw.trimStart().length);
94
+ const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
94
95
  const before = content.slice(0, bodyStart);
95
96
  const bodyLine = before.split("\n").length;
96
97
  const bodyColumn = bodyStart - before.lastIndexOf("\n");
@@ -148,8 +149,8 @@ export function* walkMarkdownTree(
148
149
 
149
150
  /**
150
151
  * Resolve the required `sohl.archetype` frontmatter for an Item/Actor entry
151
- * (see the archetype contract, #604 — `flags.sohl.docArchetype`). The property
152
- * is a nullable number that authors must state explicitly:
152
+ * (the archetype contract, #604). The property is a nullable number that
153
+ * authors must state explicitly:
153
154
  * - a number → the document is an archetype of that priority.
154
155
  * - `null` → the document is not an archetype.
155
156
  * - absent → an authoring error (throws), so "not an archetype" is never
@@ -183,30 +184,29 @@ export function resolveArchetype(fm, label) {
183
184
  }
184
185
 
185
186
  /**
186
- * Merge the required `sohl.archetype` frontmatter into a document's `flags`,
187
- * returning a new object (the input is never mutated). A numeric archetype
188
- * seeds `flags.sohl.docArchetype`; `null` omits the flag (and clears any stale
189
- * `docArchetype` while preserving sibling `sohl` flags); an absent value
190
- * throws. See {@link resolveArchetype}.
191
- *
192
- * @param {object} fm Parsed frontmatter.
193
- * @param {object} [flags] The entry's existing flags (e.g. `fm.flags`).
194
- * @param {string} label Human-readable context for error messages.
195
- * @returns {object} The flags object with the archetype applied.
187
+ * The value a document's `system.archetype` carries, from the required
188
+ * `sohl.archetype` frontmatter (#126, sohl#1780).
189
+ *
190
+ * A **schema field**, so the tri-state is written out in full rather than
191
+ * expressed by a key's presence: a number is an archetype at that priority,
192
+ * and `null` is not an archetype. This is where {@link resolveArchetype}'s
193
+ * `undefined` becomes the field's `null` — an emitted `undefined` would be
194
+ * dropped by `JSON.stringify`, leaving the compiled document with no
195
+ * `archetype` at all and the tri-state readable as two.
196
+ *
197
+ * **`0` is an archetype.** It is the priority SoHL's own archetypes ship at,
198
+ * and it is falsy, so this returns it unchanged and every caller must ask
199
+ * `typeof v === "number"` rather than testing truthiness.
200
+ *
201
+ * @param {object} fm Parsed frontmatter.
202
+ * @param {string} label Human-readable context for error messages.
203
+ * @returns {number|null} The archetype priority, or `null` for a document
204
+ * that is not an archetype.
196
205
  * @throws {Error} When `sohl.archetype` is absent or invalid.
197
206
  */
198
- export function withArchetypeFlag(fm, flags, label) {
207
+ export function systemArchetype(fm, label) {
199
208
  const archetype = resolveArchetype(fm, label);
200
- const out = { ...(flags || {}) };
201
- const sohl = { ...(out.sohl || {}) };
202
- if (archetype === undefined) {
203
- delete sohl.docArchetype;
204
- } else {
205
- sohl.docArchetype = archetype;
206
- }
207
- if (Object.keys(sohl).length > 0) out.sohl = sohl;
208
- else delete out.sohl;
209
- return out;
209
+ return archetype === undefined ? null : archetype;
210
210
  }
211
211
 
212
212
  /**
@@ -321,10 +321,7 @@ export function supportedCoreVersion(config = loadPackConfig()) {
321
321
  * The resolved build configuration. Defaults to this repository's.
322
322
  * @returns {object} The `_stats` block.
323
323
  */
324
- export function buildStats(
325
- systemVersion = undefined,
326
- config = loadPackConfig(),
327
- ) {
324
+ export function buildStats(systemVersion = undefined, config = loadPackConfig()) {
328
325
  return {
329
326
  systemId: config.stats.systemId,
330
327
  systemVersion: systemVersion ?? config.stats.systemVersion,
@@ -453,12 +450,10 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
453
450
  docPack: router.resolveOrNull(fm, "JournalEntry"),
454
451
  shortcode: fm.shortcode ?? null,
455
452
  name: fm.name?.full ?? base,
456
- aliases: [
457
- ...(Array.isArray(fm.aliases) ? fm.aliases : []),
458
- ...(fm.name?.full ? [fm.name.full] : []),
459
- ...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
460
- base,
461
- ].filter(Boolean),
453
+ // The shared alias sources (#131): `aliases`, `name.aliases` and
454
+ // `name.full`, and deliberately not the filename — see
455
+ // {@link aliasesOf} for what that admitted and why it went.
456
+ aliases: aliasesOf(fm),
462
457
  });
463
458
  }
464
459
  // Packages this build links *into* but does not publish. Their manifests
@@ -473,9 +468,7 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
473
468
  );
474
469
  if (stale.length) {
475
470
  for (const st of stale) {
476
- log.error(
477
- `Unusable link manifest for "${st.package}": ${st.reason}`,
478
- );
471
+ log.error(`Unusable link manifest for "${st.package}": ${st.reason}`);
479
472
  }
480
473
  throw new Error(
481
474
  "Cross-package links cannot be resolved from a stale manifest; " +
@@ -486,12 +479,7 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
486
479
  `Wikilink index: ${docs.length} local document(s), ` +
487
480
  `${foreign.size} foreign address(es)`,
488
481
  );
489
- return buildWikilinkIndex(
490
- docs,
491
- foundryPackageId(),
492
- foreign,
493
- contentPackage(),
494
- );
482
+ return buildWikilinkIndex(docs, foundryPackageId(), foreign, contentPackage());
495
483
  }
496
484
 
497
485
  /**
@@ -518,18 +506,7 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
518
506
  */
519
507
  export function convertNoteWikilinks(
520
508
  body,
521
- {
522
- type,
523
- id,
524
- pack,
525
- docPack,
526
- index,
527
- name,
528
- file,
529
- bodyLine,
530
- bodyColumn,
531
- lineMap,
532
- },
509
+ { type, id, pack, docPack, index, name, file, bodyLine, bodyColumn, lineMap },
533
510
  ) {
534
511
  const result = convertWikilinks(body ?? "", {
535
512
  type,
@@ -580,9 +557,7 @@ export function convertNoteWikilinks(
580
557
  const claims = u.candidates ?? [];
581
558
  const named =
582
559
  claims.length ?
583
- claims
584
- .map((c) => `"${c.name}" (${c.type}-${c.shortcode})`)
585
- .join(" and ")
560
+ claims.map((c) => `"${c.name}" (${c.type}-${c.shortcode})`).join(" and ")
586
561
  : "two or more notes";
587
562
  fail(
588
563
  u,
@@ -591,6 +566,19 @@ export function convertNoteWikilinks(
591
566
  `as [[type-shortcode|Text]].`,
592
567
  );
593
568
  }
569
+ // The author wrote a pipe, so they meant an address — and this target
570
+ // is not one. Its own message, because the correction is its own: a
571
+ // note *name* has to become an address, which is not the same job as
572
+ // fixing a shortcode that resolves nowhere (#131).
573
+ if (u.reason === "not-an-address") {
574
+ fail(
575
+ u,
576
+ `wikilink ${u.link} in "${name}" is written as an address — ` +
577
+ `the "|" says so — but "${u.target}" is not one. Write ` +
578
+ `[[type-shortcode|Text]], or drop the "|" to name it as ` +
579
+ `an alias within this note's own type.`,
580
+ );
581
+ }
594
582
  // A qualified address resolving nowhere is a typo, now that every
595
583
  // linkable package is either built here or vendored (#1499) — so it
596
584
  // fails the note rather than degrading to text. A bare alias stays a
@@ -613,9 +601,7 @@ export function convertNoteWikilinks(
613
601
  `unresolved wikilink ${u.link} (${u.reason}) in "${name}"` +
614
602
  // A link this build wrote is not at any authored position, so
615
603
  // say where it came from instead of implying an edit site.
616
- (at.generated ?
617
- " — emitted by the content table on this line"
618
- : ""),
604
+ (at.generated ? " — emitted by the content table on this line" : ""),
619
605
  });
620
606
  }
621
607
  return result;
@@ -668,8 +654,7 @@ export function collectContentDocs(contentBase) {
668
654
  * unlinkable; a note missing either renders as plain text rather than shipping a
669
655
  * literal wikilink into a journal.
670
656
  */
671
- const packLinkable = (doc) =>
672
- Boolean(doc.fm?.shortcode) && Boolean(doc.fm?.type);
657
+ const packLinkable = (doc) => Boolean(doc.fm?.shortcode) && Boolean(doc.fm?.type);
673
658
 
674
659
  /**
675
660
  * Expand the fenced `dataview` tables in one note's markdown, before wikilinks
@@ -710,9 +695,7 @@ export function expandNoteTables(body, { docs, name, fm, bodyLine }) {
710
695
  self,
711
696
  });
712
697
  if (errors.length) {
713
- const err = new Error(
714
- errors.map((e) => `content table — ${e.reason}`).join("; "),
715
- );
698
+ const err = new Error(errors.map((e) => `content table — ${e.reason}`).join("; "));
716
699
  // The first failing directive's line. Reporting one position for a
717
700
  // message that may name several is honest here: a caller opens the
718
701
  // file at the first thing to fix, and the message lists the rest.
@@ -735,18 +718,14 @@ export function expandNoteTables(body, { docs, name, fm, bodyLine }) {
735
718
  */
736
719
  export function loadFolders(foldersFile) {
737
720
  if (!fs.existsSync(foldersFile)) {
738
- log.warn(
739
- `No folders.yaml at ${foldersFile}; no folders will be emitted`,
740
- );
721
+ log.warn(`No folders.yaml at ${foldersFile}; no folders will be emitted`);
741
722
  return [];
742
723
  }
743
724
  const raw = fs.readFileSync(foldersFile, "utf8");
744
725
  const parsed = yaml.parse(raw);
745
726
  if (parsed == null) return [];
746
727
  if (!Array.isArray(parsed)) {
747
- throw new Error(
748
- `folders.yaml must contain a YAML list; got ${typeof parsed}`,
749
- );
728
+ throw new Error(`folders.yaml must contain a YAML list; got ${typeof parsed}`);
750
729
  }
751
730
  return parsed;
752
731
  }
@@ -818,10 +797,7 @@ export function buildFolderResolver(folders) {
818
797
  * underscores.
819
798
  */
820
799
  export function folderFilename(name, id) {
821
- return (
822
- `folder_${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") +
823
- ".json"
824
- );
800
+ return `folder_${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") + ".json";
825
801
  }
826
802
 
827
803
  /**
@@ -843,10 +819,7 @@ export function writeFolderDocs(folders, stats, destDir, documentType) {
843
819
  _stats: stats,
844
820
  _key: `!folders!${folder.id}`,
845
821
  };
846
- const outPath = path.join(
847
- destDir,
848
- folderFilename(folder.name, folder.id),
849
- );
822
+ const outPath = path.join(destDir, folderFilename(folder.name, folder.id));
850
823
  fs.writeFileSync(outPath, JSON.stringify(doc, null, 2), "utf8");
851
824
  }
852
825
  log.info(`Emitted ${folders.length} folder document(s) to ${destDir}`);
@@ -262,9 +262,7 @@ export function checkHomepageCount(found, { contentBase, contentPackage }) {
262
262
  if (pages.length === 1) return [];
263
263
 
264
264
  return pages.map((page) => {
265
- const others = pages
266
- .filter((p) => p !== page)
267
- .map((p) => formatLocator({ file: p.file }));
265
+ const others = pages.filter((p) => p !== page).map((p) => formatLocator({ file: p.file }));
268
266
  return {
269
267
  file: page.file,
270
268
  ...positionOfType(page.file),
@@ -292,11 +290,7 @@ export function checkHomepageCount(found, { contentBase, contentPackage }) {
292
290
  */
293
291
  function positionOfType(file) {
294
292
  try {
295
- return positionInFrontmatter(
296
- fs.readFileSync(file, "utf8"),
297
- "type",
298
- HOMEPAGE_TYPE,
299
- );
293
+ return positionInFrontmatter(fs.readFileSync(file, "utf8"), "type", HOMEPAGE_TYPE);
300
294
  } catch {
301
295
  return {};
302
296
  }
@@ -325,9 +319,7 @@ export function homepageTitle(fm, config) {
325
319
  config?.packageBuild?.manifest
326
320
  );
327
321
  const title = manifest?.title;
328
- return typeof title === "string" && title.trim() ?
329
- title
330
- : config.contentPackage;
322
+ return typeof title === "string" && title.trim() ? title : config.contentPackage;
331
323
  }
332
324
 
333
325
  /**
@@ -394,10 +386,7 @@ export const HOMEPAGE_ADDRESS_KEYS = Object.freeze(new Set(["url", "href"]));
394
386
  */
395
387
  function collectProse(text, field, kind, out, skipCode = false) {
396
388
  const pattern = new RegExp(MARKDOWN_LINK.source, "g");
397
- const matches =
398
- skipCode ?
399
- matchAllOutsideCode(text, pattern)
400
- : [...text.matchAll(pattern)];
389
+ const matches = skipCode ? matchAllOutsideCode(text, pattern) : [...text.matchAll(pattern)];
401
390
  for (const m of matches) out.push({ field, url: m[1], kind });
402
391
  }
403
392
 
package/engine/ids.mjs CHANGED
@@ -36,11 +36,7 @@ import crypto from "crypto";
36
36
  * @returns {string} A 16-character hexadecimal Foundry id.
37
37
  */
38
38
  export function makeId(namespace, value) {
39
- return crypto
40
- .createHash("sha1")
41
- .update(`${namespace}:${value}`)
42
- .digest("hex")
43
- .slice(0, 16);
39
+ return crypto.createHash("sha1").update(`${namespace}:${value}`).digest("hex").slice(0, 16);
44
40
  }
45
41
 
46
42
  /**
@@ -55,9 +51,18 @@ export function makeId(namespace, value) {
55
51
  *
56
52
  * @type {ReadonlySet<string>}
57
53
  */
58
- export const MAP_TYPES = Object.freeze(
59
- new Set(["battlemap", "localmap", "regionalmap"]),
60
- );
54
+ export const MAP_TYPES = Object.freeze(new Set(["map"]));
55
+
56
+ /**
57
+ * The map subTypes, which differ only in the canvas defaults derived for them.
58
+ *
59
+ * They were three *types* until #174, which cost three entries in the pack
60
+ * router, three in the claims set and three in every consumer's section config
61
+ * — for one idea that the specification had always described as one type.
62
+ *
63
+ * @type {readonly string[]}
64
+ */
65
+ export const MAP_SUBTYPES = Object.freeze(["battlemap", "localmap", "regionalmap"]);
61
66
 
62
67
  /**
63
68
  * Content type → the pack its documents compile into, and the document type
@@ -77,10 +82,7 @@ export const PACK_BY_TYPE = Object.freeze({
77
82
  macro: { pack: "macros", docType: "Macro" },
78
83
  being: { pack: "actors", docType: "Actor" },
79
84
  ...Object.fromEntries(
80
- [...MAP_TYPES].map((type) => [
81
- type,
82
- { pack: "scenes", docType: "Scene" },
83
- ]),
85
+ [...MAP_TYPES].map((type) => [type, { pack: "scenes", docType: "Scene" }]),
84
86
  ),
85
87
  });
86
88
 
@@ -104,6 +106,13 @@ export const PACK_BY_TYPE = Object.freeze({
104
106
  export const RETIRED_TYPES = Object.freeze({
105
107
  character: "being",
106
108
  creature: "being",
109
+ // The three map spellings, retired in favour of the single `map` whose
110
+ // subType they became (#174). Recorded rather than deleted for the same
111
+ // reason as the two above: an unnamed type falls through to the open item
112
+ // set, so a note or link left on one would be routed to the items pack.
113
+ battlemap: "map",
114
+ localmap: "map",
115
+ regionalmap: "map",
107
116
  });
108
117
 
109
118
  /**
package/engine/index.mjs CHANGED
@@ -36,6 +36,9 @@
36
36
  /** Deterministic document ids, the conventional pack map, and compendium UUIDs. */
37
37
  export * as ids from "./ids.mjs";
38
38
 
39
+ /** The per-system frontmatter block, and the declared shared→system mapping. */
40
+ export * as systemBlock from "./system-block.mjs";
41
+
39
42
  /** Fenced-code detection, so a rewrite never edits a code block. */
40
43
  export * as codeFences from "./code-fences.mjs";
41
44
 
@@ -51,6 +54,9 @@ export * as packConfig from "./pack-config.mjs";
51
54
  /** Which pack of a document type a note's document lands in (#1566). */
52
55
  export * as packRouter from "./pack-router.mjs";
53
56
 
57
+ /** Which note types a configuration compiles, and the notes nothing claims (#146). */
58
+ export * as noteClaims from "./note-claims.mjs";
59
+
54
60
  /** The content package a build compiles, and the Foundry package it ships in. */
55
61
  export * as contentPackage from "./content-package.mjs";
56
62
 
@@ -66,6 +72,9 @@ export * as homepage from "./homepage.mjs";
66
72
  /** The note types the engine itself declares, whatever a consumer registers. */
67
73
  export * as noteSchemas from "./note-schemas.mjs";
68
74
 
75
+ /** The closed frontmatter regions: the `data:` container and each type's `subType`. */
76
+ export * as noteVocabulary from "./note-vocabulary.mjs";
77
+
69
78
  /** The shipped Foundry manifest: locating it, reading it, guarding its id. */
70
79
 
71
80
  /** The URL a content note is published at — the one web-address rule. */
@@ -104,6 +113,9 @@ export * as helpers from "./helpers.mjs";
104
113
  /** The consumer's resolved item-type registry: the whitelist and its builders. */
105
114
  export * as itemRegistry from "./item-registry.mjs";
106
115
 
116
+ /** The per-system note-type → document-subtype map, and looking a note up in it. */
117
+ export * as documentSubtypes from "./document-subtypes.mjs";
118
+
107
119
  /** Which types carry documentation of their own, and where it is addressed. */
108
120
  export * as itemDocs from "./item-docs.mjs";
109
121
 
@@ -113,6 +125,9 @@ export * as wikilinks from "./wikilinks.mjs";
113
125
  /** What a `[[…]]` is, before either resolver decides where it points. */
114
126
  export * as wikilinkSyntax from "./wikilink-syntax.mjs";
115
127
 
128
+ /** The alias namespace: what a note may be called, and who may claim a name. */
129
+ export * as aliasIndex from "./alias-index.mjs";
130
+
116
131
  /** The address index a site build resolves its wikilinks against. */
117
132
  export * as siteIndex from "./site-index.mjs";
118
133
 
@@ -18,7 +18,11 @@
18
18
  * Both are read from the one resolved configuration, so they are literally the
19
19
  * same object's keys and values: a type cannot be whitelisted for compilation
20
20
  * without the builder that compiles it, which is the guarantee #1504 exists
21
- * for. The Item compiler dispatches through {@link itemBuilder}, so the table a
21
+ * for. Where a consumer declares **several** registries, one per system (#58),
22
+ * the vocabulary is their union and every lookup below takes the system that is
23
+ * asking — a type both systems declare has two builders, and answering with one
24
+ * of them because it was declared first is the silent-wrong-output failure this
25
+ * package spends its time removing. The Item compiler dispatches through {@link itemBuilder}, so the table a
22
26
  * consumer configured is the table its notes compile with — the whitelist and
23
27
  * the dispatch used to come from different places, and a consumer supplying its
24
28
  * own registry got the types it asked for and the builders it did not (#1563).
@@ -60,6 +64,43 @@ export function itemTypes() {
60
64
  return loadPackConfig().itemTypes;
61
65
  }
62
66
 
67
+ /**
68
+ * Look one type up in the table a system declared, or in the flat one.
69
+ *
70
+ * The flat table is the union with the first declaring registry winning, which
71
+ * is the right answer for every single-system build and no answer at all where
72
+ * two systems declare the type. So a contested type without a system **throws**
73
+ * rather than resolving: the alternative is a document built by one system's
74
+ * builder and stamped with another's, which is exactly the shape of defect the
75
+ * document-subtype map exists to stop (#79).
76
+ *
77
+ * @param {string} what - What is being looked up, for the message.
78
+ * @param {"itemBuilders"|"itemArt"|"itemFields"} table - Which table.
79
+ * @param {string} type - The item type.
80
+ * @param {string} [system] - The system asking, where a build has more than one.
81
+ * @returns {any} The entry, or `undefined`.
82
+ * @throws {Error} When several systems declare the type and none is named.
83
+ */
84
+ function lookup(what, table, type, system) {
85
+ const config = loadPackConfig();
86
+ if (system !== undefined) {
87
+ const perSystem = /** @type {Record<string, Record<string, unknown>>} */ (
88
+ config[`${table}BySystem`]
89
+ );
90
+ // A consumer declaring one, system-less registry keeps answering for
91
+ // every system: it *is* the only vocabulary in the build.
92
+ if (Object.keys(perSystem).length) return perSystem[system]?.[type];
93
+ } else if (config.itemTypesBySeveralSystems.has(type)) {
94
+ throw new Error(
95
+ `More than one declared registry defines the item type "${type}", so ` +
96
+ `the ${what} of a "${type}" depends on which system is asking. ` +
97
+ `Name the system at the call site, or stop declaring the type ` +
98
+ `twice.`,
99
+ );
100
+ }
101
+ return /** @type {Record<string, unknown>} */ (config[table])[type];
102
+ }
103
+
63
104
  /**
64
105
  * The builder the consuming repository registered for an item type.
65
106
  *
@@ -68,13 +109,16 @@ export function itemTypes() {
68
109
  * failing as an anonymous `is not a function` (#1504).
69
110
  *
70
111
  * @param {string} type - The note's `type` frontmatter.
112
+ * @param {string} [system] - The system compiling it, where a build declares
113
+ * more than one registry. Omitted, a type only one registry declares still
114
+ * resolves; a contested one throws rather than picking a side.
71
115
  * @returns {(fm: object) => object} The builder for that type.
72
116
  * @throws {Error} When the configuration registers no builder for `type`.
73
117
  */
74
- export function itemBuilder(type) {
75
- const builder = /** @type {Record<string, Function>} */ (
76
- loadPackConfig().itemBuilders
77
- )[type];
118
+ export function itemBuilder(type, system) {
119
+ const builder = /** @type {Function|undefined} */ (
120
+ lookup("builder", "itemBuilders", type, system)
121
+ );
78
122
  if (typeof builder !== "function") {
79
123
  throw new Error(
80
124
  `No builder registered for item type "${type}" — add one to the ` +
@@ -85,6 +129,25 @@ export function itemBuilder(type) {
85
129
  return /** @type {(fm: object) => object} */ (builder);
86
130
  }
87
131
 
132
+ /**
133
+ * The frontmatter fields a type's registry entry declares, if any.
134
+ *
135
+ * Sparse by design: a type whose entry declares none compiles normally and is
136
+ * simply undocumented (#22). What reads it is the `system`-block passthrough,
137
+ * which has to know which paths a declared field already writes before it
138
+ * writes the rest (#58).
139
+ *
140
+ * @param {string} type - The item type.
141
+ * @param {string} [system] - The system compiling it, where a build declares
142
+ * more than one registry.
143
+ * @returns {readonly object[]|undefined} The declaration, or `undefined`.
144
+ */
145
+ export function itemFields(type, system) {
146
+ return /** @type {readonly object[]|undefined} */ (
147
+ lookup("field declaration", "itemFields", type, system)
148
+ );
149
+ }
150
+
88
151
  /**
89
152
  * The default art for an item type — the image a note of that type is given
90
153
  * when it carries no `img:` of its own.
@@ -108,13 +171,13 @@ export function itemBuilder(type) {
108
171
  * One spelling, one meaning, wherever it is written.
109
172
  *
110
173
  * @param {string} type - the item type.
174
+ * @param {string} [system] - The system compiling it, where a build declares
175
+ * more than one registry.
111
176
  * @returns {string} The default image path for that type.
112
177
  * @throws {Error} When the type's registry entry pairs no `img`.
113
178
  */
114
- export function itemArt(type) {
115
- const art = /** @type {Record<string, string|undefined>} */ (
116
- loadPackConfig().itemArt
117
- )[type];
179
+ export function itemArt(type, system) {
180
+ const art = /** @type {string|undefined} */ (lookup("default art", "itemArt", type, system));
118
181
  if (!art) {
119
182
  throw new Error(
120
183
  `No default art for item type "${type}" — the note carries no ` +