@heroiclands/package-build 22.28.0 → 23.1.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 (156) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +1 -1
  3. package/bin/content-commands.mjs +1 -1
  4. package/ci/content-format-reference.mjs +46 -0
  5. package/docs/api.md +125 -142
  6. package/docs/authoring/assets.md +1 -1
  7. package/docs/authoring/dates-and-calendars.md +12 -6
  8. package/docs/authoring/first-note.md +1 -1
  9. package/docs/authoring/frontmatter.md +2 -2
  10. package/docs/authoring/links-and-markup.md +145 -29
  11. package/docs/commands.md +8 -10
  12. package/docs/configuration.md +13 -11
  13. package/docs/content-format.md +3 -3
  14. package/docs/guides/book.md +59 -4
  15. package/docs/guides/icons.md +4 -5
  16. package/docs/guides/packs.md +6 -0
  17. package/docs/guides/reference-pages.md +21 -1
  18. package/docs/guides/release-and-deploy.md +17 -1
  19. package/docs/guides/site.md +17 -18
  20. package/docs/reference/format-details.md +904 -275
  21. package/docs/reference/note-types.md +282 -91
  22. package/docs/reference/theme-pages.md +58 -66
  23. package/engine/address-values.mjs +124 -4
  24. package/engine/address.mjs +44 -3
  25. package/engine/anchors.mjs +103 -3
  26. package/engine/art-slots.mjs +2 -9
  27. package/engine/base-compiler.mjs +25 -6
  28. package/engine/calendar-artifacts.mjs +4 -1
  29. package/engine/calendar-notes.mjs +166 -102
  30. package/engine/content-format-check.mjs +2 -3
  31. package/engine/content-format.mjs +44 -1
  32. package/engine/content-format.yaml +84 -16
  33. package/engine/content-images.mjs +10 -1
  34. package/engine/content-index.mjs +37 -9
  35. package/engine/content-links.mjs +24 -5
  36. package/engine/content-tables.mjs +24 -3
  37. package/engine/data-keys.mjs +347 -0
  38. package/engine/derived-sections.mjs +322 -0
  39. package/engine/diagnostics.mjs +28 -0
  40. package/engine/event-fields.mjs +187 -0
  41. package/engine/event-rows.mjs +126 -0
  42. package/engine/event-views.mjs +305 -0
  43. package/engine/exported-scene.mjs +29 -53
  44. package/engine/frontmatter-lint.mjs +103 -77
  45. package/engine/frontmatter.mjs +7 -2
  46. package/engine/generate.mjs +53 -0
  47. package/engine/generated-sections.mjs +223 -0
  48. package/engine/heading-attributes.mjs +4 -2
  49. package/engine/helpers.mjs +12 -1
  50. package/engine/holdings.mjs +12 -33
  51. package/engine/ids.mjs +17 -11
  52. package/engine/index.mjs +4 -4
  53. package/engine/infobox-render.mjs +21 -6
  54. package/engine/infobox.mjs +217 -99
  55. package/engine/journals.mjs +104 -47
  56. package/engine/literature-notes.mjs +136 -0
  57. package/engine/literature-works.mjs +161 -0
  58. package/engine/map-graphviz.mjs +4 -18
  59. package/engine/markdown-expressions.mjs +116 -11
  60. package/engine/metadata-index.mjs +34 -0
  61. package/engine/near-miss.mjs +90 -0
  62. package/engine/note-addresses.mjs +75 -19
  63. package/engine/note-dates.mjs +5 -3
  64. package/engine/note-event-terms.mjs +364 -0
  65. package/engine/note-events.mjs +823 -0
  66. package/engine/note-vocabulary.mjs +195 -92
  67. package/engine/office-holders.mjs +20 -54
  68. package/engine/packagebuild-index.mjs +16 -1
  69. package/engine/pdf-build.mjs +97 -60
  70. package/engine/pdf-index.mjs +263 -0
  71. package/engine/pdf-render.mjs +47 -5
  72. package/engine/place-relation-terms.mjs +174 -0
  73. package/engine/place-relations.mjs +72 -160
  74. package/engine/rank-ladder.mjs +14 -41
  75. package/engine/reckoning-markers.mjs +2 -0
  76. package/engine/region-events.mjs +6 -12
  77. package/engine/scenes.mjs +73 -468
  78. package/engine/site-build.mjs +74 -85
  79. package/engine/site-index.mjs +3 -1
  80. package/engine/site-maps.mjs +43 -29
  81. package/engine/site-preview.mjs +40 -31
  82. package/engine/social-ties.mjs +2 -1
  83. package/engine/sql-tables.mjs +212 -9
  84. package/engine/standing-terms.mjs +145 -6
  85. package/engine/standings.mjs +37 -49
  86. package/engine/stub-lint.mjs +6 -0
  87. package/engine/web-wikilinks.mjs +21 -1
  88. package/engine/wikilink-syntax.mjs +34 -4
  89. package/engine/wikilinks.mjs +27 -2
  90. package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
  91. package/hugo-theme/layouts/partials/page-body.html +0 -2
  92. package/hugo-theme/static/css/style.css +25 -55
  93. package/package.json +1 -2
  94. package/packagebuild-metadata.jsonl +13 -13
  95. package/sohl/default-item-art.mjs +6 -6
  96. package/sohl/note-schemas.mjs +8 -73
  97. package/types/engine/address-values.d.mts +50 -3
  98. package/types/engine/address.d.mts +21 -0
  99. package/types/engine/anchors.d.mts +49 -2
  100. package/types/engine/art-slots.d.mts +1 -1
  101. package/types/engine/base-compiler.d.mts +13 -4
  102. package/types/engine/calendar-notes.d.mts +7 -0
  103. package/types/engine/content-format.d.mts +52 -0
  104. package/types/engine/content-images.d.mts +5 -1
  105. package/types/engine/content-tables.d.mts +9 -1
  106. package/types/engine/data-keys.d.mts +145 -0
  107. package/types/engine/derived-sections.d.mts +73 -0
  108. package/types/engine/diagnostics.d.mts +15 -0
  109. package/types/engine/event-fields.d.mts +99 -0
  110. package/types/engine/event-rows.d.mts +12 -0
  111. package/types/engine/event-views.d.mts +67 -0
  112. package/types/engine/exported-scene.d.mts +5 -2
  113. package/types/engine/frontmatter-lint.d.mts +3 -3
  114. package/types/engine/generated-sections.d.mts +143 -0
  115. package/types/engine/holdings.d.mts +6 -20
  116. package/types/engine/ids.d.mts +15 -12
  117. package/types/engine/index.d.mts +1 -1
  118. package/types/engine/infobox.d.mts +13 -3
  119. package/types/engine/journals.d.mts +24 -11
  120. package/types/engine/literature-notes.d.mts +29 -0
  121. package/types/engine/literature-works.d.mts +67 -0
  122. package/types/engine/map-graphviz.d.mts +2 -10
  123. package/types/engine/markdown-expressions.d.mts +12 -3
  124. package/types/engine/near-miss.d.mts +42 -0
  125. package/types/engine/note-addresses.d.mts +7 -1
  126. package/types/engine/note-event-terms.d.mts +68 -0
  127. package/types/engine/note-events.d.mts +71 -0
  128. package/types/engine/note-vocabulary.d.mts +37 -8
  129. package/types/engine/office-holders.d.mts +3 -0
  130. package/types/engine/pdf-index.d.mts +35 -0
  131. package/types/engine/pdf-render.d.mts +8 -2
  132. package/types/engine/place-relation-terms.d.mts +83 -0
  133. package/types/engine/place-relations.d.mts +11 -66
  134. package/types/engine/rank-ladder.d.mts +2 -0
  135. package/types/engine/region-events.d.mts +6 -12
  136. package/types/engine/scenes.d.mts +6 -32
  137. package/types/engine/site-build.d.mts +8 -22
  138. package/types/engine/site-maps.d.mts +15 -7
  139. package/types/engine/sql-tables.d.mts +77 -2
  140. package/types/engine/standing-terms.d.mts +37 -4
  141. package/types/engine/standings.d.mts +14 -9
  142. package/types/engine/wikilink-syntax.d.mts +20 -0
  143. package/types/engine/wikilinks.d.mts +3 -2
  144. package/docs/guides/government-migration.md +0 -52
  145. package/engine/itinerary-scenes.mjs +0 -166
  146. package/engine/lore-events.mjs +0 -197
  147. package/engine/map-notes.mjs +0 -1309
  148. package/engine/map-raster.mjs +0 -51
  149. package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
  150. package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
  151. package/hugo-theme/layouts/partials/holdings.html +0 -61
  152. package/hugo-theme/layouts/partials/place-map.html +0 -9
  153. package/types/engine/itinerary-scenes.d.mts +0 -29
  154. package/types/engine/lore-events.d.mts +0 -14
  155. package/types/engine/map-notes.d.mts +0 -297
  156. package/types/engine/map-raster.d.mts +0 -8
