@heroiclands/package-build 22.21.0 → 22.22.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 (110) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +26 -3
  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 +73 -4
  8. package/docs/api.md +36 -18
  9. package/docs/authoring/dates-and-calendars.md +4 -2
  10. package/docs/authoring/frontmatter.md +2 -2
  11. package/docs/authoring/links-and-markup.md +237 -28
  12. package/docs/configuration.md +27 -1
  13. package/docs/content-format.md +3 -1
  14. package/docs/guides/reference-pages.md +15 -0
  15. package/docs/reference/format-details.md +224 -46
  16. package/docs/reference/note-types.md +21 -4
  17. package/engine/actor-compiler.mjs +23 -0
  18. package/engine/anchored-sections.mjs +35 -4
  19. package/engine/anchors.mjs +10 -20
  20. package/engine/base-compiler.mjs +86 -21
  21. package/engine/base-styles.mjs +149 -0
  22. package/engine/bundles.mjs +0 -1
  23. package/engine/compendiums.mjs +8 -1
  24. package/engine/content-blocks.mjs +410 -0
  25. package/engine/content-captions.mjs +57 -8
  26. package/engine/content-footnotes.mjs +189 -0
  27. package/engine/content-format-check.mjs +15 -12
  28. package/engine/content-index.mjs +2 -2
  29. package/engine/content-links.mjs +14 -6
  30. package/engine/content-tables.mjs +45 -4
  31. package/engine/content-tasklists.mjs +189 -0
  32. package/engine/docs-checks.mjs +4 -4
  33. package/engine/extension-attributes.mjs +48 -2
  34. package/engine/frontmatter-lint.mjs +47 -64
  35. package/engine/frontmatter.mjs +3 -3
  36. package/engine/heading-attributes.mjs +308 -0
  37. package/engine/helpers.mjs +14 -13
  38. package/engine/homepage.mjs +8 -8
  39. package/engine/index.mjs +3 -0
  40. package/engine/infobox-render.mjs +15 -3
  41. package/engine/infobox.mjs +83 -8
  42. package/engine/journals.mjs +36 -17
  43. package/engine/macros.mjs +1 -1
  44. package/engine/markdown-expressions.mjs +178 -53
  45. package/engine/note-dates.mjs +49 -0
  46. package/engine/note-frontmatter.mjs +7 -16
  47. package/engine/note-vocabulary.mjs +172 -37
  48. package/engine/page-lists.mjs +358 -0
  49. package/engine/pdf-build.mjs +76 -42
  50. package/engine/pdf-render.mjs +422 -49
  51. package/engine/rank-ladder.mjs +198 -0
  52. package/engine/reckoning-markers.mjs +1 -1
  53. package/engine/retired-fields.mjs +8 -13
  54. package/engine/scenes.mjs +10 -3
  55. package/engine/site-build.mjs +41 -12
  56. package/engine/site-config.mjs +10 -1
  57. package/engine/site-index.mjs +18 -2
  58. package/engine/site-preview.mjs +18 -7
  59. package/engine/social-ties.mjs +52 -14
  60. package/engine/sql-tables.mjs +66 -9
  61. package/engine/standings.mjs +89 -6
  62. package/engine/system-block.mjs +12 -14
  63. package/engine/web-wikilinks.mjs +48 -1
  64. package/hm3/actors.mjs +6 -3
  65. package/manifest.mjs +13 -0
  66. package/package.json +1 -1
  67. package/sohl/actors.mjs +22 -99
  68. package/sohl/item-fields.mjs +4 -5
  69. package/sohl/items.mjs +1 -1
  70. package/sohl/note-schemas.mjs +11 -21
  71. package/types/config.d.mts +9 -0
  72. package/types/engine/actor-compiler.d.mts +11 -0
  73. package/types/engine/anchored-sections.d.mts +24 -0
  74. package/types/engine/base-compiler.d.mts +19 -0
  75. package/types/engine/base-styles.d.mts +93 -0
  76. package/types/engine/content-blocks.d.mts +87 -0
  77. package/types/engine/content-captions.d.mts +14 -1
  78. package/types/engine/content-footnotes.d.mts +73 -0
  79. package/types/engine/content-format-check.d.mts +8 -6
  80. package/types/engine/content-tables.d.mts +12 -4
  81. package/types/engine/content-tasklists.d.mts +72 -0
  82. package/types/engine/extension-attributes.d.mts +22 -1
  83. package/types/engine/frontmatter.d.mts +1 -1
  84. package/types/engine/heading-attributes.d.mts +174 -0
  85. package/types/engine/helpers.d.mts +7 -4
  86. package/types/engine/homepage.d.mts +7 -7
  87. package/types/engine/index.d.mts +1 -0
  88. package/types/engine/infobox-render.d.mts +6 -1
  89. package/types/engine/journals.d.mts +14 -7
  90. package/types/engine/markdown-expressions.d.mts +23 -0
  91. package/types/engine/note-frontmatter.d.mts +1 -4
  92. package/types/engine/note-vocabulary.d.mts +82 -6
  93. package/types/engine/page-lists.d.mts +126 -0
  94. package/types/engine/pdf-render.d.mts +72 -2
  95. package/types/engine/rank-ladder.d.mts +32 -0
  96. package/types/engine/retired-fields.d.mts +4 -6
  97. package/types/engine/site-build.d.mts +13 -1
  98. package/types/engine/site-config.d.mts +9 -0
  99. package/types/engine/site-index.d.mts +13 -1
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/sql-tables.d.mts +31 -7
  102. package/types/engine/standings.d.mts +21 -3
  103. package/types/engine/system-block.d.mts +11 -12
  104. package/types/engine/web-wikilinks.d.mts +3 -1
  105. package/types/hm3/actors.d.mts +3 -3
  106. package/types/sohl/actors.d.mts +6 -7
  107. package/engine/content-admonitions.mjs +0 -114
  108. package/engine/content-secrets.mjs +0 -112
  109. package/types/engine/content-admonitions.d.mts +0 -24
  110. package/types/engine/content-secrets.d.mts +0 -17
