@heroiclands/package-build 22.21.0 → 22.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. package/types/engine/content-secrets.d.mts +0 -17
@@ -9,9 +9,12 @@ import { formatGenerated } from "../engine/format-generated.mjs";
9
9
  import { loadContentFormat } from "../engine/content-format.mjs";
10
10
  import {
11
11
  BEING_ARCHETYPES,
12
+ NOTE_TOP_LEVEL_FIELDS,
12
13
  NOTE_VOCABULARY,
13
14
  SHARED_DATA_FIELDS,
14
15
  } from "../engine/note-vocabulary.mjs";
16
+ import { EXPRESSION_HELPERS } from "../engine/markdown-expressions.mjs";
17
+ import { PAGE_LIST_ATTRIBUTES } from "../engine/page-lists.mjs";
15
18
 
16
19
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
20
  const output = path.join(root, "docs/reference/note-types.md");
@@ -39,6 +42,16 @@ const rows = [
39
42
  "",
40
43
  "The [first-note guide](../authoring/first-note.md) teaches the shape of a note. [Frontmatter](../authoring/frontmatter.md) explains shared and system-specific values. The [detailed reference](format-details.md) gives the full behavior and examples.",
41
44
  "",
45
+ "## Top-level keys",
46
+ "",
47
+ "These are the only keys a note writes at its top level, in the order the formatter puts them. A key absent from this table is a lint error at its own line: the region is closed, so the key reaches no document and no page. Facts about the subject belong under `data`, and values for one game system belong inside that system's block.",
48
+ "",
49
+ "| Key | Meaning |",
50
+ "| --- | --- |",
51
+ ...NOTE_TOP_LEVEL_FIELDS.map(
52
+ (field) => `| \`${cell(field.name)}\` | ${cell(field.describe)} |`,
53
+ ),
54
+ "",
42
55
  "## Shared `data` fields",
43
56
  "",
44
57
  "These fields are accepted by every note type. A field's value can still be irrelevant to a particular output; the build reports that where it can.",
@@ -106,7 +119,61 @@ if (process.argv.includes("--check")) {
106
119
  process.exitCode = 1;
107
120
  }
108
121
  } else {
109
- process.stdout.write(result);
122
+ fs.writeFileSync(output, result);
123
+ }
124
+
125
+ // The two tables `docs/authoring/links-and-markup.md` generates: every helper
126
+ // an expression may call, and every attribute a page-list fence takes. Both are
127
+ // written from the one list the engine reads, so neither document can fall
128
+ // behind a new entry. Replaced in one pass and written once, so a `--check` run
129
+ // names the file rather than one of the tables in it.
130
+ const markupPath = path.join(root, "docs/authoring/links-and-markup.md");
131
+ const markup = fs.readFileSync(markupPath, "utf8");
132
+ const GENERATED_TABLES = [
133
+ {
134
+ marker: "expression-helpers",
135
+ rows: [
136
+ "| Helper | Parameters | What it gives you |",
137
+ "| ------ | ---------- | ----------------- |",
138
+ ...Object.entries(EXPRESSION_HELPERS).map(
139
+ ([name, { params, summary }]) =>
140
+ `| \`${cell(name)}\` | \`${cell(params)}\` | ${cell(summary)} |`,
141
+ ),
142
+ ],
143
+ },
144
+ {
145
+ marker: "page-list-attributes",
146
+ rows: [
147
+ "| Attribute | Value | Default | What it does |",
148
+ "| --------- | ----- | ------- | ------------ |",
149
+ ...Object.entries(PAGE_LIST_ATTRIBUTES).map(
150
+ ([name, { value, default: fallback, summary }]) =>
151
+ `| \`${name}\` | ${cell(value)} | ${cell(fallback)} | ${cell(summary)} |`,
152
+ ),
153
+ ],
154
+ },
155
+ ];
156
+ let markupBody = markup;
157
+ for (const { marker, rows } of GENERATED_TABLES) {
158
+ const region = new RegExp(`<!-- ${marker}:start -->[\\s\\S]*?<!-- ${marker}:end -->`);
159
+ if (!region.test(markupBody)) {
160
+ throw new Error(`The ${marker} reference requires its generation markers`);
161
+ }
162
+ markupBody = markupBody.replace(
163
+ region,
164
+ `<!-- ${marker}:start -->\n\n${rows.join("\n")}\n\n<!-- ${marker}:end -->`,
165
+ );
166
+ }
167
+ const updatedMarkup = await formatGenerated(markupBody, markupPath);
168
+ if (process.argv.includes("--check")) {
169
+ if (markup !== updatedMarkup) {
170
+ console.error(
171
+ "docs/authoring/links-and-markup.md: error: run node ci/content-format-reference.mjs to refresh its generated tables",
172
+ );
173
+ process.exitCode = 1;
174
+ }
175
+ } else {
176
+ fs.writeFileSync(markupPath, updatedMarkup);
110
177
  }
