@heroiclands/package-build 22.22.0 → 22.24.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 (140) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +51 -14
  4. package/ci/content-format-reference.mjs +1 -1
  5. package/content-config.mjs +80 -2
  6. package/docs/api.md +85 -72
  7. package/docs/authoring/assets.md +29 -0
  8. package/docs/authoring/dates-and-calendars.md +37 -1
  9. package/docs/authoring/links-and-markup.md +112 -31
  10. package/docs/commands.md +30 -29
  11. package/docs/configuration.md +92 -19
  12. package/docs/content-format.md +1 -1
  13. package/docs/guides/book.md +5 -4
  14. package/docs/guides/module-project.md +2 -2
  15. package/docs/guides/site.md +41 -0
  16. package/docs/project-setup.md +4 -4
  17. package/docs/reference/format-details.md +323 -60
  18. package/docs/reference/note-types.md +9 -9
  19. package/docs/reference/theme-pages.md +496 -0
  20. package/engine/anchors.mjs +16 -10
  21. package/engine/art-fields.mjs +118 -0
  22. package/engine/art-slots.mjs +9 -2
  23. package/engine/asset-index.mjs +160 -10
  24. package/engine/asset-replacement.mjs +93 -0
  25. package/engine/asset-types.mjs +240 -0
  26. package/engine/base-compiler.mjs +54 -2
  27. package/engine/bundles.mjs +13 -1
  28. package/engine/content-blocks.mjs +4 -4
  29. package/engine/content-embeds.mjs +19 -0
  30. package/engine/content-figures.mjs +468 -0
  31. package/engine/content-footnotes.mjs +10 -2
  32. package/engine/content-format.yaml +1 -1
  33. package/engine/content-images.mjs +126 -25
  34. package/engine/content-index.mjs +36 -10
  35. package/engine/field-reference.mjs +31 -10
  36. package/engine/field-spec.mjs +10 -8
  37. package/engine/foreign-catalog.mjs +145 -19
  38. package/engine/frontmatter-lint.mjs +73 -1
  39. package/engine/heading-attributes.mjs +31 -18
  40. package/engine/helpers.mjs +102 -33
  41. package/engine/infobox-registry.mjs +4 -1
  42. package/engine/infobox.mjs +47 -8
  43. package/engine/itinerary-scenes.mjs +4 -10
  44. package/engine/journals.mjs +264 -39
  45. package/engine/lore-events.mjs +197 -0
  46. package/engine/markdown-expressions.mjs +257 -7
  47. package/engine/note-dates.mjs +305 -12
  48. package/engine/note-vocabulary.mjs +11 -5
  49. package/engine/pathnames.mjs +36 -0
  50. package/engine/pdf-build.mjs +357 -34
  51. package/engine/pdf-images.mjs +64 -0
  52. package/engine/pdf-render.mjs +160 -36
  53. package/engine/scenes.mjs +16 -4
  54. package/engine/site-build.mjs +127 -13
  55. package/engine/site-config.mjs +19 -24
  56. package/engine/site-index.mjs +72 -1
  57. package/engine/site-preview.mjs +2 -0
  58. package/engine/system-block.mjs +44 -16
  59. package/hm3/actors.mjs +2 -2
  60. package/hugo-theme/data/banners.yaml +69 -0
  61. package/hugo-theme/layouts/404.html +85 -0
  62. package/hugo-theme/layouts/_default/baseof.html +64 -0
  63. package/hugo-theme/layouts/_default/list.html +139 -0
  64. package/hugo-theme/layouts/_default/single.html +4 -0
  65. package/hugo-theme/layouts/blog/list.html +54 -0
  66. package/hugo-theme/layouts/blog-post/single.html +36 -0
  67. package/hugo-theme/layouts/index.html +75 -0
  68. package/hugo-theme/layouts/partials/banner-url.html +117 -0
  69. package/hugo-theme/layouts/partials/cdn-url.html +36 -0
  70. package/hugo-theme/layouts/partials/draft-notice.html +41 -0
  71. package/hugo-theme/layouts/partials/draft-state.html +26 -0
  72. package/hugo-theme/layouts/partials/footer.html +29 -0
  73. package/hugo-theme/layouts/partials/header.html +35 -0
  74. package/hugo-theme/layouts/partials/hero-banner.html +86 -0
  75. package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
  76. package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
  77. package/hugo-theme/layouts/partials/holdings.html +61 -0
  78. package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
  79. package/hugo-theme/layouts/partials/infobox/items.html +26 -0
  80. package/hugo-theme/layouts/partials/infobox/section.html +56 -0
  81. package/hugo-theme/layouts/partials/infobox/value.html +43 -0
  82. package/hugo-theme/layouts/partials/infobox.html +57 -0
  83. package/hugo-theme/layouts/partials/page-body.html +154 -0
  84. package/hugo-theme/layouts/partials/place-map.html +9 -0
  85. package/hugo-theme/layouts/partials/related.html +121 -0
  86. package/hugo-theme/layouts/partials/search.html +84 -0
  87. package/hugo-theme/layouts/partials/site-url.html +32 -0
  88. package/hugo-theme/layouts/partials/tags.html +30 -0
  89. package/hugo-theme/layouts/partials/toc.html +28 -0
  90. package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
  91. package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
  92. package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
  93. package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
  94. package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
  95. package/hugo-theme/static/css/sohl-icons.css +57 -0
  96. package/hugo-theme/static/css/style.css +2953 -0
  97. package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
  98. package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
  99. package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
  100. package/hugo-theme/theme.toml +11 -0
  101. package/manifest.mjs +30 -24
  102. package/package.json +2 -1
  103. package/packagebuild-metadata.jsonl +13 -13
  104. package/sohl/item-fields.mjs +4 -3
  105. package/types/content-config.d.mts +48 -1
  106. package/types/engine/anchors.d.mts +9 -4
  107. package/types/engine/art-fields.d.mts +64 -0
  108. package/types/engine/art-slots.d.mts +2 -1
  109. package/types/engine/asset-index.d.mts +43 -0
  110. package/types/engine/asset-replacement.d.mts +22 -0
  111. package/types/engine/asset-types.d.mts +18 -0
  112. package/types/engine/base-compiler.d.mts +0 -17
  113. package/types/engine/content-embeds.d.mts +16 -0
  114. package/types/engine/content-figures.d.mts +101 -0
  115. package/types/engine/content-footnotes.d.mts +7 -0
  116. package/types/engine/content-images.d.mts +78 -12
  117. package/types/engine/content-index.d.mts +8 -2
  118. package/types/engine/field-reference.d.mts +21 -0
  119. package/types/engine/field-spec.d.mts +20 -16
  120. package/types/engine/foreign-catalog.d.mts +39 -0
  121. package/types/engine/heading-attributes.d.mts +13 -8
  122. package/types/engine/helpers.d.mts +28 -10
  123. package/types/engine/infobox-registry.d.mts +4 -1
  124. package/types/engine/infobox.d.mts +6 -0
  125. package/types/engine/journals.d.mts +31 -9
  126. package/types/engine/lore-events.d.mts +14 -0
  127. package/types/engine/markdown-expressions.d.mts +23 -2
  128. package/types/engine/note-dates.d.mts +64 -1
  129. package/types/engine/pathnames.d.mts +26 -0
  130. package/types/engine/pdf-build.d.mts +28 -6
  131. package/types/engine/pdf-images.d.mts +52 -0
  132. package/types/engine/pdf-render.d.mts +12 -0
  133. package/types/engine/site-build.d.mts +14 -3
  134. package/types/engine/site-config.d.mts +9 -9
  135. package/types/engine/site-index.d.mts +54 -0
  136. package/types/engine/system-block.d.mts +26 -2
  137. package/types/hm3/actors.d.mts +18 -0
  138. package/types/manifest.d.mts +19 -23
  139. package/engine/content-captions.mjs +0 -230
  140. package/types/engine/content-captions.d.mts +0 -58
