@heroiclands/package-build 20.6.0 → 21.0.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 (65) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/CONTENT.md +134 -44
  3. package/bin/content-build.mjs +37 -5
  4. package/bin/package-build.mjs +77 -0
  5. package/content-config.mjs +59 -1
  6. package/docs/api.md +149 -19
  7. package/docs/commands.md +75 -0
  8. package/docs/configuration.md +37 -10
  9. package/docs/content-format.md +450 -49
  10. package/engine/content-format.mjs +52 -3
  11. package/engine/content-images.mjs +699 -0
  12. package/engine/dependency-bump.mjs +218 -0
  13. package/engine/frontmatter-lint.mjs +89 -2
  14. package/engine/helpers.mjs +81 -142
  15. package/engine/index.mjs +15 -0
  16. package/engine/infobox-registry.mjs +81 -0
  17. package/engine/infobox-render.mjs +381 -0
  18. package/engine/infobox.mjs +963 -0
  19. package/engine/item-registry.mjs +5 -5
  20. package/engine/journals.mjs +22 -1
  21. package/engine/map-notes.mjs +11 -5
  22. package/engine/metadata-index.mjs +5 -0
  23. package/engine/note-vocabulary.mjs +57 -2
  24. package/engine/pathnames.mjs +374 -0
  25. package/engine/pdf-build.mjs +208 -9
  26. package/engine/pdf-render.mjs +461 -21
  27. package/engine/pdf-toc.mjs +77 -5
  28. package/engine/scenes.mjs +2 -1
  29. package/engine/site-build.mjs +115 -8
  30. package/engine/site-index.mjs +93 -4
  31. package/engine/wikilinks.mjs +93 -0
  32. package/hm3/default-item-art.mjs +14 -15
  33. package/hm3/index.mjs +3 -0
  34. package/hm3/infobox.mjs +64 -0
  35. package/package.json +3 -2
  36. package/sohl/being-info.mjs +9 -3
  37. package/sohl/default-item-art.mjs +18 -16
  38. package/sohl/index.mjs +3 -0
  39. package/sohl/infobox.mjs +499 -0
  40. package/types/content-config.d.mts +7 -0
  41. package/types/engine/content-format.d.mts +36 -0
  42. package/types/engine/content-images.d.mts +281 -0
  43. package/types/engine/dependency-bump.d.mts +89 -0
  44. package/types/engine/frontmatter-lint.d.mts +23 -0
  45. package/types/engine/helpers.d.mts +30 -72
  46. package/types/engine/index.d.mts +5 -0
  47. package/types/engine/infobox-registry.d.mts +36 -0
  48. package/types/engine/infobox-render.d.mts +87 -0
  49. package/types/engine/infobox.d.mts +443 -0
  50. package/types/engine/item-registry.d.mts +5 -5
  51. package/types/engine/journals.d.mts +9 -1
  52. package/types/engine/note-vocabulary.d.mts +51 -0
  53. package/types/engine/pathnames.d.mts +189 -0
  54. package/types/engine/pdf-build.d.mts +46 -0
  55. package/types/engine/pdf-render.d.mts +99 -1
  56. package/types/engine/pdf-toc.d.mts +10 -5
  57. package/types/engine/site-build.d.mts +19 -3
  58. package/types/engine/site-index.d.mts +35 -3
  59. package/types/engine/wikilinks.d.mts +22 -0
  60. package/types/hm3/default-item-art.d.mts +5 -6
  61. package/types/hm3/index.d.mts +1 -0
  62. package/types/hm3/infobox.d.mts +22 -0
  63. package/types/sohl/being-info.d.mts +4 -3
  64. package/types/sohl/index.d.mts +1 -0
  65. package/types/sohl/infobox.d.mts +145 -0