111
178
 
112
179
  const detailsPath = path.join(root, "docs/reference/format-details.md");
package/config.mjs CHANGED
@@ -65,6 +65,7 @@ const SECTION_KEYS = [
65
65
  "stageDir",
66
66
  "assets",
67
67
  "assetTransform",
68
+ "baseStyles",
68
69
  "manifest",
69
70
  "manifestFlags",
70
71
  "schema",
@@ -205,6 +206,23 @@ function requireNonEmptyString(value, where) {
205
206
  return /** @type {string} */ (value);
206
207
  }
207
208
 
209
+ /**
210
+ * A declared switch, with the behaviour that applies when it is not declared.
211
+ *
212
+ * A truthy string is refused rather than coerced: `baseStyles: "false"` reads
213
+ * as off and would be on, which is the one wrong answer available here.
214
+ *
215
+ * @param {unknown} value - What was declared, or `undefined`.
216
+ * @param {boolean} fallback - The undeclared behaviour.
217
+ * @param {string} where - Dotted path, for the error.
218
+ * @returns {boolean} The setting.
219
+ */
220
+ function requireBoolean(value, fallback, where) {
221
+ if (value === undefined) return fallback;
222
+ if (typeof value !== "boolean") fail(where, "must be true or false");
223
+ return value;
224
+ }
225
+
208
226
  /**
209
227
  * One staging copy: a source path in the repository, and where it lands under
210
228
  * the staged package root.
@@ -537,6 +555,9 @@ function normalizeExceptions(value, field, where) {
537
555
  * @property {string} stageDir The staged package root, relative to
538
556
  * `rootDir`. Every asset `to:` lands under it.
539
557
  * @property {readonly Readonly<AssetSpec>[]} assets
558
+ * @property {boolean} baseStyles Whether the package takes the shared base
559
+ * stylesheet, staged into `stageDir` and
560
+ * named first in the manifest's `styles`.
540
561
  * @property {Readonly<Record<string, unknown>>} manifest The manifest
541
562
  * specification, emitted as declared.
542
563
  * @property {string|null} manifestFlags Module to load a `flags` function
@@ -877,6 +898,11 @@ export function resolvePackageBuildConfig(shared) {
877
898
  ARTIFACT_OF_KIND[/** @type {"systems"|"modules"} */ (shared.packageKind)]
878
899
  : requireNonEmptyString(releaseInput.artifact, "packageBuild.release.artifact"),
879
900
  assets: Object.freeze(assets),
901
+ // On by default, because the rules it carries are what compiled content
902
+ // needs to be legible and a package that declined them by omission
903
+ // would ship the content unstyled without anything saying so. A package
904
+ // that wants the surface entirely to itself says so.
905
+ baseStyles: requireBoolean(section.baseStyles, true, "packageBuild.baseStyles"),
880
906
  schema: normalizeSchema(section.schema),
881
907
  assetTransform:
882
908
  section.assetTransform === undefined ?
