@heroiclands/package-build 22.21.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 (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. package/types/engine/content-secrets.d.mts +0 -17
@@ -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,16 +73,50 @@ 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";
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";
82
+
83
+ /**
84
+ * `CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE` — the default ownership a withheld
85
+ * page states. A GM reads every document whatever it says; everyone else reads
86
+ * this one not at all.
87
+ */
88
+ const WITHHELD_OWNERSHIP = 0;
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
+ }
74
111
 
75
112
  /**
76
113
  * Splits a markdown body into pages by top-level H1 headings. Fenced
77
- * code blocks are respected so `# foo` inside ``` blocks doesn't trigger
78
- * a split. Content before the first H1 (if non-empty) becomes a leading
79
- * 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
80
117
  * text (with any `{#anchor-id}` suffix stripped out and surfaced as
81
- * `anchorSlug`).
118
+ * `anchorSlug`). The classes the heading declares come along as `classes`, so
119
+ * a surface that honours one has it without reading the suffix again.
82
120
  *
83
121
  * `leadName` names that leading page. A journal note's is "Introduction",
84
122
  * because it introduces the pages that follow. An item doc's is the item — a
@@ -86,26 +124,32 @@ import { separateFootnotes } from "./content-footnotes.mjs";
86
124
  * calling that page "Introduction" would label the description as a preamble to
87
125
  * nothing.
88
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
+ *
89
131
  * @param {string} body - Markdown body to split.
90
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}.
91
137
  * @returns {Array<{name: string, anchorSlug: string|null, level: number,
92
- * markdown: string}>} Pages in document order.
138
+ * classes: string[], figure: object|null, markdown: string}>} Pages in
139
+ * document order.
93
140
  */