@@ -124,9 +124,15 @@ function namedBlockLines(markdown) {
124
124
  * calling that page "Introduction" would label the description as a preamble to
125
125
  * nothing.
126
126
  *
127
- * A figure fence carries its own record along onto the page it starts, as
128
- * `figure` — `null` for a page a heading started. {@link buildPages} reads it
129
- * to build the `image` or `text` page a fence begins; nothing else sets it.
127
+ * A captioned item's page holds the item alone, and carries the item's record
128
+ * as `figure` — `null` for every other page. {@link buildPages} reads it to
129
+ * build the `image` or `text` page; nothing else sets it. The page is named
130
+ * for the item's number, or else its caption's visible text.
131
+ *
132
+ * Body that follows an item, up to the next page opening, resumes in a
133
+ * **continuation** of the section the item interrupted: the section's name,
134
+ * level and classes, with `continues` naming the item it follows. A
135
+ * continuation holding nothing is not emitted.
130
136
  *
131
137
  * @param {string} body - Markdown body to split.
132
138
  * @param {string} [leadName] - Name of the page before the first heading.
@@ -135,8 +141,8 @@ function namedBlockLines(markdown) {
135
141
  * name reads `Map 1` rather than `Figure 1` where it is due — see
136
142
  * {@link module:engine/content-figures.scanFigures}.
137
143
  * @returns {Array<{name: string, anchorSlug: string|null, level: number,
138
- * classes: string[], figure: object|null, markdown: string}>} Pages in
139
- * document order.
144
+ * classes: string[], figure: object|null, continues?: string,
145
+ * markdown: string}>} Pages in document order.
140
146
  */