@@ -368,7 +368,8 @@ export function publishesContentPages(config) {
368
368
  * @property {string} [stage] Compiled LevelDB packs.
369
369
  * @property {string} [unpack] Where `unpack` extracts JSON back to.
370
370
  * @property {string} [foreignCache] Where a dependency declaring
371
- * `itemCatalog: true` is unpacked.
371
+ * `itemCatalog: true` or
372
+ * `assetArchive: true` is unpacked.
372
373
  * Inbound, and fetched rather than
373
374
  * committed.
374
375
  * @property {string} [metadataCache] Where a dependency's published content
@@ -486,6 +487,15 @@ export function publishesContentPages(config) {
486
487
  * package this one targets — for a system
487
488
  * relationship, `verified` is what
488
489
  * `_stats.systemVersion` is stamped from.
490
+ * @property {boolean} [itemCatalog] Whether `deps fetch` unpacks this
491
+ * dependency's release archive and extracts
492
+ * its Item packs, so the actors pass can
493
+ * resolve embedded items this repository
494
+ * does not hold. Needs a `manifest`.
495
+ * @property {boolean} [assetArchive] Whether `deps fetch` unpacks this
496
+ * dependency's release archive for its
497
+ * asset bytes alone, building no item
498
+ * catalogue from it. Needs a `manifest`.
489
499
  * @property {boolean} [contentIndex] Whether `deps fetch` fetches this
490
500
  * dependency's content index. Default
491
501
  * `true`. `false` declares the dependency
@@ -494,6 +504,13 @@ export function publishesContentPages(config) {
494
504
  * `itemCatalog: true` on the same entry,
495
505
  * since a catalogue is fetched from the same
496
506
  * index.
507
+ * @property {boolean} [assetReplacement] Whether this dependency's asset
508
+ * tree answers this package's own asset
509
+ * addresses ahead of its local record.
510
+ * Default `false`. Refuses `contentIndex:
511
+ * false` on the same entry, since a
512
+ * replacement with no fetched index to
513
+ * check against resolves nothing.
497
514
  */
498
515
 
499
516
  /**
@@ -620,6 +637,9 @@ export function publishesContentPages(config) {
620
637
  * @property {string[]} [skipDirectories] Directory names the content walk ignores
621
638
  * wherever they appear (e.g. Obsidian's
622
639
  * `Templates`). Default `[]`.
640
+ * @property {boolean} [forbidGeneratedArt] Whether `lint` refuses an asset
641
+ * record of this package carrying
642
+ * `ai: true`. Default `false`.
623
643
  * @property {PackageBuildSection} [packageBuild] Reserved for
624
644
  * `@heroiclands/package-build`, which
625
645
  * validates it. Not read here.
@@ -700,6 +720,8 @@ export function publishesContentPages(config) {
700
720
  * types. The one set the compilers and the
701
721
  * link-manifest emitter both read.
702
722
  * @property {readonly string[]} skipDirectories
723
+ * @property {boolean} forbidGeneratedArt Whether `lint` refuses an asset
724
+ * record of this package carrying `ai: true`.
703
725
  * @property {import("./engine/content-icons.mjs").IconRegistry} icons The
704
726
  * fonts this package ships and the names it
705
727
  * draws from them; empty when it declares
@@ -728,6 +750,7 @@ const CONFIG_KEYS = [
728
750
  "itemBuilders",
729
751
  "paths",
730
752
  "skipDirectories",
753
+ "forbidGeneratedArt",
731
754
  "icons",
732
755
  "packs",
733
756
  "docs",
@@ -765,14 +788,25 @@ const PDF_FONT_KEYS = ["serif", "sans", "mono", "path"];
765
788
  const EMPTY_PDF_FONTS = Object.freeze({ serif: "", sans: "", mono: "", path: "" });
766
789
  const DOC_PAGE_KEYS = ["title", "out", "preamble", "frontmatter"];
767
790
  const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
768
- const RELATIONSHIP_KEYS = [
791
+ /**
792
+ * Every key a declared relationship may carry.
793
+ *
794
+ * Read by the manifest writer as well as by the configuration check: the keys
795
+ * Foundry's own relationship schema does not name are the build's own, and the
796
+ * published manifest carries none of them.
797
+ *
798
+ * @type {readonly string[]}
799
+ */
800
+ export const RELATIONSHIP_KEYS = [
769
801
  "id",
770
802
  "contentPackage",
771
803
  "type",
772
804
  "manifest",
773
805
  "compatibility",
774
806
  "itemCatalog",
807
+ "assetArchive",
775
808
  "contentIndex",
809
+ "assetReplacement",
776
810
  ];
777
811
  const AUTHOR_KEYS = ["name", "email", "url"];
778
812
  const ITEM_BUILDER_KEYS = ["system", "img", "fields"];
@@ -1580,6 +1614,7 @@ export const DERIVED_HUGO_KEYS = Object.freeze({
1580
1614
  "params.brand": "the organisation's brand links, in `engine/site-config.mjs`",
1581
1615
  "params.notfound": "`site.notfound`",
1582
1616
  "markup.goldmark.renderer.unsafe": "the toolchain, whose pages carry raw HTML",
1617
+ "markup.goldmark.extensions.taskList": "the toolchain, whose format has no checkbox",
1583
1618
  menu: "the navigation `package-build deps fetch` caches from heroiclands.org",
1584
1619
  });
1585
1620
 
@@ -1903,6 +1938,44 @@ function normalizePdf(value, rootDir) {
1903
1938
  });
1904
1939
  }
1905
1940
 
1941
+ /**
1942
+ * A dotted version string as the three-element tuple Foundry compares.
1943
+ *
1944
+ * A missing component reads as `0`, so `"14"` compares as `14.0.0` — the
1945
+ * common way an author writes a major-only floor or pin. A non-numeric
1946
+ * component reads as `0` too, rather than throwing: this runs on values
1947
+ * {@link requireNonEmptyString} has already accepted as non-empty strings,
1948
+ * and a version with a qualifier (`"14.359.0-beta"`) is not this function's
1949
+ * place to refuse.
1950
+ *
1951
+ * @param {string} version - A dotted version string.
1952
+ * @returns {readonly [number, number, number]} Major, minor, patch.
1953
+ */
1954
+ function versionTriple(version) {
1955
+ const parts = version.split(".");
1956
+ return [0, 1, 2].map((index) => {
1957
+ const n = Number.parseInt(parts[index] ?? "0", 10);
1958
+ return Number.isNaN(n) ? 0 : n;
1959
+ });
1960
+ }
1961
+
1962
+ /**
1963
+ * Whether `verified` names a build below `minimum`, compared as version
1964
+ * triples.
1965
+ *
1966
+ * @param {string} minimum - The declared floor.
1967
+ * @param {string} verified - The declared verified build.
1968
+ * @returns {boolean} `true` when `verified` sorts below `minimum`.
1969
+ */
1970
+ function verifiedBelowMinimum(minimum, verified) {
1971
+ const a = versionTriple(verified);
1972
+ const b = versionTriple(minimum);
1973
+ for (let i = 0; i < 3; i++) {
1974
+ if (a[i] !== b[i]) return a[i] < b[i];
1975
+ }
1976
+ return false;
1977
+ }
1978
+
1906
1979
  /**
1907
1980
  * Validate a Foundry version range.
1908
1981
  *
@@ -1912,6 +1985,12 @@ function normalizePdf(value, rootDir) {
1912
1985
  * load-bearing there is `verified`, and a relationship may reasonably name a
1913
1986
  * package without pinning a floor at all.
1914
1987
  *
1988
+ * **The two keys are a range, not two independent facts.** Foundry reads
1989
+ * `verified` as the build inside `minimum`'s floor that was actually tested,
1990
+ * so a `verified` below `minimum` names a build the package already refuses
1991
+ * to install on. Caught only when both are present — a relationship naming
1992
+ * `verified` alone has nothing to compare it against.
1993
+ *
1915
1994
  * @param {unknown} value - The declared range, or `undefined`.
1916
1995
  * @param {string} where - Dotted path, for the error.
1917
1996
  * @param {boolean} [requireMinimum] - Whether `minimum` must be present.
@@ -1929,6 +2008,18 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1929
2008
  if (input.verified !== undefined) {
1930
2009
  out.verified = requireNonEmptyString(input.verified, `${where}.verified`);
1931
2010
  }
2011
+ if (
2012
+ out.minimum !== undefined &&
2013
+ out.verified !== undefined &&
2014
+ verifiedBelowMinimum(out.minimum, out.verified)
2015
+ ) {
2016
+ fail(
2017
+ `${where}.verified`,
2018
+ `declares \`${out.verified}\`, below \`${where}.minimum\`'s \`${out.minimum}\` — ` +
2019
+ "Foundry reads the two as one range, so a build below the floor is " +
2020
+ "never the one verified against it",
2021
+ );
2022
+ }
1932
2023
  return Object.freeze(out);
1933
2024
  }
1934
2025
 
@@ -2022,6 +2113,21 @@ function normalizeSystems(value) {
2022
2113
  // declaration that cannot answer "which version was this built
2023
2114
  // against" is the gap this block exists to close.
2024
2115
  const verified = requireNonEmptyString(compat.verified, `${at}.compatibility.verified`);
2116
+ const minimum =
2117
+ compat.minimum === undefined || compat.minimum === null ?
2118
+ null
2119
+ : requireNonEmptyString(compat.minimum, `${at}.compatibility.minimum`);
2120
+ // Caught here too: the shape is the one {@link normalizeCompatibility}
2121
+ // validates, and the same contradiction is possible wherever it
2122
+ // appears.
2123
+ if (minimum !== null && verifiedBelowMinimum(minimum, verified)) {
2124
+ fail(
2125
+ `${at}.compatibility.verified`,
2126
+ `declares \`${verified}\`, below \`${at}.compatibility.minimum\`'s ` +
2127
+ `\`${minimum}\` — Foundry reads the two as one range, so a build ` +
2128
+ "below the floor is never the one verified against it",
2129
+ );
2130
+ }
2025
2131
 
2026
2132
  out[id] = Object.freeze({
2027
2133
  manifest:
@@ -2029,10 +2135,7 @@ function normalizeSystems(value) {
2029
2135
  null
2030
2136
  : requireNonEmptyString(spec.manifest, `${at}.manifest`),
2031
2137
  compatibility: Object.freeze({
2032
- minimum:
2033
- compat.minimum === undefined || compat.minimum === null ?
2034
- null
2035
- : requireNonEmptyString(compat.minimum, `${at}.compatibility.minimum`),
2138
+ minimum,
2036
2139
  verified,
2037
2140
  }),
2038
2141
  });
@@ -2129,6 +2232,23 @@ function normalizeRelationships(value) {
2129
2232
  }
2130
2233
  spec.itemCatalog = rel.itemCatalog;
2131
2234
  }
2235
+ // Opt-in, sibling to `itemCatalog`: unpack this dependency's
2236
+ // release archive for its asset bytes alone, building no item
2237
+ // catalogue from it. Buys the archive for a package with no
2238
+ // items of its own to extract — an asset-replacement source
2239
+ // ships no Item packs and has no use for `itemCatalog: true`.
2240
+ if (rel.assetArchive !== undefined) {
2241
+ if (typeof rel.assetArchive !== "boolean") {
2242
+ fail(`${at}.assetArchive`, "must be true or false");
2243
+ }
2244
+ if (rel.assetArchive && spec.manifest === undefined) {
2245
+ fail(
2246
+ `${at}.assetArchive`,
2247
+ "needs a `manifest` naming the package to fetch",
2248
+ );
2249
+ }
2250
+ spec.assetArchive = rel.assetArchive;
2251
+ }
2132
2252
  // Opt-out: declares the dependency for the Foundry manifest
2133
2253
  // only, so `deps fetch` fetches no content index for it and a
2134
2254
  // wikilink into it is refused rather than silently dead. A
@@ -2147,6 +2267,26 @@ function normalizeRelationships(value) {
2147
2267
  }
2148
2268
  spec.contentIndex = rel.contentIndex;
2149
2269
  }
2270
+ // Opt-in: this dependency's asset tree answers this
2271
+ // package's own asset addresses ahead of its local record —
2272
+ // the "rewrite table" `engine/asset-replacement.mjs` reads.
2273
+ // A replacement with no fetched index to check against
2274
+ // resolves nothing, so it cannot pair with
2275
+ // `contentIndex: false`.
2276
+ if (rel.assetReplacement !== undefined) {
2277
+ if (typeof rel.assetReplacement !== "boolean") {
2278
+ fail(`${at}.assetReplacement`, "must be true or false");
2279
+ }
2280
+ if (rel.assetReplacement && rel.contentIndex === false) {
2281
+ fail(
2282
+ `${at}.assetReplacement`,
2283
+ "cannot be true together with `contentIndex: false` — a " +
2284
+ "replacement with no fetched index to check against resolves " +
2285
+ "nothing",
2286
+ );
2287
+ }
2288
+ spec.assetReplacement = rel.assetReplacement;
2289
+ }
2150
2290
  return Object.freeze(spec);
2151
2291
  }),
2152
2292
  );
@@ -2625,6 +2765,12 @@ export function defineConfig(config) {
2625
2765
  requireNonEmptyString(name, `skipDirectories[${index}]`),
2626
2766
  );
2627
2767
 
2768
+ const forbidGeneratedArt = optionalBoolean(
2769
+ input.forbidGeneratedArt,
2770
+ "forbidGeneratedArt",
2771
+ false,
2772
+ );
2773
+
2628
2774
  // Refused above for a documentation package, so there is nothing to read
2629
2775
  // and nothing to derive an asset root or a package-wide system from.
2630
2776
  const foundryPackage =
@@ -2731,6 +2877,7 @@ export function defineConfig(config) {
2731
2877
  itemTypes,
2732
2878
  docEntryTypes,
2733
2879
  skipDirectories: Object.freeze(skipDirectories),
2880
+ forbidGeneratedArt,
2734
2881
  icons: normalizeIcons(input.icons, rootDir),
2735
2882
  packs: Object.freeze(packs),
2736
2883
  packDirectories: Object.freeze(packDirectories),