@@ -0,0 +1,699 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * SPDX-License-Identifier: GPL-3.0-or-later
9
+ */
10
+
11
+ /**
12
+ * An image saying how wide it is and where it sits.
13
+ *
14
+ * A markdown image carries no indication of either, so each of the three
15
+ * surfaces decides for itself and the author — who is the one who knows — has
16
+ * no way to say. A directive in the curly-attribute convention Pandoc and
17
+ * Kramdown use closes that:
18
+ *
19
+ * ![Brànwâal Dôrgaar](images/beings/branwldrgr-portrait.webp){float: top-left}
20
+ * ![Map of Thalorna](images/map.webp){.full-width}
21
+ *
22
+ * ## Two closed vocabularies, and closed is the point
23
+ *
24
+ * **Width is a class**, and the ordinary width carries no marker at all — the
25
+ * simple case needs no spelling. {@link IMAGE_CLASSES} holds the one class
26
+ * there is. **Position is `float:`**, and {@link IMAGE_FLOATS} holds the five
27
+ * values it takes.
28
+ *
29
+ * Both are closed, and an unrecognised value is **refused with a located
30
+ * diagnostic** rather than ignored. Ignoring is the failure worth preventing:
31
+ * `{.fullwidth}` rendering as an ordinary image looks exactly like a directive
32
+ * that worked, so the author publishes a page that is not the one they asked
33
+ * for and nothing says a word.
34
+ *
35
+ * ## No dimensions, and no general attribute plugin
36
+ *
37
+ * **No pixel values.** A number means something in a browser and nothing
38
+ * coherent in print, and a directive carrying both a class and a dimension
39
+ * gives one question two answers with no rule for which wins. A third width
40
+ * joins the vocabulary as a name.
41
+ *
42
+ * **Not `markdown-it-attrs`.** It is a general attribute injector: it will set
43
+ * `style`, `id` or `onclick`, and those reach emitted markup on the website and
44
+ * inside Foundry journal content. This package holds that data is never
45
+ * compiled into markup, so the rule here accepts a closed vocabulary on images
46
+ * alone, carries no injection surface, and can refuse what it does not know.
47
+ * The only author-supplied text that reaches markup is the address and the alt
48
+ * text, both escaped, and the address is held to {@link imageSourceProblem}'s
49
+ * schemes.
50
+ *
51
+ * ## An image is a block
52
+ *
53
+ * A directive states a width and a position, and neither means anything applied
54
+ * to a word in the middle of a sentence. So an image stands alone in its
55
+ * paragraph, every surface renders it as a figure, and an image sharing its
56
+ * paragraph with prose is reported. That is also what lets the three renderers
57
+ * agree: a figure is a block on all three, and nothing has to decide what a
58
+ * floated run of text inside a paragraph would mean.
59
+ *
60
+ * **The alt text is the caption.** Print has no `alt` attribute and has to put
61
+ * those words somewhere visible; rather than one surface showing them and two
62
+ * hiding them, every surface draws them under the picture, and the HTML
63
+ * surfaces carry them as `alt` as well.
64
+ *
65
+ * ## Where the address resolves
66
+ *
67
+ * An address is a pathname, and follows the one rule every pathname follows —
68
+ * see {@link module:engine/pathnames}. The note states which package owns the
69
+ * file, and each surface derives the address it serves: Foundry the path inside
70
+ * the install, the website one on the asset host, the book a staged copy.
71
+ *
72
+ * @module
73
+ */
74
+
75
+ import fs from "node:fs";
76
+ import path from "node:path";
77
+
78
+ import { matchAllOutsideCode } from "./code-fences.mjs";
79
+ import { positionInBody } from "./diagnostics.mjs";
80
+ import { pathnameProblem } from "./pathnames.mjs";
81
+
82
+ /**
83
+ * The width classes an image may carry, and what each means to a renderer.
84
+ *
85
+ * **The ordinary width is absent from this table on purpose.** It is what an
86
+ * image with no marker gets, and giving it a name would invite a note to write
87
+ * it — two spellings of one thing, one of which every existing note omits.
88
+ *
89
+ * `scope` is Typst's: a float placed with `scope: "parent"` spans every column
90
+ * of the page, and one placed with `scope: "column"` occupies the column it
91
+ * sits in. The book is set in one column, where the two measure the same, and
92
+ * stays correct when it is set in two.
93
+ *
94
+ * `class` is the class the HTML surfaces put on the `<figure>`. Named rather
95
+ * than reused from the authored spelling so a bare `full-width` in some other
96
+ * stylesheet cannot claim it.
97
+ *
98
+ * @type {Readonly<Record<string, {class: string, scope: string, describe: string}>>}
99
+ */
100
+ export const IMAGE_CLASSES = Object.freeze({
101
+ "full-width": {
102
+ class: "note-image-full-width",
103
+ scope: "parent",
104
+ describe: "the full page in the book, and the full content width elsewhere",
105
+ },
106
+ });
107
+
108
+ /**
109
+ * The `float:` positions an image may take, and where each puts it.
110
+ *
111
+ * `align` is the Typst alignment the float is placed at. **Print cannot wrap
112
+ * text around an arbitrary shape**: a Typst float occupies the column measure,
113
+ * so the horizontal half of a position has no effect on the page and only the
114
+ * vertical half — top of the column, or bottom of it — does. The website and a
115
+ * Foundry journal get true CSS wrap from the same directive. Same statement,
116
+ * different fidelity, which is stated in the specification rather than left for
117
+ * a reader to discover by comparing two outputs.
118
+ *
119
+ * @type {Readonly<Record<string, {class: string, align: string, describe: string}>>}
120
+ */
121
+ export const IMAGE_FLOATS = Object.freeze({
122
+ "top-left": {
123
+ class: "note-image-float-top-left",
124
+ align: "top + left",
125
+ describe: "the top left of the text it sits in",
126
+ },
127
+ "bottom-left": {
128
+ class: "note-image-float-bottom-left",
129
+ align: "bottom + left",
130
+ describe: "the bottom left of the text it sits in",
131
+ },
132
+ "top-right": {
133
+ class: "note-image-float-top-right",
134
+ align: "top + right",
135
+ describe: "the top right of the text it sits in",
136
+ },
137
+ "bottom-right": {
138
+ class: "note-image-float-bottom-right",
139
+ align: "bottom + right",
140
+ describe: "the bottom right of the text it sits in",
141
+ },
142
+ center: {
143
+ class: "note-image-float-center",
144
+ align: "top + center",
145
+ describe: "the middle of the measure, with no text beside it",
146
+ },
147
+ });
148
+
149
+ /**
150
+ * The class every figure carries, whatever its width or position.
151
+ *
152
+ * One hook a stylesheet can reach every authored image through, so the two
153
+ * vocabularies stay about what differs between images rather than what they
154
+ * share.
155
+ *
156
+ * @type {string}
157
+ */
158
+ export const IMAGE_FIGURE_CLASS = "note-image";
159
+
160
+ /**
161
+ * A markdown image, with the directive it may carry.
162
+ *
163
+ * The alt text admits no `]`, and the address no whitespace or `)`, which is
164
+ * the shape markdown-it itself accepts for the common case and the only shape
165
+ * this format asks anyone to write. A title — `![alt](src "title")` — is
166
+ * matched so it can be reported rather than silently dropped.
167
+ *
168
+ * The directive is `{…}` immediately after the closing parenthesis, holding no
169
+ * newline: a brace that opens and never closes on its line is prose, not a
170
+ * directive, and reading on to the next paragraph to find its `}` would make
171
+ * one stray character swallow a page.
172
+ *
173
+ * @type {RegExp}
174
+ */
175
+ export const IMAGE_PATTERN = /!\[([^\]\n]*)\]\(\s*([^\s)]*)(?:\s+"([^"\n]*)")?\s*\)(\{[^}\n]*\})?/g;
176
+
177
+ /** The URI schemes an address may carry. Anything else is not an image. */
178
+ const IMAGE_SCHEMES = Object.freeze(["http:", "https:"]);
179
+
180
+ /**
181
+ * What is wrong with an image's address, or `""` when nothing is.
182
+ *
183
+ * The address is the one piece of author-supplied text that has to reach an
184
+ * `src` attribute, so it is the one piece that has to be held to a shape. A
185
+ * scheme this does not name — `javascript:`, `data:`, `vbscript:` — is refused
186
+ * rather than escaped, because escaping makes it inert markup and this makes it
187
+ * a finding the author can act on.
188
+ *
189
+ * @param {string} src - The address, exactly as authored.
190
+ * @returns {string} The problem, as a finding's sentence, or `""`.
191
+ */
192
+ export function imageSourceProblem(src) {
193
+ const s = String(src ?? "").trim();
194
+ if (!s) return "names no file — an image with no address shows nothing on any surface";
195
+ if (/[\s<>"']/.test(s)) {
196
+ return (
197
+ `\`${s}\` is not an address — an image's address carries no whitespace, ` +
198
+ "angle bracket or quote"
199
+ );
200
+ }
201
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(s);
202
+ if (scheme && !IMAGE_SCHEMES.includes(scheme[0].toLowerCase())) {
203
+ return (
204
+ `\`${scheme[0]}\` is not an address this format serves — an image is ` +
205
+ `${IMAGE_SCHEMES.join(" or ")} or a path inside a package`
206
+ );
207
+ }
208
+ return "";
209
+ }
210
+
211
+ /**
212
+ * Read the directive on an image.
213
+ *
214
+ * Values are validated here rather than at the point of rendering, so a mistake
215
+ * is one finding with a position rather than three surfaces quietly drawing
216
+ * something else. A directive that holds a problem yields **no** class and no
217
+ * float: a half-honoured directive is the silent failure in a smaller costume.
218
+ *
219
+ * @param {string} [raw] - The text between the braces, braces included or not.
220
+ * @returns {{classes: string[], float: string, problems: string[]}} What was
221
+ * written, and what cannot be honoured.
222
+ */
223
+ export function parseImageDirective(raw) {
224
+ /** @type {string[]} */
225
+ const classes = [];
226
+ let float = "";
227
+ /** @type {string[]} */
228
+ const problems = [];
229
+
230
+ const inner = String(raw ?? "")
231
+ .replace(/^\{/, "")
232
+ .replace(/\}$/, "");
233
+ if (!inner.trim()) return { classes, float, problems };
234
+
235
+ // Split on commas, not whitespace: `float: top-left` is one pair with a
236
+ // space in it, and the space after the colon is the spelling people write.
237
+ for (const part of inner
238
+ .split(",")
239
+ .map((s) => s.trim())
240
+ .filter(Boolean)) {
241
+ if (part.startsWith(".")) {
242
+ const name = part.slice(1);
243
+ if (!Object.prototype.hasOwnProperty.call(IMAGE_CLASSES, name)) {
244
+ problems.push(
245
+ `\`.${name}\` is not a width an image has — the width there is ` +
246
+ `is \`.${Object.keys(IMAGE_CLASSES).join("`, `.")}\`, and an image ` +
247
+ "with no class at all is the ordinary width",
248
+ );
249
+ continue;
250
+ }
251
+ if (classes.includes(name)) {
252
+ problems.push(`\`.${name}\` is written twice, and an image has one width`);
253
+ continue;
254
+ }
255
+ classes.push(name);
256
+ continue;
257
+ }
258
+
259
+ const colon = part.indexOf(":");
260
+ if (colon === -1) {
261
+ problems.push(
262
+ `\`${part}\` is neither a width class nor \`float: <position>\` — an ` +
263
+ "image states its width as a class and its position as `float:`, " +
264
+ "and it states no dimensions at all",
265
+ );
266
+ continue;
267
+ }
268
+ const key = part.slice(0, colon).trim();
269
+ const value = part.slice(colon + 1).trim();
270
+ if (key !== "float") {
271
+ problems.push(
272
+ `\`${key}\` is not an image attribute — \`float\` is the only one, ` +
273
+ "and width is a class rather than an attribute",
274
+ );
275
+ continue;
276
+ }
277
+ if (!Object.prototype.hasOwnProperty.call(IMAGE_FLOATS, value)) {
278
+ problems.push(
279
+ `\`float: ${value}\` is not a position — the ones there are: ` +
280
+ `${Object.keys(IMAGE_FLOATS).join(", ")}`,
281
+ );
282
+ continue;
283
+ }
284
+ if (float) {
285
+ problems.push("`float:` is written twice, and an image sits in one place");
286
+ continue;
287
+ }
288
+ float = value;
289
+ }
290
+
291
+ if (classes.length > 1) {
292
+ problems.push("an image states one width, and this states more than one");
293
+ }
294
+ // Nothing partial: a directive with a problem in it is not honoured at all.
295
+ if (problems.length) return { classes: [], float: "", problems };
296
+ return { classes, float, problems };
297
+ }
298
+
299
+ /**
300
+ * The classes a figure carries, from a parsed directive.
301
+ *
302
+ * @param {{classes?: string[], float?: string}} [directive] - As parsed.
303
+ * @returns {string} A space-separated class list, always naming
304
+ * {@link IMAGE_FIGURE_CLASS} first.
305
+ */
306
+ export function figureClasses({ classes = [], float = "" } = {}) {
307
+ const names = [IMAGE_FIGURE_CLASS];
308
+ for (const name of classes) {
309
+ const spec = IMAGE_CLASSES[/** @type {keyof typeof IMAGE_CLASSES} */ (name)];
310
+ if (spec) names.push(spec.class);
311
+ }
312
+ const position = IMAGE_FLOATS[/** @type {keyof typeof IMAGE_FLOATS} */ (float)];
313
+ if (position) names.push(position.class);
314
+ return names.join(" ");
315
+ }
316
+
317
+ /**
318
+ * Text going inside an HTML attribute or between tags.
319
+ *
320
+ * @param {string} text - The raw value.
321
+ * @returns {string} The same value, safe in markup.
322
+ */
323
+ export function escapeHtml(text) {
324
+ return String(text ?? "")
325
+ .replace(/&/g, "&amp;")
326
+ .replace(/</g, "&lt;")
327
+ .replace(/>/g, "&gt;")
328
+ .replace(/"/g, "&quot;")
329
+ .replace(/'/g, "&#39;");
330
+ }
331
+
332
+ /**
333
+ * One image as the `<figure>` both HTML surfaces render.
334
+ *
335
+ * The website and a Foundry journal page get the identical string, from one
336
+ * function, so the two cannot drift into styling the same directive through
337
+ * different class names.
338
+ *
339
+ * @param {object} image - The image.
340
+ * @param {string} image.src - The address, resolved for the surface.
341
+ * @param {string} [image.alt] - The alt text, which is also the caption.
342
+ * @param {string[]} [image.classes] - Width classes, from the directive.
343
+ * @param {string} [image.float] - The float position, from the directive.
344
+ * @returns {string} The figure, as one HTML block.
345
+ */
346
+ export function imageFigureHtml({ src, alt = "", classes = [], float = "" }) {
347
+ const caption = alt ? `\n<figcaption>${escapeHtml(alt)}</figcaption>` : "";
348
+ return (
349
+ `<figure class="${figureClasses({ classes, float })}">\n` +
350
+ `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}">${caption}\n` +
351
+ `</figure>`
352
+ );
353
+ }
354
+
355
+ /**
356
+ * Whether a match sits alone in its own paragraph.
357
+ *
358
+ * "Alone" is the whole of a block: nothing else on its line, and a blank line
359
+ * or the end of the body either side of it. A leading `>` or list marker
360
+ * disqualifies it for the same reason a word does — the paragraph it belongs to
361
+ * holds something the figure would have to be lifted out of.
362
+ *
363
+ * @param {string} text - The body the match indexes into.
364
+ * @param {number} start - Where the match begins.
365
+ * @param {number} end - Where it ends.
366
+ * @returns {boolean} Whether the match is a block of its own.
367
+ */
368
+ export function standsAlone(text, start, end) {
369
+ const src = String(text ?? "");
370
+ const lineStart = src.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
371
+ // Up to three leading spaces is still a paragraph in markdown; a fourth
372
+ // makes it an indented code block, which `matchAllOutsideCode` skips.
373
+ if (!/^[ \t]*$/.test(src.slice(lineStart, start))) return false;
374
+ const lineEnd = src.indexOf("\n", end);
375
+ if (!/^[ \t]*$/.test(src.slice(end, lineEnd === -1 ? src.length : lineEnd))) return false;
376
+
377
+ const before = src.slice(0, lineStart);
378
+ if (before.trim() && !/\n[ \t]*\n[ \t]*$/.test(before)) return false;
379
+ const after = lineEnd === -1 ? "" : src.slice(lineEnd);
380
+ if (after.trim() && !/^\n[ \t]*\n/.test(after)) return false;
381
+ return true;
382
+ }
383
+
384
+ /**
385
+ * Every image in one body, with its directive and its position.
386
+ *
387
+ * Code is skipped, because an image shown as an example is prose *about* an
388
+ * image and resolving it would make this very module impossible to document.
389
+ *
390
+ * @param {string} body - The note's markdown, without its frontmatter.
391
+ * @returns {Array<{alt: string, src: string, title: string, directive: string,
392
+ * index: number, length: number, block: boolean}>} One entry per image, in
393
+ * source order.
394
+ */
395
+ export function imagesIn(body) {
396
+ const text = String(body ?? "");
397
+ return matchAllOutsideCode(text, IMAGE_PATTERN).map((match) => {
398
+ const index = /** @type {number} */ (match.index);
399
+ return {
400
+ alt: match[1] ?? "",
401
+ src: match[2] ?? "",
402
+ title: match[3] ?? "",
403
+ directive: match[4] ?? "",
404
+ index,
405
+ length: match[0].length,
406
+ block: standsAlone(text, index, index + match[0].length),
407
+ };
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Every image address one body names, in order of appearance.
413
+ *
414
+ * What a build reads to know which files it has to stage before a compiler can
415
+ * see them.
416
+ *
417
+ * @param {string} body - The note's markdown.
418
+ * @returns {string[]} The addresses, with repeats.
419
+ */
420
+ export function imageSourcesIn(body) {
421
+ return imagesIn(body)
422
+ .map((image) => image.src)
423
+ .filter(Boolean);
424
+ }
425
+
426
+ /**
427
+ * Every defect in one note's images.
428
+ *
429
+ * **Errors, not warnings.** A refusal that does not fail the build is not a
430
+ * refusal: `reportFindings` fails on an error and not on a warning, so a
431
+ * directive reported as advisory publishes anyway, looking exactly like one
432
+ * that worked.
433
+ *
434
+ * @param {string} body - The note's markdown, without its frontmatter.
435
+ * @param {string} file - The note's path, for the finding.
436
+ * @param {object} [opts]
437
+ * @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
438
+ * @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
439
+ * @returns {Array<{file: string, line: number, column: number|undefined,
440
+ * severity: "error", message: string}>} One finding per defect, in source
441
+ * order.
442
+ */
443
+ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
444
+ const text = String(body ?? "");
445
+ if (!text) return [];
446
+
447
+ /** @type {Array<{file: string, line: number, column: number|undefined, severity: "error", message: string}>} */
448
+ const findings = [];
449
+ /**
450
+ * @param {number} offset - Where in the body the finding is.
451
+ * @param {string} message - What is wrong.
452
+ */
453
+ const report = (offset, message) => {
454
+ const { line, column } = positionInBody(text, offset, { bodyLine, bodyColumn });
455
+ findings.push({ file, line, column, severity: /** @type {"error"} */ ("error"), message });
456
+ };
457
+
458
+ for (const image of imagesIn(text)) {
459
+ const problem = imageSourceProblem(image.src) || pathnameProblem(image.src);
460
+ if (problem) report(image.index, problem);
461
+ if (image.title) {
462
+ report(
463
+ image.index,
464
+ `\`"${image.title}"\` is a title on an image, and no surface here draws ` +
465
+ "one — an image's alt text is its caption",
466
+ );
467
+ }
468
+ if (!image.block) {
469
+ report(
470
+ image.index,
471
+ `\`![${image.alt}](…)\` shares its paragraph with other text — an image ` +
472
+ "is a block, standing alone with a blank line either side of it, " +
473
+ "because a width and a position mean nothing applied to a word in a " +
474
+ "sentence",
475
+ );
476
+ }
477
+ if (!image.directive) continue;
478
+ const { problems } = parseImageDirective(image.directive);
479
+ // Located on the brace rather than the image: the brace is what the
480
+ // author has to edit, and an image with two problems in one directive
481
+ // should not send them to the same column twice.
482
+ const at = image.index + image.length - image.directive.length;
483
+ for (const message of problems) report(at, message);
484
+ }
485
+ return findings;
486
+ }
487
+
488
+ /**
489
+ * A file's body, its path, and where the body starts in the file.
490
+ *
491
+ * The same split {@link module:engine/helpers.parseMarkdownFile} makes, without
492
+ * parsing the YAML: this check has no use for the frontmatter's *values*, and
493
+ * reading them would make an unparseable note silently unscanned.
494
+ *
495
+ * @param {string} content - The whole file.
496
+ * @param {string} file - Its path, for the finding.
497
+ * @returns {[string, string, {bodyLine: number, bodyColumn: number}]} The
498
+ * arguments {@link checkImages} takes.
499
+ */
500
+ function bodyOf(content, file) {
501
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
502
+ if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
503
+
504
+ const raw = match[2];
505
+ const body = raw.trim();
506
+ const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
507
+ const before = content.slice(0, bodyStart);
508
+ return [
509
+ body,
510
+ file,
511
+ {
512
+ bodyLine: before.split("\n").length,
513
+ bodyColumn: bodyStart - before.lastIndexOf("\n"),
514
+ },
515
+ ];
516
+ }
517
+
518
+ /**
519
+ * Walk a content tree and report every image it cannot render as authored.
520
+ *
521
+ * Its own walk, like the icon and HTML checks beside it, so all three stay
522
+ * leaves with nothing imported between them.
523
+ *
524
+ * @param {string} contentBase - Root of the content tree.
525
+ * @param {object} [opts]
526
+ * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
527
+ * in addition to the dot-directories always skipped.
528
+ * @returns {{findings: Array<{file: string, line: number, column: number|undefined,
529
+ * severity: "error", message: string}>, files: number}} The findings, and how
530
+ * many files were read.
531
+ */
532
+ export function lintContentImages(contentBase, { skipDirectories = [] } = {}) {
533
+ const skip = new Set(skipDirectories);
534
+ /** @type {Array<{file: string, line: number, column: number|undefined, severity: "error", message: string}>} */
535
+ const findings = [];
536
+ let files = 0;
537
+
538
+ /** @param {string} dir - Directory to descend into. */
539
+ const walk = (dir) => {
540
+ /** @type {import("node:fs").Dirent[]} */
541
+ let entries;
542
+ try {
543
+ entries = fs.readdirSync(dir, { withFileTypes: true });
544
+ } catch {
545
+ return;
546
+ }
547
+ for (const entry of entries) {
548
+ if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
549
+ const full = path.join(dir, entry.name);
550
+ if (entry.isDirectory()) {
551
+ walk(full);
552
+ continue;
553
+ }
554
+ if (!/\.(md|markdown)$/i.test(entry.name)) continue;
555
+ let content;
556
+ try {
557
+ content = fs.readFileSync(full, "utf8");
558
+ } catch {
559
+ continue;
560
+ }
561
+ files += 1;
562
+ findings.push(...checkImages(...bodyOf(content, path.relative(contentBase, full))));
563
+ }
564
+ };
565
+
566
+ walk(contentBase);
567
+ return { findings, files };
568
+ }
569
+
570
+ /**
571
+ * Rewrite every block image in a body into the figure the website publishes.
572
+ *
573
+ * **Hugo is handed markdown, not a rendered page.** The site emitter writes a
574
+ * note's body through verbatim, so a `{…}` directive left in it reaches the
575
+ * page as its own literal braces: Goldmark's block-attribute parser is off, and
576
+ * turning it on would accept `style` and `id` alongside the two vocabularies,
577
+ * which is the injection surface this rule exists to avoid. So the directive is
578
+ * resolved here, into markup Goldmark passes through.
579
+ *
580
+ * An image whose directive does not parse is left exactly as written, so the
581
+ * page shows the braces and the lint says why — the same visible degradation an
582
+ * unknown icon name gets.
583
+ *
584
+ * Call this **inside** {@link module:engine/code-fences.protectCode}: an image
585
+ * in a fence is an example of one.
586
+ *
587
+ * @param {string} body - The note's markdown.
588
+ * @param {(src: string) => string} [resolveSrc] - Translates an authored
589
+ * pathname into the address this surface serves. The default is the identity,
590
+ * for a caller rendering the format rather than publishing it.
591
+ * @returns {string} The same body, with each block image as a `<figure>`.
592
+ */
593
+ export function renderImageFigures(body, resolveSrc = (src) => src) {
594
+ const text = String(body ?? "");
595
+ let out = "";
596
+ let last = 0;
597
+ for (const image of imagesIn(text)) {
598
+ if (!image.block || imageSourceProblem(image.src) || image.title) continue;
599
+ const { classes, float, problems } = parseImageDirective(image.directive);
600
+ if (problems.length) continue;
601
+ out += text.slice(last, image.index);
602
+ out += imageFigureHtml({ src: resolveSrc(image.src), alt: image.alt, classes, float });
603
+ last = image.index + image.length;
604
+ }
605
+ return out + text.slice(last);
606
+ }
607
+
608
+ /**
609
+ * A markdown-it plugin that reads an image's directive and renders its figure.
610
+ *
611
+ * **A core rule, not an inline one.** markdown-it's own `image` rule consumes
612
+ * `![alt](src)` and leaves `{float: top-left}` behind as text, and a rule
613
+ * running before it would have to re-implement link parsing to find the brace.
614
+ * Reading the token stream afterwards costs one pass and re-implements nothing.
615
+ *
616
+ * Two things happen to a paragraph holding one image and nothing else: the
617
+ * directive is lifted off the text token that follows it, and the paragraph's
618
+ * own tokens are hidden, so the figure is a block rather than a `<figure>`
619
+ * nested inside a `<p>`. The Typst renderer reads the same `meta`, which is
620
+ * what keeps the book and the two HTML surfaces honouring one statement.
621
+ *
622
+ * An image whose directive does not parse keeps its braces and renders as its
623
+ * own literal text, which is how the author sees the mistake without reading a
624
+ * log.
625
+ *
626
+ * @param {(src: string) => string} [resolveSrc] - Translates an authored
627
+ * address into the one this surface serves. Foundry is handed the path inside
628
+ * the install; a renderer that resolves the address itself — the book stages
629
+ * its own copy — passes nothing and gets the address as authored.
630
+ * @returns {(md: object) => void} A markdown-it plugin.
631
+ */
632
+ export function imagePlugin(resolveSrc = (src) => src) {
633
+ return (md) => {
634
+ /** @type {any} */ (md).core.ruler.push("heroiclands_image", (state) => {
635
+ attachImageDirectives(state.tokens);
636
+ });
637
+ const base = /** @type {any} */ (md).renderer.rules.image;
638
+ /** @type {any} */ (md).renderer.rules.image = (
639
+ /** @type {any[]} */ tokens,
640
+ /** @type {number} */ idx,
641
+ /** @type {any} */ options,
642
+ /** @type {any} */ env,
643
+ /** @type {any} */ self,
644
+ ) => {
645
+ const token = tokens[idx];
646
+ if (!token.meta?.block) return base(tokens, idx, options, env, self);
647
+ return `${imageFigureHtml({
648
+ src: resolveSrc(token.attrGet("src") ?? ""),
649
+ alt: token.content ?? "",
650
+ classes: token.meta.classes,
651
+ float: token.meta.float,
652
+ })}\n`;
653
+ };
654
+ };
655
+ }
656
+
657
+ /**
658
+ * Mark every paragraph that is one image, and lift its directive onto it.
659
+ *
660
+ * @param {any[]} tokens - A markdown-it block token stream.
661
+ * @returns {void}
662
+ */
663
+ function attachImageDirectives(tokens) {
664
+ for (let i = 0; i + 2 < tokens.length; i += 1) {
665
+ if (tokens[i].type !== "paragraph_open") continue;
666
+ if (tokens[i + 1].type !== "inline") continue;
667
+ if (tokens[i + 2].type !== "paragraph_close") continue;
668
+
669
+ const children = tokens[i + 1].children ?? [];
670
+ const meaningful = children.filter(
671
+ (/** @type {any} */ child) => child.type !== "text" || child.content.trim(),
672
+ );
673
+ const image = meaningful[0];
674
+ if (!image || image.type !== "image") continue;
675
+
676
+ // At most one thing may follow the image, and only if it is the
677
+ // directive: anything else means the paragraph holds prose too.
678
+ const trailing = meaningful[1];
679
+ if (meaningful.length > 2) continue;
680
+ let directive = "";
681
+ if (trailing) {
682
+ if (trailing.type !== "text") continue;
683
+ const brace = /^\{[^}\n]*\}/.exec(trailing.content);
684
+ if (!brace || trailing.content.slice(brace[0].length).trim()) continue;
685
+ directive = brace[0];
686
+ }
687
+
688
+ const { classes, float, problems } = parseImageDirective(directive);
689
+ // Not ours to consume: leaving the braces in place is what makes an
690
+ // unrecognised value visible on the page instead of passing as ordinary.
691
+ if (problems.length) continue;
692
+
693
+ image.meta = { ...(image.meta ?? {}), block: true, classes, float };
694
+ if (trailing) trailing.content = trailing.content.slice(directive.length);
695
+ // The figure is a block, so the paragraph that held it renders nothing.
696
+ tokens[i].hidden = true;
697
+ tokens[i + 2].hidden = true;
698
+ }
699
+ }