@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
@@ -66,9 +66,10 @@
66
66
 
67
67
  import path from "node:path";
68
68
 
69
- // A leaf with no local imports of its own, so naming it here cannot close a
70
- // cycle around a consumer's config file (see `engine/pack-config.mjs`).
71
- import { MAP_TYPES } from "./engine/ids.mjs";
69
+ // Leaves with no local imports of their own, so naming them here cannot close
70
+ // a cycle around a consumer's config file (see `engine/pack-config.mjs`).
71
+ import { isAddressSegment } from "./engine/address-charset.mjs";
72
+ import { MAP_TYPES, PACK_BY_TYPE } from "./engine/ids.mjs";
72
73
 
73
74
  /**
74
75
  * The two kinds of Foundry package a content module can be built into. The
@@ -470,6 +471,18 @@ export function publishesContentPages(config) {
470
471
  * @typedef {((fm: object) => object)|{system: (fm: object) => object, img?: string, fields?: readonly object[]}} ItemBuilderEntry
471
472
  */
472
473
 
474
+ /**
475
+ * One **registry** of a declared set, and the system it belongs to (#58).
476
+ *
477
+ * A repository shipping content for two systems declares one of these per
478
+ * system: the accepted type vocabulary is their union, and a type both declare
479
+ * keeps a builder on each side rather than one of them winning in silence.
480
+ *
481
+ * @typedef {object} ItemRegistrySpec
482
+ * @property {string} system The system id whose vocabulary this registry is.
483
+ * @property {Record<string, ItemBuilderEntry>} builders The registry itself.
484
+ */
485
+
473
486
  /**
474
487
  * The configuration a consumer writes.
475
488
  *
@@ -485,7 +498,8 @@ export function publishesContentPages(config) {
485
498
  * `system.json` / `module.json`.
486
499
  * @property {PackageKind} packageKind Whether the package is a system or a module.
487
500
  * @property {StatsSpec} stats Identity stamped into every document's `_stats`.
488
- * @property {Record<string, ItemBuilderEntry>} [itemBuilders] The consumer's
501
+ * @property {Record<string, ItemBuilderEntry>|readonly ItemRegistrySpec[]} [itemBuilders]
502
+ * The consumer's
489
503
  * item-type registry: each content `type`
490
504
  * that compiles into an Item, paired with
491
505
  * the builder producing its `system` block
@@ -493,7 +507,11 @@ export function publishesContentPages(config) {
493
507
  * note of that type gets when it sets no
494
508
  * `img:` of its own. Default `{}` — a
495
509
  * content module that ships no items
496
- * declares none.
510
+ * declares none. A repository feeding
511
+ * two systems declares a **list** of
512
+ * `{ system, builders }` registries
513
+ * instead, and the accepted type
514
+ * vocabulary is their union (#58).
497
515
  * @property {PackSpec[]} packs Packs to compile. More than one entry
498
516
  * may share a `type`: a note then names
499
517
  * the pack it belongs in with its
@@ -547,10 +565,24 @@ export function publishesContentPages(config) {
547
565
  * declared. Sparse, like `itemArt` — a type
548
566
  * absent here compiles normally and is
549
567
  * simply undocumented (#22).
568
+ * @property {Readonly<Record<string, Readonly<Record<string, Function>>>>} itemBuildersBySystem
569
+ * Derived: the same builders, kept per
570
+ * declaring system. `{}` for the single
571
+ * registry form, which names no system
572
+ * (#58).
573
+ * @property {Readonly<Record<string, Readonly<Record<string, string>>>>} itemArtBySystem
574
+ * Derived: the default art, per system.
575
+ * @property {Readonly<Record<string, Readonly<Record<string, readonly object[]>>>>} itemFieldsBySystem
576
+ * Derived: the declared fields, per system.
577
+ * @property {ReadonlySet<string>} itemTypesBySeveralSystems Derived: the types
578
+ * more than one registry declares — the
579
+ * ones the flat tables cannot answer for
580
+ * without choosing a system for the caller.
550
581
  * @property {ReadonlySet<string>} itemTypes Derived: the keys of
551
582
  * {@link ContentBuildConfigInput.itemBuilders},
552
- * so the accepted item types and the builder
553
- * table are one list (#1504).
583
+ * unioned across every declared registry, so
584
+ * the accepted item types and the builder
585
+ * tables are one list (#1504).
554
586
  * @property {ReadonlySet<string>} docEntryTypes Derived: every type whose prose
555
587
  * compiles into a JournalEntry of its own —
556
588
  * the item types, plus `macro`, plus the map
@@ -608,14 +640,9 @@ const SITE_TREE_KEYS = ["from", "section"];
608
640
  const SECTION_META_KEYS = ["title", "banner", "description"];
609
641
  const DOC_PAGE_KEYS = ["title", "out", "preamble"];
610
642
  const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
611
- const RELATIONSHIP_KEYS = [
612
- "id",
613
- "type",
614
- "manifest",
615
- "compatibility",
616
- "itemCatalog",
617
- ];
643
+ const RELATIONSHIP_KEYS = ["id", "type", "manifest", "compatibility", "itemCatalog"];
618
644
  const ITEM_BUILDER_KEYS = ["system", "img", "fields"];
645
+ const ITEM_REGISTRY_KEYS = ["system", "builders"];
619
646
  const PACK_KEYS = [
620
647
  "name",
621
648
  "type",
@@ -644,9 +671,7 @@ const STATS_KEYS = ["lastModifiedBy"];
644
671
  *
645
672
  * @type {symbol}
646
673
  */
