@heroiclands/package-build 23.1.0 → 23.2.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 (248) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/bin/content-commands.mjs +68 -149
  3. package/bin/package-build.mjs +56 -10
  4. package/ci/check-banners.mjs +42 -6
  5. package/ci/confirm-npm-version.mjs +135 -0
  6. package/ci/content-format-reference.mjs +14 -5
  7. package/config.mjs +1 -0
  8. package/container.mjs +185 -4
  9. package/content-config.mjs +333 -210
  10. package/deploy.mjs +116 -10
  11. package/docs/api.md +238 -246
  12. package/docs/authoring/assets.md +11 -5
  13. package/docs/authoring/frontmatter.md +1 -1
  14. package/docs/authoring/links-and-markup.md +87 -0
  15. package/docs/commands.md +138 -99
  16. package/docs/configuration.md +277 -125
  17. package/docs/content-format.md +2 -0
  18. package/docs/diagnostics.md +48 -13
  19. package/docs/guides/book.md +341 -21
  20. package/docs/guides/e2e.md +13 -2
  21. package/docs/guides/icons.md +14 -4
  22. package/docs/guides/module-project.md +18 -8
  23. package/docs/guides/release-and-deploy.md +54 -6
  24. package/docs/guides/site.md +5 -2
  25. package/docs/project-setup.md +30 -10
  26. package/docs/reference/format-details.md +373 -170
  27. package/docs/reference/note-types.md +128 -109
  28. package/docs/reference/theme-pages.md +31 -6
  29. package/e2e.mjs +140 -28
  30. package/engine/actor-compiler.mjs +22 -31
  31. package/engine/address-charset.mjs +4 -17
  32. package/engine/address-fields.mjs +1 -1
  33. package/engine/address-render.mjs +12 -1
  34. package/engine/address-values.mjs +6 -0
  35. package/engine/address.mjs +46 -33
  36. package/engine/art-fields.mjs +30 -5
  37. package/engine/art-slots.mjs +10 -1
  38. package/engine/asset-bindings.mjs +12 -1
  39. package/engine/base-compiler.mjs +34 -65
  40. package/engine/being-age.mjs +2 -7
  41. package/engine/being-measurements.mjs +12 -1
  42. package/engine/calendar-artifacts.mjs +6 -0
  43. package/engine/calendar-choice.mjs +12 -1
  44. package/engine/calendar-format.mjs +12 -1
  45. package/engine/calendar-human.mjs +12 -1
  46. package/engine/compile-corpus.mjs +3 -1
  47. package/engine/content-address.mjs +9 -12
  48. package/engine/content-alerts.mjs +13 -1
  49. package/engine/content-blocks.mjs +14 -3
  50. package/engine/content-embeds.mjs +11 -3
  51. package/engine/content-figures.mjs +9 -0
  52. package/engine/content-footnotes.mjs +12 -1
  53. package/engine/content-format-check.mjs +11 -262
  54. package/engine/content-format.yaml +5 -2
  55. package/engine/content-images.mjs +3 -0
  56. package/engine/content-index.mjs +443 -20
  57. package/engine/content-links.mjs +29 -35
  58. package/engine/content-lint.mjs +2 -3
  59. package/engine/content-poetry.mjs +12 -1
  60. package/engine/content-slug.mjs +2 -14
  61. package/engine/content-spans.mjs +13 -1
  62. package/engine/content-tables.mjs +12 -1
  63. package/engine/culture-choice.mjs +210 -13
  64. package/engine/data-keys.mjs +19 -16
  65. package/engine/date-conversion.mjs +12 -1
  66. package/engine/declaration-check.mjs +9 -0
  67. package/engine/derived-fields.mjs +6 -7
  68. package/engine/diagnostics.mjs +34 -4
  69. package/engine/docs-checks.mjs +9 -0
  70. package/engine/document-subtypes.mjs +47 -34
  71. package/engine/event-views.mjs +139 -14
  72. package/engine/exported-scene.mjs +92 -28
  73. package/engine/extension-attributes.mjs +12 -1
  74. package/engine/field-spec.mjs +11 -102
  75. package/engine/folder-notes.mjs +0 -64
  76. package/engine/foreign-catalog.mjs +2 -1
  77. package/engine/format-generated.mjs +9 -0
  78. package/engine/foundry-entries.mjs +10 -28
  79. package/engine/frontmatter-lint.mjs +83 -392
  80. package/engine/frontmatter.mjs +20 -70
  81. package/engine/generate.mjs +10 -15
  82. package/engine/heading-attributes.mjs +12 -1
  83. package/engine/helpers.mjs +41 -102
  84. package/engine/holdings.mjs +1 -1
  85. package/engine/homepage.mjs +3 -10
  86. package/engine/ids.mjs +1 -167
  87. package/engine/index-records.mjs +6 -4
  88. package/engine/index.mjs +0 -3
  89. package/engine/infobox-render.mjs +15 -3
  90. package/engine/infobox.mjs +13 -14
  91. package/engine/item-compiler.mjs +4 -10
  92. package/engine/item-docs.mjs +3 -5
  93. package/engine/item-registry.mjs +2 -10
  94. package/engine/journals.mjs +1 -5
  95. package/engine/literature-notes.mjs +4 -12
  96. package/engine/macros.mjs +1 -2
  97. package/engine/map-graphviz.mjs +12 -1
  98. package/engine/markdown-expressions.mjs +12 -1
  99. package/engine/markdownlint-run.mjs +567 -0
  100. package/engine/metadata-index.mjs +42 -12
  101. package/engine/note-addresses.mjs +143 -35
  102. package/engine/note-claims.mjs +14 -28
  103. package/engine/note-dates.mjs +3 -3
  104. package/engine/note-frontmatter.mjs +6 -0
  105. package/engine/note-ids.mjs +8 -14
  106. package/engine/note-package.mjs +8 -25
  107. package/engine/note-state.mjs +1 -3
  108. package/engine/note-vocabulary.mjs +148 -84
  109. package/engine/number-words.mjs +12 -1
  110. package/engine/pack-config.mjs +15 -25
  111. package/engine/pack-router.mjs +10 -16
  112. package/engine/pathnames.mjs +8 -22
  113. package/engine/pdf-build.mjs +773 -121
  114. package/engine/pdf-coverage.mjs +188 -0
  115. package/engine/pdf-index.mjs +54 -7
  116. package/engine/pdf-pages.mjs +247 -0
  117. package/engine/pdf-render.mjs +114 -4
  118. package/engine/pdf-toc.mjs +29 -32
  119. package/engine/project-init.mjs +46 -6
  120. package/engine/prose-config.mjs +1 -1
  121. package/engine/prose-lint.mjs +52 -88
  122. package/engine/readability-lint.mjs +9 -0
  123. package/engine/reckoning-markers.mjs +12 -1
  124. package/engine/reference-targets.mjs +7 -0
  125. package/engine/runtime-only-fields.mjs +5 -8
  126. package/engine/scene-levels.mjs +2 -2
  127. package/engine/scenes.mjs +1 -1
  128. package/engine/shortcode-references.mjs +7 -0
  129. package/engine/site-build.mjs +30 -16
  130. package/engine/site-index.mjs +10 -16
  131. package/engine/site-preview.mjs +6 -0
  132. package/engine/site-root.mjs +8 -1
  133. package/engine/social-tie-terms.mjs +12 -1
  134. package/engine/social-ties.mjs +3 -0
  135. package/engine/sql-tables.mjs +56 -20
  136. package/engine/standing-terms.mjs +11 -1
  137. package/engine/standings.mjs +11 -27
  138. package/engine/system-block.mjs +39 -141
  139. package/engine/system-vocabulary.mjs +6 -0
  140. package/engine/walk-files.mjs +69 -0
  141. package/engine/web-wikilinks.mjs +23 -3
  142. package/engine/wikilink-syntax.mjs +32 -21
  143. package/engine/wikilinks.mjs +27 -16
  144. package/engine/yaml-lint.mjs +7 -4
  145. package/githooks/attribution.mjs +8 -0
  146. package/githooks/commit-message.mjs +9 -0
  147. package/hm3/actors.mjs +9 -48
  148. package/hm3/default-item-art.mjs +9 -9
  149. package/hm3/item-fields.mjs +2 -9
  150. package/hm3/template-priority.mjs +6 -7
  151. package/hugo-theme/data/banner-kinds.yaml +78 -0
  152. package/hugo-theme/data/banners.yaml +2 -3
  153. package/hugo-theme/layouts/404.html +3 -3
  154. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  155. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  156. package/hugo-theme/layouts/partials/related.html +1 -1
  157. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  158. package/hugo-theme/static/css/style.css +1 -1
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +83 -18
  163. package/sohl/actors.mjs +16 -27
  164. package/sohl/default-item-art.mjs +3 -3
  165. package/sohl/document-subtypes.mjs +1 -16
  166. package/sohl/infobox.mjs +2 -3
  167. package/sohl/item-builders.mjs +5 -8
  168. package/sohl/item-fields.mjs +5 -35
  169. package/sohl/items.mjs +0 -4
  170. package/sohl/kb-passes.mjs +3 -3
  171. package/types/container.d.mts +116 -5
  172. package/types/content-config.d.mts +83 -35
  173. package/types/deploy.d.mts +42 -6
  174. package/types/e2e.d.mts +35 -3
  175. package/types/engine/address-charset.d.mts +4 -17
  176. package/types/engine/address.d.mts +17 -8
  177. package/types/engine/art-fields.d.mts +7 -4
  178. package/types/engine/art-slots.d.mts +6 -0
  179. package/types/engine/base-compiler.d.mts +15 -17
  180. package/types/engine/content-address.d.mts +9 -12
  181. package/types/engine/content-embeds.d.mts +4 -2
  182. package/types/engine/content-format-check.d.mts +0 -64
  183. package/types/engine/content-index.d.mts +82 -3
  184. package/types/engine/content-links.d.mts +2 -4
  185. package/types/engine/culture-choice.d.mts +51 -8
  186. package/types/engine/data-keys.d.mts +2 -2
  187. package/types/engine/derived-fields.d.mts +1 -2
  188. package/types/engine/diagnostics.d.mts +27 -4
  189. package/types/engine/document-subtypes.d.mts +12 -31
  190. package/types/engine/event-views.d.mts +14 -2
  191. package/types/engine/exported-scene.d.mts +3 -2
  192. package/types/engine/field-spec.d.mts +13 -122
  193. package/types/engine/folder-notes.d.mts +0 -39
  194. package/types/engine/foreign-catalog.d.mts +2 -1
  195. package/types/engine/foundry-entries.d.mts +4 -5
  196. package/types/engine/frontmatter-lint.d.mts +2 -2
  197. package/types/engine/frontmatter.d.mts +19 -45
  198. package/types/engine/generate.d.mts +1 -1
  199. package/types/engine/helpers.d.mts +25 -42
  200. package/types/engine/homepage.d.mts +3 -10
  201. package/types/engine/ids.d.mts +0 -114
  202. package/types/engine/index-records.d.mts +6 -4
  203. package/types/engine/index.d.mts +0 -1
  204. package/types/engine/infobox-render.d.mts +7 -1
  205. package/types/engine/infobox.d.mts +3 -2
  206. package/types/engine/item-docs.d.mts +1 -1
  207. package/types/engine/item-registry.d.mts +2 -4
  208. package/types/engine/literature-notes.d.mts +1 -1
  209. package/types/engine/markdownlint-run.d.mts +40 -0
  210. package/types/engine/metadata-index.d.mts +9 -1
  211. package/types/engine/note-addresses.d.mts +27 -2
  212. package/types/engine/note-claims.d.mts +3 -5
  213. package/types/engine/note-ids.d.mts +2 -4
  214. package/types/engine/note-package.d.mts +1 -1
  215. package/types/engine/note-state.d.mts +1 -3
  216. package/types/engine/note-vocabulary.d.mts +28 -21
  217. package/types/engine/pack-config.d.mts +2 -6
  218. package/types/engine/pack-router.d.mts +4 -4
  219. package/types/engine/pathnames.d.mts +3 -3
  220. package/types/engine/pdf-build.d.mts +98 -12
  221. package/types/engine/pdf-coverage.d.mts +60 -0
  222. package/types/engine/pdf-index.d.mts +34 -5
  223. package/types/engine/pdf-pages.d.mts +143 -0
  224. package/types/engine/pdf-render.d.mts +45 -1
  225. package/types/engine/pdf-toc.d.mts +12 -14
  226. package/types/engine/prose-config.d.mts +1 -1
  227. package/types/engine/prose-lint.d.mts +11 -10
  228. package/types/engine/runtime-only-fields.d.mts +2 -2
  229. package/types/engine/site-build.d.mts +9 -5
  230. package/types/engine/site-index.d.mts +4 -5
  231. package/types/engine/site-root.d.mts +1 -1
  232. package/types/engine/sql-tables.d.mts +31 -2
  233. package/types/engine/standing-terms.d.mts +2 -1
  234. package/types/engine/standings.d.mts +7 -9
  235. package/types/engine/system-block.d.mts +10 -44
  236. package/types/engine/walk-files.d.mts +15 -0
  237. package/types/engine/wikilink-syntax.d.mts +18 -19
  238. package/types/engine/yaml-lint.d.mts +5 -4
  239. package/types/hm3/actors.d.mts +1 -2
  240. package/types/hm3/item-fields.d.mts +2 -4
  241. package/types/hm3/template-priority.d.mts +6 -7
  242. package/types/manifest.d.mts +3 -2
  243. package/types/release.d.mts +12 -7
  244. package/types/sohl/actors.d.mts +2 -1
  245. package/types/sohl/document-subtypes.d.mts +1 -2
  246. package/types/sohl/item-fields.d.mts +3 -7
  247. package/engine/retired-fields.mjs +0 -631
  248. package/types/engine/retired-fields.d.mts +0 -354
