@heroiclands/package-build 6.0.0 → 7.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 (43) hide show
  1. package/CHANGELOG.md +798 -0
  2. package/CONTENT.md +228 -4
  3. package/bin/content-build.mjs +196 -10
  4. package/bin/package-build.mjs +8 -1
  5. package/config.mjs +25 -3
  6. package/content-config.mjs +283 -29
  7. package/engine/address-diff.mjs +290 -0
  8. package/engine/base-compiler.mjs +25 -0
  9. package/engine/content-links.mjs +132 -27
  10. package/engine/content-lint.mjs +23 -2
  11. package/engine/diagnostics.mjs +61 -1
  12. package/engine/frontmatter-lint.mjs +22 -0
  13. package/engine/generate.mjs +10 -5
  14. package/engine/helpers.mjs +38 -0
  15. package/engine/homepage.mjs +206 -2
  16. package/engine/journals.mjs +8 -1
  17. package/engine/macros.mjs +2 -0
  18. package/engine/pack-config.mjs +143 -13
  19. package/engine/prose-lint.mjs +10 -2
  20. package/engine/scenes.mjs +2 -2
  21. package/engine/site-build.mjs +74 -19
  22. package/engine/web-wikilinks.mjs +13 -4
  23. package/engine/wikilink-syntax.mjs +25 -0
  24. package/engine/wikilinks.mjs +6 -3
  25. package/manifest.mjs +37 -2
  26. package/package.json +5 -3
  27. package/sohl/actors.mjs +23 -10
  28. package/sohl/items.mjs +1 -1
  29. package/types/content-config.d.mts +14 -0
  30. package/types/engine/address-diff.d.mts +108 -0
  31. package/types/engine/base-compiler.d.mts +18 -1
  32. package/types/engine/content-links.d.mts +11 -3
  33. package/types/engine/content-lint.d.mts +4 -1
  34. package/types/engine/diagnostics.d.mts +33 -1
  35. package/types/engine/generate.d.mts +3 -2
  36. package/types/engine/helpers.d.mts +29 -3
  37. package/types/engine/homepage.d.mts +117 -0
  38. package/types/engine/journals.d.mts +7 -1
  39. package/types/engine/pack-config.d.mts +22 -0
  40. package/types/engine/prose-lint.d.mts +10 -2
  41. package/types/engine/site-build.d.mts +28 -3
  42. package/types/engine/wikilink-syntax.d.mts +24 -0
  43. package/types/sohl/actors.d.mts +3 -3
package/config.mjs CHANGED
@@ -59,7 +59,11 @@
59
59
  */
60
60
 
61
61
  import path from "node:path";
62
- import { loadPackConfig } from "./engine/pack-config.mjs";
62
+ import {
63
+ loadPackConfig,
64
+ locateConfigError,
65
+ packConfigPath,
66
+ } from "./engine/pack-config.mjs";
63
67
 
64
68
  /** Keys the reserved section may declare. */
