@heroiclands/package-build 23.1.1 → 23.2.1

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 (247) hide show
  1. package/CHANGELOG.md +69 -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 +32 -0
  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/githooks/attribution.mjs +8 -0
  145. package/githooks/commit-message.mjs +9 -0
  146. package/hm3/actors.mjs +9 -48
  147. package/hm3/default-item-art.mjs +9 -9
  148. package/hm3/item-fields.mjs +2 -9
  149. package/hm3/template-priority.mjs +6 -7
  150. package/hugo-theme/data/banner-kinds.yaml +78 -0
  151. package/hugo-theme/data/banners.yaml +2 -3
  152. package/hugo-theme/layouts/404.html +3 -3
  153. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  154. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  155. package/hugo-theme/layouts/partials/related.html +1 -1
  156. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  157. package/hugo-theme/static/css/style.css +1 -1
  158. package/labels.mjs +2 -2
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +21 -14
  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/hm3/actors.d.mts +1 -2
  239. package/types/hm3/item-fields.d.mts +2 -4
  240. package/types/hm3/template-priority.d.mts +6 -7
  241. package/types/manifest.d.mts +3 -2
  242. package/types/release.d.mts +8 -7
  243. package/types/sohl/actors.d.mts +2 -1
  244. package/types/sohl/document-subtypes.d.mts +1 -2
  245. package/types/sohl/item-fields.d.mts +3 -7
  246. package/engine/retired-fields.mjs +0 -631
  247. 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
 
@@ -63,11 +63,10 @@
63
63
  * @module
64
64
  */
65
65
 
66
- import { assertTypeNotRetired, currentType } from "./ids.mjs";
67
66
  import { NO_SYSTEM } from "./systems.mjs";
68
67
 
69
68
  export { NO_SYSTEM };
70
- import { locateFrontmatterKey } from "./retired-fields.mjs";
69
+ import { locateFrontmatterKey } from "./diagnostics.mjs";
71
70
 
72
71
  /**
73
72
  * One row of a system's map: what a note of this type becomes.
@@ -201,12 +200,9 @@ function frozenRow(system, noteType, row) {
201
200
  /**
202
201
  * The row a system declares for a note type, or nothing.
203
202
  *
204
- * The **one** place a map is indexed, so the retirement window for a renamed
205
- * type is honoured everywhere a row is asked for — {@link documentSubtype},
206
- * {@link mapsNoteType}, {@link noteTypesFor}'s consumers and
207
- * {@link systemOf} all arrive here. A note still spelling `armorgear` finds the
208
- * `armor` row, compiles into the `armorgear` document it always did, and keeps
209
- * the address it publishes at; only the *report* tells it to move.
203
+ * The **one** place a map is indexed — {@link documentSubtype},
204
+ * {@link mapsNoteType}, {@link noteTypesFor}'s consumers and {@link systemOf}
205
+ * all arrive here.
210
206
  *
211
207
  * @param {DocumentSubtypeMap} map - The system's map.
212
208
  * @param {string|undefined} noteType - The note's declared `type`.
@@ -215,7 +211,7 @@ function frozenRow(system, noteType, row) {
215
211
  */
216
212
  export function subtypeRow(map, noteType) {
217
213
  if (!noteType || typeof noteType !== "string") return undefined;
218
- return map?.types?.[currentType(noteType)];
214
+ return map?.types?.[noteType];
219
215
  }
220
216
 
221
217
  /**
@@ -315,6 +311,29 @@ export function documentSubtype(map, noteType, fm, { file, absPath } = {}) {
315
311
  * @property {string} [problem] - Why the reference names no document subtype.
316
312
  */
317
313
 
