@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
@@ -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
+ }
@@ -1,5 +1,37 @@
1
1
  /* SPDX-License-Identifier: GPL-3.0-or-later */
2
2
 
3
+ /**
4
+ * Where a footnote's two halves belong, and the two findings that hold the
5
+ * line.
6
+ *
7
+ * A reference (`[^id]`) may be written anywhere prose can be written — a
8
+ * paragraph, a list item, a block quote, a table cell, a heading, a
9
+ * definition-list term. A definition (`[^id]: text`) belongs at the top
10
+ * level of the note, the one place {@link separateFootnotes} looks for it:
11
+ * the web and Foundry can resolve one nested in a list or a block quote, but
12
+ * the book cannot, so the three surfaces would otherwise disagree about what
13
+ * one note means. {@link misplacedFootnoteDefinitions} and
14
+ * {@link unresolvedFootnoteReferences} are what every surface reports
15
+ * instead of rendering the three different wrong things a misplaced or
16
+ * missing definition otherwise produces.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { matchAllOutsideCode } from "./code-fences.mjs";
22
+
23
+ /**
24
+ * Where a character offset in `source` falls, as a 1-based line and column.
25
+ * @param {string} source - The text the offset indexes into.
26
+ * @param {number} offset - A 0-based character offset.
27
+ * @returns {{line: number, column: number}}
28
+ */
29
+ function lineColumn(source, offset) {
30
+ const before = source.slice(0, offset);
31
+ const nl = before.lastIndexOf("\n");
32
+ return { line: before.split("\n").length, column: offset - nl };
33
+ }
34
+
3
35
  /**
4
36
  * Separate top-level footnote definitions from prose while retaining line numbers.
5
37
  * Definitions belong to the note and can be used by any rendered page.
@@ -32,3 +64,168 @@ export function separateFootnotes(source) {
32
64
  }
33
65
  return { markdown: body.join("\n"), definitions: definitions.join("\n\n") };
34
66
  }
67
+
68
+ /** A top-level footnote definition's opening line. */
69
+ const TOP_LEVEL_DEF = /^\[\^([^\]\s]+)\]:/;
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
+
79
+ /**
80
+ * A list marker or a block quote marker, stripped as many times as it
81
+ * repeats — a block quote can hold a list, and either can nest.
82
+ */
83
+ const CONTAINER_PREFIX = /^(?:[ \t]{0,3}>[ \t]?|[ \t]{0,3}(?:[-*+]|\d{1,9}[.)])[ \t]+)+/;
84
+
85
+ /**
86
+ * A footnote definition written inside a list item or a block quote, where
87
+ * the web and Foundry can resolve it but the book drops it silently.
88
+ *
89
+ * A definition inside a table cell is not reported here: a table cell is
90
+ * inline content, so `[^id]:` there is never recognised as a definition on
91
+ * any surface and simply prints as written — the reference it was meant to
92
+ * answer is what {@link unresolvedFootnoteReferences} reports.
93
+ *
94
+ * @param {string} source - A note's markdown body.
95
+ * @returns {Array<{line: number, column: number, label: string, message: string}>}
96
+ */
97
+ export function misplacedFootnoteDefinitions(source) {
98
+ const lines = String(source ?? "").split("\n");
99
+ const errors = [];
100
+ let fence = null;
101
+ // Inside the continuation of a genuine top-level definition: a blank
102
+ // line or a four-space indent belongs to it, not to a new mistake.
103
+ let continuation = false;
104
+ for (let i = 0; i < lines.length; i++) {
105
+ const line = lines[i];
106
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
107
+ if (fence) {
108
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length)
109
+ fence = null;
110
+ continue;
111
+ }
112
+ if (marker) {
113
+ fence = marker[1];
114
+ continue;
115
+ }
116
+ if (TOP_LEVEL_DEF.test(line)) {
117
+ continuation = true;
118
+ continue;
119
+ }
120
+ if (continuation) {
121
+ if (!line.trim() || /^(?: {4}|\t)/.test(line)) continue;
122
+ continuation = false;
123
+ }
124
+ const prefix = CONTAINER_PREFIX.exec(line);
125
+ if (!prefix) continue;
126
+ const rest = line.slice(prefix[0].length);
127
+ const match = TOP_LEVEL_DEF.exec(rest);
128
+ if (!match) continue;
129
+ errors.push({
130
+ line: i + 1,
131
+ column: prefix[0].length + 1,
132
+ label: match[1],
133
+ message:
134
+ "a footnote definition belongs at the top level of the note " +
135
+ "— a reference may be written anywhere, but move this " +
136
+ `\`[^${match[1]}]:\` definition out of the list or block quote`,
137
+ });
138
+ }
139
+ return errors;
140
+ }
141
+
142
+ /**
143
+ * A footnote reference with no top-level definition to resolve against.
144
+ *
145
+ * Covers both commoner slips alike: a label never defined at all, and one
146
+ * "defined" only inside a table cell, which is inline content and never
147
+ * registers as a definition on any surface. A label misplaced in a list or a
148
+ * block quote is excluded — {@link misplacedFootnoteDefinitions} already
149
+ * reports that mistake at the definition's own line, and reporting it again
150
+ * at the reference would say the same thing twice.
151
+ *
152
+ * @param {string} source - A note's markdown body.
153
+ * @param {string} [definitions] - Its top-level definitions, when a caller
154
+ * already extracted them with {@link separateFootnotes}. Derived from
155
+ * `source` when omitted; a caller already holding both passes its own
156
+ * extraction instead, so the two cannot disagree about what is defined.
157
+ * @returns {Array<{line: number, column: number, message: string}>}
158
+ */
159
+ export function unresolvedFootnoteReferences(source, definitions) {
160
+ const src = String(source ?? "");
161
+ const resolvedAgainst = definitions ?? separateFootnotes(src).definitions;
162
+ const resolved = new Set([...resolvedAgainst.matchAll(/\[\^([^\]\s]+)\]:/g)].map((m) => m[1]));
163
+ const misplaced = new Set(misplacedFootnoteDefinitions(src).map((e) => e.label));
164
+ const errors = [];
165
+ for (const match of matchAllOutsideCode(src, new RegExp(FOOTNOTE_REFERENCE.source, "g"))) {
166
+ const label = match[1];
167
+ if (resolved.has(label) || misplaced.has(label)) continue;
168
+ errors.push({
169
+ ...lineColumn(src, match.index),
170
+ message:
171
+ `footnote [^${label}] has no definition, so it is set as text — write ` +
172
+ `\`[^${label}]: …\` at the top level of the note`,
173
+ });
174
+ }
175
+ return errors;
176
+ }
177
+
178
+ /**
179
+ * A top-level footnote definition no reference anywhere in the note uses.
180
+ *
181
+ * `separateFootnotes` lifts it out correctly, nothing resolves against it,
182
+ * and the paragraph it held vanishes from every surface with the build
183
+ * exiting 0 — a dropped paragraph is not something a reader can notice is
184
+ * missing, so it is an error here rather than a warning, the same severity
185
+ * every other silent loss this pass catches already carries. A note mid-edit,
186
+ * with the reference already deleted and the definition not yet followed, is
187
+ * exactly the case this refuses — which is the point: the alternative is
188
+ * shipping it.
189
+ *
190
+ * @param {string} source - A note's markdown body.
191
+ * @returns {Array<{line: number, column: number, message: string}>}
192
+ */
193
+ export function unusedFootnoteDefinitions(source) {
194
+ const src = String(source ?? "");
195
+ const { definitions } = separateFootnotes(src);
196
+ const referenced = new Set(
197
+ [...matchAllOutsideCode(src, new RegExp(FOOTNOTE_REFERENCE.source, "g"))].map((m) => m[1]),
198
+ );
199
+ const errors = [];
200
+ for (const match of definitions.matchAll(/^\[\^([^\]\s]+)\]:/gm)) {
201
+ const label = match[1];
202
+ if (referenced.has(label)) continue;
203
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
204
+ const at = new RegExp(`^\\[\\^${escaped}\\]:`, "m").exec(src);
205
+ if (!at) continue;
206
+ errors.push({
207
+ ...lineColumn(src, at.index),
208
+ message:
209
+ `footnote [^${label}] is defined but no reference uses it — delete the ` +
210
+ `definition, or add a [^${label}] where it belongs`,
211
+ });
212
+ }
213
+ return errors;
214
+ }
215
+
216
+ /**
217
+ * Every footnote placement finding for one note body — the union
218
+ * {@link misplacedFootnoteDefinitions}, {@link unresolvedFootnoteReferences}
219
+ * and {@link unusedFootnoteDefinitions} report, in document order.
220
+ *
221
+ * @param {string} source - A note's markdown body.
222
+ * @returns {Array<{line: number, column: number, message: string}>}
223
+ */
224
+ export function footnoteFindings(source) {
225
+ const misplaced = misplacedFootnoteDefinitions(source).map(({ label, ...rest }) => rest);
226
+ const unresolved = unresolvedFootnoteReferences(source);
227
+ const unused = unusedFootnoteDefinitions(source);
228
+ return [...misplaced, ...unresolved, ...unused].sort(
229
+ (a, b) => a.line - b.line || a.column - b.column,
230
+ );
231
+ }