@heroiclands/package-build 22.22.0 → 22.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/content-commands.mjs +51 -14
  3. package/ci/content-format-reference.mjs +1 -1
  4. package/content-config.mjs +80 -2
  5. package/docs/api.md +85 -72
  6. package/docs/authoring/assets.md +14 -0
  7. package/docs/authoring/dates-and-calendars.md +37 -1
  8. package/docs/authoring/links-and-markup.md +112 -31
  9. package/docs/commands.md +12 -10
  10. package/docs/configuration.md +90 -13
  11. package/docs/content-format.md +1 -1
  12. package/docs/guides/book.md +5 -4
  13. package/docs/reference/format-details.md +323 -60
  14. package/docs/reference/note-types.md +9 -9
  15. package/engine/anchors.mjs +16 -10
  16. package/engine/art-fields.mjs +118 -0
  17. package/engine/art-slots.mjs +7 -1
  18. package/engine/asset-index.mjs +160 -10
  19. package/engine/asset-replacement.mjs +93 -0
  20. package/engine/asset-types.mjs +240 -0
  21. package/engine/base-compiler.mjs +54 -2
  22. package/engine/bundles.mjs +13 -1
  23. package/engine/content-blocks.mjs +4 -4
  24. package/engine/content-embeds.mjs +19 -0
  25. package/engine/content-figures.mjs +468 -0
  26. package/engine/content-footnotes.mjs +10 -2
  27. package/engine/content-format.yaml +1 -1
  28. package/engine/content-images.mjs +126 -25
  29. package/engine/content-index.mjs +36 -10
  30. package/engine/field-reference.mjs +31 -10
  31. package/engine/field-spec.mjs +10 -8
  32. package/engine/foreign-catalog.mjs +143 -18
  33. package/engine/frontmatter-lint.mjs +73 -1
  34. package/engine/heading-attributes.mjs +31 -18
  35. package/engine/helpers.mjs +97 -30
  36. package/engine/infobox-registry.mjs +4 -1
  37. package/engine/infobox.mjs +47 -8
  38. package/engine/itinerary-scenes.mjs +4 -10
  39. package/engine/journals.mjs +264 -39
  40. package/engine/lore-events.mjs +197 -0
  41. package/engine/markdown-expressions.mjs +257 -7
  42. package/engine/note-dates.mjs +305 -12
  43. package/engine/note-vocabulary.mjs +11 -5
  44. package/engine/pathnames.mjs +36 -0
  45. package/engine/pdf-build.mjs +357 -34
  46. package/engine/pdf-images.mjs +64 -0
  47. package/engine/pdf-render.mjs +160 -36
  48. package/engine/scenes.mjs +16 -4
  49. package/engine/site-build.mjs +127 -13
  50. package/engine/site-index.mjs +72 -1
  51. package/engine/site-preview.mjs +2 -0
  52. package/engine/system-block.mjs +44 -16
  53. package/hm3/actors.mjs +2 -2
  54. package/manifest.mjs +30 -24
  55. package/package.json +1 -1
  56. package/packagebuild-metadata.jsonl +13 -13
  57. package/sohl/item-fields.mjs +4 -3
  58. package/types/content-config.d.mts +48 -1
  59. package/types/engine/anchors.d.mts +9 -4
  60. package/types/engine/art-fields.d.mts +64 -0
  61. package/types/engine/asset-index.d.mts +43 -0
  62. package/types/engine/asset-replacement.d.mts +22 -0
  63. package/types/engine/asset-types.d.mts +18 -0
  64. package/types/engine/base-compiler.d.mts +0 -17
  65. package/types/engine/content-embeds.d.mts +16 -0
  66. package/types/engine/content-figures.d.mts +101 -0
  67. package/types/engine/content-footnotes.d.mts +7 -0
  68. package/types/engine/content-images.d.mts +78 -12
  69. package/types/engine/content-index.d.mts +8 -2
  70. package/types/engine/field-reference.d.mts +21 -0
  71. package/types/engine/field-spec.d.mts +20 -16
  72. package/types/engine/foreign-catalog.d.mts +39 -0
  73. package/types/engine/heading-attributes.d.mts +13 -8
  74. package/types/engine/helpers.d.mts +23 -7
  75. package/types/engine/infobox-registry.d.mts +4 -1
  76. package/types/engine/infobox.d.mts +6 -0
  77. package/types/engine/journals.d.mts +31 -9
  78. package/types/engine/lore-events.d.mts +14 -0
  79. package/types/engine/markdown-expressions.d.mts +23 -2
  80. package/types/engine/note-dates.d.mts +64 -1
  81. package/types/engine/pathnames.d.mts +26 -0
  82. package/types/engine/pdf-build.d.mts +28 -6
  83. package/types/engine/pdf-images.d.mts +52 -0
  84. package/types/engine/pdf-render.d.mts +12 -0
  85. package/types/engine/site-build.d.mts +14 -3
  86. package/types/engine/site-index.d.mts +54 -0
  87. package/types/engine/system-block.d.mts +26 -2
  88. package/types/hm3/actors.d.mts +18 -0
  89. package/types/manifest.d.mts +19 -23
  90. package/engine/content-captions.mjs +0 -230
  91. package/types/engine/content-captions.d.mts +0 -58