@@ -0,0 +1,410 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ /**
4
+ * Named body blocks — `:::secret`, `:::info` and `:::warn` — as one construct.
5
+ *
6
+ * ```
7
+ * :::info {#route title="The road north" .wide}
8
+ * The ford floods in Azlet.
9
+ * :::
10
+ * ```
11
+ *
12
+ * The block's name becomes its first class, the author's classes follow it, and
13
+ * every other attribute is written onto the element. `title` is reserved: it
14
+ * supplies the heading, and a block that states none takes the capitalised name
15
+ * — `Secret`, `Info`, `Warn`.
16
+ *
17
+ * The three names differ in the class they carry and in nothing else, so a
18
+ * stylesheet owns their appearance. None is emitted with an inline `style`.
19
+ *
20
+ * Each block stands on its own: a malformed one is a finding at its own line
21
+ * and the blocks around it still render.
22
+ *
23
+ * **An H1, or an anchored heading at any level, is refused inside a block.**
24
+ * Either starts a Foundry journal page, which would tear the block's own
25
+ * page in two and publish the rest with no wrapper around it at all.
26
+ *
27
+
28
+ * **One pass reads all three names.** Reading them in two passes made the first
29
+ * one meet the second's closers with nothing open, and report a block the author
30
+ * had not written. A construct this pass does not own — `:::caption` — is
31
+ * counted rather than claimed, so its closer is attributed to it and the pass
32
+ * that owns it still finds it in the markdown this one passes through.
33
+ *
34
+ * **A block's body is left as Markdown, not pre-rendered.** The wrapper is
35
+ * written with a blank line after the opening tag and before the closing one,
36
+ * which is what lets the surrounding renderer — Foundry's single parse of the
37
+ * whole page, or Hugo's of the whole site page — read the body as part of its
38
+ * own document rather than as an isolated fragment. A fragment rendered on its
39
+ * own has no access to the note's footnote definitions, which live at the top
40
+ * level outside every block, so a reference inside one used to fall through as
41
+ * literal `[^id]` text; reached by the one parse that also sees the
42
+ * definitions, it resolves exactly as a reference inside a list item or a
43
+ * block quote already does.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ import crypto from "node:crypto";
49
+ import MarkdownIt from "markdown-it";
50
+ import footnotePlugin from "markdown-it-footnote";
51
+ import deflistPlugin from "markdown-it-deflist";
52
+ import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
53
+ import { WITHHELD_CLASS, parseHeadingLine, withheldSections } from "./heading-attributes.mjs";
54
+
55
+ const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
56
+
57
+ /**
58
+ * Titles carry emphasis and nothing else. `html: false` escapes any tag an
59
+ * author writes there, because a title is written into an element's first child
60
+ * and a stray `<section>` would close the block early.
61
+ */
62
+ const titleParser = new MarkdownIt({ html: false });
63
+
64
+ const OPEN = /^:::([A-Za-z][A-Za-z0-9-]*)(?:[ \t]+(.*?))?[ \t]*$/;
65
+ const CLOSE = /^:::[ \t]*$/;
66
+
67
+ /** The blocks an author may open, with the heading each takes by default. */
68
+ export const BLOCK_NAMES = Object.freeze({
69
+ info: "Info",
70
+ secret: "Secret",
71
+ warn: "Warn",
72
+ });
73
+
74
+ /**
75
+ * Blocks that may hold another named block.
76
+ *
77
+ * A box inside a GM-only section is a real thing to write — the section is a
78
+ * container for whatever the GM reads, boxes included — so a `secret` holds one
79
+ * and the box stays inside it. A box inside a box is noise, so `info` and `warn`
80
+ * hold no named block and a second opener inside one is a finding.
81
+ *
82
+ * Exported because a renderer has to know it: the body of a container is read
83
+ * for blocks of its own, and the body of a box is not.
84
+ *
85
+ * @type {readonly string[]}
86
+ */
87
+ export const BLOCK_CONTAINERS = Object.freeze(["secret"]);
88
+
89
+ /**
90
+ * Constructs another pass owns. A `:::caption` is read by the caption pass, so
91
+ * this one **counts it and does not claim it**: the opener and its closer pass
92
+ * through untouched, and the count is what lets a closer be attributed to the
93
+ * innermost block actually open. Claiming one would report a block that does not
94
+ * exist and take the caption out of the note.
95
+ */
96
+ const FOREIGN = Object.freeze(["caption"]);
97
+
98
+ /** `title` is the heading. Everything else an author writes becomes an attribute. */
99
+ const TITLE = "title";
100
+
101
+ const names = () => Object.keys(BLOCK_NAMES).join(", ");
102
+
103
+ /** Text safe inside a double-quoted attribute. */
104
+ function escapeAttribute(value) {
105
+ return String(value)
106
+ .replace(/&/g, "&amp;")
107
+ .replace(/</g, "&lt;")
108
+ .replace(/>/g, "&gt;")
109
+ .replace(/"/g, "&quot;");
110
+ }
111
+
112
+ /**
113
+ * Find named blocks, leaving code examples alone.
114
+ *
115
+ * A block reaches `blocks` only when it is well formed and closed; everything
116
+ * else is a finding, and the surrounding blocks are unaffected.
117
+ *
118
+ * @param {string} source - Markdown that may contain named blocks.
119
+ * @returns {{blocks: Array<{name: string, title: string, id: string, classes: string[], attributes: Record<string, string>, start: number, end: number, body: string}>, errors: Array<{line: number, column: number, message: string}>}}
120
+ */
121
+ export function scanBlocks(source) {
122
+ const lines = String(source ?? "").split("\n");
123
+ const blocks = [];
124
+ const errors = [];
125
+ let opening = null;
126
+ let codeFence = null;
127
+ // Blocks open inside this pass's own block, and blocks of another
128
+ // construct open outside it, counted so a `:::` closes the innermost thing
129
+ // rather than whatever this pass happens to have open.
130
+ let innerOpen = 0;
131
+ let foreignOutside = 0;
132
+
133
+ for (let i = 0; i < lines.length; i++) {
134
+ const line = lines[i];
135
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
136
+ if (codeFence) {
137
+ if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
138
+ codeFence = null;
139
+ }
140
+ continue;
141
+ }
142
+ if (fence) {
143
+ codeFence = fence[1];
144
+ continue;
145
+ }
146
+
147
+ if (CLOSE.test(line)) {
148
+ // A closer belongs to the innermost block still open, whichever
149
+ // pass owns it.
150
+ if (opening && innerOpen > 0) {
151
+ innerOpen -= 1;
152
+ continue;
153
+ }
154
+ if (!opening) {
155
+ if (foreignOutside > 0) {
156
+ foreignOutside -= 1;
157
+ continue;
158
+ }
159
+ errors.push({ line: i + 1, column: 1, message: "a ::: line closes no block" });
160
+ continue;
161
+ }
162
+ if (opening.rejected) {
163
+ opening = null;
164
+ continue;
165
+ }
166
+ const body = lines
167
+ .slice(opening.start + 1, i)
168
+ .join("\n")
169
+ .trim();
170
+ if (!body) {
171
+ errors.push({
172
+ line: opening.start + 1,
173
+ column: 1,
174
+ message: `${opening.name} block is empty`,
175
+ });
176
+ opening = null;
177
+ continue;
178
+ }
179
+ // An H1, or an anchored heading at any level, starts a Foundry
180
+ // journal page — splitting the block's own page in two and
181
+ // publishing the rest with no wrapper around it, a GM-only
182
+ // section included. Refused rather than split: a lower heading
183
+ // with no anchor is still the ordinary way to structure a box.
184
+ for (let at = opening.start + 1; at < i; at++) {
185
+ if (!parseHeadingLine(lines[at])?.startsPage) continue;
186
+ const article = /^[aeiou]/i.test(opening.name) ? "an" : "a";
187
+ errors.push({
188
+ line: at + 1,
189
+ column: 1,
190
+ message:
191
+ `a heading that starts a page cannot be written inside ${article} ` +
192
+ `${opening.name} block — keep an H1 or an anchored heading ` +
193
+ "at the top level, or drop the anchor and the level to stay inside it",
194
+ });
195
+ }
196
+ blocks.push({ ...opening, end: i, body });
197
+ opening = null;
198
+ continue;
199
+ }
200
+
201
+ const open = OPEN.exec(line);
202
+ if (!open) continue;
203
+ const [, name, raw = ""] = open;
204
+ const at = { line: i + 1, column: 1 };
205
+ if (FOREIGN.includes(name)) {
206
+ if (opening) innerOpen += 1;
207
+ else foreignOutside += 1;
208
+ continue;
209
+ }
210
+ if (!Object.hasOwn(BLOCK_NAMES, name)) {
211
+ errors.push({ ...at, message: `there is no ${name} block; the blocks are ${names()}` });
212
+ opening = { start: i, rejected: true };
213
+ continue;
214
+ }
215
+ if (opening) {
216
+ if (BLOCK_CONTAINERS.includes(opening.name) && !BLOCK_CONTAINERS.includes(name)) {
217
+ // Held by the block it is written in, and counted so the
218
+ // closers are attributed in the order they were opened. The
219
+ // body is rendered for its own blocks before it is rendered as
220
+ // markdown, so the inner block comes out as a block.
221
+ innerOpen += 1;
222
+ continue;
223
+ }
224
+ // Not counted, unlike a block the outer one holds: the nesting is
225
+ // already reported, and counting it would leave the outer block
226
+ // looking unclosed as well — two findings for one mistake.
227
+ errors.push({ ...at, message: `nested ${name} blocks are not supported` });
228
+ continue;
229
+ }
230
+
231
+ let id = "";
232
+ let classes = [];
233
+ const attributes = {};
234
+ let title = BLOCK_NAMES[name];
235
+ const text = raw.trim();
236
+ if (text) {
237
+ if (!/^\{[^}\n]*\}$/.test(text)) {
238
+ errors.push({ ...at, message: "block attributes need {…} braces" });
239
+ opening = { start: i, rejected: true };
240
+ continue;
241
+ }
242
+ const parsed = parseExtensionAttributes(text.slice(1, -1));
243
+ if (parsed.problems.length) {
244
+ for (const message of parsed.problems) errors.push({ ...at, message });
245
+ opening = { start: i, rejected: true };
246
+ continue;
247
+ }
248
+ id = parsed.id;
249
+ classes = parsed.classes;
250
+ const refused = refusedAttributes(parsed.values);
251
+ for (const message of refused) errors.push({ ...at, message });
252
+ if (refused.length) {
253
+ opening = { start: i, rejected: true };
254
+ continue;
255
+ }
256
+ for (const [key, value] of Object.entries(parsed.values)) {
257
+ if (key === TITLE) title = value;
258
+ else attributes[key] = value;
259
+ }
260
+ }
261
+ opening = { start: i, name, title, id, classes, attributes };
262
+ }
263
+
264
+ if (opening && !opening.rejected) {
265
+ errors.push({
266
+ line: opening.start + 1,
267
+ column: 1,
268
+ message: `${opening.name} block needs a closing ::: line`,
269
+ });
270
+ }
271
+ return { blocks, errors };
272
+ }
273
+
274
+ /**
275
+ * The id an element carries.
276
+ *
277
+ * A `secret` with no id of its own is given one derived from its body, because
278
+ * Foundry tracks a revealed section by id and a section without one cannot be
279
+ * remembered.
280
+ */
281
+ function elementId(block, target) {
282
+ if (block.id) return block.id;
283
+ if (target !== "foundry" || block.name !== "secret") return "";
284
+ const digest = crypto
285
+ .createHash("sha256")
286
+ .update(`${block.start}:${block.body}`)
287
+ .digest("hex")
288
+ .slice(0, 12);
289
+ return `secret-${digest}`;
290
+ }
291
+
292
+ /** The attribute text shared by both surfaces, id and classes first. */
293
+ function attributeText(block, target, classes) {
294
+ const id = elementId(block, target);
295
+ const parts = [`class="${escapeAttribute(classes.join(" "))}"`];
296
+ if (id) parts.push(`id="${escapeAttribute(id)}"`);
297
+ for (const [key, value] of Object.entries(block.attributes)) {
298
+ parts.push(`${key}="${escapeAttribute(value)}"`);
299
+ }
300
+ return parts.join(" ");
301
+ }
302
+
303
+ /**
304
+ * Render named blocks for one publishing surface.
305
+ *
306
+ * `foundry` emits a `<section>` whose heading is its first line; `web` emits a
307
+ * `<details>` the reader opens. Neither carries an inline `style`.
308
+ *
309
+ * The body is left as Markdown between the wrapper's tags, each separated from
310
+ * its tag by a blank line — see the module docs for why that is what lets the
311
+ * surrounding render treat the body as real content rather than an opaque
312
+ * fragment.
313
+ *
314
+ * A finding does not stop the well-formed blocks around it from rendering.
315
+ *
316
+ * @param {string} source - Markdown containing named blocks.
317
+ * @param {"foundry"|"web"} target - Publishing surface.
318
+ * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
319
+ */
320
+ export function renderBlocks(source, target) {
321
+ const { blocks, errors } = scanBlocks(source);
322
+ if (!blocks.length) return { markdown: String(source ?? ""), errors };
323
+ const lines = String(source ?? "").split("\n");
324
+ const output = [];
325
+ let cursor = 0;
326
+ for (const block of blocks) {
327
+ output.push(...lines.slice(cursor, block.start));
328
+ const classes = [block.name, ...block.classes];
329
+ const attributes = attributeText(block, target, classes);
330
+ const title = titleParser.renderInline(block.title);
331
+ // A container's body may hold a block of its own, rendered first so
332
+ // its own wrapper is already written when the outer one wraps it in
333
+ // turn — nesting composes because each level is markdown-with-raw-HTML
334
+ // at every depth, never a rendered fragment.
335
+ const held =
336
+ BLOCK_CONTAINERS.includes(block.name) ?
337
+ renderBlocks(block.body, target).markdown
338
+ : block.body;
339
+ if (target === "foundry") {
340
+ output.push(
341
+ "",
342
+ `<section ${attributes}>`,
343
+ `<strong>${title}</strong>:<br/>`,
344
+ "",
345
+ held,
346
+ "",
347
+ "</section>",
348
+ "",
349
+ );
350
+ } else {
351
+ output.push(
352
+ "",
353
+ `<details ${attributes}>`,
354
+ `<summary class="${escapeAttribute(block.name)}">${title}</summary>`,
355
+ "",
356
+ held,
357
+ "",
358
+ "</details>",
359
+ "",
360
+ );
361
+ }
362
+ cursor = block.end + 1;
363
+ }
364
+ output.push(...lines.slice(cursor));
365
+ return { markdown: output.join("\n"), errors };
366
+ }
367
+
368
+ /**
369
+ * Wrap each withheld section in the disclosure a `secret` block renders as.
370
+ *
371
+ * The section's own Markdown is left as Markdown, with a blank line either side
372
+ * of it, so the page's renderer still reads it: the heading keeps its id, the
373
+ * prose keeps its links, and only the disclosure around them is written as HTML.
374
+ *
375
+ * **A spoiler, not access control.** The section is in the published page, so a
376
+ * reader who opens the element or reads the source reads it. Foundry is the one
377
+ * surface that withholds anything.
378
+ *
379
+ * @param {string} source - A page's Markdown, headings intact.
380
+ * @returns {string} The same Markdown, each withheld section inside a
381
+ * `<details>`.
382
+ */
383
+ export function renderWithheldSections(source) {
384
+ const text = String(source ?? "");
385
+ const { sections } = withheldSections(text);
386
+ if (!sections.length) return text;
387
+ const lines = text.split("\n");
388
+ const output = [];
389
+ let cursor = 0;
390
+ for (const section of sections) {
391
+ output.push(...lines.slice(cursor, section.start));
392
+ const classes = [
393
+ WITHHELD_CLASS,
394
+ ...section.heading.classes.filter((name) => name !== WITHHELD_CLASS),
395
+ ];
396
+ output.push(
397
+ "",
398
+ `<details class="${escapeAttribute(classes.join(" "))}">`,
399
+ `<summary class="${escapeAttribute(WITHHELD_CLASS)}">${BLOCK_NAMES[WITHHELD_CLASS]}</summary>`,
400
+ "",
401
+ ...lines.slice(section.start, section.end),
402
+ "",
403
+ "</details>",
404
+ "",
405
+ );
406
+ cursor = section.end;
407
+ }
408
+ output.push(...lines.slice(cursor));
409
+ return output.join("\n");
410
+ }
@@ -8,6 +8,8 @@ import deflistPlugin from "markdown-it-deflist";
8
8
 