65
69
  const SECTION_KEYS = [
@@ -145,12 +149,21 @@ const ARTIFACT_OF_KIND = Object.freeze({
145
149
  });
146
150
 
147
151
  /**
152
+ * Reject a configured value, naming the key it was written under.
153
+ *
154
+ * The dotted path rides on the error as `field` as well as appearing in the
155
+ * message, so {@link loadPackageBuildConfig} — the half that knows which file
156
+ * was read — can resolve it to a line and column (#95). This half stays pure.
157
+ *
148
158
  * @param {string} where - Dotted path of the offending key.
149
159
  * @param {string} problem - What is wrong with it.
150
160
  * @returns {never}
151
161
  */
152
162
  function fail(where, problem) {
153
- throw new TypeError(`package-build config: \`${where}\` ${problem}.`);
163
+ throw Object.assign(
164
+ new TypeError(`package-build config: \`${where}\` ${problem}.`),
165
+ { field: where },
166
+ );
154
167
  }
155
168
 
156
169
  /**
@@ -842,5 +855,14 @@ export function resolvePackageBuildConfig(shared) {
842
855
  * declares something malformed.
843
856
  */
844
857
  export function loadPackageBuildConfig() {
845
- return resolvePackageBuildConfig(loadPackConfig());
858
+ const shared = loadPackConfig();
859
+ try {
860
+ return resolvePackageBuildConfig(shared);
861
+ } catch (err) {
862
+ // The pure half names the offending key and nothing else; this half
863
+ // knows the file it was read from, so the position is attached here
864
+ // (#95) — the same boundary `configFromData` is for the rest of the
865
+ // configuration.
866
+ throw locateConfigError(err, packConfigPath());
867
+ }
846
868
  }
@@ -584,9 +584,12 @@ const CONFIG_KEYS = [
584
584
  "site",
585
585
  "compatibility",
586
586
  "relationships",
587
+ "systems",
588
+ "requiresSystem",
587
589
  "packageBuild",
588
590
  "publish",
589
591
  ];
592
+ const SYSTEM_KEYS = ["manifest", "compatibility"];
590
593
  const COMPATIBILITY_KEYS = ["minimum", "verified"];
591
594
  const DOCS_KEYS = ["itemFields"];
592
595
  const SITE_KEYS = [
@@ -602,7 +605,7 @@ const SITE_KEYS = [
602
605
  "backfillSections",
603
606
  ];
604
607
  const SITE_TREE_KEYS = ["from", "section"];
605
- const SECTION_META_KEYS = ["title", "banner"];
608
+ const SECTION_META_KEYS = ["title", "banner", "description"];
606
609
  const DOC_PAGE_KEYS = ["title", "out", "preamble"];
607
610
  const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
608
611
  const RELATIONSHIP_KEYS = [
@@ -626,7 +629,24 @@ const PACK_KEYS = [
626
629
  "system",
627
630
  ];
628
631
  const PATH_KEYS = Object.keys(DEFAULT_PATHS);
629
- const STATS_KEYS = ["systemId", "systemVersion", "lastModifiedBy"];
632
+ const STATS_KEYS = ["lastModifiedBy"];
633
+
634
+ /**
635
+ * How the loader hands {@link defineConfig} the system version it resolved.
636
+ *
637
+ * A **Symbol**, deliberately. `stats.systemVersion` is refused from an authored
638
+ * configuration (#48), but the value still has to reach here from the loader —
639
+ * which is the half that may do I/O, and which reads a system package's version
640
+ * out of the adjacent `package.json`. A string key would be a second spelling of
641
+ * the refused one, forgeable from YAML and reachable by `rejectUnknownKeys`; a
642
+ * symbol key cannot be written in YAML at all and does not appear in
643
+ * `Object.keys`, so the refusal has no back door.
644
+ *
645
+ * @type {symbol}
646
+ */
647
+ export const DERIVED_SYSTEM_VERSION = Symbol.for(
648
+ "package-build.derivedSystemVersion",
649
+ );
630
650
  const PUBLISH_KEYS = ["site", "manifests", "address"];
631
651
  const MANIFEST_KEYS = ["publish", "consume"];
632
652
  const ADDRESS_KEYS = ["prefix", "landing"];
@@ -637,12 +657,25 @@ function isPlainObject(value) {
637
657
  }
638
658
 
639
659
  /**
640
- * @param {string} field
641
- * @param {string} problem
660
+ * Reject a configured value, naming the key it was written under.
661
+ *
662
+ * The dotted path is carried on the error as `field` as well as spelled into
663
+ * the message, because the message alone is a good description and a bad
664
+ * locator: the loader that read the file can resolve that path to a line and
665
+ * column, and does (`locateConfigError` in `engine/pack-config.mjs`, #95).
666
+ * Attaching it here rather than formatting here is what keeps this module
667
+ * free of I/O — it is the leaf an `.mjs` configuration imports, so it may not
668
+ * reach for the file it is validating.
669
+ *
670
+ * @param {string} field - Dotted path of the offending key.
671
+ * @param {string} problem - What is wrong with it.
642
672
  * @returns {never}
643
673
  */
644
674
  function fail(field, problem) {
645
- throw new TypeError(`package-build config: \`${field}\` ${problem}.`);
675
+ throw Object.assign(
676
+ new TypeError(`package-build config: \`${field}\` ${problem}.`),
677
+ { field },
678
+ );
646
679
  }
647
680
 
648
681
  /**
@@ -869,28 +902,42 @@ function normalizePaths(value, rootDir) {
869
902
  * @param {unknown} value
870
903
  * @returns {Readonly<StatsSpec>}
871
904
  */
872
- function normalizeStats(value) {
905
+ function normalizeStats(value, derived) {
873
906
  if (!isPlainObject(value)) fail("stats", "must be an object");
874
907
  const input = /** @type {Record<string, unknown>} */ (value);
908
+
909
+ // **`systemId` and `systemVersion` are derived, and authoring a derived
910
+ // value is an error rather than an override (#48).** `systems:` is the
911
+ // single source: it says which systems this package stamps against, and
912
+ // `requiresSystem` — or a lone declared system — says which one the
913
+ // package-wide block takes. A system package answers for itself.
914
+ //
915
+ // Refused rather than ignored, because the two would silently disagree.
916
+ // That is exactly how `stats.systemVersion` came to sit at `0.6.0` for four
917
+ // releases: a transcribed copy is free to drift from what it copied, and
918
+ // nothing reads a stamped `_stats` until something migrates on it.
919
+ for (const key of ["systemId", "systemVersion"]) {
920
+ if (input[key] === undefined) continue;
921
+ fail(
922
+ `stats.${key}`,
923
+ `is derived and may not be authored. ` +
924
+ (key === "systemId" ?
925
+ `A system package is its own system; a module takes it ` +
926
+ `from \`requiresSystem\`, or from \`systems:\` when it ` +
927
+ `declares exactly one. `
928
+ : `It is the \`compatibility.verified\` of the system in ` +
929
+ `\`systems:\`, or a system package's own \`package.json\` ` +
930
+ `version. `) +
931
+ `Remove the key`,
932
+ );
933
+ }
875
934
  rejectUnknownKeys(input, STATS_KEYS, "stats.");
876
935
 
877
936
  return Object.freeze({
878
- // Optional: a package whose packs are not all for one system declares
879
- // the system per pack instead (`packs[].system`), and a package that
880
- // ships only system-agnostic documents declares none at all.
881
- systemId:
882
- input.systemId === undefined || input.systemId === null ?
883
- null
884
- : requireNonEmptyString(input.systemId, "stats.systemId"),
885
- // Optional for the same reason as `systemId`: a system-agnostic module
886
- // is not built against a system, so it has no version of one to stamp.
887
- systemVersion:
888
- input.systemVersion === undefined || input.systemVersion === null ?
889
- null
890
- : requireNonEmptyString(
891
- input.systemVersion,
892
- "stats.systemVersion",
893
- ),
937
+ // Per pack where the packs differ see `statsForPack` and this is
938
+ // the package-wide answer for everything that has no pack in hand.
939
+ systemId: derived.systemId,
940
+ systemVersion: derived.systemVersion,
894
941
  lastModifiedBy: requireNonEmptyString(
895
942
  input.lastModifiedBy,
896
943
  "stats.lastModifiedBy",
@@ -973,16 +1020,32 @@ function normalizeDocs(value) {
973
1020
  }
974
1021
 
975
1022
  /**
976
- * One section's landing metadata — the title and hero a generated `_index.md`
977
- * carries.
978
- *
979
- * `banner` is optional because the hero images are external assets and not
980
- * every section has one. It is left off entirely rather than written as
1023
+ * One section's landing metadata — what a section says about itself on the
1024
+ * `_index.md` this build generates for it.
1025
+ *
1026
+ * A generated landing is the *only* place a section can speak: a content
1027
+ * package has no authored `_index.md` for `weapongear` or `affliction`, so the
1028
+ * file the theme reads is the one the site build writes. This is therefore the
1029
+ * whole vocabulary, and it is deliberately a **closed** one.
1030
+ *
1031
+ * The alternative — passing whatever a section declared straight through, as
1032
+ * `site.landing` does — was weighed and refused. `landing` is written once, for
1033
+ * the mount, and its keys are one landing template's own; a section entry is
1034
+ * written fourteen to twenty times per build against a contract every package
1035
+ * and every section shares. Unbounded there, a mistyped `descrption:` publishes
1036
+ * into front matter, is read by nobody, and says nothing to anyone — which is
1037
+ * the failure #91 was filed about, moved one step downstream where no build can
1038
+ * see it. So the keys are named here, and the writers emit what this produced
1039
+ * rather than transcribing a second list of their own (#91).
1040
+ *
1041
+ * `banner` and `description` are optional — the hero images are external assets
1042
+ * and not every section has one, and a section may reasonably have nothing to
1043
+ * add to its title. Each is left off entirely rather than written as
981
1044
  * `undefined`, which is not a value YAML can carry.
982
1045
  *
983
1046
  * @param {unknown} value - The declared entry.
984
1047
  * @param {string} where - Dotted path, for the error.
985
- * @returns {Readonly<{title: string, banner?: string}>}
1048
+ * @returns {Readonly<{title: string, banner?: string, description?: string}>}
986
1049
  */
987
1050
  function normalizeSectionMeta(value, where) {
988
1051
  if (!isPlainObject(value)) fail(where, "must be a mapping");
@@ -992,6 +1055,12 @@ function normalizeSectionMeta(value, where) {
992
1055
  if (input.banner !== undefined) {
993
1056
  out.banner = requireNonEmptyString(input.banner, `${where}.banner`);
994
1057
  }
1058
+ if (input.description !== undefined) {
1059
+ out.description = requireNonEmptyString(
1060
+ input.description,
1061
+ `${where}.description`,
1062
+ );
1063
+ }
995
1064
  return Object.freeze(out);
996
1065
  }
997
1066
 
@@ -1162,6 +1231,98 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1162
1231
  * @param {unknown} value - The `relationships` block, or `undefined`.
1163
1232
  * @returns {Readonly<Relationships>} It, frozen; `{}` when absent.
1164
1233
  */
1234
+ /**
1235
+ * The systems this package can stamp content against — declaration only (#48).
1236
+ *
1237
+ * **Declaring is not requiring, and that separation is the whole point.** The
1238
+ * only place to state a system version used to be `relationships.systems`, and
1239
+ * that list is a *restriction*: Foundry's `supportsSystem` drops a module from
1240
+ * any world whose system it does not name. So a module shipping content for two
1241
+ * systems — `harn-ensemble` ships an HM3 pack, a SoHL pack and a system-neutral
1242
+ * journals pack — had to choose between naming its systems and remaining
1243
+ * loadable, and choosing the second meant stamping no system version at all on
1244
+ * content that certainly has one.
1245
+ *
1246
+ * Naming a system here restricts nothing. {@link normalizeRequiresSystem} is
1247
+ * what restricts, and it is separate and optional.
1248
+ *
1249
+ * Each entry carries the same `compatibility` shape a relationship does, and
1250
+ * `verified` is what a pack stamps: `_stats.systemVersion` records what the
1251
+ * content was *built against*, not the floor it tolerates.
1252
+ *
1253
+ * @param {unknown} value - The declared `systems:` mapping.
1254
+ * @returns {Readonly<Record<string, Readonly<object>>>} Frozen; `{}` when absent.
1255
+ */
1256
+ function normalizeSystems(value) {
1257
+ if (value === undefined || value === null) return Object.freeze({});
1258
+ if (!isPlainObject(value))
1259
+ fail("systems", "must be a mapping of id to spec");
1260
+ const input = /** @type {Record<string, unknown>} */ (value);
1261
+
1262
+ const out = {};
1263
+ for (const [id, entry] of Object.entries(input)) {
1264
+ const at = `systems.${id}`;
1265
+ if (!id) fail("systems", "declares an empty system id");
1266
+ if (!isPlainObject(entry)) fail(at, "must be a mapping");
1267
+ const spec = /** @type {Record<string, unknown>} */ (entry);
1268
+ rejectUnknownKeys(spec, SYSTEM_KEYS, `${at}.`);
1269
+
1270
+ const compatibility = spec.compatibility;
1271
+ if (!isPlainObject(compatibility)) {
1272
+ fail(`${at}.compatibility`, "must be a mapping");
1273
+ }
1274
+ const compat = /** @type {Record<string, unknown>} */ (compatibility);
1275
+ rejectUnknownKeys(compat, COMPATIBILITY_KEYS, `${at}.compatibility.`);
1276
+ // `verified` is required because it is the value a pack stamps. A
1277
+ // declaration that cannot answer "which version was this built
1278
+ // against" is the gap this block exists to close.
1279
+ const verified = requireNonEmptyString(
1280
+ compat.verified,
1281
+ `${at}.compatibility.verified`,
1282
+ );
1283
+
1284
+ out[id] = Object.freeze({
1285
+ manifest:
1286
+ spec.manifest === undefined || spec.manifest === null ?
1287
+ null
1288
+ : requireNonEmptyString(spec.manifest, `${at}.manifest`),
1289
+ compatibility: Object.freeze({
1290
+ minimum:
1291
+ compat.minimum === undefined || compat.minimum === null ?
1292
+ null
1293
+ : requireNonEmptyString(
1294
+ compat.minimum,
1295
+ `${at}.compatibility.minimum`,
1296
+ ),
1297
+ verified,
1298
+ }),
1299
+ });
1300
+ }
1301
+ return Object.freeze(out);
1302
+ }
1303
+
1304
+ /**
1305
+ * The one system this package refuses to load without, or `null` (#48).
1306
+ *
1307
+ * The gate half of the split. Naming a system here emits
1308
+ * `relationships.systems` for it, which is what Foundry's `supportsSystem`
1309
+ * reads — so the package becomes unavailable under any other system. Omitted,
1310
+ * no relationship is emitted and the package loads anywhere, each pack stamping
1311
+ * whatever its own `system:` names.
1312
+ *
1313
+ * It reuses the {@link normalizeSystems} entry rather than restating the
1314
+ * compatibility: `stats.systemVersion` froze at `0.6.0` for four releases
1315
+ * because a transcription was free to disagree with what it copied, and a
1316
+ * second transcription invites the same.
1317
+ *
1318
+ * @param {unknown} value - The declared `requiresSystem:`.
1319
+ * @returns {string|null} The system id, or `null`.
1320
+ */
1321
+ function normalizeRequiresSystem(value) {
1322
+ if (value === undefined || value === null) return null;
1323
+ return requireNonEmptyString(value, "requiresSystem");
1324
+ }
1325
+
1165
1326
  function normalizeRelationships(value) {
1166
1327
  if (value === undefined) return Object.freeze({});
1167
1328
  if (!isPlainObject(value)) fail("relationships", "must be a mapping");
@@ -1519,6 +1680,54 @@ export function defineConfig(config) {
1519
1680
  seen.add(name);
1520
1681
  }
1521
1682
 
1683
+ // ── systems: declaring, and requiring, are separate decisions (#48) ──────
1684
+ const systems = normalizeSystems(input.systems);
1685
+ const requiresSystem = normalizeRequiresSystem(input.requiresSystem);
1686
+ const declaredSystems = new Set(Object.keys(systems));
1687
+ /** `relationships.systems`, for the derivations that still consult it. */
1688
+ const relationshipSystems = /** @type {{id?: string}[]} */ (
1689
+ (isPlainObject(input.relationships) ?
1690
+ input.relationships.systems
1691
+ : null) ?? []
1692
+ );
1693
+
1694
+ // A name that resolves to nothing is a build error rather than a
1695
+ // fall-through, in the spirit the rest of this file already follows: a pack
1696
+ // stamping a system nobody declared would stamp `undefined`, which is the
1697
+ // plausible lie #43 was about.
1698
+ if (requiresSystem !== null && !declaredSystems.has(requiresSystem)) {
1699
+ fail(
1700
+ "requiresSystem",
1701
+ `names \`${requiresSystem}\`, which \`systems:\` does not declare` +
1702
+ (declaredSystems.size ?
1703
+ `. Declared: ${[...declaredSystems].join(", ")}`
1704
+ : ` — the \`systems:\` block is empty or absent`),
1705
+ );
1706
+ }
1707
+ for (const pack of packs.flatMap((p) => [p, ...p.companions])) {
1708
+ if (!pack.system) continue;
1709
+ if (declaredSystems.size && !declaredSystems.has(pack.system)) {
1710
+ fail(
1711
+ `packs.${pack.name}.system`,
1712
+ `names \`${pack.system}\`, which \`systems:\` does not ` +
1713
+ `declare. Declared: ${[...declaredSystems].join(", ")}`,
1714
+ );
1715
+ }
1716
+ // With a gate set, a pack for any other system could never be seen:
1717
+ // Foundry drops the whole package under a system `requiresSystem` does
1718
+ // not name, so the pack would ship and be unreachable.
1719
+ if (requiresSystem !== null && pack.system !== requiresSystem) {
1720
+ fail(
1721
+ `packs.${pack.name}.system`,
1722
+ `names \`${pack.system}\` while \`requiresSystem\` is ` +
1723
+ `\`${requiresSystem}\`, so this pack could never be seen — ` +
1724
+ `Foundry hides the whole package from any world whose ` +
1725
+ `system \`requiresSystem\` does not name. Drop ` +
1726
+ `\`requiresSystem\`, or correct the pack`,
1727
+ );
1728
+ }
1729
+ }
1730
+
1522
1731
  // Several packs of one document type are allowed — editorial grouping of
1523
1732
  // same-type documents is ordinary Foundry practice, and collapsing such a
1524
1733
  // layout breaks every stored compendium UUID (#1566). What is not allowed
@@ -1570,7 +1779,50 @@ export function defineConfig(config) {
1570
1779
  // one place `systems/sohl` (or `modules/sohl-thalorna`) is spelled.
1571
1780
  assetRoot: `${packageKind}/${foundryPackage}/assets`,
1572
1781
  paths: normalizePaths(input.paths, rootDir),
1573
- stats: normalizeStats(input.stats),
1782
+ // The package-wide system, derived (#48). A **system** package is its
1783
+ // own system, which is true by construction and needs no declaration. A
1784
+ // **module** takes the one it requires, or the one system it declares
1785
+ // when there is exactly one; with several and no gate there is no
1786
+ // package-wide answer, and each pack carries its own.
1787
+ stats: normalizeStats(input.stats, {
1788
+ systemId:
1789
+ packageKind === "systems" ? foundryPackage
1790
+ : requiresSystem ? requiresSystem
1791
+ : Object.keys(systems).length === 1 ? Object.keys(systems)[0]
1792
+ // A lone `relationships.systems` entry is a declaration of
1793
+ // the system as much as a gate, so it still answers. That
1794
+ // matters because the relationship carries `itemCatalog`
1795
+ // too — a separate concern the split does not replace — so
1796
+ // a repository using it would otherwise have to restate its
1797
+ // compatibility under `systems:` purely to keep stamping,
1798
+ // which is the duplication this whole change exists to
1799
+ // remove. Several entries have no single answer and get
1800
+ // none.
1801
+ : relationshipSystems.length === 1 ?
1802
+ (relationshipSystems[0]?.id ?? null)
1803
+ : null,
1804
+ // Derived here where the answer is pure data — the `verified` of
1805
+ // whichever system the package-wide block takes — and supplied by
1806
+ // the loader otherwise. The loader is the half that may do I/O, and
1807
+ // the two cases needing it are a *system* package (its own
1808
+ // `package.json` version) and a module still deriving from
1809
+ // `relationships.systems`.
1810
+ systemVersion:
1811
+ (() => {
1812
+ const id =
1813
+ 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;
1820
+ })() ??
1821
+ (isPlainObject(input.stats) ?
1822
+ input.stats[DERIVED_SYSTEM_VERSION]
1823
+ : null) ??
1824
+ null,
1825
+ }),
1574
1826
  itemBuilders,
1575
1827
  itemArt,
1576
1828
  itemFields,
@@ -1594,6 +1846,8 @@ export function defineConfig(config) {
1594
1846
  "compatibility",
1595
1847
  ),
1596
1848
  relationships: normalizeRelationships(input.relationships),
1849
+ systems,
1850
+ requiresSystem,
1597
1851
  packageBuild: normalizePackageBuild(input.packageBuild),
1598
1852
  publish: normalizePublish(input.publish),
1599
1853
  });