314
+ /** Every subtype a map's rows produce, per map. @type {WeakMap<object, Set<string>>} */
315
+ const PRODUCED = new WeakMap();
316
+
317
+ /**
318
+ * The document subtypes the rows of a map produce.
319
+ *
320
+ * @param {DocumentSubtypeMap} map - The system's map.
321
+ * @returns {Set<string>} Each one-to-one row's subtype and every subtype a
322
+ * one-to-many row permits.
323
+ */
324
+ function producedSubtypes(map) {
325
+ let produced = PRODUCED.get(map);
326
+ if (!produced) {
327
+ produced = new Set();
328
+ for (const row of Object.values(map?.types ?? {})) {
329
+ if (row.subType) produced.add(row.subType);
330
+ for (const subType of row.subTypes ?? []) produced.add(subType);
331
+ }
332
+ PRODUCED.set(map, produced);
333
+ }
334
+ return produced;
335
+ }
336
+
318
337
  /**
319
338
  * The document subtype a `(type, shortcode)` **reference** addresses.
320
339
  *
@@ -354,41 +373,35 @@ export function documentSubtype(map, noteType, fm, { file, absPath } = {}) {
354
373
  * only the row has no block to read a discriminator from, so nothing here
355
374
  * can choose, and choosing anyway would be right about half the time.
356
375
  *
357
- * A **retired** spelling is refused by name before any of that. Without it a
358
- * reference left behind by a merge would take the unmapped fallback and address
359
- * a document of the old name — resolving silently, which is precisely what a
360
- * retirement exists to stop.
361
- *
362
- * A **renamed** spelling is the opposite case and resolves normally, through
363
- * the same {@link currentType} every other lookup goes through. It has to
364
- * be this side as much as the note's own `type:`: the overwhelming majority of
365
- * the 31,000 occurrences of the old names are `(type, shortcode)` references
366
- * inside a being's `items:` list, so a window that resolved notes but not
367
- * references would silently drop 30,000 embedded items rather than compile them
368
- * unchanged.
376
+ * A type that no row names, that no row produces as a subtype and that the
377
+ * consumer does not declare is refused: stamping the authored spelling on it
378
+ * would address a document that cannot exist.
369
379
  *
370
380
  * @param {DocumentSubtypeMap} map - The system's map.
371
381
  * @param {string|undefined} noteType - The type the reference names.
372
382
  * @param {string} document - The Foundry document class the reference must
373
383
  * address — `"Item"` for a being's embedded items.
384
+ * @param {object} [options] - Options.
385
+ * @param {ReadonlySet<string>} [options.declared] - The item types the
386
+ * consuming repository declares itself, which the map does not name.
374
387
  * @returns {ReferencedSubtype} The subtype, or why there is none.
375
388
  */