@@ -0,0 +1,468 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ /**
6
+ * `:::figure` — the fence that declares what is numbered and captioned.
7
+ *
8
+ * ```
9
+ * :::figure {#thorn .border}
10
+ * ![[being-foobar|The Great Beast]]
11
+ * ///
12
+ * The great beast, as drawn by [[person-havard]].
13
+ * :::
14
+ * ```
15
+ *
16
+ * The fence states the unit, so nothing is inferred about scope and a group —
17
+ * two portraits as one plate, a table with its key beneath it — is one figure
18
+ * with one number.
19
+ *
20
+ * **The caption is optional and the fence is what numbers a thing.** A fence
21
+ * with no `///` draws its label alone. So the rule is "no fence, no number":
22
+ * a bare embed takes no number and nothing is drawn beneath it.
23
+ *
24
+ * **`{#id}` is optional too.** A fence that declares none is numbered and drawn
25
+ * and carries no anchor, so nothing can reference it.
26
+ *
27
+ * ## The split is lexical
28
+ *
29
+ * The first `///` at the top level of the body divides the contents from the
30
+ * caption, and it is found **on the raw lines, before any markdown parsing**.
31
+ * The alternative delimiters are claimed by the grammar: `---` or `===` on the
32
+ * line after a paragraph is a setext heading, so an image tight against one
33
+ * becomes an `<h2>` with nothing said. `///` has no CommonMark meaning, which
34
+ * leaves a genuine thematic break available inside a grouped figure.
35
+ *
36
+ * A delimiter inside a nested code fence is content: a captioned listing
37
+ * carries `///` as a doc comment, so the scan for both the delimiter and the
38
+ * closing `:::` tracks nested fences and reads neither inside one.
39
+ *
40
+ * ## The kind is derived, never authored
41
+ *
42
+ * A table is self-evidently a table and a lone picture a figure, so the counter
43
+ * comes from the fence's contents: a table is `table`, an `sql` fence is
44
+ * `table`, any other fence or code block is `code`, pictures alone are
45
+ * `figure`, and everything else is `prose`. A fence around prose, with
46
+ * `.border`, is a numbered, referable boxed aside, which is what `Prose` names.
47
+ *
48
+ * **A picture counts as `map` instead of `figure` when every picture the fence
49
+ * holds is one.** The fence's own markup cannot say so — a picture of a map
50
+ * looks like any other picture — so the fact travels through a `resolveRole`
51
+ * function the caller supplies, from the address each picture draws (an
52
+ * embed's address, an image's path, or an `<img>` tag's `src`) to the role its
53
+ * asset declares. This pass resolves no address of its own; `resolveRole` is
54
+ * the one seam a caller reaches through with whatever lookup it already holds.
55
+ * A caller that supplies none, or whose lookup answers nothing for a picture,
56
+ * gets `figure` — the same reading as a picture with a role other than `map`.
57
+ *
58
+ * ## The class vocabulary is closed
59
+ *
60
+ * {@link FIGURE_CLASSES} holds what an author may write, and a class outside it
61
+ * is a finding naming the class — so `.border` does not become the first of
62
+ * twenty presentational classes. `key=value` is refused: the construct takes an
63
+ * id and classes.
64
+ *
65
+ * @module
66
+ */
67
+
68
+ import MarkdownIt from "markdown-it";
69
+ import footnotePlugin from "markdown-it-footnote";
70
+ import deflistPlugin from "markdown-it-deflist";
71
+
72
+ import { slugify } from "./content-slug.mjs";
73
+ import { parseExtensionAttributes } from "./extension-attributes.mjs";
74
+ import { HEADING_LINE, parseHeadingLine, splitHeadingAttributes } from "./heading-attributes.mjs";
75
+ import { IMAGE_PATTERN } from "./content-images.mjs";
76
+
77
+ const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
78
+ const OPEN = /^:::figure(?:[ \t]+(\{[^}\n]*\}))?[ \t]*$/;
79
+ const FIGURE_LINE = /^:::figure\b/;
80
+ const CLOSE = /^:::\s*$/;
81
+ const SPLIT = /^\/\/\/\s*$/;
82
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
83
+
84
+ /**
85
+ * An asset embed as authored, with the directive it may carry.
86
+ *
87
+ * Written here rather than imported so this module stays a leaf: it is read by
88
+ * {@link module:engine/anchors}, which is asked by the link checker, the
89
+ * content index and every build, and none of them can afford the address
90
+ * machinery an embed reader pulls in. Only the spelling is needed — this pass
91
+ * resolves no address.
92
+ */
93
+ const EMBED = /!\[\[[^\]\n]+\]\](?:\{[^}\n]*\})?/;
94
+
95
+ /** An embed, capturing its interior — reused to read the address it draws. */
96
+ const EMBED_INNER = /!\[\[([^\]\n]+)\]\]/g;
97
+
98
+ /**
99
+ * An `<img>` tag's `src`, as the website hands this pass once its own image
100
+ * pass has already turned a block picture into HTML.
101
+ */
102
+ const HTML_IMG_SRC = /<img\b[^>]*\bsrc="([^"]*)"/g;
103
+
104
+ /** A figure's automatically determined kind, and the word that labels it. */
105
+ export const FIGURE_NAMES = Object.freeze({
106
+ code: "Code",
107
+ table: "Table",
108
+ figure: "Figure",
109
+ map: "Map",
110
+ prose: "Prose",
111
+ });
112
+
113
+ /**
114
+ * The classes a figure takes.
115
+ *
116
+ * `.border` states that the captioned thing is drawn inside a border, and each
117
+ * medium owns what that means. The vocabulary is closed: a class absent from it
118
+ * is a finding naming the class.
119
+ *
120
+ * @type {readonly string[]}
121
+ */
122
+ export const FIGURE_CLASSES = Object.freeze(["border"]);
123
+
124
+ /** Number a sequence of figures with counters shared across documents. */
125
+ export function numberFigures(figures, counts) {
126
+ return figures.map((figure) => {
127
+ const number = (counts[figure.kind] ?? 0) + 1;
128
+ counts[figure.kind] = number;
129
+ return { ...figure, number, label: `${FIGURE_NAMES[figure.kind]} ${number}` };
130
+ });
131
+ }
132
+
133
+ /** Text safe inside a double-quoted attribute, and as element text. */
134
+ function escapeHtml(value) {
135
+ return String(value)
136
+ .replace(/&/g, "&amp;")
137
+ .replace(/</g, "&lt;")
138
+ .replace(/>/g, "&gt;")
139
+ .replace(/"/g, "&quot;");
140
+ }
141
+
142
+ /**
143
+ * Whether a fence's contents are pictures and nothing else.
144
+ *
145
+ * Read line by line on the raw contents, because the contents of a fence are
146
+ * what the author grouped: a plate of two embeds is as much a figure as one is,
147
+ * and a trailing sentence or a footnote reference beside a picture makes the
148
+ * passage prose with a picture in it.
149
+ *
150
+ * @param {string} contents - The captioned contents, as authored.
151
+ * @returns {boolean} Whether every line that carries anything is a picture.
152
+ */
153
+ function picturesOnly(contents) {
154
+ const lines = contents.split("\n").filter((line) => line.trim());
155
+ if (!lines.length) return false;
156
+ return lines.every((line) => {
157
+ const stripped = line
158
+ .replace(new RegExp(EMBED.source, "g"), "")
159
+ .replace(new RegExp(IMAGE_PATTERN.source, "g"), "");
160
+ return stripped !== line && !stripped.trim();
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Every address a fence's pictures draw, across the three forms a picture
166
+ * reaches this pass in: an embed's inner text before its `|`, a markdown
167
+ * image's path, or an HTML `<img>`'s `src`.
168
+ *
169
+ * @param {string} contents - The fence's contents.
170
+ * @returns {string[]} One address per picture, in source order.
171
+ */
172
+ function pictureAddresses(contents) {
173
+ const addresses = [];
174
+ for (const match of contents.matchAll(EMBED_INNER)) {
175
+ addresses.push(match[1].split("|")[0].trim());
176
+ }
177
+ for (const match of contents.matchAll(IMAGE_PATTERN)) {
178
+ if (match[2]) addresses.push(match[2]);
179
+ }
180
+ for (const match of contents.matchAll(HTML_IMG_SRC)) {
181
+ addresses.push(match[1]);
182
+ }
183
+ return addresses.filter(Boolean);
184
+ }
185
+
186
+ /**
187
+ * Whether every picture a fence holds declares the `map` role.
188
+ *
189
+ * @param {string} contents - The fence's contents.
190
+ * @param {(address: string) => string|undefined} resolveRole - The caller's
191
+ * lookup from an address to the role its asset declares.
192
+ * @returns {boolean} Whether the fence draws at least one picture and every
193
+ * one of them resolves to `map`.
194
+ */
195
+ function picturesAreMap(contents, resolveRole) {
196
+ const addresses = pictureAddresses(contents);
197
+ return addresses.length > 0 && addresses.every((address) => resolveRole(address) === "map");
198
+ }
199
+
200
+ /**
201
+ * Which counter a fence's contents belong to.
202
+ *
203
+ * @param {object|undefined} first - The first top-level token of the contents.
204
+ * @param {string} contents - The contents, trimmed.
205
+ * @param {(address: string) => string|undefined} resolveRole - The caller's
206
+ * lookup from a picture's address to the role its asset declares.
207
+ * @returns {"code"|"table"|"figure"|"map"|"prose"} The kind.
208
+ */
209
+ function figureKind(first, contents, resolveRole) {
210
+ if (first?.type === "table_open") return "table";
211
+ if (first?.type === "fence" && /^\s*sql\b/i.test(first.info ?? "")) return "table";
212
+ if (first?.type === "fence" || first?.type === "code_block") return "code";
213
+ // `<figure>` and `<img>` reach this pass from the website, where the image
214
+ // pass runs first and hands over HTML rather than markdown.
215
+ if (picturesOnly(contents) || /^(?:<figure\b|<img\b)/.test(contents)) {
216
+ return picturesAreMap(contents, resolveRole) ? "map" : "figure";
217
+ }
218
+ return "prose";
219
+ }
220
+
221
+ /**
222
+ * Read every `:::figure` fence in a body.
223
+ *
224
+ * SQL fences are expanded before this pass, so their output is a table.
225
+ *
226
+ * @param {string} source - A note body with expanded tables.
227
+ * @param {object} [options] - Options.
228
+ * @param {(address: string) => string|undefined} [options.resolveRole] - From
229
+ * a picture's address to the role its asset declares, so a picture whose
230
+ * asset declares `role: map` counts as `map` rather than `figure`. Omitted,
231
+ * every picture counts as `figure` — the same reading a role other than
232
+ * `map` gets.
233
+ * @returns {{figures: Array<{id: string, slug: string, caption: string,
234
+ * hasCaption: boolean, classes: string[], kind: string, number: number,
235
+ * label: string, line: number, bodyStart: number, bodyEnd: number,
236
+ * captionStart: number, captionEnd: number, close: number}>,
237
+ * errors: Array<{line: number, column: number, message: string}>}}
238
+ */
239
+ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
240
+ const lines = String(source ?? "").split("\n");
241
+ const figures = [];
242
+ const errors = [];
243
+ const counts = { code: 0, table: 0, figure: 0, map: 0, prose: 0 };
244
+ const ids = new Set();
245
+ const headingIds = [];
246
+ let codeFence = null;
247
+
248
+ for (let i = 0; i < lines.length; i++) {
249
+ const line = lines[i];
250
+ const fence = FENCE.exec(line);
251
+ if (codeFence) {
252
+ if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
253
+ codeFence = null;
254
+ continue;
255
+ }
256
+ if (fence) {
257
+ codeFence = fence[1];
258
+ continue;
259
+ }
260
+ const heading = HEADING_LINE.exec(line);
261
+ const headingId = heading ? splitHeadingAttributes(heading[2]).id : "";
262
+ if (headingId) headingIds.push({ id: slugify(headingId), line: i + 1 });
263
+ if (!FIGURE_LINE.test(line)) continue;
264
+ const open = OPEN.exec(line);
265
+ if (!open) {
266
+ errors.push({
267
+ line: i + 1,
268
+ column: 1,
269
+ message: "a figure's attributes are written as {#id .border}",
270
+ });
271
+ continue;
272
+ }
273
+ const start = i;
274
+ const attributes = parseExtensionAttributes((open[1] ?? "{}").slice(1, -1));
275
+ const faults = [...attributes.problems];
276
+ for (const name of attributes.classes) {
277
+ if (FIGURE_CLASSES.includes(name)) continue;
278
+ faults.push(
279
+ `a figure takes no .${name} class — the classes a figure takes are ` +
280
+ FIGURE_CLASSES.map((known) => `.${known}`).join(", "),
281
+ );
282
+ }
283
+ for (const key of Object.keys(attributes.values)) {
284
+ faults.push(
285
+ `${key}= is not an attribute a figure takes — a figure takes an id and classes`,
286
+ );
287
+ }
288
+ for (const message of faults) errors.push({ line: start + 1, column: 1, message });
289
+
290
+ // The body, read line by line: the first top-level `///` splits it, the
291
+ // closing `:::` ends it, and a nested fence holds neither.
292
+ let close = start + 1;
293
+ let split = -1;
294
+ let nested = null;
295
+ const fenced = new Set();
296
+ while (close < lines.length) {
297
+ const body = lines[close];
298
+ const bodyFence = FENCE.exec(body);
299
+ if (nested) {
300
+ fenced.add(close);
301
+ if (
302
+ bodyFence &&
303
+ bodyFence[1][0] === nested[0] &&
304
+ bodyFence[1].length >= nested.length
305
+ )
306
+ nested = null;
307
+ close++;
308
+ continue;
309
+ }
310
+ if (bodyFence) {
311
+ nested = bodyFence[1];
312
+ fenced.add(close);
313
+ close++;
314
+ continue;
315
+ }
316
+ if (CLOSE.test(body)) break;
317
+ if (SPLIT.test(body)) {
318
+ if (split < 0) split = close;
319
+ else
320
+ errors.push({
321
+ line: close + 1,
322
+ column: 1,
323
+ message: "a figure holds one caption, so it carries one /// line",
324
+ });
325
+ }
326
+ close++;
327
+ }
328
+ if (close === lines.length) {
329
+ errors.push({ line: start + 1, column: 1, message: "a figure needs a closing :::" });
330
+ continue;
331
+ }
332
+
333
+ const bodyStart = start + 1;
334
+ const bodyEnd = split < 0 ? close : split;
335
+ const captionStart = split < 0 ? -1 : split + 1;
336
+ const captionEnd = split < 0 ? -1 : close;
337
+ const contents = lines.slice(bodyStart, bodyEnd).join("\n");
338
+ const caption = split < 0 ? "" : lines.slice(captionStart, captionEnd).join("\n").trim();
339
+ if (!contents.trim())
340
+ errors.push({ line: start + 1, column: 1, message: "a figure has no contents" });
341
+ if (split >= 0 && !caption)
342
+ errors.push({
343
+ line: split + 1,
344
+ column: 1,
345
+ message:
346
+ "a figure's /// section carries no caption — write no /// to leave the " +
347
+ "figure uncaptioned",
348
+ });
349
+ // An H1, or an anchored heading at any level, starts a Foundry
350
+ // journal page — see `engine/content-blocks.mjs`'s identical refusal
351
+ // for a named block.
352
+ for (let at = bodyStart; at < close; at++) {
353
+ if (fenced.has(at)) continue;
354
+ if (!parseHeadingLine(lines[at])?.startsPage) continue;
355
+ errors.push({
356
+ line: at + 1,
357
+ column: 1,
358
+ message:
359
+ "a heading that starts a page cannot be written inside a figure — " +
360
+ "keep an H1 or an anchored heading at the top level, or drop the " +
361
+ "anchor and the level to stay inside it",
362
+ });
363
+ }
364
+ const id = attributes.id;
365
+ if (id) {
366
+ if (ids.has(slugify(id)))
367
+ errors.push({ line: start + 1, column: 1, message: `duplicate figure id "${id}"` });
368
+ ids.add(slugify(id));
369
+ }
370
+
371
+ const trimmed = contents.trim();
372
+ const tokens = parser.parse(contents, {});
373
+ const first = tokens.find((token) => token.level === 0 && token.map);
374
+ const kind = figureKind(first, trimmed, resolveRole);
375
+ const number = ++counts[kind];
376
+ figures.push({
377
+ id,
378
+ slug: id ? slugify(id) : "",
379
+ caption,
380
+ hasCaption: Boolean(caption),
381
+ classes: attributes.classes,
382
+ kind,
383
+ number,
384
+ label: `${FIGURE_NAMES[kind]} ${number}`,
385
+ line: start + 1,
386
+ bodyStart,
387
+ bodyEnd,
388
+ captionStart,
389
+ captionEnd,
390
+ close,
391
+ });
392
+ i = close;
393
+ }
394
+ for (const heading of headingIds) {
395
+ if (ids.has(heading.id))
396
+ errors.push({
397
+ line: heading.line,
398
+ column: 1,
399
+ message: `heading and figure declare the same anchor "${heading.id}"`,
400
+ });
401
+ }
402
+ return { figures, errors };
403
+ }
404
+
405
+ /**
406
+ * Render figure fences as HTML, leaving other Markdown untouched.
407
+ *
408
+ * A code, table or prose figure's contents are left as Markdown inside the
409
+ * wrapper, blank-line separated from its tags exactly as
410
+ * {@link module:engine/content-blocks.renderBlocks} leaves a named block's
411
+ * body — so the surrounding render sees it as part of its own document and a
412
+ * footnote reference inside it resolves against the note's own definitions.
413
+ *
414
+ * A figure's contents are rendered here rather than left for a later pass,
415
+ * because a picture-only fence has to become the surface's own `<figure>`
416
+ * markup directly inside the wrapper this function writes; the surrounding
417
+ * render never sees it as Markdown, so a footnote reference inside one is not
418
+ * resolved, the same gap a block's own title carries.
419
+ *
420
+ * @param {string} source - A note body with expanded tables.
421
+ * @param {(markdown: string) => string} [renderMarkdown] - How to render a
422
+ * figure's own contents.
423
+ * @param {Array<{id: string, label: string}>} [numbers] - Labels assigned by a
424
+ * surface that counts across documents, matched by id.
425
+ * @param {object} [options] - Options.
426
+ * @param {(address: string) => string|undefined} [options.resolveRole] - See
427
+ * {@link scanFigures}.
428
+ * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
429
+ */
430
+ export function renderFigureBlocks(
431
+ source,
432
+ renderMarkdown = parser.render.bind(parser),
433
+ numbers,
434
+ { resolveRole } = {},
435
+ ) {
436
+ const { figures, errors } = scanFigures(source, { resolveRole });
437
+ if (errors.length) return { markdown: source, errors };
438
+ const byId =
439
+ numbers ?
440
+ new Map(numbers.filter((entry) => entry.id).map((entry) => [entry.id, entry]))
441
+ : new Map();
442
+ const lines = String(source ?? "").split("\n");
443
+ const output = [];
444
+ let cursor = 0;
445
+ for (const figure of figures) {
446
+ output.push(...lines.slice(cursor, figure.line - 1));
447
+ const numbered = (figure.id ? byId.get(figure.id) : null) ?? figure;
448
+ const contents = lines.slice(figure.bodyStart, figure.bodyEnd).join("\n");
449
+ const classes = ["content-figure", `content-figure-${figure.kind}`, ...figure.classes];
450
+ const id = figure.slug ? ` id="${escapeHtml(figure.slug)}"` : "";
451
+ output.push(`<div${id} class="${escapeHtml(classes.join(" "))}">`);
452
+ if (figure.kind === "figure" || figure.kind === "map") {
453
+ output.push(renderMarkdown(contents).trim());
454
+ } else {
455
+ output.push("", contents, "");
456
+ }
457
+ const label = escapeHtml(numbered.label);
458
+ output.push(
459
+ `<p class="content-figure-label">${
460
+ figure.hasCaption ? `${label}: ${parser.renderInline(figure.caption)}` : label
461
+ }</p>`,
462
+ "</div>",
463
+ );
464
+ cursor = figure.close + 1;
465
+ }
466
+ output.push(...lines.slice(cursor));
467
+ return { markdown: output.join("\n"), errors: [] };
468
+ }
@@ -68,6 +68,14 @@ export function separateFootnotes(source) {
68
68
  /** A top-level footnote definition's opening line. */
69
69
  const TOP_LEVEL_DEF = /^\[\^([^\]\s]+)\]:/;
70
70
 
71
+ /**
72
+ * A footnote reference's shape, wherever it is written — excluding a
73
+ * definition's own `[^id]:` opener, which this never matches.
74
+ *
75
+ * @type {RegExp}
76
+ */
77
+ export const FOOTNOTE_REFERENCE = /\[\^([^\]\s]+)\](?!:)/;
78
+
71
79
  /**
72
80
  * A list marker or a block quote marker, stripped as many times as it
73
81
  * repeats — a block quote can hold a list, and either can nest.
@@ -154,7 +162,7 @@ export function unresolvedFootnoteReferences(source, definitions) {
154
162
  const resolved = new Set([...resolvedAgainst.matchAll(/\[\^([^\]\s]+)\]:/g)].map((m) => m[1]));
155
163
  const misplaced = new Set(misplacedFootnoteDefinitions(src).map((e) => e.label));
156
164
  const errors = [];
157
- for (const match of matchAllOutsideCode(src, /\[\^([^\]\s]+)\](?!:)/g)) {
165
+ for (const match of matchAllOutsideCode(src, new RegExp(FOOTNOTE_REFERENCE.source, "g"))) {
158
166
  const label = match[1];
159
167
  if (resolved.has(label) || misplaced.has(label)) continue;
160
168
  errors.push({
@@ -186,7 +194,7 @@ export function unusedFootnoteDefinitions(source) {
186
194
  const src = String(source ?? "");
187
195
  const { definitions } = separateFootnotes(src);
188
196
  const referenced = new Set(
189
- [...matchAllOutsideCode(src, /\[\^([^\]\s]+)\](?!:)/g)].map((m) => m[1]),
197
+ [...matchAllOutsideCode(src, new RegExp(FOOTNOTE_REFERENCE.source, "g"))].map((m) => m[1]),
190
198
  );
191
199
  const errors = [];
192
200
  for (const match of definitions.matchAll(/^\[\^([^\]\s]+)\]:/gm)) {
@@ -242,7 +242,7 @@ types:
242
242
  - namedDays
243
243
  - eras
244
244
  - formats
245
- - event
245
+ - events
246
246
  subTypes:
247
247
  - cosmology
248
248
  - deity