141
147
  export function splitPages(body, leadName = "Introduction", resolveRole) {
142
148
  const { markdown, definitions } = separateFootnotes(body);
@@ -150,16 +156,26 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
150
156
  const beforeFirstH1 = [];
151
157
  let current = null;
152
158
  let codeFence = null;
159
+ /** The page a heading last opened, which a captioned item interrupts. */
160
+ let section = { name: leadName, level: 1, classes: [] };
153
161
 
154
162
  const closeCurrent = () => {
155
163
  if (!current) return;
164
+ const markdown = current.lines.join("\n").trim();
165
+ // A continuation exists only to carry what follows its item; one
166
+ // holding nothing is not a page.
167
+ if (current.continues && !markdown) {
168
+ current = null;
169
+ return;
170
+ }
156
171
  pages.push({
157
172
  name: current.name,
158
173
  anchorSlug: current.anchorSlug,
159
174
  level: current.level,
160
175
  classes: current.classes,
161
176
  figure: current.figure ?? null,
162
- markdown: current.lines.join("\n").trim(),
177
+ ...(current.continues ? { continues: current.continues } : {}),
178
+ markdown,
163
179
  });
164
180
  current = null;
165
181
  };
@@ -190,16 +206,32 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
190
206
  // lines it claims here are collected by `pageOpenings`, which also
191
207
  // skips a fence and a named block. A figure fence opens a page of its
192
208
  // own, named for the figure's label.
209
+ // A captioned item's page holds the item alone. The body after it
210
+ // resumes in a continuation of the section it interrupted, keyed on
211
+ // the item so its id holds still when pages move around it.
212
+ if (current?.figure && lineIndex > current.figure.close) {
213
+ const item = current.figure;
214
+ closeCurrent();
215
+ current = {
216
+ name: section.name,
217
+ anchorSlug: null,
218
+ level: section.level,
219
+ classes: section.classes,
220
+ continues: item.slug || item.label || captionPageName(item.caption),
221
+ lines: [],
222
+ };
223
+ }
224
+
193
225
  const opening = openings.get(lineIndex);
194
226
  const figure =
195
227
  !inCodeBlock && !blockLines.has(lineIndex) ? figureStarts.get(lineIndex) : null;
196
228
  if (figure) {
197
229
  closeCurrent();
198
230
  current = {
199
- name: figure.label || figure.caption,
231
+ name: figure.label || captionPageName(figure.caption),
200
232
  anchorSlug: figure.id || null,
201
233
  level: 1,
202
- classes: [],
234
+ classes: section.classes.includes(WITHHELD_CLASS) ? [WITHHELD_CLASS] : [],
203
235
  figure,
204
236
  lines: [line],
205
237
  };
@@ -214,6 +246,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
214
246
  classes: opening.classes,
215
247
  lines: [],
216
248
  };
249
+ section = current;
217
250
  continue;
218
251
  }
219
252
 
