@heroiclands/package-build 23.1.1 → 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 (246) hide show
  1. package/CHANGELOG.md +63 -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/manifest.mjs +11 -4
  159. package/markdownlint-config.mjs +1 -1
  160. package/package.json +6 -3
  161. package/release.mjs +21 -14
  162. package/sohl/actors.mjs +16 -27
  163. package/sohl/default-item-art.mjs +3 -3
  164. package/sohl/document-subtypes.mjs +1 -16
  165. package/sohl/infobox.mjs +2 -3
  166. package/sohl/item-builders.mjs +5 -8
  167. package/sohl/item-fields.mjs +5 -35
  168. package/sohl/items.mjs +0 -4
  169. package/sohl/kb-passes.mjs +3 -3
  170. package/types/container.d.mts +116 -5
  171. package/types/content-config.d.mts +83 -35
  172. package/types/deploy.d.mts +42 -6
  173. package/types/e2e.d.mts +35 -3
  174. package/types/engine/address-charset.d.mts +4 -17
  175. package/types/engine/address.d.mts +17 -8
  176. package/types/engine/art-fields.d.mts +7 -4
  177. package/types/engine/art-slots.d.mts +6 -0
  178. package/types/engine/base-compiler.d.mts +15 -17
  179. package/types/engine/content-address.d.mts +9 -12
  180. package/types/engine/content-embeds.d.mts +4 -2
  181. package/types/engine/content-format-check.d.mts +0 -64
  182. package/types/engine/content-index.d.mts +82 -3
  183. package/types/engine/content-links.d.mts +2 -4
  184. package/types/engine/culture-choice.d.mts +51 -8
  185. package/types/engine/data-keys.d.mts +2 -2
  186. package/types/engine/derived-fields.d.mts +1 -2
  187. package/types/engine/diagnostics.d.mts +27 -4
  188. package/types/engine/document-subtypes.d.mts +12 -31
  189. package/types/engine/event-views.d.mts +14 -2
  190. package/types/engine/exported-scene.d.mts +3 -2
  191. package/types/engine/field-spec.d.mts +13 -122
  192. package/types/engine/folder-notes.d.mts +0 -39
  193. package/types/engine/foreign-catalog.d.mts +2 -1
  194. package/types/engine/foundry-entries.d.mts +4 -5
  195. package/types/engine/frontmatter-lint.d.mts +2 -2
  196. package/types/engine/frontmatter.d.mts +19 -45
  197. package/types/engine/generate.d.mts +1 -1
  198. package/types/engine/helpers.d.mts +25 -42
  199. package/types/engine/homepage.d.mts +3 -10
  200. package/types/engine/ids.d.mts +0 -114
  201. package/types/engine/index-records.d.mts +6 -4
  202. package/types/engine/index.d.mts +0 -1
  203. package/types/engine/infobox-render.d.mts +7 -1
  204. package/types/engine/infobox.d.mts +3 -2
  205. package/types/engine/item-docs.d.mts +1 -1
  206. package/types/engine/item-registry.d.mts +2 -4
  207. package/types/engine/literature-notes.d.mts +1 -1
  208. package/types/engine/markdownlint-run.d.mts +40 -0
  209. package/types/engine/metadata-index.d.mts +9 -1
  210. package/types/engine/note-addresses.d.mts +27 -2
  211. package/types/engine/note-claims.d.mts +3 -5
  212. package/types/engine/note-ids.d.mts +2 -4
  213. package/types/engine/note-package.d.mts +1 -1
  214. package/types/engine/note-state.d.mts +1 -3
  215. package/types/engine/note-vocabulary.d.mts +28 -21
  216. package/types/engine/pack-config.d.mts +2 -6
  217. package/types/engine/pack-router.d.mts +4 -4
  218. package/types/engine/pathnames.d.mts +3 -3
  219. package/types/engine/pdf-build.d.mts +98 -12
  220. package/types/engine/pdf-coverage.d.mts +60 -0
  221. package/types/engine/pdf-index.d.mts +34 -5
  222. package/types/engine/pdf-pages.d.mts +143 -0
  223. package/types/engine/pdf-render.d.mts +45 -1
  224. package/types/engine/pdf-toc.d.mts +12 -14
  225. package/types/engine/prose-config.d.mts +1 -1
  226. package/types/engine/prose-lint.d.mts +11 -10
  227. package/types/engine/runtime-only-fields.d.mts +2 -2
  228. package/types/engine/site-build.d.mts +9 -5
  229. package/types/engine/site-index.d.mts +4 -5
  230. package/types/engine/site-root.d.mts +1 -1
  231. package/types/engine/sql-tables.d.mts +31 -2
  232. package/types/engine/standing-terms.d.mts +2 -1
  233. package/types/engine/standings.d.mts +7 -9
  234. package/types/engine/system-block.d.mts +10 -44
  235. package/types/engine/walk-files.d.mts +15 -0
  236. package/types/engine/wikilink-syntax.d.mts +18 -19
  237. package/types/hm3/actors.d.mts +1 -2
  238. package/types/hm3/item-fields.d.mts +2 -4
  239. package/types/hm3/template-priority.d.mts +6 -7
  240. package/types/manifest.d.mts +3 -2
  241. package/types/release.d.mts +8 -7
  242. package/types/sohl/actors.d.mts +2 -1
  243. package/types/sohl/document-subtypes.d.mts +1 -2
  244. package/types/sohl/item-fields.d.mts +3 -7
  245. package/engine/retired-fields.mjs +0 -631
  246. package/types/engine/retired-fields.d.mts +0 -354