647
- export const DERIVED_SYSTEM_VERSION = Symbol.for(
648
- "package-build.derivedSystemVersion",
649
- );
674
+ export const DERIVED_SYSTEM_VERSION = Symbol.for("package-build.derivedSystemVersion");
650
675
  const PUBLISH_KEYS = ["site", "manifests", "address"];
651
676
  const MANIFEST_KEYS = ["publish", "consume"];
652
677
  const ADDRESS_KEYS = ["prefix", "landing"];
@@ -672,10 +697,7 @@ function isPlainObject(value) {
672
697
  * @returns {never}
673
698
  */
674
699
  function fail(field, problem) {
675
- throw Object.assign(
676
- new TypeError(`package-build config: \`${field}\` ${problem}.`),
677
- { field },
678
- );
700
+ throw Object.assign(new TypeError(`package-build config: \`${field}\` ${problem}.`), { field });
679
701
  }
680
702
 
681
703
  /**
@@ -706,6 +728,65 @@ function requireNonEmptyString(value, field) {
706
728
  return /** @type {string} */ (value);
707
729
  }
708
730
 
731
+ /**
732
+ * The `contentPackage`, checked against the two rules an address puts on it.
733
+ *
734
+ * It is the first segment of every canonical address this repository publishes
735
+ * (`sohl-skill-clmb`), and an address is read by counting hyphen-separated
736
+ * segments. So the value carries two obligations that the rest of the
737
+ * configuration does not, and #59 asks for both to be **enforced rather than
738
+ * assumed** — the alternative is a package whose addresses are simply
739
+ * unreadable, reported nowhere and discovered as links that resolve to nothing.
740
+ *
741
+ * 1. _Alphanumeric_, so the hyphen stays purely a separator. `harn-adventures`
742
+ * was the one violator, and its keys read as four segments and failed as a
743
+ * `null` return from `readCanonicalKey` — a silence, not an error.
744
+ * 2. _Not a note type_, because the package and the type are adjacent segments
745
+ * drawn from two vocabularies. Keeping them disjoint is what lets a reader
746
+ * take a name at face value instead of deciding which slot it is filling.
747
+ * One such collision is structural and cannot be fixed — `sohl` is both a
748
+ * content package and a system id, because Foundry requires a system
749
+ * package's id to *be* its system id — which is the reason to prevent the
750
+ * ones that are avoidable.
751
+ *
752
+ * @param {unknown} value - The configured `contentPackage`.
753
+ * @param {ReadonlySet<string>} docEntryTypes - Every type whose prose compiles
754
+ * to a documentation entry: the item types plus `macro` and the map types.
755
+ * With {@link PACK_BY_TYPE} and the `doc`-prefixed forms, this is the whole
756
+ * type vocabulary an address may write.
757
+ * @returns {string} The value, unchanged.
758
+ */
759
+ function requireContentPackage(value, docEntryTypes) {
760
+ const pkg = requireNonEmptyString(value, "contentPackage");
761
+ if (!isAddressSegment(pkg)) {
762
+ fail(
763
+ "contentPackage",
764
+ `is \`${pkg}\`, which is not alphanumeric. It is the first ` +
765
+ `segment of every address this package publishes ` +
766
+ `(\`${pkg}-<type>-<shortcode>\`), and an address is read by ` +
767
+ `counting hyphen-separated segments — so anything outside ` +
768
+ "`[A-Za-z0-9]` here makes those addresses unreadable rather " +
769
+ "than merely ugly. `harn-adventures` became `harnadventures`",
770
+ );
771
+ }
772
+ const typeNames = new Set([
773
+ ...Object.keys(PACK_BY_TYPE),
774
+ ...docEntryTypes,
775
+ ...[...docEntryTypes].map((type) => `doc${type}`),
776
+ ]);
777
+ if (typeNames.has(pkg)) {
778
+ fail(
779
+ "contentPackage",
780
+ `is \`${pkg}\`, which is also a note type — \`${pkg}-<shortcode>\` ` +
781
+ "already addresses one. The package and the type are adjacent " +
782
+ "segments of an address, and the two vocabularies are kept " +
783
+ "disjoint so a reader never has to decide which slot a name " +
784
+ "is filling. Rename the package",
785
+ );
786
+ }
787
+ return pkg;
788
+ }
789
+
709
790
  /**
710
791
  * @param {unknown} value
711
792
  * @param {string} field
@@ -752,10 +833,7 @@ function normalizePack(value, where, nested = false) {
752
833
  typeof type !== "string" ||
753
834
  !(/** @type {readonly string[]} */ (PACK_DOCUMENT_TYPES).includes(type))
754
835
  ) {
755
- fail(
756
- `${where}.type`,
757
- `must be one of: ${PACK_DOCUMENT_TYPES.join(", ")}`,
758
- );
836
+ fail(`${where}.type`, `must be one of: ${PACK_DOCUMENT_TYPES.join(", ")}`);
759
837
  }