@@ -119,7 +119,12 @@ import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
119
119
  import { readAliasedField } from "./retired-fields.mjs";
120
120
  import { subtypeRow } from "./document-subtypes.mjs";
121
121
  import { authoredKey } from "./system-block.mjs";
122
- import { formatDateInCalendar, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
122
+ import {
123
+ formatDateInCalendar,
124
+ formatNoteDate,
125
+ parseNoteDate,
126
+ resolvedDateFields,
127
+ } from "./note-dates.mjs";
123
128
  import { displayBeingHeight, displayBeingWeight } from "./being-measurements.mjs";
124
129
  import {
125
130
  officeAnchor,
@@ -290,7 +295,7 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
290
295
  bgImage: Object.freeze({ withheld: "an image, which the box never carries" }),
291
296
  banner: Object.freeze({ withheld: "an image, which the box never carries" }),
292
297
  overlay: Object.freeze({ withheld: "an image, which the box never carries" }),
293
- "lore.event": Object.freeze({ withheld: "chronology machinery, not a summary row" }),
298
+ "lore.events": Object.freeze({ withheld: "chronology machinery, not a summary row" }),
294
299
 
295
300
  assocSkill: Object.freeze({ label: "Skill" }),
296
301
  assocAffiliation: Object.freeze({ label: "Affiliation" }),
@@ -695,8 +700,15 @@ function rankRows(raw, resolve) {
695
700
  return rows;
696
701
  }
697
702
 
698
- /** Expand structured references using the existing linked-row shape. */
699
- function structuredRows(field, raw, resolve, label, fm) {
703
+ /**
704
+ * Expand structured references using the existing linked-row shape.
705
+ *
706
+ * @param {string} [contentPackage] - This build's content package, the
707
+ * default a short-form Address resolves against — the same value the site
708
+ * build and the pack compiler read `contentPackage()` for. A row naming a
709
+ * short Address with none handed to it skips rather than guesses.
710
+ */
711
+ function structuredRows(field, raw, resolve, label, contentPackage) {
700
712
  if (field.standings) return standingRows(field, raw, resolve, label);
701
713
  if (field.roster) {
702
714
  const roster = rosterRows(raw);
@@ -739,7 +751,7 @@ function structuredRows(field, raw, resolve, label, fm) {
739
751
  const address = parseAddress(
740
752
  target,
741
753
  {
742
- package: "local",
754
+ package: contentPackage,
743
755
  system: "note",
744
756
  types: new Set(field.accepts),
745
757
  },
@@ -832,6 +844,8 @@ export function linkValue(ref, resolve, hint) {
832
844
  * @param {object} [options] - Options.
833
845
  * @param {(ref: unknown) => object|undefined} [options.resolve] - Resolves a
834
846
  * reference to `{name, url?, uuid?, address?}`.
847
+ * @param {string} [options.contentPackage] - This build's content package,
848
+ * the default a relation row's short-form Address resolves against.
835
849
  * @param {object} [options.vocabulary] - The note vocabulary to read.
836
850
  * @param {object} [options.presentation] - The overlay to read.
837
851
  * @returns {object} The box.
@@ -857,7 +871,13 @@ export function noteInfobox(fm, options = {}) {
857
871
  */
858
872
  function noteBox(
859
873
  fm,
860
- { resolve, dates, vocabulary = NOTE_VOCABULARY, presentation = NOTE_FIELD_PRESENTATION } = {},
874
+ {
875
+ resolve,
876
+ dates,
877
+ contentPackage,
878
+ vocabulary = NOTE_VOCABULARY,
879
+ presentation = NOTE_FIELD_PRESENTATION,
880
+ } = {},
861
881
  ) {
862
882
  const rows = [];
863
883
  /** @type {Set<string>} */
@@ -916,7 +936,7 @@ function noteBox(
916
936
  raw,
917
937
  resolve,
918
938
  overlay.label ?? humanizeFieldName(field.name),
919
- fm,
939
+ contentPackage,
920
940
  );
921
941
  if (structured) {
922
942
  rows.push(...structured);
@@ -954,6 +974,22 @@ function noteBox(
954
974
  shown.add(field.name);
955
975
  }
956
976
 
977
+ // `events` itself stays withheld — the family's shape is chronology
978
+ // machinery, not a summary row — but a reader meets each occurrence's
979
+ // own next date, the way a being's computed `age` reaches the box.
980
+ if (fm?.type === "lore" && Array.isArray(data.events) && data.events.length && dates) {
981
+ const resolved = resolvedDateFields(fm, dates);
982
+ for (const eventEntry of resolved.events ?? []) {
983
+ if (!eventEntry.next) continue;
984
+ rows.push({
985
+ label: "Next occurrence",
986
+ kind: "text",
987
+ value: eventEntry.next.prose ?? eventEntry.next.text,
988
+ });
989
+ shown.add("events");
990
+ }
991
+ }
992
+
957
993
  return {
958
994
  box: {
959
995
  id: NOTE_BOX_ID,
@@ -1136,6 +1172,8 @@ export function systemRowsSection(
1136
1172
  * Resolves one declared field against the note.
1137
1173
  * @param {(ref: unknown) => object|undefined} [options.resolve] - Resolves a
1138
1174
  * reference to `{name, url?, uuid?, address?}`.
1175
+ * @param {string} [options.contentPackage] - This build's content package,
1176
+ * the default a note box's short-form Address resolves against.
1139
1177
  * @param {object} [options.vocabulary] - The note vocabulary to read.
1140
1178
  * @returns {object[]} The boxes.
1141
1179
  */
@@ -1146,11 +1184,12 @@ export function buildInfoboxes(fm, options) {
1146
1184
  compilesDocument,
1147
1185
  resolveField,
1148
1186
  resolve,
1187
+ contentPackage,
1149
1188
  vocabulary = NOTE_VOCABULARY,
1150
1189
  dates,
1151
1190
  } = options;
1152
1191
 
1153
- const { box: note, shown: taken } = noteBox(fm, { resolve, vocabulary, dates });
1192
+ const { box: note, shown: taken } = noteBox(fm, { resolve, vocabulary, dates, contentPackage });
1154
1193
  const boxes = [note];
1155
1194
 
1156
1195
  for (const map of maps ?? []) {
@@ -15,18 +15,10 @@ import { loadForeignIndexes } from "./metadata-index.mjs";
15
15
  import { authoredFrontmatter, isNoteRecord } from "./index-records.mjs";
16
16
  import { buildScene } from "./map-notes.mjs";
17
17
  import { buildJournalEntry } from "./journals.mjs";
18
+ import { imageDimensions } from "./asset-types.mjs";
18
19
  import { makeId } from "./ids.mjs";
19
20
  import { stripAdventureKeys } from "./bundle-notes.mjs";
20
21
 
21
- /** The PNG's dimensions, from its fixed header. */
22
- function pngDimensions(file) {
23
- const bytes = fs.readFileSync(file);
24
- if (bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") {
25
- throw new Error(`${file} is not a PNG`);
26
- }
27
- return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
28
- }
29
-
30
22
  /** GraphViz's plain coordinates, converted to pixel positions on its PNG. */
31
23
  export function pinPositions(plain, width, height) {
32
24
  const graph = /^graph\s+\S+\s+(\S+)\s+(\S+)/m.exec(plain);
@@ -111,7 +103,9 @@ export function buildItineraryScenes({
111
103
  const plain = path.join(outDir, `from-${centre}.plain`);
112
104
  renderDot(dot, png, { engine: "neato", format: "png", nop: 2, dpi: 150 });
113
105
  renderDot(dot, plain, { engine: "neato", format: "plain", nop: 2 });
114
- const [width, height] = pngDimensions(png);
106
+ const size = imageDimensions(png, ".png");
107
+ if (!size) throw new Error(`${png} carries no readable PNG size`);
108
+ const { width, height } = size;
115
109
  const positions = pinPositions(fs.readFileSync(plain, "utf8"), width, height);
116
110
  const sceneId = makeId("itinerary-scene", `${config.contentPackage}:${centre}`);
117
111
  const src = `${buildConfig.packageKind}/${buildConfig.packageId}/assets/maps/generated-itineraries/from-${centre}.png`;
@@ -51,16 +51,20 @@
51
51
 
52
52
  import log from "loglevel";
53
53
 
54
+ import path from "node:path";
55
+
54
56
  import {
55
57
  sohlField,
56
58
  makeId,
57
59
  resolveName,
60
+ resolveImg,
58
61
  defaultStats,
59
62
  renderFoundryMarkdown,
60
63
  md,
61
64
  folderField,
62
65
  } from "./helpers.mjs";
63
66
  import { BasePackCompiler } from "./base-compiler.mjs";
67
+ import { scanBlocks } from "./content-blocks.mjs";
64
68
  import { anchorPageId, resolveReference } from "./wikilinks.mjs";
65
69
  import { infoboxesToHtml, linkToUuid } from "./infobox-render.mjs";
66
70
  import { noteInfoboxes } from "./infobox-registry.mjs";
@@ -69,9 +73,12 @@ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
69
73
  import { JOURNAL_TYPES } from "./ids.mjs";
70
74
  import { journalHasContent } from "./note-state.mjs";
71
75
  import { draftNoticeFor } from "./draft-notice.mjs";
72
- import { scanCaptions } from "./content-captions.mjs";
76
+ import { scanFigures } from "./content-figures.mjs";
73
77
  import { separateFootnotes } from "./content-footnotes.mjs";
74
78
  import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
79
+ import { imagesIn } from "./content-images.mjs";
80
+ import { IMAGE_EXTENSIONS } from "./asset-types.mjs";
81
+ import { pathnameRoles } from "./art-fields.mjs";
75
82
 
76
83
  /**
77
84
  * `CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE` — the default ownership a withheld
@@ -80,11 +87,33 @@ import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
80
87
  */
81
88
  const WITHHELD_OWNERSHIP = 0;
82
89
 
90
+ /**
91
+ * Every line a named block's own markup occupies, from its `:::name` opener
92
+ * to its closing `:::` inclusive.
93
+ *
94
+ * Read from {@link module:engine/content-blocks.scanBlocks}'s own ranges
95
+ * rather than restated here, so a figure cannot disagree with the blocks pass
96
+ * about what counts as a named block or where one ends. `scanBlocks` already
97
+ * recognises every name in its registry, carrying an attribute block or not,
98
+ * and already tracks a nested construct's own closer by depth rather than by
99
+ * a flag a bare `:::` clears regardless of whose closer it is.
100
+ *
101
+ * @param {string} markdown - A note's body, as {@link splitPages} receives it.
102
+ * @returns {Set<number>} 0-based line indices inside a named block.
103
+ */
104
+ function namedBlockLines(markdown) {
105
+ const lines = new Set();
106
+ for (const { start, end } of scanBlocks(markdown).blocks) {
107
+ for (let i = start; i <= end; i++) lines.add(i);
108
+ }
109
+ return lines;
110
+ }
111
+
83
112
  /**
84
113
  * Splits a markdown body into pages by top-level H1 headings. Fenced
85
- * code blocks are respected so `# foo` inside ``` blocks doesn't trigger
86
- * a split. Content before the first H1 (if non-empty) becomes a leading
87
- * page. Each H1 yields a page whose name is the heading
114
+ * code blocks are respected so `# foo` inside ``` or ~~~ blocks doesn't
115
+ * trigger a split. Content before the first H1 (if non-empty) becomes a
116
+ * leading page. Each H1 yields a page whose name is the heading
88
117
  * text (with any `{#anchor-id}` suffix stripped out and surfaced as
89
118
  * `anchorSlug`). The classes the heading declares come along as `classes`, so
90
119
  * a surface that honours one has it without reading the suffix again.
@@ -95,27 +124,32 @@ const WITHHELD_OWNERSHIP = 0;
95
124
  * calling that page "Introduction" would label the description as a preamble to
96
125
  * nothing.
97
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.
130
+ *
98
131
  * @param {string} body - Markdown body to split.
99
132
  * @param {string} [leadName] - Name of the page before the first heading.
133
+ * @param {(address: string) => string|undefined} [resolveRole] - From a
134
+ * picture's address to the role its asset declares, so a figure page's
135
+ * name reads `Map 1` rather than `Figure 1` where it is due — see
136
+ * {@link module:engine/content-figures.scanFigures}.
100
137
  * @returns {Array<{name: string, anchorSlug: string|null, level: number,
101
- * classes: string[], markdown: string}>} Pages in document order.
138
+ * classes: string[], figure: object|null, markdown: string}>} Pages in
139
+ * document order.
102
140
  */
103
- export function splitPages(body, leadName = "Introduction") {
141
+ export function splitPages(body, leadName = "Introduction", resolveRole) {
104
142
  const { markdown, definitions } = separateFootnotes(body);
105
143
  const lines = markdown.split("\n");
106
- const captions = scanCaptions(markdown).captions;
107
- const captionStarts = new Map(captions.map((caption) => [caption.line - 1, caption]));
108
- const captionedHeadings = new Set(
109
- captions
110
- .filter((caption) => /^\s*#{1,6}\s/.test(lines[caption.blockStart] ?? ""))
111
- .map((caption) => caption.blockStart),
144
+ const figureStarts = new Map(
145
+ scanFigures(markdown, { resolveRole }).figures.map((figure) => [figure.line - 1, figure]),
112
146
  );
113
- const openings = pageOpenings(markdown);
147
+ const blockLines = namedBlockLines(markdown);
148
+ const openings = pageOpenings(markdown, blockLines);
114
149
  const pages = [];
115
150
  const beforeFirstH1 = [];
116
151
  let current = null;
117
- let inCodeBlock = false;
118
- let inSecret = false;
152
+ let codeFence = null;
119
153
 
120
154
  const closeCurrent = () => {
121
155
  if (!current) return;
@@ -124,41 +158,54 @@ export function splitPages(body, leadName = "Introduction") {
124
158
  anchorSlug: current.anchorSlug,
125
159
  level: current.level,
126
160
  classes: current.classes,
161
+ figure: current.figure ?? null,
127
162
  markdown: current.lines.join("\n").trim(),
128
163
  });
129
164
  current = null;
130
165
  };
131
166
 
132
167
  for (const [lineIndex, line] of lines.entries()) {
133
- if (line.trim().startsWith("```")) {
134
- inCodeBlock = !inCodeBlock;
168
+ // A fence's closer must carry the same character as its opener and
169
+ // be at least as long — a longer fence is what lets a fenced example
170
+ // carry a shorter fence of its own as literal content, same as
171
+ // {@link module:engine/content-blocks.scanBlocks} and
172
+ // {@link module:engine/content-figures.scanFigures} track it.
173
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
174
+ if (codeFence) {
175
+ if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
176
+ codeFence = null;
177
+ }
178
+ } else if (fence) {
179
+ codeFence = fence[1];
135
180
  }
136
- if (!inCodeBlock && /^:::secret[ \t]*$/.test(line)) inSecret = true;
137
- else if (!inCodeBlock && /^:::[ \t]*$/.test(line)) inSecret = false;
181
+ const inCodeBlock = codeFence !== null;
138
182
 
139
183
  // An H1 starts a page, as does any heading carrying an `{#slug}`
140
184
  // anchor: a Foundry UUID can only address a page, so a linkable
141
185
  // section has to be one.
142
186
  // {@link module:engine/heading-attributes.parseHeadingLine} is the one
143
- // reading of that rule — `scanBlocks` and `scanCaptions` refuse such a
187
+ // reading of that rule — `scanBlocks` and `scanFigures` refuse such a
144
188
  // heading written where it cannot become a page, from the same
145
189
  // function, so none of them can disagree about what starts one. The
146
190
  // lines it claims here are collected by `pageOpenings`, which also
147
- // skips a fence and a named block.
191
+ // skips a fence and a named block. A figure fence opens a page of its
192
+ // own, named for the figure's label.
148
193
  const opening = openings.get(lineIndex);
149
- const caption = !inCodeBlock && !inSecret ? captionStarts.get(lineIndex) : null;
150
- if (caption) {
194
+ const figure =
195
+ !inCodeBlock && !blockLines.has(lineIndex) ? figureStarts.get(lineIndex) : null;
196
+ if (figure) {
151
197
  closeCurrent();
152
198
  current = {
153
- name: caption.label,
154
- anchorSlug: caption.id,
199
+ name: figure.label,
200
+ anchorSlug: figure.id || null,
155
201
  level: 1,
156
202
  classes: [],
203
+ figure,
157
204
  lines: [line],
158
205
  };
159
206
  continue;
160
207
  }
161
- if (opening && !captionedHeadings.has(lineIndex)) {
208
+ if (opening) {
162
209
  closeCurrent();
163
210
  current = {
164
211
  name: opening.text,
@@ -185,6 +232,7 @@ export function splitPages(body, leadName = "Introduction") {
185
232
  anchorSlug: null,
186
233
  level: 1,
187
234
  classes: [],
235
+ figure: null,
188
236
  markdown: intro,
189
237
  });
190
238
  }
@@ -283,6 +331,145 @@ export function journalPageId(entryId, page) {
283
331
  : makeId("journal-page", `${entryId}:${page.name}`);
284
332
  }
285
333
 
334
+ /**
335
+ * Whether a caption carries markup an `image` page's `caption` field cannot
336
+ * hold.
337
+ *
338
+ * `image.caption` is a `StringField`; `text.content` is an `HTMLField`.
339
+ * Parsed as inline Markdown, plain prose tokenises as `text` alone, wrapping
340
+ * at most on a `softbreak` — anything else (emphasis, a code span, a link, an
341
+ * inline image) is a token an image page has nowhere to render.
342
+ *
343
+ * @param {string} caption - The figure's raw caption markdown.
344
+ * @returns {boolean} Whether the caption needs a `text` page.
345
+ */
346
+ function captionCarriesMarkup(caption) {
347
+ if (!caption) return false;
348
+ const [inline] = md.parseInline(caption, {});
349
+ return (inline?.children ?? []).some(
350
+ (token) => token.type !== "text" && token.type !== "softbreak",
351
+ );
352
+ }
353
+
354
+ /**
355
+ * The one picture a `figure`-kind fence holds, when it holds exactly one and
356
+ * nothing else — the shape an `image` page can draw on its own. A grouped
357
+ * fence, two pictures or more, keeps the `text` page a single `src` cannot
358
+ * hold, and an embed that resolves to a sound rather than a picture does too,
359
+ * there being no audio page type.
360
+ *
361
+ * By the time a journal note's markdown reaches this pass, an embed has
362
+ * already been rewritten into the ordinary `![alt](src)` image every surface
363
+ * renders ({@link module:engine/content-embeds}), so the file this needs is
364
+ * already sitting in the fence's own contents rather than behind an address
365
+ * this pass would have to resolve a second time.
366
+ *
367
+ * @param {object} figure - The figure record, `kind` already derived.
368
+ * @param {object} page - From {@link splitPages}; `page.markdown` starts on
369
+ * the figure's own opening line, so `figure`'s line-based offsets translate
370
+ * into it directly.
371
+ * @returns {string|null} The picture's `src`, or `null`.
372
+ */
373
+ function soleFigurePicture(figure, page) {
374
+ if (figure.kind !== "figure" && figure.kind !== "map") return null;
375
+ const offset = figure.line - 1;
376
+ const lines = page.markdown.split("\n");
377
+ const contents = lines.slice(figure.bodyStart - offset, figure.bodyEnd - offset).join("\n");
378
+ const images = imagesIn(contents);
379
+ if (images.length !== 1) return null;
380
+ const extension = path.extname(images[0].src).toLowerCase();
381
+ return IMAGE_EXTENSIONS.includes(extension) ? images[0].src : null;
382
+ }
383
+
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
+ /**
407
+ * The fields particular to the Foundry page a `:::figure` fence begins.
408
+ *
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 —
414
+ * becomes a page of type `text`.
415
+ *
416
+ * The `text` page's content is rendered and then relabeled. The render sees
417
+ * only this one fence — {@link scanFigures}, called again inside
418
+ * {@link module:engine/content-figures.renderFigureBlocks} on this page's own
419
+ * markdown, recounts it as the first of its kind regardless of its true,
420
+ * note-wide number. The fence is the whole of the figure on this page, so its
421
+ * label paragraph is the one element to correct, to the number the note-wide
422
+ * scan in {@link buildJournalEntry} already assigned.
423
+ *
424
+ * @param {object} figure - The figure record from {@link scanFigures},
425
+ * carried onto the page by {@link splitPages}.
426
+ * @param {object} page - From {@link splitPages}.
427
+ * @param {string} pageId - The page's `_id`, threaded into footnote anchors.
428
+ * @param {Map<string, number>} footnoteNumbers - Shared across the note.
429
+ * @param {(address: string) => string|undefined} [resolveRole] - See
430
+ * {@link splitPages}.
431
+ * @returns {{name: string, type: "image", src: string, image: {caption?: string}}
432
+ * |{name: string, type: "text", text: {format: number, content: string}}}
433
+ */
434
+ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRole) {
435
+ const picture = soleFigurePicture(figure, page);
436
+ if (picture && figureIsWholePage(figure, page) && !captionCarriesMarkup(figure.caption)) {
437
+ let src = picture;
438
+ try {
439
+ src = resolveImg(picture) ?? picture;
440
+ } catch {
441
+ // Left as the authored pathname — the same fallback an inline
442
+ // image's own render takes when no configuration resolves it.
443
+ }
444
+ return {
445
+ name: figure.label,
446
+ type: "image",
447
+ src,
448
+ image: figure.hasCaption ? { caption: figure.caption } : {},
449
+ };
450
+ }
451
+ const label =
452
+ figure.hasCaption ? `${figure.label}: ${md.renderInline(figure.caption)}` : figure.label;
453
+ const content = renderFoundryMarkdown(
454
+ page.markdown,
455
+ undefined,
456
+ footnoteNumbers,
457
+ pageId,
458
+ resolveRole,
459
+ );
460
+ return {
461
+ name: figure.label,
462
+ type: "text",
463
+ text: {
464
+ format: 1,
465
+ content: content.replace(
466
+ /<p class="content-figure-label">[\s\S]*?<\/p>/,
467
+ () => `<p class="content-figure-label">${label}</p>`,
468
+ ),
469
+ },
470
+ };
471
+ }
472
+
286
473
  /**
287
474
  * Compile split pages into JournalEntryPage documents.
288
475
  *
@@ -291,15 +478,22 @@ export function journalPageId(entryId, page) {
291
478
  * @param {string} noteName - The note, for error messages.
292
479
  * A page whose heading carries `.secret` is given to the GM alone, by the one
293
480
  * ownership value Foundry reads as "nobody but a GM". Every other page states
294
- * no ownership and inherits the journal's.
481
+ * no ownership and inherits the journal's. A figure-started page states none
482
+ * either: {@link scanFigures} validates a figure's classes against its own
483
+ * closed vocabulary, which carries no `.secret`.
295
484
  *
485
+ * @param {Array<object>} figures - From {@link scanFigures}, numbered across
486
+ * the whole note. Read by a page a heading started; a page a figure started
487
+ * carries its own figure already, from {@link splitPages}.
488
+ * @param {(address: string) => string|undefined} [resolveRole] - See
489
+ * {@link splitPages}.
296
490
  * @returns {Array<{_id: string, name: string, type: string,
297
491
  * title: {show: boolean, level: number},
298
492
  * text: {format: number, content: string}, _key: string,
299
493
  * ownership?: {default: number}}>} The page documents, in order.
300
494
  * @throws {Error} When the note has no content at all, or repeats an anchor.
301
495
  */
302
- export function buildPages(rawPages, entryId, noteName, captions) {
496
+ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
303
497
  if (rawPages.length === 0) {
304
498
  throw new Error(
305
499
  `note "${noteName}" has no Introduction content and no H1 headings — nothing to compile`,
@@ -317,18 +511,30 @@ export function buildPages(rawPages, entryId, noteName, captions) {
317
511
  return rawPages.map((page) => {
318
512
  const pageId = journalPageId(entryId, page);
319
513
  const withheld = (page.classes ?? []).includes(WITHHELD_CLASS);
514
+ const fields =
515
+ page.figure ?
516
+ buildFigurePageFields(page.figure, page, pageId, footnoteNumbers, resolveRole)
517
+ : {
518
+ name: page.name,
519
+ type: "text",
520
+ text: {
521
+ format: 1,
522
+ content:
523
+ page.markdown ?
524
+ renderFoundryMarkdown(
525
+ page.markdown,
526
+ figures,
527
+ footnoteNumbers,
528
+ pageId,
529
+ resolveRole,
530
+ )
531
+ : "",
532
+ },
533
+ };
320
534
  return {
321
535
  _id: pageId,
322
- name: page.name,
323
- type: "text",
536
+ ...fields,
324
537
  title: { show: true, level: page.level ?? 1 },
325
- text: {
326
- format: 1,
327
- content:
328
- page.markdown ?
329
- renderFoundryMarkdown(page.markdown, captions, footnoteNumbers, pageId)
330
- : "",
331
- },
332
538
  ...(withheld ? { ownership: { default: WITHHELD_OWNERSHIP } } : {}),
333
539
  _key: `!journal.pages!${entryId}.${pageId}`,
334
540
  };
@@ -365,6 +571,10 @@ export function buildPages(rawPages, entryId, noteName, captions) {
365
571
  * module:engine/draft-notice}. It leads the first authored page,
366
572
  * because a reader deciding whether to rely on the entry has to be told
367
573
  * before they read it rather than after.
574
+ * @param {(address: string) => string|undefined} [params.resolveRole] - From
575
+ * a picture's address to the role its asset declares, so a figure page's
576
+ * name reads `Map 1` rather than `Figure 1` where it is due — see
577
+ * {@link module:engine/content-figures.scanFigures}.
368
578
  * @returns {object} The JournalEntry document, keyed for the pack.
369
579
  */
370
580
  export function buildJournalEntry({
@@ -376,9 +586,16 @@ export function buildJournalEntry({
376
586
  stats = defaultStats(),
377
587
  infoboxes = [],
378
588
  notice = "",
589
+ resolveRole,
379
590
  }) {
380
- const rawPages = splitPages(markdown, leadName);
381
- const pages = buildPages(rawPages, id, name, scanCaptions(markdown).captions);
591
+ const rawPages = splitPages(markdown, leadName, resolveRole);
592
+ const pages = buildPages(
593
+ rawPages,
594
+ id,
595
+ name,
596
+ scanFigures(markdown, { resolveRole }).figures,
597
+ resolveRole,
598
+ );
382
599
  for (const box of infoboxes) {
383
600
  const pageId = makeId("journal-infobox-page", `${id}:${box.id}`);
384
601
  pages.push({
@@ -541,12 +758,20 @@ export class Journals extends BasePackCompiler {
541
758
  // by the pack list this build is being driven by.
542
759
  router: this.router,
543
760
  dates: reckoningContext(this.linkIndex),
761
+ contentPackage: this.linkIndex?.contentPackage,
544
762
  });
545
763
 
764
+ // The markdown this pass reads has already had its embeds rewritten
765
+ // into ordinary images, each `src` the pathname `pathnameRoles` keys
766
+ // its roles by — so a `:::figure` of a map names itself `Map 1` here
767
+ // exactly as the website and the book name the same picture.
768
+ const roles = pathnameRoles(this.linkIndex);
769
+
546
770
  return buildJournalEntry({
547
771
  id,
548
772
  name,
549
773
  markdown,
774
+ resolveRole: (pathname) => roles.get(pathname),
550
775
  infoboxes: boxes.map((box) => ({
551
776
  id: box.id,
552
777
  name: `${