@@ -0,0 +1,188 @@
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
+ */
13
+
14
+ /**
15
+ * What the declared books cover, as a pure function of their selections.
16
+ *
17
+ * Every book's filters run against one corpus before any book renders, so the
18
+ * questions that span books are answered once, here: which printable notes no
19
+ * book selects, which book is each selected note's home, and how many notes
20
+ * each book prints. Nothing is read or written; the build hands in the records
21
+ * and prints what comes back.
22
+ *
23
+ * - **A candidate** is a note a book can print — not a stub, not a `gm` note —
24
+ * and not named by `pdf.unprinted`. A candidate no book selects is a finding
25
+ * at the severity `pdf.coverage` names, and `off` emits none.
26
+ * - **The home book** is the first book in configuration order that selects the
27
+ * note. A note several books select prints in each; a note selected twice
28
+ * inside one book is still one book.
29
+ *
30
+ * @module engine/pdf-coverage
31
+ */
32
+
33
+ import { isNoteRecord, isStub } from "./index-records.mjs";
34
+ import { encodeAddresses } from "./address-values.mjs";
35
+ import { isGmNote } from "./note-vocabulary.mjs";
36
+
37
+ /** The message a note in no book carries. */
38
+ export const UNCOVERED_MESSAGE =
39
+ "no book selects this note; add it to a book's query or to `pdf.unprinted`";
40
+
41
+ /**
42
+ * Whether a record is a note a book can print.
43
+ *
44
+ * @param {object} record - An index record.
45
+ * @returns {boolean} False for an asset, a documentation row, a stub and a `gm` note.
46
+ */
47
+ export function isPrintable(record) {
48
+ return isNoteRecord(record) && !isStub(record) && !isGmNote(record);
49
+ }
50
+
51
+ /**
52
+ * The key a note is counted by: its canonical address as text. An index record
53
+ * holds the address as a tuple and a row of the notes database as a string;
54
+ * both give the same key.
55
+ *
56
+ * @param {object} record - A printable record.
57
+ * @returns {string} The key.
58
+ */
59
+ export function recordKey(record) {
60
+ const canonical = record?.address?.canonical;
61
+ return String(
62
+ canonical == null ?
63
+ (record?.file?.path ?? record?.shortcode ?? "")
64
+ : encodeAddresses(canonical),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Whether a note has no resolved culture.
70
+ *
71
+ * @param {object} record - A record.
72
+ * @returns {boolean} True when `data.culture` is absent, null or empty.
73
+ */
74
+ function lacksCulture(record) {
75
+ const culture = record?.data?.culture;
76
+ return culture == null || culture === "" || (Array.isArray(culture) && !culture.length);
77
+ }
78
+
79
+ /**
80
+ * Compute coverage, homes and the summary.
81
+ *
82
+ * @param {object} input - Options.
83
+ * @param {Array<{id: string, records: object[]}>} input.books - Each book's
84
+ * selected records, in configuration order; a record selected twice appears
85
+ * twice.
86
+ * @param {object[]} input.corpus - Every record in the index.
87
+ * @param {object[]} [input.unprinted] - The records `pdf.unprinted` selects.
88
+ * @param {"warning"|"error"|"off"} [input.mode] - `pdf.coverage`.
89
+ * @param {(record: object) => {file?: string, line?: number, column?: number}} [input.locate]
90
+ * Where a note's `type:` line sits; a field it cannot supply is dropped.
91
+ * @returns {{findings: object[], homes: Map<string, string>, summary: object}}
92
+ * The findings in corpus order, each note's home book id by address, and the
93
+ * summary: per-book counts, totals and every note several books select.
94
+ */
95
+ export function coverage({ books, corpus, unprinted = [], mode = "warning", locate = () => ({}) }) {
96
+ const shelved = new Set(unprinted.filter(isPrintable).map(recordKey));
97
+ const candidates = corpus.filter(
98
+ (record) => isPrintable(record) && !shelved.has(recordKey(record)),
99
+ );
100
+
101
+ /** @type {Map<string, string>} */
102
+ const homes = new Map();
103
+ /** @type {Map<string, {record: object, books: string[]}>} */
104
+ const carriers = new Map();
105
+ const perBook = books.map((book) => {
106
+ const distinct = new Map();
107
+ for (const record of book.records) {
108
+ const key = recordKey(record);
109
+ distinct.set(key, record);
110
+ if (!homes.has(key)) homes.set(key, book.id);
111
+ const carrier = carriers.get(key) ?? { record, books: [] };
112
+ if (!carrier.books.includes(book.id)) carrier.books.push(book.id);
113
+ carriers.set(key, carrier);
114
+ }
115
+ return {
116
+ id: book.id,
117
+ entries: book.records.length,
118
+ notes: distinct.size,
119
+ uncultured: [...distinct.values()].filter(lacksCulture).length,
120
+ };
121
+ });
122
+
123
+ const uncovered = candidates.filter((record) => !carriers.has(recordKey(record)));
124
+ const findings =
125
+ mode === "off" ?
126
+ []
127
+ : uncovered.map((record) => ({
128
+ ...locate(record),
129
+ severity: mode,
130
+ message: UNCOVERED_MESSAGE,
131
+ }));
132
+
133
+ const order = new Map(books.map((book, index) => [book.id, index]));
134
+ const multiple = [...carriers.values()]
135
+ .filter((carrier) => carrier.books.length > 1)
136
+ .map((carrier) => ({
137
+ name: String(carrier.record?.name?.full ?? carrier.record?.shortcode ?? ""),
138
+ path: String(carrier.record?.file?.path ?? ""),
139
+ books: carrier.books,
140
+ home: carrier.books[0],
141
+ }))
142
+ .sort(
143
+ (a, b) =>
144
+ order.get(a.home) - order.get(b.home) ||
145
+ (a.path < b.path ? -1
146
+ : a.path > b.path ? 1
147
+ : 0),
148
+ );
149
+
150
+ return {
151
+ findings,
152
+ homes,
153
+ summary: {
154
+ books: perBook,
155
+ totals: {
156
+ candidates: candidates.length,
157
+ uncovered: uncovered.length,
158
+ unprinted: shelved.size,
159
+ },
160
+ multiple,
161
+ },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * The summary as the lines the command prints.
167
+ *
168
+ * @param {object} summary - From {@link coverage}.
169
+ * @returns {string[]} One line per book, one for the totals, one per note
170
+ * several books select.
171
+ */
172
+ export function summaryLines(summary) {
173
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
174
+ return [
175
+ ...summary.books.map(
176
+ (book) =>
177
+ `${book.id}: ${plural(book.entries, "entry", "entries")}, ` +
178
+ `${plural(book.notes, "note", "notes")}, ${book.uncultured} without a culture`,
179
+ ),
180
+ `${plural(summary.totals.candidates, "printable note", "printable notes")}, ` +
181
+ `${summary.totals.uncovered} in no book, ${summary.totals.unprinted} in pdf.unprinted`,
182
+ ...summary.multiple.map(
183
+ (note) =>
184
+ `${note.name} (${note.path}) is printed by ${note.books.join(", ")}; ` +
185
+ `home: ${note.home}`,
186
+ ),
187
+ ];
188
+ }
@@ -88,11 +88,22 @@ function letterOf(key) {
88
88
  /**
89
89
  * Every term the book's index lists, in index order.
90
90
  *
91
+ * A book lists the notes it is home to. An entry `owns` rejects is a reprint of
92
+ * another book's note: it prints there, and its term belongs to its home
93
+ * book's index.
94
+ *
91
95
  * @param {object[]} entries - The plan's entries.
92
- * @returns {Array<{letter: string, name: string, slug: string, see?: string}>}
93
- * Terms with `see` set for a cross-reference to the main name it points at.
96
+ * @param {object} [opts] - Options.
97
+ * @param {(entry: object) => boolean} [opts.owns] - Whether this book is the
98
+ * entry's home. Every entry when absent.
99
+ * @param {Array<{slug: string, name: string, title: string, page: string|number}>} [opts.foreign] -
100
+ * Notes another book prints, listed as `Name: Book title, p. N` once this
101
+ * book mentions them.
102
+ * @returns {Array<{letter: string, name: string, slug: string, see?: string,
103
+ * foreign?: {title: string, page: string}}>} Terms with `see` set for a
104
+ * cross-reference to the main name it points at.
94
105
  */
95
- export function indexTerms(entries) {
106
+ export function indexTerms(entries, { owns = () => true, foreign = [] } = {}) {
96
107
  const terms = [];
97
108
  const info = new Map();
98
109
  const nameByCanonical = new Map();
@@ -103,7 +114,7 @@ export function indexTerms(entries) {
103
114
  if (canonical && name) nameByCanonical.set(String(canonical), name);
104
115
  }
105
116
  for (const entry of entries ?? []) {
106
- if (entry?.kind !== "note") continue;
117
+ if (entry?.kind !== "note" || !owns(entry)) continue;
107
118
  const slug = slugOf(entry.record);
108
119
  const name = String(entry.record?.name?.full ?? "").trim();
109
120
  if (!slug || !name || info.has(slug)) continue;
@@ -166,6 +177,18 @@ export function indexTerms(entries) {
166
177
  ...(term.main ? {} : { see: mainShown.get(term.slug) }),
167
178
  key: sortKey(shown[i]),
168
179
  }));
180
+ const listed = new Set(terms.map((term) => term.slug));
181
+ for (const other of foreign) {
182
+ const name = String(other.name ?? "").trim();
183
+ if (!other.slug || !name || listed.has(other.slug)) continue;
184
+ listed.add(other.slug);
185
+ keyed.push({
186
+ name,
187
+ slug: other.slug,
188
+ foreign: { title: String(other.title), page: String(other.page) },
189
+ key: sortKey(name),
190
+ });
191
+ }
169
192
  keyed.sort((a, b) => {
170
193
  const la = letterOf(a.key);
171
194
  const lb = letterOf(b.key);
@@ -190,9 +213,12 @@ export function indexTerms(entries) {
190
213
  /**
191
214
  * The Typst function definitions the markers and the chapter rely on.
192
215
  *
216
+ * @param {object} [opts] - Options.
217
+ * @param {boolean} [opts.seeAlso] - Whether the chapter may list notes another
218
+ * book prints.
193
219
  * @returns {string} Typst source.
194
220
  */
195
- export function indexPreamble() {
221
+ export function indexPreamble({ seeAlso = false } = {}) {
196
222
  return [
197
223
  // Zero-size and invisible; the label is what the chapter queries.
198
224
  "#let book-ix(key, main: false) = [#metadata((key: key, main: main)) <book-index>]",
@@ -218,10 +244,25 @@ export function indexPreamble() {
218
244
  " }",
219
245
  " let letter = none",
220
246
  " for term in terms {",
247
+ ...(seeAlso ?
248
+ [
249
+ // A note another book prints is listed once this book mentions it.
250
+ ' let away = term.at("foreign", default: none)',
251
+ " if away != none and by.at(term.slug, default: ()).len() == 0 { continue }",
252
+ ]
253
+ : []),
221
254
  " if term.letter != letter {",
222
255
  " letter = term.letter",
223
256
  " heading(level: 5, outlined: false, bookmarked: false)[#letter]",
224
257
  " }",
258
+ ...(seeAlso ?
259
+ [
260
+ " if away != none {",
261
+ " par[#term.name: #emph[#away.title], p. #away.page]",
262
+ " continue",
263
+ " }",
264
+ ]
265
+ : []),
225
266
  " if term.see != none {",
226
267
  " let main = merged(term.slug).filter(p => p.main)",
227
268
  " let shown = if main.len() > 0 { link(main.first().loc)[#term.see] } else { term.see }",
@@ -241,16 +282,22 @@ export function indexPreamble() {
241
282
  *
242
283
  * @param {object[]} entries - The plan's entries.
243
284
  * @param {string} title - The book's title, which kicks the chapter.
285
+ * @param {object} [opts] - As {@link indexTerms}.
244
286
  * @returns {string} Typst source, or "" when the book has nothing to index.
245
287
  */
246
- export function indexChapter(entries, title) {
247
- const terms = indexTerms(entries);
288
+ export function indexChapter(entries, title, opts = {}) {
289
+ const terms = indexTerms(entries, opts);
248
290
  if (!terms.length) return "";
249
291
  const rows = terms.map(
250
292
  (term) =>
251
293
  `(letter: "${escapeString(term.letter)}", name: "${escapeString(term.name)}", ` +
252
294
  `slug: "${escapeString(term.slug)}", see: ${
253
295
  term.see === undefined ? "none" : `"${escapeString(term.see)}"`
296
+ }${
297
+ term.foreign === undefined ?
298
+ ""
299
+ : `, foreign: (title: "${escapeString(term.foreign.title)}", ` +
300
+ `page: "${escapeString(term.foreign.page)}")`
254
301
  })`,
255
302
  );
256
303
  return [
@@ -0,0 +1,247 @@
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
+ */
13
+
14
+ /**
15
+ * Where each book prints each note, and how a book names a note another book
16
+ * prints.
17
+ *
18
+ * A PDF link cannot reach another file, so a reference to a note whose **home
19
+ * book** — the first book in configuration order that selects it — is another
20
+ * book is set as words: `Name (_Book title_, p. N)`. The page is the one the
21
+ * note's entry starts on in its home book. It is read from the laid-out
22
+ * document rather than predicted, so the build renders every book, measures the
23
+ * pages, and renders again with the table — see {@link module:engine/pdf-build}.
24
+ *
25
+ * - {@link homeEntries} says which book and which anchor each homed note has.
26
+ * - {@link readEntryPages} measures one book's `.typ` with `typst eval`, which
27
+ * lays the document out and answers from its introspection. The page a
28
+ * reader sees printed is the page counter at the entry's heading, so the
29
+ * number needs neither the outline nor a `query` that cannot see layout.
30
+ * - {@link pageTable} is the table written as `pages.json`.
31
+ * - {@link elsewhereFor} is the lookup a book renders its cross-book
32
+ * references from.
33
+ *
34
+ * @module engine/pdf-pages
35
+ */
36
+
37
+ import { spawnSync } from "node:child_process";
38
+
39
+ import { encodeAddresses } from "./address-values.mjs";
40
+ import { recordKey } from "./pdf-coverage.mjs";
41
+ import { labelFor } from "./pdf-render.mjs";
42
+
43
+ /** What a reference prints for a page that has not been measured. */
44
+ export const UNMEASURED_PAGE = "?";
45
+
46
+ /**
47
+ * The Typst expression `typst eval` answers: every labelled heading and the
48
+ * page number the book prints for it.
49
+ */
50
+ const PAGES_EXPRESSION =
51
+ 'query(heading).filter(e => e.at("label", default: none) != none)' +
52
+ '.map(e => (str(e.at("label")), counter(page).at(e.location()).first()))';
53
+
54
+ /**
55
+ * The arguments that measure a book, as data.
56
+ *
57
+ * Fonts are the compile's, because a face with other metrics lays out other
58
+ * pages.
59
+ *
60
+ * @param {string} typPath - The `.typ` file.
61
+ * @param {string[]} fontArgs - `--ignore-system-fonts --font-path …`, as the
62
+ * compile passes them.
63
+ * @returns {string[]} The arguments, in order.
64
+ */
65
+ export function evalArgs(typPath, fontArgs) {
66
+ return ["eval", "--in", typPath, ...fontArgs, PAGES_EXPRESSION];
67
+ }
68
+
69
+ /**
70
+ * Measure the page each labelled entry of a book starts on.
71
+ *
72
+ * @param {string} typPath - The `.typ` file.
73
+ * @param {string[]} fontArgs - The compile's font arguments.
74
+ * @param {string} [binary] - The Typst binary.
75
+ * @returns {{ok: boolean, message: string, pages: Map<string, number>}} The
76
+ * printed page of every labelled heading, keyed by label.
77
+ */
78
+ export function readEntryPages(typPath, fontArgs, binary = "typst") {
79
+ const fail = (message) => ({ ok: false, message, pages: new Map() });
80
+ let result;
81
+ try {
82
+ result = spawnSync(binary, evalArgs(typPath, fontArgs), {
83
+ encoding: "utf8",
84
+ maxBuffer: 1 << 28,
85
+ });
86
+ } catch (err) {
87
+ return fail(`could not run \`${binary}\`: ${err.message}`);
88
+ }
89
+ if (result.error) return fail(`could not run \`${binary}\`: ${result.error.message}`);
90
+ if (result.status !== 0) {
91
+ const detail = String(result.stderr || result.stdout || "")
92
+ .trim()
93
+ .split("\n")[0];
94
+ return fail(`\`${binary} eval\` failed: ${detail}`);
95
+ }
96
+ let rows;
97
+ try {
98
+ rows = JSON.parse(result.stdout);
99
+ } catch {
100
+ return fail(`\`${binary} eval\` printed no page table`);
101
+ }
102
+ return {
103
+ ok: true,
104
+ message: "",
105
+ pages: new Map(rows.map(([label, page]) => [String(label), Number(page)])),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Every note that has a home book, with the anchor its entry carries there.
111
+ *
112
+ * **A note a book prints twice has one page**: the first entry, which is the
113
+ * one the book's links reach. A note a book reprints from another book's home
114
+ * keeps its home book's page here, because the home is the first book in
115
+ * configuration order that selects it.
116
+ *
117
+ * @param {object} input - Options.
118
+ * @param {Array<{id: string, title: string, plan: {entries: object[]}}>} input.books -
119
+ * The books that render, in configuration order.
120
+ * @param {Map<string, string>} input.homes - Each note's key → its home book's
121
+ * id, from {@link module:engine/pdf-coverage.coverage}.
122
+ * @returns {Array<{key: string, book: string, title: string, anchor: string,
123
+ * record: object, slug: string, aliases: string[]}>} One per homed note, in book order and
124
+ * then entry order.
125
+ */
126
+ export function homeEntries({ books, homes }) {
127
+ const found = [];
128
+ const claimed = new Set();
129
+ for (const book of books) {
130
+ for (const entry of book.plan.entries) {
131
+ if (entry.kind !== "note") continue;
132
+ const key = recordKey(entry.record);
133
+ if (claimed.has(key) || homes.get(key) !== book.id) continue;
134
+ claimed.add(key);
135
+ const record = entry.record;
136
+ found.push({
137
+ key,
138
+ book: book.id,
139
+ title: book.title,
140
+ anchor: entry.anchor,
141
+ record,
142
+ slug: String(record?.address?.slug ?? record?.shortcode ?? ""),
143
+ aliases: [record?.address?.canonical, record?.documentation]
144
+ .filter(Boolean)
145
+ .map((address) => encodeAddresses(address)),
146
+ });
147
+ }
148
+ }
149
+ return found;
150
+ }
151
+
152
+ /**
153
+ * The table written as `pages.json`.
154
+ *
155
+ * @param {ReturnType<typeof homeEntries>} entries - From {@link homeEntries}.
156
+ * @param {Map<string, Map<string, number>>} measured - Book id → label → page.
157
+ * @returns {Record<string, {book: string, page: number}>} Keyed by the note's
158
+ * key, in sorted key order. A note whose entry was not measured is absent.
159
+ */
160
+ export function pageTable(entries, measured) {
161
+ const table = {};
162
+ const sorted = [...entries].sort((a, b) =>
163
+ a.key < b.key ? -1
164
+ : a.key > b.key ? 1
165
+ : 0,
166
+ );
167
+ for (const entry of sorted) {
168
+ const page = measured.get(entry.book)?.get(labelFor(entry.anchor));
169
+ if (page !== undefined) table[entry.key] = { book: entry.book, page };
170
+ }
171
+ return table;
172
+ }
173
+
174
+ /**
175
+ * The notes whose page differs between two tables.
176
+ *
177
+ * @param {Record<string, {book: string, page: number}>} before - A table.
178
+ * @param {Record<string, {book: string, page: number}>} after - A later one.
179
+ * @returns {Array<{key: string, book: string, before: number|undefined,
180
+ * after: number|undefined}>} In sorted key order.
181
+ */
182
+ export function movedPages(before, after) {
183
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
184
+ return keys
185
+ .filter((key) => before[key]?.page !== after[key]?.page)
186
+ .map((key) => ({
187
+ key,
188
+ book: (after[key] ?? before[key]).book,
189
+ before: before[key]?.page,
190
+ after: after[key]?.page,
191
+ }));
192
+ }
193
+
194
+ /**
195
+ * The lookup one book renders its cross-book references from.
196
+ *
197
+ * A note the book prints (a reprint included) is never in it: its link stays an
198
+ * internal destination. Keys are the three forms the book's own link map
199
+ * answers to — slug, canonical address and documentation address.
200
+ *
201
+ * `get` counts the references it answers, which is how the build knows whether
202
+ * a measured pass can change anything.
203
+ *
204
+ * @param {object} input - Options.
205
+ * @param {ReturnType<typeof homeEntries>} input.entries - From {@link homeEntries}.
206
+ * @param {string} input.book - The id of the book that renders.
207
+ * @param {Map<string, string>} input.printed - The book's own link map.
208
+ * @param {Record<string, {book: string, page: number}>} [input.table] - The
209
+ * measured pages; every page is {@link UNMEASURED_PAGE} when absent.
210
+ * @param {boolean} [input.seeAlso] - Whether the book's index lists the notes
211
+ * it mentions here (`pdf.indexSeeAlso`). `listed` is empty when not.
212
+ * @returns {{used: number, seeAlso: boolean,
213
+ * listed: Array<{slug: string, name: string, title: string, page: string|number}>,
214
+ * get: (key: string) => ({slug: string, title: string, page: string|number}|undefined)}}
215
+ * The lookup. `listed` is every note another book prints, for the index to
216
+ * show those this book mentions.
217
+ */
218
+ export function elsewhereFor({ entries, book, printed, table, seeAlso = false }) {
219
+ const map = new Map();
220
+ const listed = [];
221
+ for (const entry of entries) {
222
+ if (entry.book === book) continue;
223
+ const keys = [entry.slug, ...entry.aliases].filter((key) => key && !printed.has(key));
224
+ const page = table?.[entry.key]?.page ?? UNMEASURED_PAGE;
225
+ for (const key of keys) {
226
+ if (!map.has(key)) map.set(key, { slug: entry.slug, title: entry.title, page });
227
+ }
228
+ if (seeAlso && entry.slug && !printed.has(entry.slug)) {
229
+ listed.push({
230
+ slug: entry.slug,
231
+ name: String(entry.record?.name?.full ?? "").trim(),
232
+ title: entry.title,
233
+ page,
234
+ });
235
+ }
236
+ }
237
+ return {
238
+ used: 0,
239
+ seeAlso,
240
+ listed,
241
+ get(key) {
242
+ const found = map.get(key);
243
+ if (found) this.used += 1;
244
+ return found;
245
+ },
246
+ };
247
+ }