760
838
 
761
839
  if (pack.folders !== undefined && pack.folders !== null) {
@@ -837,20 +915,14 @@ function normalizePack(value, where, nested = false) {
837
915
  name,
838
916
  type: /** @type {PackDocumentType} */ (type),
839
917
  label:
840
- pack.label === undefined ?
841
- name
842
- : requireNonEmptyString(pack.label, `${where}.label`),
918
+ pack.label === undefined ? name : requireNonEmptyString(pack.label, `${where}.label`),
843
919
  private: optionalBoolean(pack.private, `${where}.private`, false),
844
920
  folders:
845
921
  pack.folders === undefined || pack.folders === null ?
846
922
  null
847
923
  : /** @type {string} */ (pack.folders),
848
924
  companions: Object.freeze(companions),
849
- mayBeEmpty: optionalBoolean(
850
- pack.mayBeEmpty,
851
- `${where}.mayBeEmpty`,
852
- false,
853
- ),
925
+ mayBeEmpty: optionalBoolean(pack.mayBeEmpty, `${where}.mayBeEmpty`, false),
854
926
  // Which pack of a type receives a note that declares none. Validated
855
927
  // across the whole list in `defineConfig` — at most one per type.
856
928
  default: optionalBoolean(pack.default, `${where}.default`, false),
@@ -938,10 +1010,7 @@ function normalizeStats(value, derived) {
938
1010
  // the package-wide answer for everything that has no pack in hand.
939
1011
  systemId: derived.systemId,
940
1012
  systemVersion: derived.systemVersion,
941
- lastModifiedBy: requireNonEmptyString(
942
- input.lastModifiedBy,
943
- "stats.lastModifiedBy",
944
- ),
1013
+ lastModifiedBy: requireNonEmptyString(input.lastModifiedBy, "stats.lastModifiedBy"),
945
1014
  });
946
1015
  }
947
1016
 
@@ -1056,10 +1125,7 @@ function normalizeSectionMeta(value, where) {
1056
1125
  out.banner = requireNonEmptyString(input.banner, `${where}.banner`);
1057
1126
  }
1058
1127
  if (input.description !== undefined) {
1059
- out.description = requireNonEmptyString(
1060
- input.description,
1061
- `${where}.description`,
1062
- );
1128
+ out.description = requireNonEmptyString(input.description, `${where}.description`);
1063
1129
  }
1064
1130
  return Object.freeze(out);
1065
1131
  }