@@ -270,7 +303,18 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
270
303
  export function assertUniquePages(rawPages, noteName) {
271
304
  const anchors = new Set();
272
305
  const names = new Set();
306
+ const continuations = new Set();
273
307
  for (const page of rawPages) {
308
+ if (page.continues) {
309
+ const key = `${page.name}\u0000${page.continues}`;
310
+ if (continuations.has(key)) {
311
+ throw new Error(
312
+ `note "${noteName}" has two captioned items both captioned "${page.continues}" in "${page.name}"; give one an {#anchor}`,
313
+ );
314
+ }
315
+ continuations.add(key);
316
+ continue;
317
+ }
274
318
  if (page.anchorSlug) {
275
319
  if (anchors.has(page.anchorSlug)) {
276
320
  throw new Error(
@@ -321,16 +365,49 @@ export function assertUniqueAnchors(rawPages, noteName) {
321
365
  * a lint error. {@link assertUniquePages} states the same thing at compile
322
366
  * time, where the packer would otherwise report only an opaque duplicate key.
323
367
  *
368
+ * A continuation page shares its section's name, so it is keyed by that name
369
+ * and the captioned item it follows.
370
+ *
324
371
  * @param {string} entryId - The owning JournalEntry's `_id`.
325
- * @param {{anchorSlug: string|null, name: string}} page - From {@link splitPages}.
372
+ * @param {{anchorSlug: string|null, name: string, continues?: string}} page -
373
+ * From {@link splitPages}.
326
374
  * @returns {string} A 16-character Foundry id.
327
375
  */
328
376
  export function journalPageId(entryId, page) {
329
- return page.anchorSlug ?
330
- anchorPageId(entryId, page.anchorSlug)
377
+ if (page.anchorSlug) return anchorPageId(entryId, page.anchorSlug);
378
+ return page.continues ?
379
+ makeId("journal-page", `${entryId}:${page.name}:after:${page.continues}`)
331
380
  : makeId("journal-page", `${entryId}:${page.name}`);
332
381
  }
333
382
 
383
+ /**
384
+ * The name of the page a caption begins: the caption's visible text.
385
+ *
386
+ * Foundry shows a page name as plain text, so a caption's markup — emphasis, a
387
+ * code span, a Markdown link, or the `@UUID[...]{label}` a resolved wikilink
388
+ * has already become, inside its wrapping `<span>` — reduces to the text a
389
+ * reader sees, the same text a reference to the caption displays. The markup
390
+ * itself stays in the page's content, where it renders.
391
+ *
392
+ * @param {string} caption - The figure's caption, as the journal pass sees it.
393
+ * @returns {string} Its visible text, whitespace collapsed.
394
+ */
395
+ function captionPageName(caption) {
396
+ if (!caption) return "";
397
+ const [inline] = md.parseInline(caption, {});
398
+ const text = (inline?.children ?? [])
399
+ .map((token) =>
400
+ token.type === "text" || token.type === "code_inline" ? token.content
401
+ : token.type === "softbreak" || token.type === "hardbreak" ? " "
402
+ : "",
403
+ )
404
+ .join("");
405
+ return text
406
+ .replace(/@UUID\[[^\]]*\]\{([^}]*)\}/g, "$1")
407
+ .replace(/\s+/g, " ")
408
+ .trim();
409
+ }
410
+
334
411
  /**
335
412
  * Whether a caption carries markup an `image` page's `caption` field cannot
336
413
  * hold.
@@ -381,36 +458,13 @@ function soleFigurePicture(figure, page) {
381
458
  return IMAGE_EXTENSIONS.includes(extension) ? images[0].src : null;
382
459
  }
383
460
 
384
- /**
385
- * Whether a figure fence is the whole of the page {@link splitPages} gave it.
386
- *
387
- * A figure always starts a page of its own, but nothing closes one: trailing
388
- * prose before the next heading or figure stays on it, exactly as it would
389
- * behind an ordinary heading. An `image` page holds a `src` and a caption and
390
- * nowhere else to put that prose, so it is refused the type and kept a `text`
391
- * page, where the prose renders after the figure's own div as it always has.
392
- *
393
- * @param {object} figure - The figure record.
394
- * @param {object} page - From {@link splitPages}.
395
- * @returns {boolean} Whether nothing follows the fence's closing `:::`.
396
- */
397
- function figureIsWholePage(figure, page) {
398
- const offset = figure.line - 1;
399
- const after = page.markdown
400
- .split("\n")
401
- .slice(figure.close - offset + 1)
402
- .join("\n");
403
- return after.trim() === "";
404
- }
405
-
406
461
  /**
407
462
  * The fields particular to the Foundry page a captioned item begins.
408
463
  *
409
- * A fence holding exactly one picture, captioned with no inline markup, and
410
- * standing alone on its page becomes a page of type `image`: `src` the
411
- * picture, `image.caption` the caption text, the page named for the figure's
412
- * own number. Every other fence — grouped, an audio embed, a caption
413
- * `image.caption` cannot hold, or one trailing prose keeps company with —
464
+ * A captioned item holding exactly one picture, captioned with no inline
465
+ * markup, becomes a page of type `image`: `src` the picture, `image.caption`
466
+ * the caption text, the page named for the figure's own number. Every other
467
+ * item — grouped, an audio embed, or a caption `image.caption` cannot hold —
414
468
  * becomes a page of type `text`.
415
469
  *
416
470
  * The `text` page's content is rendered and then relabeled. The render sees
@@ -433,7 +487,7 @@ function figureIsWholePage(figure, page) {
433
487
  */
434
488
  function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRole) {
435
489
  const picture = soleFigurePicture(figure, page);
436
- if (picture && figureIsWholePage(figure, page) && !captionCarriesMarkup(figure.caption)) {
490
+ if (picture && !captionCarriesMarkup(figure.caption)) {
437
491
  let src = picture;
438
492
  try {
439
493
  src = resolveImg(picture) ?? picture;
@@ -442,7 +496,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
442
496
  // image's own render takes when no configuration resolves it.
443
497
  }
444
498
  return {
445
- name: figure.label || figure.caption,
499
+ name: figure.label || captionPageName(figure.caption),
446
500
  type: "image",
447
501
  src,
448
502
  image: figure.hasCaption ? { caption: figure.caption } : {},
@@ -460,7 +514,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
460
514
  resolveRole,
461
515
  );
462
516
  return {
463
- name: figure.label || figure.caption,
517
+ name: figure.label || captionPageName(figure.caption),
464
518
  type: "text",
465
519
  text: {
466
520
  format: 1,
@@ -478,11 +532,12 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
478
532
  * @param {Array<object>} rawPages - From {@link splitPages}.
479
533
  * @param {string} entryId - The owning JournalEntry's `_id`.
480
534
  * @param {string} noteName - The note, for error messages.
481
- * A page whose heading carries `.secret` is given to the GM alone, by the one
482
- * ownership value Foundry reads as "nobody but a GM". Every other page states
483
- * no ownership and inherits the journal's. A figure-started page states none
484
- * either: {@link scanFigures} validates a figure's classes against its own
485
- * closed vocabulary, which carries no `.secret`.
535
+ * A page whose opening heading carries `.secret` is given to the GM alone, by
536
+ * the one ownership value Foundry reads as "nobody but a GM"; so is a captioned
537
+ * item's page and a continuation page inside such a section. Every other page
538
+ * states no ownership and inherits the journal's. A caption's own classes carry
539
+ * no `.secret`, because {@link scanFigures} validates them against a closed
540
+ * vocabulary without it.
486
541
  *
487
542
  * @param {Array<object>} figures - From {@link scanFigures}, numbered across
488
543
  * the whole note. Read by a page a heading started; a page a figure started
@@ -536,7 +591,9 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
536
591
  return {
537
592
  _id: pageId,
538
593
  ...fields,
539
- title: { show: true, level: page.level ?? 1 },
594
+ // A continuation resumes its section, whose title its first
595
+ // page already shows.
596
+ title: { show: !page.continues, level: page.level ?? 1 },
540
597
  ...(withheld ? { ownership: { default: WITHHELD_OWNERSHIP } } : {}),
541
598
  _key: `!journal.pages!${entryId}.${pageId}`,
542
599
  };
@@ -0,0 +1,136 @@
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
+ * A work a people tells, sings or writes: the `literature` lore subType and the
16
+ * four optional facts such a note may state about itself.
17
+ *
18
+ * `DataFieldSpec` declares the keys a type accepts, not the subType that may
19
+ * write them, so {@link checkLiteratureNote} scopes these keys to `literature`
20
+ * the way the calendar check scopes its own to `calendar`.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
26
+ import { checkCultureChoice } from "./culture-choice.mjs";
27
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
28
+
29
+ /** The `lore` subType whose notes are works of literature. */
30
+ export const LITERATURE_SUBTYPE = "literature";
31
+
32
+ /**
33
+ * Require `data.language` to address a `skill` note whose subType is
34
+ * `language`.
35
+ *
36
+ * An Address that does not resolve is the reference check's to report, so
37
+ * this speaks only about a target that exists and is the wrong kind.
38
+ *
39
+ * @param {object} note
40
+ * @param {{index?: object}} [options]
41
+ * @returns {object[]} The findings.
42
+ */
43
+ export function checkLanguageChoice(note, { index } = {}) {
44
+ const value = note.fm?.data?.language;
45
+ if (value === undefined || value === null || !index?.addressHit) return [];
46
+ const tuple = parseAddress(value, {
47
+ package: index.contentPackage,
48
+ system: "note",
49
+ type: "skill",
50
+ types: index.types,
51
+ packages: index.packages,
52
+ });
53
+ if (tuple.reason) return [];
54
+ const target = index.addressHit(renderAddress(tuple));
55
+ if (!target) return [];
56
+ const fm = target.fm ?? target;
57
+ if (tuple.type === "skill" && fm.subType === "language") return [];
58
+ return [
59
+ {
60
+ file: note.file,
61
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "language"]),
62
+ severity: "error",
63
+ message: `data.language ${isAddressTuple(value) ? renderAddress(value) : value} names a note that is not a skill whose subType is language`,
64
+ },
65
+ ];
66
+ }
67
+
68
+ /**
69
+ * The fields a `literature` note may state. Every one is optional.
70
+ *
71
+ * @type {ReadonlyArray<object>}
72
+ */
73
+ export const LITERATURE_FIELDS = Object.freeze([
74
+ {
75
+ name: "culture",
76
+ shape: "an Address",
77
+ kind: "address",
78
+ ref: "lore",
79
+ accepts: ["lore"],
80
+ check: checkCultureChoice,
81
+ describe: "The people whose work this is, as a culture lore note.",
82
+ },
83
+ {
84
+ name: "form",
85
+ shape: "string",
86
+ kind: "string",
87
+ describe:
88
+ "The kind of work in its people's own terms — an epic, a saga, a " +
89
+ "praise-song, an elegy. Free text.",
90
+ },
91
+ {
92
+ name: "subjects",
93
+ shape: "list of Addresses",
94
+ kind: "list",
95
+ entryKind: "address",
96
+ anchors: Object.freeze(["event"]),
97
+ describe:
98
+ "The beings, places, gods and other notes the work concerns, and each " +
99
+ "event it concerns, named as `place-ironfells#sack`.",
100
+ },
101
+ {
102
+ name: "language",
103
+ shape: "an Address",
104
+ kind: "address",
105
+ ref: "skill",
106
+ accepts: ["skill"],
107
+ check: checkLanguageChoice,
108
+ describe: "The tongue the work is composed in, as a language skill note.",
109
+ },
110
+ ]);
111
+
112
+ const LITERATURE_KEYS = Object.freeze(LITERATURE_FIELDS.map((field) => field.name));
113
+
114
+ /**
115
+ * Refuse a literature field on a lore note of any other subType.
116
+ *
117
+ * @param {object} note
118
+ * @returns {object[]} One error per literature key written off `literature`.
119
+ */
120
+ export function checkLiteratureNote(note) {
121
+ const type = String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
122
+ const subType = String(note?.fm?.subType ?? "").toLowerCase();
123
+ if (type !== "lore" || subType === LITERATURE_SUBTYPE) return [];
124
+ const data = note?.fm?.data;
125
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
126
+ return LITERATURE_KEYS.filter((key) => data[key] !== undefined && data[key] !== null).map(
127
+ (key) => ({
128
+ file: note.file,
129
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", key], { key: true }),
130
+ severity: "error",
131
+ message:
132
+ `\`data.${key}\` describes a work of literature, and this note's ` +
133
+ `\`subType\` is not \`literature\``,
134
+ }),
135
+ );
136
+ }
@@ -0,0 +1,161 @@
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
+ * The works of literature that concern each page, derived from what the works
16
+ * say. A `literature` lore note names its subjects in `data.subjects`; the build
17
+ * inverts that into the **In song and story** section of every subject — see
18
+ * {@link module:engine/derived-sections} — so a reader of a hero, a place or a
19
+ * god reaches the epics and legends about it. The subject note writes nothing.
20
+ *
21
+ * A subject naming an event lists the work on the page of the note holding
22
+ * that event. A work in a fetched index lists on this package's subject pages
23
+ * when that index carries its `subjects`. A subject in another package has no page here,
24
+ * so a local work naming one lists nowhere.
25
+ *
26
+ * @module
27
+ */
28
+
29
+ import { parseAddress, renderAddress, splitAnchor } from "./address.mjs";
30
+ import { AddressLink } from "./address-values.mjs";
31
+ import { readCanonicalKey } from "./content-address.mjs";
32
+ import { LITERATURE_SUBTYPE } from "./literature-notes.mjs";
33
+
34
+ /** @typedef {{title: string, url?: string, address: string, form?: string}} WorkEntry */
35
+ /**
36
+ * @typedef {object} WorksNode
37
+ * @property {string} key Own Address, normalized to the `note` system.
38
+ * @property {string} title
39
+ * @property {string} [url] Absent for a note that publishes no page.
40
+ * @property {string} [package] Owning content package.
41
+ * @property {string} [form] A work's `data.form`.
42
+ * @property {unknown[]} subjects A work's `data.subjects`; empty for any other note.
43
+ */
44
+
45
+ /**
46
+ * The note-system key of one Address, or "" where it names nothing. A subject
47
+ * naming an event — `place-ironfells#sack`, or an {@link AddressLink} read
48
+ * from one — keys the note that holds the event.
49
+ */
50
+ function addressKey(value, pkg, types) {
51
+ if (value == null) return "";
52
+ const note =
53
+ value instanceof AddressLink ? value.target
54
+ : typeof value === "string" ? splitAnchor(value).address
55
+ : value;
56
+ const tuple = parseAddress(note, { package: pkg ?? "local", system: "note", types });
57
+ if (tuple.reason) return "";
58
+ return renderAddress({ ...tuple, system: "note" }).toLowerCase();
59
+ }
60
+
61
+ /** Whether a note is a work of literature. */
62
+ function isWork(type, subType) {
63
+ return type === "lore" && String(subType ?? "").toLowerCase() === LITERATURE_SUBTYPE;
64
+ }
65
+
66
+ /**
67
+ * One local page's node.
68
+ *
69
+ * @param {object} fm - The note's frontmatter.
70
+ * @param {{title: string, url?: string, package?: string}} page
71
+ * @returns {WorksNode|null} Null for a note with no type or shortcode.
72
+ */
73
+ export function worksNode(fm, { title, url, package: pkg }) {
74
+ const type = String(fm?.type ?? "");
75
+ const shortcode = String(fm?.shortcode ?? "").toLowerCase();
76
+ if (!type || !shortcode) return null;
77
+ const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
78
+ const work = isWork(type, fm.subType);
79
+ return {
80
+ key: renderAddress({ package: pkg ?? "local", system: "note", type, shortcode }),
81
+ title,
82
+ ...(url ? { url } : {}),
83
+ ...(pkg ? { package: pkg } : {}),
84
+ ...(work && typeof data.form === "string" && data.form ? { form: data.form } : {}),
85
+ subjects:
86
+ !work ? []
87
+ : Array.isArray(data.subjects) ? data.subjects
88
+ : data.subjects ? [data.subjects]
89
+ : [],
90
+ };
91
+ }
92
+
93
+ /**
94
+ * The works a fetched index carries, as nodes. Only a work can list itself on
95
+ * a page, and only a page of this package can carry the list, so a foreign
96
+ * entry that is not a work is never needed here.
97
+ *
98
+ * @param {Map<string, object>|undefined} foreignIndex
99
+ * @returns {WorksNode[]}
100
+ */
101
+ export function foreignWorksNodes(foreignIndex) {
102
+ const out = [];
103
+ for (const [canonical, entry] of foreignIndex ?? []) {
104
+ const tuple = readCanonicalKey(canonical);
105
+ if (!tuple || !isWork(entry?.type, entry?.subType)) continue;
106
+ if (!Array.isArray(entry.subjects) || !entry.subjects.length) continue;
107
+ out.push({
108
+ key: renderAddress({ ...tuple, system: "note" }),
109
+ title: String(entry.name ?? tuple.shortcode),
110
+ ...(entry.url ? { url: entry.url } : {}),
111
+ package: tuple.package,
112
+ ...(typeof entry.form === "string" && entry.form ? { form: entry.form } : {}),
113
+ subjects: entry.subjects,
114
+ });
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * Invert every work's subjects into the list each subject's page carries.
121
+ *
122
+ * A subject is resolved in its work's own package. A work naming one subject
123
+ * twice lists once; a work naming itself is not listed on its own page.
124
+ * Entries sort by title.
125
+ *
126
+ * @param {Iterable<WorksNode|null>} nodes - Every local page, then any foreign work.
127
+ * @param {object} options
128
+ * @param {ReadonlySet<string>} options.types - Note types an Address may name.
129
+ * @returns {Map<string, {works: WorkEntry[]}>} Keyed by the subject page's URL.
130
+ */
131
+ export function worksPages(nodes, { types }) {
132
+ const all = [...nodes].filter(Boolean);
133
+ /** @type {Map<string, WorksNode>} */
134
+ const pages = new Map();
135
+ for (const node of all) {
136
+ if (!node.url) continue;
137
+ const key = node.key.toLowerCase();
138
+ if (!pages.has(key)) pages.set(key, node);
139
+ }
140
+ /** @type {Map<string, {works: WorkEntry[]}>} */
141
+ const lists = new Map();
142
+ for (const work of all) {
143
+ const seen = new Set();
144
+ for (const subject of work.subjects) {
145
+ const target = pages.get(addressKey(subject, work.package, types));
146
+ if (!target || target.key === work.key || seen.has(target.url)) continue;
147
+ seen.add(target.url);
148
+ let block = lists.get(target.url);
149
+ if (!block) lists.set(target.url, (block = { works: [] }));
150
+ block.works.push({
151
+ title: work.title,
152
+ ...(work.url ? { url: work.url } : {}),
153
+ address: work.key,
154
+ ...(work.form ? { form: work.form } : {}),
155
+ });
156
+ }
157
+ }
158
+ for (const block of lists.values())
159
+ block.works.sort((a, b) => a.title.localeCompare(b.title, "en"));
160
+ return lists;
161
+ }
@@ -4,7 +4,6 @@
4
4
 
5
5
  import fs from "node:fs";
6
6
  import { instance } from "@viz-js/viz";
7
- import { Resvg } from "@resvg/resvg-js";
8
7
 
9
8
  /** The layout engines accepted by the map command. */
10
9
  export const GRAPHVIZ_ENGINES = Object.freeze(["dot", "twopi", "neato"]);
@@ -12,22 +11,16 @@ export const GRAPHVIZ_ENGINES = Object.freeze(["dot", "twopi", "neato"]);
12
11
  const graphviz = await instance();
13
12
 
14
13
  /**
15
- * Render a DOT source file to SVG, plain positions, or a PNG background.
16
- *
17
- * A PNG uses the same Graphviz SVG layout as the vector map. SVG dimensions
18
- * are in points; resvg starts at 96 pixels per inch, so the zoom converts
19
- * the requested DPI to the same physical size as Graphviz's PNG output.
14
+ * Render a DOT source file to SVG.
20
15
  *
21
16
  * @param {string} dotPath - The saved DOT source.
22
17
  * @param {string} outPath - The output file.
23
18
  * @param {object} opts
24
19
  * @param {"dot"|"twopi"|"neato"} opts.engine - Layout engine.
25
- * @param {"svg"|"plain"|"png"} [opts.format="svg"] - Output format.
26
20
  * @param {2} [opts.nop] - Preserve positioned nodes as `neato -n2` does.
27
- * @param {number} [opts.dpi=96] - PNG resolution.
28
21
  * @returns {{warnings: string}} Renderer warnings.
29
22
  */
30
- export function renderDot(dotPath, outPath, { engine, format = "svg", nop, dpi = 96 }) {
23
+ export function renderDot(dotPath, outPath, { engine, nop }) {
31
24
  if (!GRAPHVIZ_ENGINES.includes(engine)) {
32
25
  throw new Error(`Unsupported Graphviz engine "${engine}" for ${dotPath}`);
33
26
  }
@@ -35,19 +28,12 @@ export function renderDot(dotPath, outPath, { engine, format = "svg", nop, dpi =
35
28
  try {
36
29
  const result = graphviz.render(dot, {
37
30
  engine: nop === 2 ? "nop2" : engine,
38
- format: format === "png" ? "svg" : format,
31
+ format: "svg",
39
32
  });
40
33
  if (result.status !== "success" || !result.output) {
41
34
  throw new Error(result.errors.map((error) => error.message).join("; ") || "no output");
42
35
  }
43
- if (format === "png") {
44
- const image = new Resvg(result.output, {
45
- fitTo: { mode: "zoom", value: dpi / 96 },
46
- }).render();
47
- fs.writeFileSync(outPath, image.asPng());
48
- } else {
49
- fs.writeFileSync(outPath, result.output);
50
- }
36
+ fs.writeFileSync(outPath, result.output);
51
37
  // The embedded renderer uses Times metrics for the generated bold
52
38
  // headings; its warning repeats for every map and needs no action.
53
39
  return {