@@ -1,4 +1,13 @@
1
1
  /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
2
11
  * SPDX-License-Identifier: GPL-3.0-or-later
3
12
  */
4
13
 
@@ -1,4 +1,15 @@
1
- /* SPDX-License-Identifier: GPL-3.0-or-later */
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
2
13
 
3
14
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
4
15
  import {
@@ -1,6 +1,13 @@
1
1
  /*
2
2
  * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
3
  * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
4
11
  * SPDX-License-Identifier: GPL-3.0-or-later
5
12
  */
6
13
 
@@ -15,12 +15,9 @@
15
15
  * Schema fields a note may **never** author, because the document writes them
16
16
  * in play.
17
17
  *
18
- * `retired-fields.mjs` refuses a field a note may no longer declare; this
19
- * refuses one it never could. The two read alike deliberately — a note says one
20
- * thing, the build does another, and nothing says so — but they are different
21
- * facts, and only one of them is ever swept: a retired field goes away, while
18
+ * A note says one thing and the build does another, and nothing says so:
22
19
  * `onsetDate` is a permanent part of `affliction`'s schema that simply is not
23
- * content.
20
+ * content, so it is refused by name.
24
21
  *
25
22
  * ## What was going wrong
26
23
  *
@@ -65,7 +62,7 @@
65
62
 
66
63
  import { runtimeOnlyFields } from "./field-spec.mjs";
67
64
  import { getFrontmatter } from "./frontmatter.mjs";
68
- import { locateFrontmatterKey } from "./retired-fields.mjs";
65
+ import { locateFrontmatterKey } from "./diagnostics.mjs";
69
66
  import { SYSTEM_DATA_KEY, systemData } from "./system-block.mjs";
70
67
 
71
68
  /**
@@ -107,7 +104,7 @@ export function runtimeOnlyMessage(key, field, file) {
107
104
  /**
108
105
  * The runtime-only fields a note actually writes, in declaration order.
109
106
  *
110
- * **Presence is the whole test**, as it is for every retired field: an authored
107
+ * **Presence is the whole test**: an authored
111
108
  * `onsetDate: null` is as much a claim about play state as a number is, and it
112
109
  * is exactly the belief the message exists to correct. So the question is
113
110
  * whether the path resolves to anything at all, never whether the value is a
@@ -115,7 +112,7 @@ export function runtimeOnlyMessage(key, field, file) {
115
112
  *
116
113
  * Only `<block>.system.<to>` is searched, because it is the only position a
117
114
  * runtime-only field is reachable at. Such a declaration carries no `name`, so
118
- * it has neither a legacy in-block key nor a shared top-level source — and a
115
+ * it has neither an in-block key nor a shared top-level source — and a
119
116
  * bare `<block>.onsetDate` is an unrecognized block key, which
120
117
  * {@link module:engine/system-block.unknownBlockKeys} already reports as an
121
118
  * error naming the note and the line.
@@ -26,7 +26,7 @@
26
26
  *
27
27
  * and yields an empty collection. The very next world launch migrates that
28
28
  * scene, **persists `levels: []`**, and leaves `initialLevel` pointing at an id
29
- * that no longer exists — measured on both 14.359 and 14.367. The map image is
29
+ * that does not exist — measured on both 14.359 and 14.367. The map image is
30
30
  * gone for good, and the only symptom a human sees is a blank battlemap.
31
31
  *
32
32
  * Foundry is behaving correctly there: a scene with no Level records genuinely
@@ -34,7 +34,7 @@
34
34
  * permanent. So the pack build asserts the invariant on the artefact it just
35
35
  * wrote — the compiled LevelDB, not the JSON it was compiled from — because the
36
36
  * gap this closes is the write path (the emitter is already unit-tested, and
37
- * the compendium CLI has previously mishandled Scene Levels).
37
+ * the compendium CLI can mishandle Scene Levels).
38
38
  *
39
39
  * An {@link https://foundryvtt.com/api/classes/foundry.documents.BaseAdventure.html Adventure}
40
40
  * carries its scenes *inline*, levels and all, so the same invariant has a
package/engine/scenes.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * A map note carries its Scene as exported from Foundry, at `data.scene`. The
19
19
  * build never constructs a Scene: {@link module:engine/exported-scene} passes
20
- * the export through, applying `data.fixup` and binding pins marked `#anchor`
20
+ * the export through, applying its fixups and binding pins marked `#anchor`
21
21
  * to the note's own journal pages.
22
22
  *
23
23
  * **Two outputs, for two different jobs.**
@@ -1,6 +1,13 @@
1
1
  /*
2
2
  * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
3
  * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
4
11
  * SPDX-License-Identifier: GPL-3.0-or-later
5
12
  */
6
13
 
@@ -71,6 +71,7 @@ import { renderFigureBlocks, scanFigures } from "./content-figures.mjs";
71
71
  import { footnoteFindings } from "./content-footnotes.mjs";
72
72
  import { collectAnchors, eventAnchors, markupAnchorFindings } from "./anchors.mjs";
73
73
  import { checkImages, renderImageFigures } from "./content-images.mjs";
74
+ import { isAddressTuple } from "./address.mjs";
74
75
  import { assetPathnameKey, pathnameProblem, resolvePathname } from "./pathnames.mjs";
75
76
  import {
76
77
  buildSiteIndex,
@@ -229,7 +230,7 @@ export function collectContentPages(contentBase, ctx) {
229
230
  if (!note) continue;
230
231
  applyComputedBeingAge(note.fm, present, dates);
231
232
  const { fm, body, bodyLine } = note;
232
- // The configuration's, never a note's: `package:` is retired, so every
233
+ // The configuration's, never a note's: a note declares no `package:`, so every
233
234
  // note in the tree belongs to the package this repository compiles.
234
235
  const pkg = ctx.contentPackage;
235
236
  if (!ctx.packages.has(pkg) || !fm.type) continue;
@@ -377,15 +378,26 @@ export function collectHomepages(contentBase, ctx) {
377
378
  * @param {import("./related-pages.mjs").Related} [options.related] - The
378
379
  * homepage's backlinks and mentions. Absent where nothing connects to it,
379
380
  * and when the tree contains only a homepage, where no content link resolves.
381
+ * @param {readonly object[]} [options.records] - The corpus, so a `data.banner`
382
+ * address resolves against the images this package ships.
383
+ * @param {object} [options.foreign] - The dependencies' vendored indexes, so a
384
+ * `data.banner` may name an image a dependency ships.
380
385
  * @returns {number} How many pages were written.
381
386
  */
382
- export function writeHomepages(outRoot, pages, config, { related } = {}) {
387
+ export function writeHomepages(outRoot, pages, config, { related, records, foreign } = {}) {
388
+ const artIndex = assetAddressIndex(records ?? [], { config, foreign });
389
+ // A pathname no asset host serves resolves to `null`, which the theme reads
390
+ // as an unset banner rather than as a URL to nowhere.
391
+ const webSrc = (src) => resolvePathname(src, config)?.web ?? null;
392
+ const artSrc = (value, type, accepts) => artPathname(artIndex, value, type, accepts).pathname;
383
393
  for (const page of pages) {
384
394
  const data = homepageFrontmatter(page.fm, {
385
395
  contentPackage: config.contentPackage,
386
396
  title: homepageTitle(page.fm, config),
387
397
  related,
388
398
  });
399
+ if (isPlainObject(data.data)) data.data = { ...data.data };
400
+ resolveArtFields(data, webSrc, artSrc);
389
401
  const dest = path.join(outRoot, HOMEPAGE_DESTINATION);
390
402
  fs.mkdirSync(path.dirname(dest), { recursive: true });
391
403
  fs.writeFileSync(dest, matter.stringify(page.body, encodeAddresses(data)));
@@ -532,9 +544,7 @@ export function tableUniverse(pages) {
532
544
  /**
533
545
  * The frontmatter a page publishes with.
534
546
  *
535
- * An authored `aliases` is retired and refused before a build reaches
536
- * here, which makes this a guard rather than a working path. It is a list of
537
- * names a reader might call the note. Hugo reads
547
+ * An authored `aliases` is a list of names a reader might call the note. Hugo reads
538
548
  * `aliases` as **URL redirects**, so passing them through would publish a
539
549
  * redirect stub at each name. They are dropped, and this build emits no
540
550
  * redirects of its own.
@@ -556,7 +566,7 @@ export function tableUniverse(pages) {
556
566
  * `<base><slug>/`.
557
567
  *
558
568
  * A content page carries the package the build **derived**. No note
559
- * declares one — `package:` is retired — so the note's frontmatter alone
569
+ * declares one, so the note's frontmatter alone
560
570
  * would publish a page that does not say which package it belongs to. The
561
571
  * emitted page is what a theme reads: `breadcrumbs.html` reads
562
572
  * `.Params.package`. Writing the derived value keeps a page
@@ -629,7 +639,8 @@ function pageTitle(page) {
629
639
  * added to the vocabulary reaches the page without anyone remembering this
630
640
  * function exists. Only an authored **string** is touched: `null` is a note
631
641
  * naming no art and `""` is one naming none on purpose, and neither is an
632
- * address to resolve.
642
+ * address to resolve. A slot's declining word (`banner: none`) is emitted as
643
+ * a blank, the value a theme reads as "draw no image".
633
644
  *
634
645
  * **An address nothing answers is dropped**, not emitted as written. The theme
635
646
  * renders nothing where a value is absent — its silent-disappear convention —
@@ -643,11 +654,15 @@ function pageTitle(page) {
643
654
  * @returns {void}
644
655
  */
645
656
  function resolveArtFields(data, webSrc, artSrc) {
646
- for (const { key, type, accepts } of ART_SLOTS) {
657
+ for (const { key, type, accepts, declines } of ART_SLOTS) {
647
658
  const holder = isPlainObject(data.data) ? data.data : null;
648
659
  if (!holder) continue;
649
660
  const value = holder[key];
650
- if (typeof value !== "string" || value === "") continue;
661
+ if (declines !== undefined && value === declines) {
662
+ holder[key] = "";
663
+ continue;
664
+ }
665
+ if ((typeof value !== "string" && !isAddressTuple(value)) || value === "") continue;
651
666
  const pathname = artSrc(value, type, accepts);
652
667
  if (pathname === null) delete holder[key];
653
668
  else holder[key] = webSrc(pathname);
@@ -1229,8 +1244,8 @@ export function buildSite({ config, sqlTables } = {}) {
1229
1244
 
1230
1245
  const ctx = {
1231
1246
  packages,
1232
- // The package every note in the tree belongs to. `package:` is
1233
- // retired, so this is the only source of it.
1247
+ // The package every note in the tree belongs to. a note declares no
1248
+ // `package:`, so this is the only source of it.
1234
1249
  contentPackage: resolved.contentPackage,
1235
1250
  skipDirectories: resolved.skipDirectories,
1236
1251
  config: resolved,
@@ -1292,7 +1307,7 @@ export function buildSite({ config, sqlTables } = {}) {
1292
1307
  }
1293
1308
 
1294
1309
  // The whole tree is a build artifact, regenerated every run: a page whose
1295
- // note was deleted or renamed would otherwise linger and keep publishing.
1310
+ // note was deleted or moved would otherwise linger and keep publishing.
1296
1311
  fs.rmSync(outBase, { recursive: true, force: true });
1297
1312
 
1298
1313
  // Homepage-only stops here, and stopping is the point: nothing below reads
@@ -1313,7 +1328,7 @@ export function buildSite({ config, sqlTables } = {}) {
1313
1328
  embedErrors: [],
1314
1329
  mapFindings: [],
1315
1330
  stats: {
1316
- homepages: writeHomepages(homeRoot, homepages, resolved),
1331
+ homepages: writeHomepages(homeRoot, homepages, resolved, { records: ctx.records }),
1317
1332
  out: homeRoot,
1318
1333
  },
1319
1334
  };
@@ -1397,9 +1412,6 @@ export function buildSite({ config, sqlTables } = {}) {
1397
1412
  maps: drawn.maps,
1398
1413
  pass,
1399
1414
  // What counts as a being is the toolchain's to say, not a consumer's.
1400
- // Asking in a consumer's script is how one came to still be checking
1401
- // `character` and `creature` months after they were retired, and to
1402
- // publish 95 pages with empty sidebars for months without noticing.
1403
1415
  decorate: sitePageDecorator(resolved, gates.index),
1404
1416
  });
1405
1417
 
@@ -1408,6 +1420,8 @@ export function buildSite({ config, sqlTables } = {}) {
1408
1420
  // graph keys its block.
1409
1421
  const homepagesWritten = writeHomepages(homeRoot, homepages, resolved, {
1410
1422
  related: rendered.related.get(base),
1423
+ records: ctx.records,
1424
+ foreign: gates.foreign,
1411
1425
  });
1412
1426
 
1413
1427
  return {
@@ -36,11 +36,8 @@
36
36
  * matched by the segments it supplies rather than fetched by an exact key —
37
37
  * one hit resolves, and anything else is a finding.
38
38
  *
39
- * **A page's *name* is not a key**. It was, as one of a set of
40
- * collision-aware fallbacks a bare `[[Name]]` was looked up in — which is what
41
- * made two pages of one type forbidden from sharing a display name. The
42
- * bare form is retired, so the fallbacks answer nothing and the constraint they
43
- * imposed is gone with them.
39
+ * **A page's *name* is not a key**, so two pages of one type may share a
40
+ * display name.
44
41
  *
45
42
  * **It reports rather than exits.** A build script owns its diagnostics and its
46
43
  * exit code; this returns what it found. That is the same rule the rest of the
@@ -200,11 +197,9 @@ export function buildSiteIndex(
200
197
  const packages = new Set(ownPackage ? [ownPackage] : []);
201
198
 
202
199
  // A page is addressed by `(type, shortcode)` and nothing else. Its name,
203
- // filename and bare slug are not keys: the bare `[[Name]]` form that
204
- // looked them up is retired, and with it the rule that two pages of a
205
- // type may not share a name. A page carries no section either — a
206
- // section is a listing the configuration declares, not part of any
207
- // address.
200
+ // filename and bare slug are not keys, so two pages of a type may share
201
+ // a name. A page carries no section either — a section is a listing the
202
+ // configuration declares, not part of any address.
208
203
 
209
204
  // A foreign package may use a type this build has never seen. Seeding those
210
205
  // is what lets the resolver recognise `polity-xyz` as an address at all —
@@ -265,7 +260,7 @@ export function buildSiteIndex(
265
260
  // canonical form is what cross-package links use.
266
261
  // The page's package is the configured one — the site collection
267
262
  // resolves it and records it as `pkg`. Never read out of
268
- // frontmatter: `package:` is retired.
263
+ // frontmatter: a note declares no `package:`.
269
264
  index.set(
270
265
  canonicalKey(e.pkg ?? ownPackage, ownDocumentSystem(type), type, shortcode),
271
266
  value,
@@ -387,11 +382,10 @@ export function resolveCrossNoteFigures(
387
382
  * the one pass that resolves them.
388
383
  * @returns {object} The resolver context.
389
384
  *
390
- * There is deliberately **no `manifestsComplete`**. It used to let a resolver
391
- * soften an unresolved cross-package address while any package's manifest was
392
- * missing; the softening is retired, since the pack compilers and the link
393
- * checker never had it and one authored link must not get two verdicts. A
394
- * caller still passing it is ignored rather than obeyed.
385
+ * There is deliberately **no `manifestsComplete`**: an unresolved cross-package
386
+ * address is never softened while a manifest is missing, since the pack
387
+ * compilers and the link checker do not soften it and one authored link must
388
+ * not get two verdicts.
395
389
  */
396
390
  export function wikiContext(
397
391
  built,
@@ -2,6 +2,12 @@
2
2
  * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
3
  * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
4
  *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
5
11
  * SPDX-License-Identifier: GPL-3.0-or-later
6
12
  */
7
13
 
@@ -1,5 +1,12 @@
1
1
  /*
2
2
  * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
3
10
  *
4
11
  * SPDX-License-Identifier: GPL-3.0-or-later
5
12
  */
@@ -216,7 +223,7 @@ export async function indexSite({ site, output }) {
216
223
  * sits there. Left in place it would redirect the prefix root — the homepage —
217
224
  * to an address nothing publishes. The same holds for a `pagefind/` left by an
218
225
  * earlier build when `search` is off: deployed, it is a working search over
219
- * pages that may no longer exist, so it goes too.
226
+ * pages that may not exist, so it goes too.
220
227
  *
221
228
  * `_headers` is written before the index is built, so a failed index leaves
222
229
  * the root files as they would be — the failure is the indexer's, reported as
@@ -1,4 +1,15 @@
1
- /* SPDX-License-Identifier: GPL-3.0-or-later */
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
2
13
 
3
14
  /** Relationships a being states from its own point of view. */
4
15
  export const SOCIAL_TIES = Object.freeze([
@@ -5,6 +5,9 @@
5
5
  * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
6
  * You may copy, modify, and distribute it under the terms of that license.
7
7
  *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
8
11
  * SPDX-License-Identifier: GPL-3.0-or-later
9
12
  */
10
13
 
@@ -569,7 +569,7 @@ function cellText(value, column) {
569
569
  * ships a link that does not resolve. A `_section` alias splits the result into
570
570
  * a headed table per distinct value, in the order the rows arrive — so the
571
571
  * authored `ORDER BY` decides the section order too, and one query replaces the
572
- * forty near-identical blocks the language used to require.
572
+ * forty near-identical blocks it would otherwise take.
573
573
  *
574
574
  * **A result selecting nothing still renders its header and rule.** The finding
575
575
  * is the point, not withholding the output: an authored heading with an empty
@@ -677,6 +677,17 @@ export function renderSqlTable(
677
677
  export async function prepareSqlTables(db, sources, { linkable, records } = {}) {
678
678
  const prepared = new Map();
679
679
  if (records) prepared.pageLists = preparePageLists(records, sources);
680
+ // The corpus does not change while a preparation runs, so a query written
681
+ // twice has one answer. Each use gets its own copy, because a caller
682
+ // rewrites the rows it is handed.
683
+ const answers = new Map();
684
+ const reader = {
685
+ ...db,
686
+ async query(sql) {
687
+ if (!answers.has(sql)) answers.set(sql, await db.query(sql));
688
+ return structuredClone(answers.get(sql));
689
+ },
690
+ };
680
691
  for (const { source, markdown } of sources) {
681
692
  const blocks = findSqlBlocks(markdown);
682
693
  if (!blocks.length) continue;
@@ -685,7 +696,7 @@ export async function prepareSqlTables(db, sources, { linkable, records } = {})
685
696
  for (const block of blocks) {
686
697
  try {
687
698
  if (block.problems.length) throw new Error(block.problems.join("; "));
688
- const result = await runSqlQuery(db, block.query);
699
+ const result = await runSqlQuery(reader, block.query);
689
700
  forNote.push({
690
701
  markdown: renderSqlTable(result, {
691
702
  linkable,
@@ -810,9 +821,11 @@ export async function generatedSectionsFor(
810
821
  * From {@link generatedSectionsFor}.
811
822
  * @param {Array<{source: string, markdown: string, frontmatter?: object}>} sources -
812
823
  * The bodies to answer, extended in place.
824
+ * @param {Map<string, {body: string, frontmatter: object|null}>} [parsed] -
825
+ * Notes already parsed, keyed by file; a note absent from it is read.
813
826
  * @returns {void}
814
827
  */
815
- export function joinGeneratedSections(generated, sources) {
828
+ export function joinGeneratedSections(generated, sources, parsed) {
816
829
  for (const [absPath, text] of generated.sections) {
817
830
  const source = sources.find((one) => one.source === absPath);
818
831
  if (source) {
@@ -820,7 +833,7 @@ export function joinGeneratedSections(generated, sources) {
820
833
  continue;
821
834
  }
822
835
  if (!generated.views.has(absPath)) continue;
823
- const { body, frontmatter } = parseMarkdownFile(absPath);
836
+ const { body, frontmatter } = parsed?.get(absPath) ?? parseMarkdownFile(absPath);
824
837
  sources.push({
825
838
  source: absPath,
826
839
  markdown: appendGeneratedSections(body, text),
@@ -847,6 +860,23 @@ export function attachGeneratedSections(prepared, generated) {
847
860
  }
848
861
  }
849
862
 
863
+ /**
864
+ * Each declared dependency's cached index, as {@link openNotesDatabase} attaches
865
+ * them. Empty where none has been fetched.
866
+ *
867
+ * @param {object} [config] - Resolved configuration, defaulting to ambient.
868
+ * @returns {Promise<Array<{id: string, file: string}>>} The dependencies.
869
+ */
870
+ export async function cachedDependencies(config) {
871
+ try {
872
+ const { cachedMetadataIndexes } = await import("./metadata-index.mjs");
873
+ const { loadPackConfig } = await import("./pack-config.mjs");
874
+ return cachedMetadataIndexes(config ?? loadPackConfig());
875
+ } catch {
876
+ return [];
877
+ }
878
+ }
879
+
850
880
  /**
851
881
  * Answer every `sql` directive in a content tree.
852
882
  *
@@ -871,6 +901,14 @@ export function attachGeneratedSections(prepared, generated) {
871
901
  * @param {object[]} [opts.records] - Index records the caller already derived.
872
902
  * A command that also builds a link index holds them already, and deriving
873
903
  * them twice is the duplicated-corpus failure this closes.
904
+ * @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] -
905
+ * The notes {@link module:engine/content-index.indexRecordsFor} parsed for
906
+ * `records`, keyed by file, so no note is parsed twice. A note absent from it
907
+ * is read.
908
+ * @param {object} [opts.database] - An open notes database over `records`, from
909
+ * {@link openNotesDatabase} with this call's `audience` and the declared
910
+ * dependencies from {@link cachedDependencies}. It is queried and left open;
911
+ * absent, one is opened and closed here.
874
912
  * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes.
875
913
  * @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes the
876
914
  * generated sections take in, where the caller holds them already; absent,
@@ -881,21 +919,22 @@ export function attachGeneratedSections(prepared, generated) {
881
919
  */
882
920
  export async function prepareTreeSqlTables(
883
921
  contentBase,
884
- { config, skipDirectories, records, audience = "all", foreignIndex } = {},
922
+ { config, skipDirectories, records, parsed, database, audience = "all", foreignIndex } = {},
885
923
  ) {
886
924
  // Imported here rather than at module scope: the index reaches the pack
887
925
  // compilers through `manifest-emit` → `journals`, so a static import from a
888
926
  // module they load would close a cycle and leave `BasePackCompiler`
889
927
  // uninitialised for whichever module the runtime happened to load first.
890
928
  const { indexRecordsFor } = await import("./content-index.mjs");
929
+ const notes = parsed ?? new Map();
891
930
  const indexRecords = (
892
- records ?? indexRecordsFor({ contentBase, config, skipDirectories })
931
+ records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes })
893
932
  ).filter((record) => audience !== "public" || !isGmNote(record));
894
933
 
895
934
  // Which notes carry a directive, discovered over the same corpus every
896
935
  // other pass reads rather than over a walk of this one's own. The
897
936
  // body has to be read to find a fence — the index carries no note text —
898
- // but *which files* to read is no longer a second answer.
937
+ // but *which files* to read is not a second answer.
899
938
  //
900
939
  // `parseMarkdownFile` yields the body `walkMarkdownTree` yielded, trimmed
901
940
  // the same way, which matters: results are keyed by note and looked up by
@@ -905,7 +944,7 @@ export async function prepareTreeSqlTables(
905
944
  for (const record of indexRecords) {
906
945
  if (!isNoteRecord(record)) continue;
907
946
  const absPath = noteFile(contentBase, record);
908
- const { body, frontmatter } = parseMarkdownFile(absPath);
947
+ const { body, frontmatter } = notes.get(absPath) ?? parseMarkdownFile(absPath);
909
948
  if (
910
949
  body &&
911
950
  (findSqlBlocks(body).length ||
@@ -953,7 +992,7 @@ export async function prepareTreeSqlTables(
953
992
  );
954
993
  if (!needsDatabase) {
955
994
  const pageOnly = new Map();
956
- joinGeneratedSections(derivedOnly, sources);
995
+ joinGeneratedSections(derivedOnly, sources, notes);
957
996
  pageOnly.inline = new Map();
958
997
  pageOnly.pageLists = preparePageLists(indexRecords, sources);
959
998
  attachGeneratedSections(pageOnly, derivedOnly);
@@ -978,20 +1017,17 @@ export async function prepareTreeSqlTables(
978
1017
  // same cycle reason the index is, and tolerated when absent: a tree with no
979
1018
  // `sql` directive never reaches this line, and one whose dependency has not
980
1019
  // been fetched already fails earlier with a message naming the fetch.
981
- let dependencies = [];
982
- try {
983
- const { cachedMetadataIndexes } = await import("./metadata-index.mjs");
984
- const { loadPackConfig } = await import("./pack-config.mjs");
985
- dependencies = cachedMetadataIndexes(config ?? loadPackConfig());
986
- } catch {
987
- dependencies = [];
988
- }
989
- const db = await openNotesDatabase(indexRecords, { dependencies, audience });
1020
+ const db =
1021
+ database ??
1022
+ (await openNotesDatabase(indexRecords, {
1023
+ dependencies: await cachedDependencies(config),
1024
+ audience,
1025
+ }));
990
1026
  try {
991
1027
  const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
992
1028
  derived: derivedOnly.derived,
993
1029
  });
994
- joinGeneratedSections(generated, sources);
1030
+ joinGeneratedSections(generated, sources, notes);
995
1031
  const prepared = await prepareSqlTables(db, sources, {
996
1032
  linkable: (ref) => addresses.has(renderAddress(ref)),
997
1033
  records: indexRecords,
@@ -1000,6 +1036,6 @@ export async function prepareTreeSqlTables(
1000
1036
  attachGeneratedSections(prepared, generated);
1001
1037
  return prepared;
1002
1038
  } finally {
1003
- await db.close();
1039
+ if (!database) await db.close();
1004
1040
  }
1005
1041
  }
@@ -36,7 +36,8 @@ export const STANDING_BODY_TYPES = Object.freeze(["affiliation"]);
36
36
  *
37
37
  * `rank` is the level on the body's own `governance.ranks` ladder, and every
38
38
  * membership states one. `office` is a key of its `governance.offices` map, and
39
- * is stated only where the being holds a post.
39
+ * is stated only where the being holds a post. `societies` names the societies
40
+ * the membership is held in, as a list because a membership can span several.
40
41
  *
41
42
  * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
42
43
  */
@@ -56,6 +57,15 @@ export const STANDING_FIELDS = Object.freeze([
56
57
  shape: "a post from that body's `governance.offices`",
57
58
  describe: "A post the body names in its `governance.offices`, where one is held.",
58
59
  }),
60
+ Object.freeze({
61
+ name: "societies",
62
+ kind: "list",
63
+ shape: "a list of society names",
64
+ entries: Object.freeze({ kind: "string", shape: "a string" }),
65
+ describe:
66
+ "The societies the membership is held in, by name. SoHL carries one, " +
67
+ "the first, as the affiliation item's `system.society`.",
68
+ }),
59
69
  ]);
60
70
 
61
71
  /** The keys of {@link STANDING_FIELDS}, in declared order. */