@@ -1075,9 +1141,7 @@ function normalizeSectionMap(value, where) {
1075
1141
  if (value === undefined) return Object.freeze({});
1076
1142
  if (!isPlainObject(value)) fail(where, "must be a mapping");
1077
1143
  const out = {};
1078
- for (const [name, meta] of Object.entries(
1079
- /** @type {Record<string, unknown>} */ (value),
1080
- )) {
1144
+ for (const [name, meta] of Object.entries(/** @type {Record<string, unknown>} */ (value))) {
1081
1145
  out[name] = normalizeSectionMeta(meta, `${where}.${name}`);
1082
1146
  }
1083
1147
  return Object.freeze(out);
@@ -1124,10 +1188,7 @@ function normalizeSite(value) {
1124
1188
  trees.push(
1125
1189
  Object.freeze({
1126
1190
  from: requireNonEmptyString(tree.from, `${where}.from`),
1127
- section: requireNonEmptyString(
1128
- tree.section,
1129
- `${where}.section`,
1130
- ),
1191
+ section: requireNonEmptyString(tree.section, `${where}.section`),
1131
1192
  // The tree's own path, POSIX-separated — what a
1132
1193
  // repository-relative link inside it is resolved against.
1133
1194
  rel: String(tree.from).split(path.sep).join("/"),
@@ -1141,9 +1202,7 @@ function normalizeSite(value) {
1141
1202
  if (!Array.isArray(input.packages)) {
1142
1203
  fail("site.packages", "must be a list");
1143
1204
  }
1144
- packages = input.packages.map((p, i) =>
1145
- requireNonEmptyString(p, `site.packages[${i}]`),
1146
- );
1205
+ packages = input.packages.map((p, i) => requireNonEmptyString(p, `site.packages[${i}]`));
1147
1206
  }
1148
1207
 
1149
1208
  let landing = null;
@@ -1157,35 +1216,19 @@ function normalizeSite(value) {
1157
1216
  }
1158
1217
 
1159
1218
  return Object.freeze({
1160
- out:
1161
- input.out === undefined ?
1162
- ""
1163
- : requireNonEmptyString(input.out, "site.out"),
1164
- base:
1165
- input.base === undefined ?
1166
- ""
1167
- : requireNonEmptyString(input.base, "site.base"),
1219
+ out: input.out === undefined ? "" : requireNonEmptyString(input.out, "site.out"),
1220
+ base: input.base === undefined ? "" : requireNonEmptyString(input.base, "site.base"),
1168
1221
  packages: Object.freeze(packages),
1169
1222
  sections: normalizeSectionMap(input.sections, "site.sections"),
1170
- readmeSections: normalizeSectionMap(
1171
- input.readmeSections,
1172
- "site.readmeSections",
1173
- ),
1223
+ readmeSections: normalizeSectionMap(input.readmeSections, "site.readmeSections"),
1174
1224
  landing,
1175
1225
  trees: Object.freeze(trees),
1176
- pass:
1177
- input.pass === undefined ?
1178
- ""
1179
- : requireNonEmptyString(input.pass, "site.pass"),
1226
+ pass: input.pass === undefined ? "" : requireNonEmptyString(input.pass, "site.pass"),
1180
1227
  passOptions:
1181
1228
  input.passOptions === undefined ?
1182
1229
  Object.freeze({})
1183
1230
  : Object.freeze({ ...input.passOptions }),
1184
- backfillSections: optionalBoolean(
1185
- input.backfillSections,
1186
- "site.backfillSections",
1187
- false,
1188
- ),
1231
+ backfillSections: optionalBoolean(input.backfillSections, "site.backfillSections", false),
1189
1232
  });
1190
1233
  }
1191
1234
 
@@ -1213,10 +1256,7 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1213
1256
  out.minimum = requireNonEmptyString(input.minimum, `${where}.minimum`);
1214
1257
  }
1215
1258
  if (input.verified !== undefined) {
1216
- out.verified = requireNonEmptyString(
1217
- input.verified,
1218
- `${where}.verified`,
1219
- );
1259
+ out.verified = requireNonEmptyString(input.verified, `${where}.verified`);
1220
1260
  }
1221
1261
  return Object.freeze(out);
1222
1262
  }
@@ -1255,8 +1295,7 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1255
1295
  */
1256
1296
  function normalizeSystems(value) {
1257
1297
  if (value === undefined || value === null) return Object.freeze({});
1258
- if (!isPlainObject(value))
1259
- fail("systems", "must be a mapping of id to spec");
1298
+ if (!isPlainObject(value)) fail("systems", "must be a mapping of id to spec");
1260
1299
  const input = /** @type {Record<string, unknown>} */ (value);
1261
1300
 
1262
1301
  const out = {};
@@ -1276,10 +1315,7 @@ function normalizeSystems(value) {
1276
1315
  // `verified` is required because it is the value a pack stamps. A
1277
1316
  // declaration that cannot answer "which version was this built
1278
1317
  // against" is the gap this block exists to close.
1279
- const verified = requireNonEmptyString(
1280
- compat.verified,
1281
- `${at}.compatibility.verified`,
1282
- );
1318
+ const verified = requireNonEmptyString(compat.verified, `${at}.compatibility.verified`);
1283
1319
 
1284
1320
  out[id] = Object.freeze({
1285
1321
  manifest:
@@ -1290,10 +1326,7 @@ function normalizeSystems(value) {
1290
1326
  minimum:
1291
1327
  compat.minimum === undefined || compat.minimum === null ?
1292
1328
  null
1293
- : requireNonEmptyString(
1294
- compat.minimum,
1295
- `${at}.compatibility.minimum`,
1296
- ),
1329
+ : requireNonEmptyString(compat.minimum, `${at}.compatibility.minimum`),
1297
1330
  verified,
1298
1331
  }),
1299
1332
  });
@@ -1346,10 +1379,7 @@ function normalizeRelationships(value) {
1346
1379
  };
1347
1380
  for (const key of ["type", "manifest"]) {
1348
1381
  if (rel[key] !== undefined) {
1349
- spec[key] = requireNonEmptyString(
1350
- rel[key],
1351
- `${at}.${key}`,
1352
- );
1382
+ spec[key] = requireNonEmptyString(rel[key], `${at}.${key}`);
1353
1383
  }
1354
1384
  }
1355
1385
  const compat = normalizeCompatibility(
@@ -1367,10 +1397,7 @@ function normalizeRelationships(value) {
1367
1397
  fail(`${at}.itemCatalog`, "must be true or false");
1368
1398
  }
1369
1399
  if (rel.itemCatalog && spec.manifest === undefined) {
1370
- fail(
1371
- `${at}.itemCatalog`,
1372
- "needs a `manifest` naming the package to fetch",
1373
- );
1400
+ fail(`${at}.itemCatalog`, "needs a `manifest` naming the package to fetch");
1374
1401
  }
1375
1402
  spec.itemCatalog = rel.itemCatalog;
1376
1403
  }
@@ -1397,9 +1424,7 @@ function normalizePackageBuild(value) {
1397
1424
  "reads, and that package validates what is inside it",
1398
1425
  );
1399
1426
  }
1400
- return /** @type {Readonly<PackageBuildSection>} */ (
1401
- deepFreeze(structuredClone(value))
1402
- );
1427
+ return /** @type {Readonly<PackageBuildSection>} */ (deepFreeze(structuredClone(value)));
1403
1428
  }
1404
1429
 
1405
1430
  /**
@@ -1427,19 +1452,18 @@ function normalizePackageBuild(value) {
1427
1452
  * Art now travels with the builder it belongs to, which is the one place a type
1428
1453
  * is already declared.
1429
1454
  *
1430
- * @param {unknown} value
1431
- * @returns {{itemBuilders: Readonly<Record<string, Function>>,
1432
- * itemArt: Readonly<Record<string, string>>}}
1455
+ * @param {unknown} value - One registry: type → entry.
1456
+ * @param {string} at - The configuration path to report against.
1457
+ * @returns {{itemBuilders: Record<string, Function>,
1458
+ * itemArt: Record<string, string>,
1459
+ * itemFields: Record<string, readonly object[]>}}
1433
1460
  * The `system` builder for each type, and the default art for those types
1434
1461
  * that paired one. The art table is deliberately *sparse*: a bare-function
1435
1462
  * entry contributes no key, which is what distinguishes "no default art" from
1436
1463
  * an empty one.
1437
1464
  */
1438
- function normalizeItemBuilders(value) {
1439
- if (value === undefined) {
1440
- return { itemBuilders: Object.freeze({}), itemArt: Object.freeze({}) };
1441
- }
1442
- if (!isPlainObject(value)) fail("itemBuilders", "must be an object");
1465
+ function normalizeOneRegistry(value, at) {
1466
+ if (!isPlainObject(value)) fail(at, "must be an object");
1443
1467
  const input = /** @type {Record<string, unknown>} */ (value);
1444
1468
 
1445
1469
  /** @type {Record<string, Function>} */
@@ -1456,46 +1480,167 @@ function normalizeItemBuilders(value) {
1456
1480
  }
1457
1481
  if (!isPlainObject(entry)) {
1458
1482
  fail(
1459
- `itemBuilders.${type}`,
1483
+ `${at}.${type}`,
1460
1484
  "must be a builder function, or an object with a `system` builder",
1461
1485
  );
1462
1486
  }
1463
1487
  const paired = /** @type {Record<string, unknown>} */ (entry);
1464
- rejectUnknownKeys(paired, ITEM_BUILDER_KEYS, `itemBuilders.${type}.`);
1488
+ rejectUnknownKeys(paired, ITEM_BUILDER_KEYS, `${at}.${type}.`);
1465
1489
  if (typeof paired.system !== "function") {
1466
- fail(`itemBuilders.${type}.system`, "must be a function");
1490
+ fail(`${at}.${type}.system`, "must be a function");
1467
1491
  }
1468
1492
  itemBuilders[type] = /** @type {Function} */ (paired.system);
1469
1493
  if (paired.img !== undefined) {
1470
- itemArt[type] = requireNonEmptyString(
1471
- paired.img,
1472
- `itemBuilders.${type}.img`,
1473
- );
1494
+ itemArt[type] = requireNonEmptyString(paired.img, `${at}.${type}.img`);
1474
1495
  }
1475
1496
  if (paired.fields !== undefined) {
1476
1497
  if (!Array.isArray(paired.fields)) {
1477
- fail(`itemBuilders.${type}.fields`, "must be an array");
1498
+ fail(`${at}.${type}.fields`, "must be an array");
1478
1499
  }
1479
1500
  for (const [index, field] of paired.fields.entries()) {
1480
1501
  if (!isPlainObject(field)) {
1481
- fail(
1482
- `itemBuilders.${type}.fields[${index}]`,
1483
- "must be a field declaration object",
1484
- );
1502
+ fail(`${at}.${type}.fields[${index}]`, "must be a field declaration object");
1485
1503
  }
1486
1504
  requireNonEmptyString(
1487
1505
  /** @type {Record<string, unknown>} */ (field).to,
1488
- `itemBuilders.${type}.fields[${index}].to`,
1506
+ `${at}.${type}.fields[${index}].to`,
1489
1507
  );
1490
1508
  }
1491
1509
  itemFields[type] = Object.freeze([...paired.fields]);
1492
1510
  }
1493
1511
  }
1494
1512
 
1513
+ return { itemBuilders, itemArt, itemFields };
1514
+ }
1515
+
1516
+ /**
1517
+ * The declared item-builder registries, and the vocabulary their union gives
1518
+ * (#58).
1519
+ *
1520
+ * **One registry is a ceiling, not a default.** The accepted type list is the
1521
+ * registry's keys, which is what makes a type impossible to accept without a
1522
+ * builder behind it (#1504) — and, with one registry, impossible to accept a
1523
+ * type a *second* system declares. A tree feeding two systems has both:
1524
+ * `spell`, `invocation` and `psionic` are HM3's, `mysticalability` and
1525
+ * `projectilegear` are SoHL's, and `skill` is both systems' under one name and
1526
+ * two data models.
1527
+ *
1528
+ * So `itemBuilders` accepts either form:
1529
+ *
1530
+ * - **A registry** — `{ skill: fn, … }`. Unchanged, and what every existing
1531
+ * configuration declares. It names no system, because there is only one.
1532
+ * - **A list of registries** — `[{ system: "sohl", builders: {…} }, …]`. The
1533
+ * vocabulary is the **union** of their keys; a type more than one declares
1534
+ * keeps a builder per system, so nothing is chosen for the build silently.
1535
+ *
1536
+ * The **flat** tables — `itemBuilders`, `itemArt`, `itemFields` — are the union
1537
+ * with the first declaring registry winning a collision. They answer a
1538
+ * single-system build, where a collision cannot arise; a build with two systems
1539
+ * asks by system, and `itemTypesBySeveralSystems` names the types where asking
1540
+ * flatly would be answering the wrong question. See `engine/item-registry.mjs`,
1541
+ * which refuses exactly those without a system.
1542
+ *
1543
+ * @param {unknown} value - The declared `itemBuilders`.
1544
+ * @returns {{itemBuilders: Readonly<Record<string, Function>>,
1545
+ * itemArt: Readonly<Record<string, string>>,
1546
+ * itemFields: Readonly<Record<string, readonly object[]>>,
1547
+ * itemBuildersBySystem: Readonly<Record<string, Readonly<Record<string, Function>>>>,
1548
+ * itemArtBySystem: Readonly<Record<string, Readonly<Record<string, string>>>>,
1549
+ * itemFieldsBySystem: Readonly<Record<string, Readonly<Record<string, readonly object[]>>>>,
1550
+ * itemTypesBySeveralSystems: ReadonlySet<string>}}
1551
+ * The flat tables, the per-system ones, and the contested types.
1552
+ */
1553
+ function normalizeItemBuilders(value) {
1554
+ const empty = Object.freeze({});
1555
+ if (value === undefined) {
1556
+ return {
1557
+ itemBuilders: empty,
1558
+ itemArt: empty,
1559
+ itemFields: empty,
1560
+ itemBuildersBySystem: empty,
1561
+ itemArtBySystem: empty,
1562
+ itemFieldsBySystem: empty,
1563
+ itemTypesBySeveralSystems: Object.freeze(new Set()),
1564
+ };
1565
+ }
1566
+
1567
+ /** @type {{system: string|null, tables: ReturnType<typeof normalizeOneRegistry>}[]} */
1568
+ const registries = [];
1569
+
1570
+ if (Array.isArray(value)) {
1571
+ const seen = new Set();
1572
+ for (const [index, entry] of value.entries()) {
1573
+ const at = `itemBuilders[${index}]`;
1574
+ if (!isPlainObject(entry)) {
1575
+ fail(
1576
+ at,
1577
+ "must be `{ system, builders }` — a registry and the system it belongs to",
1578
+ );
1579
+ }
1580
+ const declared = /** @type {Record<string, unknown>} */ (entry);
1581
+ rejectUnknownKeys(declared, ITEM_REGISTRY_KEYS, `${at}.`);
1582
+ const system = requireNonEmptyString(declared.system, `${at}.system`);
1583
+ if (seen.has(system)) {
1584
+ fail(
1585
+ at,
1586
+ `declares a second registry for \`${system}\` — a system has one ` +
1587
+ `item vocabulary, so merge them at their source`,
1588
+ );
1589
+ }
1590
+ seen.add(system);
1591
+ registries.push({
1592
+ system,
1593
+ tables: normalizeOneRegistry(declared.builders, `${at}.builders`),
1594
+ });
1595
+ }
1596
+ } else {
1597
+ registries.push({ system: null, tables: normalizeOneRegistry(value, "itemBuilders") });
1598
+ }
1599
+
1600
+ /** @type {Record<string, Function>} */
1601
+ const itemBuilders = {};
1602
+ /** @type {Record<string, string>} */
1603
+ const itemArt = {};
1604
+ /** @type {Record<string, readonly object[]>} */
1605
+ const itemFields = {};
1606
+ /** @type {Record<string, Readonly<Record<string, Function>>>} */
1607
+ const itemBuildersBySystem = {};
1608
+ /** @type {Record<string, Readonly<Record<string, string>>>} */
1609
+ const itemArtBySystem = {};
1610
+ /** @type {Record<string, Readonly<Record<string, readonly object[]>>>} */
1611
+ const itemFieldsBySystem = {};
1612
+ /** @type {Map<string, number>} */
1613
+ const declaringSystems = new Map();
1614
+
1615
+ for (const { system, tables } of registries) {
1616
+ for (const [type, builder] of Object.entries(tables.itemBuilders)) {
1617
+ declaringSystems.set(type, (declaringSystems.get(type) ?? 0) + 1);
1618
+ if (!(type in itemBuilders)) itemBuilders[type] = builder;
1619
+ }
1620
+ for (const [type, art] of Object.entries(tables.itemArt)) {
1621
+ if (!(type in itemArt)) itemArt[type] = art;
1622
+ }
1623
+ for (const [type, fields] of Object.entries(tables.itemFields)) {
1624
+ if (!(type in itemFields)) itemFields[type] = fields;
1625
+ }
1626
+ if (system === null) continue;
1627
+ itemBuildersBySystem[system] = Object.freeze(tables.itemBuilders);
1628
+ itemArtBySystem[system] = Object.freeze(tables.itemArt);
1629
+ itemFieldsBySystem[system] = Object.freeze(tables.itemFields);
1630
+ }
1631
+
1495
1632
  return {
1496
1633
  itemBuilders: Object.freeze(itemBuilders),
1497
1634
  itemArt: Object.freeze(itemArt),
1498
1635
  itemFields: Object.freeze(itemFields),
1636
+ itemBuildersBySystem: Object.freeze(itemBuildersBySystem),
1637
+ itemArtBySystem: Object.freeze(itemArtBySystem),
1638
+ itemFieldsBySystem: Object.freeze(itemFieldsBySystem),
1639
+ itemTypesBySeveralSystems: Object.freeze(
1640
+ new Set(
1641
+ [...declaringSystems.entries()].filter(([, count]) => count > 1).map(([t]) => t),
1642
+ ),
1643
+ ),
1499
1644
  };
1500
1645
  }
1501
1646
 
@@ -1556,9 +1701,7 @@ function normalizePublish(value) {
1556
1701
  if (manifestsInput !== undefined && !isPlainObject(manifestsInput)) {
1557
1702
  fail("publish.manifests", "must be an object");
1558
1703
  }
1559
- const manifests = /** @type {Record<string, unknown>} */ (
1560
- manifestsInput ?? {}
1561
- );
1704
+ const manifests = /** @type {Record<string, unknown>} */ (manifestsInput ?? {});
1562
1705
  rejectUnknownKeys(manifests, MANIFEST_KEYS, "publish.manifests.");
1563
1706
 
1564
1707
  const addressInput = publish.address;
@@ -1589,26 +1732,15 @@ function normalizePublish(value) {
1589
1732
  DEFAULT_ADDRESS_SCHEME.landing
1590
1733
  : optionalString(address.landing, "publish.address.landing");
1591
1734
  if (!LANDING_RULES.includes(landing)) {
1592
- fail(
1593
- "publish.address.landing",
1594
- `must be one of ${LANDING_RULES.join(", ")}`,
1595
- );
1735
+ fail("publish.address.landing", `must be one of ${LANDING_RULES.join(", ")}`);
1596
1736
  }
1597
1737
 
1598
1738
  return Object.freeze({
1599
1739
  site: normalizeSiteMode(publish.site),
1600
1740
  address: Object.freeze({ prefix, landing }),
1601
1741
  manifests: Object.freeze({
1602
- publish: optionalBoolean(
1603
- manifests.publish,
1604
- "publish.manifests.publish",
1605
- false,
1606
- ),
1607
- consume: optionalBoolean(
1608
- manifests.consume,
1609
- "publish.manifests.consume",
1610
- false,
1611
- ),
1742
+ publish: optionalBoolean(manifests.publish, "publish.manifests.publish", false),
1743
+ consume: optionalBoolean(manifests.consume, "publish.manifests.consume", false),
1612
1744
  }),
1613
1745
  });
1614
1746
  }
@@ -1629,13 +1761,9 @@ function normalizePublish(value) {
1629
1761
  */
1630
1762
  export function defineConfig(config) {
1631
1763
  if (!isPlainObject(config)) {
1632
- throw new TypeError(
1633
- "package-build config: expected a configuration object.",
1634
- );
1764
+ throw new TypeError("package-build config: expected a configuration object.");
1635
1765
  }
1636
- const input = /** @type {Record<string, unknown>} */ (
1637
- /** @type {unknown} */ (config)
1638
- );
1766
+ const input = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (config));
1639
1767
  rejectUnknownKeys(input, CONFIG_KEYS, "");
1640
1768
 
1641
1769
  const rootDir = requireNonEmptyString(input.rootDir, "rootDir");
@@ -1650,21 +1778,14 @@ export function defineConfig(config) {
1650
1778
  const packageKind = input.packageKind;
1651
1779
  if (
1652
1780
  typeof packageKind !== "string" ||
1653
- !(
1654
- /** @type {readonly string[]} */ (PACKAGE_KINDS).includes(
1655
- packageKind,
1656
- )
1657
- )
1781
+ !(/** @type {readonly string[]} */ (PACKAGE_KINDS).includes(packageKind))
1658
1782
  ) {
1659
1783
  fail("packageKind", `must be one of: ${PACKAGE_KINDS.join(", ")}`);
1660
1784
  }
1661
1785
 
1662
1786
  if (!Array.isArray(input.packs)) fail("packs", "must be an array");
1663
- if (input.packs.length === 0)
1664
- fail("packs", "must declare at least one pack");
1665
- const packs = input.packs.map((pack, index) =>
1666
- normalizePack(pack, `packs[${index}]`),
1667
- );
1787
+ if (input.packs.length === 0) fail("packs", "must declare at least one pack");
1788
+ const packs = input.packs.map((pack, index) => normalizePack(pack, `packs[${index}]`));
1668
1789
 
1669
1790
  // One list, so the compile order and the directory list cannot disagree —
1670
1791
  // they used to be `PACK_CONFIGS` and `SOURCE_PACKS`, maintained apart (#1508).
@@ -1686,9 +1807,7 @@ export function defineConfig(config) {
1686
1807
  const declaredSystems = new Set(Object.keys(systems));
1687
1808
  /** `relationships.systems`, for the derivations that still consult it. */
1688
1809
  const relationshipSystems = /** @type {{id?: string}[]} */ (
1689
- (isPlainObject(input.relationships) ?
1690
- input.relationships.systems
1691
- : null) ?? []
1810
+ (isPlainObject(input.relationships) ? input.relationships.systems : null) ?? []
1692
1811
  );
1693
1812
 
1694
1813
  // A name that resolves to nothing is a build error rather than a
@@ -1747,32 +1866,33 @@ export function defineConfig(config) {
1747
1866
  defaultsByType.set(pack.type, pack.name);
1748
1867
  }
1749
1868
 
1750
- if (
1751
- input.skipDirectories !== undefined &&
1752
- !Array.isArray(input.skipDirectories)
1753
- ) {
1869
+ if (input.skipDirectories !== undefined && !Array.isArray(input.skipDirectories)) {
1754
1870
  fail("skipDirectories", "must be an array");
1755
1871
  }
1756
1872
  const skipDirectories = (input.skipDirectories ?? []).map((name, index) =>
1757
1873
  requireNonEmptyString(name, `skipDirectories[${index}]`),
1758
1874
  );
1759
1875
 
1760
- const foundryPackage = requireNonEmptyString(
1761
- input.foundryPackage,
1762
- "foundryPackage",
1763
- );
1876
+ const foundryPackage = requireNonEmptyString(input.foundryPackage, "foundryPackage");
1764
1877
 
1765
- const { itemBuilders, itemArt, itemFields } = normalizeItemBuilders(
1766
- input.itemBuilders,
1767
- );
1878
+ const {
1879
+ itemBuilders,
1880
+ itemArt,
1881
+ itemFields,
1882
+ itemBuildersBySystem,
1883
+ itemArtBySystem,
1884
+ itemFieldsBySystem,
1885
+ itemTypesBySeveralSystems,
1886
+ } = normalizeItemBuilders(input.itemBuilders);
1887
+ // The union across every declared registry (#58) — the flat table already
1888
+ // holds every key any of them declares, so this stays "the registry's keys"
1889
+ // rather than becoming a second list to keep in step (#1504).
1768
1890
  const itemTypes = Object.freeze(new Set(Object.keys(itemBuilders)));
1891
+ const docEntryTypes = Object.freeze(new Set([...itemTypes, "macro", ...MAP_TYPES]));
1769
1892
 
1770
1893
  return Object.freeze({
1771
1894
  rootDir,
1772
- contentPackage: requireNonEmptyString(
1773
- input.contentPackage,
1774
- "contentPackage",
1775
- ),
1895
+ contentPackage: requireContentPackage(input.contentPackage, docEntryTypes),
1776
1896
  foundryPackage,
1777
1897
  packageKind: /** @type {PackageKind} */ (packageKind),
1778
1898
  // Foundry serves a package's files from `<kind>/<id>/`, so this is the
@@ -1798,9 +1918,8 @@ export function defineConfig(config) {
1798
1918
  // which is the duplication this whole change exists to
1799
1919
  // remove. Several entries have no single answer and get
1800
1920
  // none.
1801
- : relationshipSystems.length === 1 ?
1802
- (relationshipSystems[0]?.id ?? null)
1803
- : null,
1921
+ : relationshipSystems.length === 1 ? (relationshipSystems[0]?.id ?? null)
1922
+ : null,
1804
1923
  // Derived here where the answer is pure data — the `verified` of
1805
1924
  // whichever system the package-wide block takes — and supplied by
1806
1925
  // the loader otherwise. The loader is the half that may do I/O, and
@@ -1811,21 +1930,19 @@ export function defineConfig(config) {
1811
1930
  (() => {
1812
1931
  const id =
1813
1932
  requiresSystem ??
1814
- (Object.keys(systems).length === 1 ?
1815
- Object.keys(systems)[0]
1816
- : null);
1817
- return id ?
1818
- (systems[id]?.compatibility?.verified ?? null)
1819
- : null;
1933
+ (Object.keys(systems).length === 1 ? Object.keys(systems)[0] : null);
1934
+ return id ? (systems[id]?.compatibility?.verified ?? null) : null;
1820
1935
  })() ??
1821
- (isPlainObject(input.stats) ?
1822
- input.stats[DERIVED_SYSTEM_VERSION]
1823
- : null) ??
1936
+ (isPlainObject(input.stats) ? input.stats[DERIVED_SYSTEM_VERSION] : null) ??
1824
1937
  null,
1825
1938
  }),
1826
1939
  itemBuilders,
1827
1940
  itemArt,
1828
1941
  itemFields,
1942
+ itemBuildersBySystem,
1943
+ itemArtBySystem,
1944
+ itemFieldsBySystem,
1945
+ itemTypesBySeveralSystems,
1829
1946
  // Resolved once, here, and read everywhere through
1830
1947
  // `loadPackConfig()`. The doc-entry *concept* is the engine's —
1831
1948
  // a note that carries documentation is not a SoHL idea — but the
@@ -1833,18 +1950,13 @@ export function defineConfig(config) {
1833
1950
  // runtime. Two would drift, which is the whole reason the composition
1834
1951
  // was written down in one place to begin with.
1835
1952
  itemTypes,
1836
- docEntryTypes: Object.freeze(
1837
- new Set([...itemTypes, "macro", ...MAP_TYPES]),
1838
- ),
1953
+ docEntryTypes,
1839
1954
  skipDirectories: Object.freeze(skipDirectories),
1840
1955
  packs: Object.freeze(packs),
1841
1956
  packDirectories: Object.freeze(packDirectories),
1842
1957
  docs: normalizeDocs(input.docs),
1843
1958
  site: normalizeSite(input.site),
1844
- compatibility: normalizeCompatibility(
1845
- input.compatibility,
1846
- "compatibility",
1847
- ),
1959
+ compatibility: normalizeCompatibility(input.compatibility, "compatibility"),
1848
1960
  relationships: normalizeRelationships(input.relationships),
1849
1961
  systems,
1850
1962
  requiresSystem,