9
9
  import { slugify } from "./content-slug.mjs";
10
10
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
11
+ import { HEADING_LINE, parseHeadingLine, splitHeadingAttributes } from "./heading-attributes.mjs";
12
+ import { imagesIn } from "./content-images.mjs";
11
13
 
12
14
  const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
13
15
  const OPEN = /^:::caption\s+(\{[^}\n]*\})\s*$/;
@@ -61,8 +63,9 @@ export function scanCaptions(source) {
61
63
  codeFence = fence[1];
62
64
  continue;
63
65
  }
64
- const heading = /^\s*#{1,6}\s+.+?\{#([^}]+)\}\s*$/.exec(line);
65
- if (heading) headingIds.push({ id: slugify(heading[1].trim()), line: i + 1 });
66
+ const heading = HEADING_LINE.exec(line);
67
+ const headingId = heading ? splitHeadingAttributes(heading[2]).id : "";
68
+ if (headingId) headingIds.push({ id: slugify(headingId), line: i + 1 });
66
69
  if (!CAPTION_LINE.test(line)) continue;
67
70
  const open = OPEN.exec(line);
68
71
  if (!open) {
@@ -96,6 +99,20 @@ export function scanCaptions(source) {
96
99
  .join("\n")
97
100
  .trim();
98
101
  if (!caption) errors.push({ line: start + 1, column: 1, message: "caption text is empty" });
102
+ // An H1, or an anchored heading at any level, starts a Foundry
103
+ // journal page — see `engine/content-blocks.mjs`'s identical refusal
104
+ // for a named block.
105
+ for (let at = i + 1; at < close; at++) {
106
+ if (!parseHeadingLine(lines[at])?.startsPage) continue;
107
+ errors.push({
108
+ line: at + 1,
109
+ column: 1,
110
+ message:
111
+ "a heading that starts a page cannot be written inside a caption — " +
112
+ "keep an H1 or an anchored heading at the top level, or drop the " +
113
+ "anchor and the level to stay inside it",
114
+ });
115
+ }
99
116
  if (ids.has(slugify(id)))
100
117
  errors.push({ line: start + 1, column: 1, message: `duplicate caption id "${id}"` });
101
118
  ids.add(slugify(id));
@@ -116,11 +133,22 @@ export function scanCaptions(source) {
116
133
  }
117
134
  const blockEnd = next + first.map[1];
118
135
  const block = lines.slice(next, blockEnd).join("\n");
136
+ const trimmedBlock = block.trim();
137
+ // A markdown image is only a figure when it stands alone — nothing
138
+ // else in the block, which is also what decides whether the image
139
+ // plugin wraps it in a `<figure>` at all. A trailing footnote
140
+ // reference, or any other trailing text, disqualifies it exactly as
141
+ // it disqualifies `standsAlone`; such a block is prose with an inline
142
+ // image in it, left as Markdown so a reference inside resolves
143
+ // rather than classified as a figure whose own render strips nothing.
144
+ const aloneImage =
145
+ /^(?:!\[|!\[\[)/.test(trimmedBlock) &&
146
+ imagesIn(trimmedBlock).some((image) => image.block);
119
147
  const kind =
120
148
  first.type === "table_open" ? "table"
121
149
  : first.type === "fence" && /^\s*sql\b/i.test(first.info ?? "") ? "table"
122
150
  : first.type === "fence" || first.type === "code_block" ? "code"
123
- : /^(?:!\[|!\[\[|<figure\b|<img\b)/.test(block.trim()) ? "figure"
151
+ : aloneImage || /^(?:<figure\b|<img\b)/.test(trimmedBlock) ? "figure"
124
152
  : "prose";
125
153
  const number = ++counts[kind];
126
154
  captions.push({
@@ -147,7 +175,20 @@ export function scanCaptions(source) {
147
175
  return { captions, errors };
148
176
  }
149
177
 
150
- /** Render captioned blocks as HTML, leaving other Markdown untouched. */
178
+ /**
179
+ * Render captioned blocks as HTML, leaving other Markdown untouched.
180
+ *
181
+ * A code, table or prose caption's block is left as Markdown inside the
182
+ * wrapper, blank-line separated from its tags exactly as
183
+ * {@link module:engine/content-blocks.renderBlocks} leaves a named block's
184
+ * body — so the surrounding render sees it as part of its own document and a
185
+ * footnote reference inside it resolves against the note's own definitions.
186
+ *
187
+ * A figure's block is still rendered here, because its `<figcaption>` has to
188
+ * be stripped from HTML this pass controls directly; the surrounding render
189
+ * never sees it as Markdown, so a footnote reference inside a figure caption
190
+ * is not resolved, the same gap a block's own title carries.
191
+ */
151
192
  export function renderCaptionBlocks(source, renderMarkdown = parser.render.bind(parser), numbers) {
152
193
  const { captions, errors } = scanCaptions(source);
153
194
  if (errors.length) return { markdown: source, errors };
@@ -164,13 +205,21 @@ export function renderCaptionBlocks(source, renderMarkdown = parser.render.bind(
164
205
  for (const caption of captions) {
165
206
  output.push(...lines.slice(cursor, caption.line - 1));
166
207
  const numbered = byId.get(caption.id) ?? caption;
167
- let html = renderMarkdown(lines.slice(caption.blockStart, caption.blockEnd).join("\n"));
168
- if (caption.kind === "figure")
169
- html = html.replace(/<figcaption\b[^>]*>[\s\S]*?<\/figcaption>/i, "");
208
+ const block = lines.slice(caption.blockStart, caption.blockEnd).join("\n");
170
209
  const captionHtml = parser.renderInline(caption.text);
171
210
  output.push(
172
211
  `<div id="${escape(slugify(caption.id))}" class="content-caption content-caption-${caption.kind}">`,
173
- html.trim(),
212
+ );
213
+ if (caption.kind === "figure") {
214
+ const html = renderMarkdown(block).replace(
215
+ /<figcaption\b[^>]*>[\s\S]*?<\/figcaption>/i,
216
+ "",
217
+ );
218
+ output.push(html.trim());
219
+ } else {
220
+ output.push("", block, "");
221
+ }
222
+ output.push(
174
223
  `<p class="content-caption-label">${escape(numbered.label)}: ${captionHtml}</p>`,
175
224
  "</div>",
176
225
  );