376
- export function referencedSubtype(map, noteType, document) {
389
+ export function referencedSubtype(map, noteType, document, { declared } = {}) {
377
390
  if (!noteType || typeof noteType !== "string") {
378
391
  return { problem: "the reference names no type" };
379
392
  }
380
- try {
381
- assertTypeNotRetired(noteType);
382
- } catch (err) {
383
- return { problem: /** @type {Error} */ (err).message };
384
- }
385
393
 
386
394
  const row = subtypeRow(map, noteType);
387
- // No row: the type is the consumer's own, and its document is stamped with
388
- // the note type. See the note on the unmapped fallback above. Returned as
389
- // *authored*, not normalised — the Item pass stamps an unmapped document
390
- // with `fm.type` verbatim, and the two answers have to agree.
391
- if (!row) return { subType: noteType };
395
+ // No row: the type is the consumer's own, or a subtype a row produces, and
396
+ // its document is stamped with the note type. See the note on the unmapped
397
+ // fallback above. Returned as *authored* — the Item pass stamps an unmapped
398
+ // document with `fm.type` verbatim, and the two answers have to agree.
399
+ if (!row) {
400
+ if (declared?.has(noteType) || producedSubtypes(map).has(noteType)) {
401
+ return { subType: noteType };
402
+ }
403
+ return { problem: `"${noteType}" is not a type any registry declares` };
404
+ }
392
405
 
393
406
  if (row.document !== document) {
394
407
  return {
@@ -406,8 +419,8 @@ export function referencedSubtype(map, noteType, document) {
406
419
  // reference does by never matching this row's key at all. Only a
407
420
  // reference that names the row without naming a subtype is genuinely
408
421
  // undecidable.
409
- if (permitted.includes(currentType(noteType))) {
410
- return { subType: currentType(noteType) };
422
+ if (permitted.includes(noteType)) {
423
+ return { subType: noteType };
411
424
  }
412
425
  return {
413
426
  problem:
@@ -59,11 +59,14 @@ const BY_DATE = "ORDER BY e.whenSort NULLS LAST, e.address";
59
59
  * index record, and the `query` it writes for a note — given the note's
60
60
  * Address in each form an event may name it by, and the shape of the corpus —
61
61
  * or `null` where the corpus states nothing the view could select. A view's
62
- * `own` query, where it has one, is what decides whether the view has rows.
62
+ * `own` query, where it has one, is what decides whether the view has rows;
63
+ * a view with `owners` answers that for many notes in one query, given each
64
+ * note's Address forms.
63
65
  *
64
66
  * @type {ReadonlyArray<Readonly<{slug: string, heading: string,
65
67
  * applies: (record: object) => boolean,
66
- * query: (self: string[], shape: CorpusShape) => {fence: string, own?: string}|null}>>}
68
+ * query: (self: string[], shape: CorpusShape) => {fence: string, own?: string}|null,
69
+ * owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>}>>}
67
70
  */
68
71
  export const EVENT_VIEWS = Object.freeze([
69
72
  Object.freeze({
@@ -71,6 +74,7 @@ export const EVENT_VIEWS = Object.freeze([
71
74
  heading: "Chronology",
72
75
  applies: (record) => record.type === "place",
73
76
  query: chronologyQuery,
77
+ owners: chronologyOwners,
74
78
  }),
75
79
  Object.freeze({
76
80
  slug: "events",
@@ -79,6 +83,8 @@ export const EVENT_VIEWS = Object.freeze([
79
83
  record.type === "being" ||
80
84
  record.type === "affiliation" ||
81
85
  (record.type === "lore" && record.subType === "culture"),
86
+ owners: (db, selves, shape) =>
87
+ namedOwners(db, selves, "SELECT unnest(who).ref AS ref FROM events", shape.who),
82
88
  query: (self, shape) => {
83
89
  if (!shape.who) return null;
84
90
  const named = `list_filter(e.who, w -> w.ref IN (${literals(self)}))`;
@@ -102,6 +108,13 @@ export const EVENT_VIEWS = Object.freeze([
102
108
  record.type === "affiliation" ||
103
109
  record.type === "place" ||
104
110
  (record.type === "lore" && record.subType === "culture"),
111
+ owners: (db, selves, shape) =>
112
+ namedOwners(
113
+ db,
114
+ selves,
115
+ 'SELECT account."by" AS ref FROM (SELECT unnest(accounts) AS account FROM events)',
116
+ shape.accounts,
117
+ ),
105
118
  query: (self, shape) => {
106
119
  if (!shape.accounts) return null;
107
120
  return {
@@ -122,6 +135,14 @@ export const EVENT_VIEWS = Object.freeze([
122
135
  slug: "followed",
123
136
  heading: "What followed",
124
137
  applies: (record) => Array.isArray(record.data?.events) && record.data.events.length > 0,
138
+ owners: (db, selves, shape) =>
139
+ namedOwners(
140
+ db,
141
+ selves,
142
+ "SELECT split_part(edge.event, '#', 1) AS ref " +
143
+ "FROM (SELECT unnest(follows) AS edge FROM events)",
144
+ shape.follows,
145
+ ),
125
146
  query: (self, shape) => {
126
147
  if (!shape.follows) return null;
127
148
  const edges =
@@ -153,6 +174,96 @@ export const EVENT_VIEWS = Object.freeze([
153
174
  * @property {boolean} depth - Some event states `depth`.
154
175
  */
155
176
 
177
+ /**
178
+ * Which notes an event names, asked of every note in one query rather than one
179
+ * query per note: the references the events state are listed once and joined to
180
+ * every note's Address forms.
181
+ *
182
+ * @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase}.
183
+ * @param {string[][]} selves - Each note's Address, in each form.
184
+ * @param {string} refs - A query selecting one `ref` per reference an event
185
+ * states.
186
+ * @param {boolean} stated - Whether the corpus states the field `refs` reads.
187
+ * @returns {Promise<Set<string>>} The first form of each note some event names.
188
+ */
189
+ async function namedOwners(db, selves, refs, stated) {
190
+ if (!stated) return new Set();
191
+ const values = selves
192
+ .flatMap((forms) => forms.map((form) => `(${literal(forms[0])}, ${literal(form)})`))
193
+ .join(", ");
194
+ const { rows } = await db.query(
195
+ [
196
+ `WITH selves(root, form) AS (VALUES ${values}),`,
197
+ `named AS (${refs})`,
198
+ "SELECT DISTINCT selves.root AS root",
199
+ "FROM named JOIN selves ON selves.form = named.ref",
200
+ ].join("\n"),
201
+ );
202
+ return new Set(rows.map((row) => String(row.root)));
203
+ }
204
+
205
+ /** The `reach` clauses felt in the places `here` holds. */
206
+ function chronologyFelt() {
207
+ return 'list_filter(e."where".reach, r -> list_contains(here.places, r.place))';
208
+ }
209
+
210
+ /**
211
+ * The conditions under which an event is one of a place's own rows: it
212
+ * happened at the place or below it, or it is felt there.
213
+ *
214
+ * @param {CorpusShape} shape - What the corpus states.
215
+ * @returns {string[]} The alternatives, over `e` and `here.places`.
216
+ */
217
+ function chronologyOwnRows(shape) {
218
+ return [
219
+ ...(shape.locus ?
220
+ ['len(list_filter(e."where".locus, l -> list_contains(here.places, l))) > 0']
221
+ : []),
222
+ ...(shape.reach ? [`len(${chronologyFelt()}) > 0`] : []),
223
+ ];
224
+ }
225
+
226
+ /**
227
+ * Which places have a chronology of their own rows, asked of every place in one
228
+ * query rather than one query per place.
229
+ *
230
+ * The places below each root are walked together, so the walk over `entries`
231
+ * is paid once for the whole corpus instead of once per place.
232
+ *
233
+ * @param {object} db - From {@link module:engine/sql-tables.openNotesDatabase}.
234
+ * @param {string[][]} selves - Each place's Address, in each form; the `note`
235
+ * system's is the first.
236
+ * @param {CorpusShape} shape - What the corpus states.
237
+ * @returns {Promise<Set<string>>} The roots with at least one own row.
238
+ */
239
+ async function chronologyOwners(db, selves, shape) {
240
+ const values = [...new Set(selves.map((self) => self[0]))]
241
+ .map((root) => `(${literal(root)})`)
242
+ .join(", ");
243
+ const below =
244
+ shape.parents ?
245
+ [
246
+ "WITH RECURSIVE below(root, place) AS (",
247
+ ` SELECT r, r FROM (VALUES ${values}) AS t(r)`,
248
+ " UNION",
249
+ " SELECT b.root, lower(p.package || '-note-place-' || p.shortcode)",
250
+ " FROM entries p JOIN below b ON list_contains(p.data.parents, b.place)",
251
+ " WHERE p.type = 'place'",
252
+ "),",
253
+ ]
254
+ : [`WITH RECURSIVE below(root, place) AS (SELECT r, r FROM (VALUES ${values}) AS t(r)),`];
255
+ const { rows } = await db.query(
256
+ [
257
+ ...below,
258
+ "here AS (SELECT root, list(place) AS places FROM below GROUP BY root)",
259
+ "SELECT DISTINCT here.root AS root",
260
+ "FROM events e, here",
261
+ `WHERE ${chronologyOwnRows(shape).join(" OR ")}`,
262
+ ].join("\n"),
263
+ );
264
+ return new Set(rows.map((row) => String(row.root)));
265
+ }
266
+
156
267
  /**
157
268
  * A place's chronology: every event that happened at the place or anywhere
158
269
  * below it in `parents`, every event felt there — with the clause saying how —
@@ -181,13 +292,8 @@ function chronologyQuery(self, shape) {
181
292
  "),",
182
293
  ]
183
294
  : [`WITH RECURSIVE below(place) AS (SELECT ${literal(self[0])}),`];
184
- const felt = 'list_filter(e."where".reach, r -> list_contains(here.places, r.place))';
185
- const ownRows = [
186
- ...(shape.locus ?
187
- ['len(list_filter(e."where".locus, l -> list_contains(here.places, l))) > 0']
188
- : []),
189
- ...(shape.reach ? [`len(${felt}) > 0`] : []),
190
- ];
295
+ const felt = chronologyFelt();
296
+ const ownRows = chronologyOwnRows(shape);
191
297
  const head = [
192
298
  ...below,
193
299
  "here AS (SELECT list(place) AS places FROM below)",
@@ -241,7 +347,7 @@ async function corpusShape(db) {
241
347
  * @param {object} record - The note's index record.
242
348
  * @returns {string[]} The forms, the `note` one first.
243
349
  */
244
- function selfForms(record) {
350
+ export function selfForms(record) {
245
351
  const tuple = {
246
352
  package: String(record.package),
247
353
  type: String(record.type),
@@ -285,6 +391,21 @@ export async function eventViewSections(db, records, fileOf, only) {
285
391
  if (!all.some((record) => Array.isArray(record?.data?.events) && record.data.events.length))
286
392
  return out;
287
393
  const shape = await corpusShape(db);
394
+ // A view that can say which notes have rows in one query is asked once, over
395
+ // every note it will be given, and the per-note count is skipped.
396
+ const owned = new Map();
397
+ for (const view of EVENT_VIEWS) {
398
+ if (!view.owners) continue;
399
+ const selves = [];
400
+ for (const record of all) {
401
+ if (!record?.address || record.documents || !record.shortcode) continue;
402
+ if (only !== undefined && fileOf(record) !== only) continue;
403
+ if (!view.applies(record) || authoredSection(record, view.slug)) continue;
404
+ const self = selfForms(record);
405
+ if (view.query(self, shape)) selves.push(self);
406
+ }
407
+ if (selves.length) owned.set(view.slug, await view.owners(db, selves, shape));
408
+ }
288
409
  for (const record of all) {
289
410
  if (!record?.address || record.documents || !record.shortcode) continue;
290
411
  if (only !== undefined && fileOf(record) !== only) continue;
@@ -294,10 +415,14 @@ export async function eventViewSections(db, records, fileOf, only) {
294
415
  if (!view.applies(record) || authoredSection(record, view.slug)) continue;
295
416
  const query = view.query(self, shape);
296
417
  if (!query) continue;
297
- const { rows } = await db.query(
298
- `SELECT count(*) AS n FROM (${query.own ?? query.fence})`,
299
- );
300
- if (Number(rows[0]?.n ?? 0) > 0) sections.set(view.slug, section(view, query.fence));
418
+ const has =
419
+ owned.has(view.slug) ?
420
+ owned.get(view.slug).has(self[0])
421
+ : Number(
422
+ (await db.query(`SELECT count(*) AS n FROM (${query.own ?? query.fence})`))
423
+ .rows[0]?.n ?? 0,
424
+ ) > 0;
425
+ if (has) sections.set(view.slug, section(view, query.fence));
301
426
  }
302
427
  if (sections.size) out.set(fileOf(record), sections);
303
428
  }
@@ -1,10 +1,21 @@
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
 
5
14
  /** Preserve an exported Foundry Scene while binding marked pins to note pages. */
6
15
 
7
16
  import { journalPageId, splitPages } from "./journals.mjs";
17
+ import { dataFields } from "./note-vocabulary.mjs";
18
+ import { isAddressTuple, renderAddress } from "./address.mjs";
8
19
 
9
20
  const EMBEDDED = Object.freeze([
10
21
  "drawings",
@@ -96,38 +107,90 @@ function fixupTarget(scene, path) {
96
107
  return { parent: current, key: last.key };
97
108
  }
98
109
 
99
- /** Apply author-declared asset address replacements to an exported Scene. */
100
- function applyFixups(scene, fixups, resolveAddress) {
101
- if (fixups === undefined) return;
102
- if (!Array.isArray(fixups)) throw new Error("`data.fixup` must be an array");
103
- for (const [index, fixup] of fixups.entries()) {
104
- if (
105
- !fixup ||
106
- typeof fixup !== "object" ||
107
- Array.isArray(fixup) ||
108
- fixup.type !== "address" ||
109
- typeof fixup.value !== "string" ||
110
- !fixup.value
111
- ) {
110
+ /**
111
+ * Every fixup a map note states, each with the key it was authored at.
112
+ *
113
+ * Two sources, applied by one mechanism. `data.fixup` names its Scene paths
114
+ * outright. A map field declaring a `sceneProperty` — `thumbnail`, written into
115
+ * `.thumb` — names the Scene property in the vocabulary instead, so the note
116
+ * states only the Address and the compile adds the entry. A declared field and
117
+ * an authored entry writing one property is refused: either would silently
118
+ * overwrite the other.
119
+ *
120
+ * @param {object} fm - The map note's frontmatter.
121
+ * @returns {Array<{label: string, fixup: unknown}>} The fixups, authored first.
122
+ * @throws {Error} When `data.fixup` is not a list, or two fixups write one path.
123
+ */
124
+ function noteFixups(fm) {
125
+ const authored = fm.data?.fixup;
126
+ if (authored !== undefined && !Array.isArray(authored)) {
127
+ throw new Error("`data.fixup` must be an array");
128
+ }
129
+ const out = (authored ?? []).map((fixup, index) => ({ label: `data.fixup[${index}]`, fixup }));
130
+ for (const field of dataFields("map") ?? []) {
131
+ if (!field.sceneProperty) continue;
132
+ const value = fm.data?.[field.name];
133
+ if (value === undefined || value === null || value === "") continue;
134
+ const label = `data.${field.name}`;
135
+ const clash = out.find(({ fixup }) => fixup?.path === field.sceneProperty);
136
+ if (clash) {
112
137
  throw new Error(
113
- `data.fixup[${index}] needs path, type: address, and an asset address value`,
138
+ `\`${label}\` and \`${clash.label}\` both write \`${field.sceneProperty}\`; ` +
139
+ `keep one of them`,
114
140
  );
115
141
  }
116
- const { parent, key } = fixupTarget(scene, fixup.path);
117
- if (typeof parent[key] !== "string" && parent[key] !== null) {
118
- throw new Error(`Scene fixup path "${fixup.path}" must name a string asset field`);
119
- }
120
- if (typeof resolveAddress !== "function") {
121
- throw new Error("Scene address fixups need an asset resolver");
122
- }
123
- const resolved = resolveAddress(fixup.value);
124
- if (typeof resolved !== "string" || !resolved) {
125
- throw new Error(`Scene fixup address "${fixup.value}" does not resolve to an asset`);
142
+ out.push({
143
+ label,
144
+ fixup: {
145
+ path: field.sceneProperty,
146
+ type: "address",
147
+ value: isAddressTuple(value) ? renderAddress(value) : value,
148
+ },
149
+ });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** Apply a map note's asset address replacements to its exported Scene. */
155
+ function applyFixups(scene, fixups, resolveAddress) {
156
+ for (const { label, fixup } of fixups) {
157
+ try {
158
+ applyFixup(scene, label, fixup, resolveAddress);
159
+ } catch (error) {
160
+ // An entry the compile added says which key the note wrote, since
161
+ // the path it failed on appears nowhere in the note.
162
+ if (label.startsWith("data.fixup[")) throw error;
163
+ throw new Error(`\`${label}\`: ${error.message}`, { cause: error });
126
164
  }
127
- parent[key] = resolved;
128
165
  }
129
166
  }
130
167
 
168
+ /** Resolve one fixup's Address and write its path into the Scene. */
169
+ function applyFixup(scene, label, fixup, resolveAddress) {
170
+ if (
171
+ !fixup ||
172
+ typeof fixup !== "object" ||
173
+ Array.isArray(fixup) ||
174
+ fixup.type !== "address" ||
175
+ typeof fixup.value !== "string" ||
176
+ !fixup.value
177
+ ) {
178
+ throw new Error(`${label} needs path, type: address, and an asset address value`);
179
+ }
180
+ const { parent, key } = fixupTarget(scene, fixup.path);
181
+ if (typeof parent[key] !== "string" && parent[key] !== null) {
182
+ throw new Error(`Scene fixup path "${fixup.path}" must name a string asset field`);
183
+ }
184
+ if (typeof resolveAddress !== "function") {
185
+ throw new Error("Scene address fixups need an asset resolver");
186
+ }
187
+ const resolved = resolveAddress(fixup.value);
188
+ if (typeof resolved !== "string" || !resolved) {
189
+ throw new Error(`Scene fixup address "${fixup.value}" does not resolve to an asset`);
190
+ }
191
+ parent[key] = resolved;
192
+ }
193
+
131
194
  /**
132
195
  * Add a compendium key to every embedded document that carries an `_id`.
133
196
  *
@@ -156,8 +219,9 @@ function keyDocuments(docs, collection, parentIds, sceneId) {
156
219
  * Compile a Foundry Scene export into a pack document.
157
220
  *
158
221
  * The Scene passes through as exported. Only the note-derived Scene ID,
159
- * compendium metadata, `data.fixup` and pins marked `#anchor` change; nothing
160
- * else inside the Scene is read or checked.
222
+ * compendium metadata, the fixups — `data.fixup` and the map fields that
223
+ * declare a Scene property, such as `data.thumbnail` — and pins marked
224
+ * `#anchor` change; nothing else inside the Scene is read or checked.
161
225
  *
162
226
  * @param {object} fm - The map note's frontmatter.
163
227
  * @param {string} markdown - Its converted Markdown body.
@@ -175,7 +239,7 @@ export function buildExportedScene(fm, markdown, { journalEntryId, stats, resolv
175
239
  throw new Error("a map note needs a derived 16-character Scene ID");
176
240
  }
177
241
  const scene = structuredClone(source);
178
- applyFixups(scene, fm.data?.fixup, resolveAddress);
242
+ applyFixups(scene, noteFixups(fm), resolveAddress);
179
243
  scene._id = fm.id;
180
244
  scene._key = `!scenes!${fm.id}`;
181
245
  if (stats) scene._stats = stats;
@@ -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
  /**
4
15
  * A name an id, a class or an attribute key may carry.