94
- export function splitPages(body, leadName = "Introduction") {
141
+ export function splitPages(body, leadName = "Introduction", resolveRole) {
95
142
  const { markdown, definitions } = separateFootnotes(body);
96
143
  const lines = markdown.split("\n");
97
- const captions = scanCaptions(markdown).captions;
98
- const captionStarts = new Map(captions.map((caption) => [caption.line - 1, caption]));
99
- const captionedHeadings = new Set(
100
- captions
101
- .filter((caption) => /^\s*#{1,6}\s/.test(lines[caption.blockStart] ?? ""))
102
- .map((caption) => caption.blockStart),
144
+ const figureStarts = new Map(
145
+ scanFigures(markdown, { resolveRole }).figures.map((figure) => [figure.line - 1, figure]),
103
146
  );
147
+ const blockLines = namedBlockLines(markdown);
148
+ const openings = pageOpenings(markdown, blockLines);
104
149
  const pages = [];
105
150
  const beforeFirstH1 = [];
106
151
  let current = null;
107
- let inCodeBlock = false;
108
- let inSecret = false;
152
+ let codeFence = null;
109
153
 
110
154
  const closeCurrent = () => {
111
155
  if (!current) return;
@@ -113,43 +157,61 @@ export function splitPages(body, leadName = "Introduction") {
113
157
  name: current.name,
114
158
  anchorSlug: current.anchorSlug,
115
159
  level: current.level,
160
+ classes: current.classes,
161
+ figure: current.figure ?? null,
116
162
  markdown: current.lines.join("\n").trim(),
117
163
  });
118
164
  current = null;
119
165
  };
120
166
 
121
167
  for (const [lineIndex, line] of lines.entries()) {
122
- if (line.trim().startsWith("```")) {
123
- 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];
124
180
  }
125
- if (!inCodeBlock && /^:::secret[ \t]*$/.test(line)) inSecret = true;
126
- else if (!inCodeBlock && /^:::[ \t]*$/.test(line)) inSecret = false;
181
+ const inCodeBlock = codeFence !== null;
127
182
 
128
183
  // An H1 starts a page, as does any heading carrying an `{#slug}`
129
184
  // anchor: a Foundry UUID can only address a page, so a linkable
130
185
  // section has to be one.
131
- const headingMatch =
132
- !inCodeBlock && !inSecret ? line.match(/^\s*(#{1,6})\s+(.+?)\s*#*\s*$/) : null;
133
- const rawHeading = headingMatch?.[2]?.trim();
134
- const anchorMatch = rawHeading?.match(/^(.*?)\s*\{#([^}]+)\}\s*$/);
135
- const startsPage = headingMatch && (headingMatch[1].length === 1 || anchorMatch);
136
- const caption = !inCodeBlock && !inSecret ? captionStarts.get(lineIndex) : null;
137
- if (caption) {
186
+ // {@link module:engine/heading-attributes.parseHeadingLine} is the one
187
+ // reading of that rule — `scanBlocks` and `scanFigures` refuse such a
188
+ // heading written where it cannot become a page, from the same
189
+ // function, so none of them can disagree about what starts one. The
190
+ // lines it claims here are collected by `pageOpenings`, which also
191
+ // skips a fence and a named block. A figure fence opens a page of its
192
+ // own, named for the figure's label.
193
+ const opening = openings.get(lineIndex);
194
+ const figure =
195
+ !inCodeBlock && !blockLines.has(lineIndex) ? figureStarts.get(lineIndex) : null;
196
+ if (figure) {
138
197
  closeCurrent();
139
198
  current = {
140
- name: caption.label,
141
- anchorSlug: caption.id,
199
+ name: figure.label,
200
+ anchorSlug: figure.id || null,
142
201
  level: 1,
202
+ classes: [],
203
+ figure,
143
204
  lines: [line],
144
205
  };
145
206
  continue;
146
207
  }
147
- if (startsPage && !captionedHeadings.has(lineIndex)) {
208
+ if (opening) {
148
209
  closeCurrent();
149
210
  current = {
150
- name: (anchorMatch ? anchorMatch[1] : rawHeading).trim(),
151
- anchorSlug: anchorMatch?.[2]?.trim() || null,
152
- level: headingMatch[1].length,
211
+ name: opening.text,
212
+ anchorSlug: opening.id || null,
213
+ level: opening.level,
214
+ classes: opening.classes,
153
215
  lines: [],
154
216
  };
155
217
  continue;
@@ -169,6 +231,8 @@ export function splitPages(body, leadName = "Introduction") {
169
231
  name: leadName,
170
232
  anchorSlug: null,
171
233
  level: 1,
234
+ classes: [],
235
+ figure: null,
172
236
  markdown: intro,
173
237
  });
174
238
  }
@@ -267,19 +331,169 @@ export function journalPageId(entryId, page) {
267
331
  : makeId("journal-page", `${entryId}:${page.name}`);
268
332
  }
269
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
+
270
473
  /**
271
474
  * Compile split pages into JournalEntryPage documents.
272
475
  *
273
476
  * @param {Array<object>} rawPages - From {@link splitPages}.
274
477
  * @param {string} entryId - The owning JournalEntry's `_id`.
275
478
  * @param {string} noteName - The note, for error messages.
479
+ * A page whose heading carries `.secret` is given to the GM alone, by the one
480
+ * ownership value Foundry reads as "nobody but a GM". Every other page states
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`.
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}.
276
490
  * @returns {Array<{_id: string, name: string, type: string,
277
491
  * title: {show: boolean, level: number},
278
- * text: {format: number, content: string}, _key: string}>} The page
279
- * documents, in order.
492
+ * text: {format: number, content: string}, _key: string,
493
+ * ownership?: {default: number}}>} The page documents, in order.
280
494
  * @throws {Error} When the note has no content at all, or repeats an anchor.
281
495
  */
282
- export function buildPages(rawPages, entryId, noteName, captions) {
496
+ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
283
497
  if (rawPages.length === 0) {
284
498
  throw new Error(
285
499
  `note "${noteName}" has no Introduction content and no H1 headings — nothing to compile`,
@@ -296,18 +510,32 @@ export function buildPages(rawPages, entryId, noteName, captions) {
296
510
  }
297
511
  return rawPages.map((page) => {
298
512
  const pageId = journalPageId(entryId, page);
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
+ };
299
534
  return {
300
535
  _id: pageId,
301
- name: page.name,
302
- type: "text",
536
+ ...fields,
303
537
  title: { show: true, level: page.level ?? 1 },
304
- text: {
305
- format: 1,
306
- content:
307
- page.markdown ?
308
- renderFoundryMarkdown(page.markdown, captions, footnoteNumbers, pageId)
309
- : "",
310
- },
538
+ ...(withheld ? { ownership: { default: WITHHELD_OWNERSHIP } } : {}),
311
539
  _key: `!journal.pages!${entryId}.${pageId}`,
312
540
  };
313
541
  });
@@ -329,7 +557,6 @@ export function buildPages(rawPages, entryId, noteName, captions) {
329
557
  * @param {string} [params.leadName] - Name for the page before the first
330
558
  * heading; see {@link splitPages}.
331
559
  * @param {string|null} [params.folder] - The folder id, or `null`.
332
- * @param {object} [params.flags] - Document flags.
333
560
  * @param {object} [params.stats] - The `_stats` block to stamp. Passed by the
334
561
  * caller because it is a property of the *pack* being written, not of the
335
562
  * entry: a module may ship the same content for two systems, and each pack's
@@ -344,6 +571,10 @@ export function buildPages(rawPages, entryId, noteName, captions) {
344
571
  * module:engine/draft-notice}. It leads the first authored page,
345
572
  * because a reader deciding whether to rely on the entry has to be told
346
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}.
347
578
  * @returns {object} The JournalEntry document, keyed for the pack.
348
579
  */
349
580
  export function buildJournalEntry({
@@ -352,13 +583,19 @@ export function buildJournalEntry({
352
583
  markdown,
353
584
  leadName,
354
585
  folder = null,
355
- flags,
356
586
  stats = defaultStats(),
357
587
  infoboxes = [],
358
588
  notice = "",
589
+ resolveRole,
359
590
  }) {
360
- const rawPages = splitPages(markdown, leadName);
361
- 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
+ );
362
599
  for (const box of infoboxes) {
363
600
  const pageId = makeId("journal-infobox-page", `${id}:${box.id}`);
364
601
  pages.push({
@@ -379,7 +616,7 @@ export function buildJournalEntry({
379
616
  folder,
380
617
  sort: 0,
381
618
  ownership: { default: 0 },
382
- flags: flags || {},
619
+ flags: {},
383
620
  _id: id,
384
621
  _stats: stats,
385
622
  _key: `!journal!${id}`,
@@ -521,12 +758,20 @@ export class Journals extends BasePackCompiler {
521
758
  // by the pack list this build is being driven by.
522
759
  router: this.router,
523
760
  dates: reckoningContext(this.linkIndex),
761
+ contentPackage: this.linkIndex?.contentPackage,
524
762
  });
525
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
+
526
770
  return buildJournalEntry({
527
771
  id,
528
772
  name,
529
773
  markdown,
774
+ resolveRole: (pathname) => roles.get(pathname),
530
775
  infoboxes: boxes.map((box) => ({
531
776
  id: box.id,
532
777
  name: `${
@@ -546,7 +791,6 @@ export class Journals extends BasePackCompiler {
546
791
  // "Introduction" — see {@link splitPages}.
547
792
  leadName: ownsDoc ? name : undefined,
548
793
  folder,
549
- flags: fm.flags,
550
794
  stats: this.stats,
551
795
  });
552
796
  }