@heroiclands/package-build 20.7.0 → 21.1.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.
- package/CHANGELOG.md +183 -0
- package/CONTENT.md +132 -44
- package/bin/content-build.mjs +37 -5
- package/bin/package-build.mjs +77 -0
- package/content-config.mjs +59 -1
- package/docs/api.md +149 -19
- package/docs/commands.md +75 -0
- package/docs/configuration.md +37 -10
- package/docs/content-format.md +450 -49
- package/engine/content-format.mjs +52 -3
- package/engine/content-images.mjs +699 -0
- package/engine/dependency-bump.mjs +218 -0
- package/engine/frontmatter-lint.mjs +89 -2
- package/engine/helpers.mjs +81 -142
- package/engine/index.mjs +15 -0
- package/engine/infobox-registry.mjs +81 -0
- package/engine/infobox-render.mjs +382 -0
- package/engine/infobox.mjs +963 -0
- package/engine/item-registry.mjs +5 -5
- package/engine/journals.mjs +22 -1
- package/engine/map-notes.mjs +11 -5
- package/engine/metadata-index.mjs +5 -0
- package/engine/note-vocabulary.mjs +57 -2
- package/engine/pathnames.mjs +374 -0
- package/engine/pdf-build.mjs +206 -9
- package/engine/pdf-render.mjs +453 -20
- package/engine/pdf-toc.mjs +77 -5
- package/engine/scenes.mjs +2 -1
- package/engine/site-build.mjs +106 -7
- package/engine/site-index.mjs +93 -4
- package/engine/wikilinks.mjs +93 -0
- package/hm3/default-item-art.mjs +14 -15
- package/hm3/index.mjs +3 -0
- package/hm3/infobox.mjs +64 -0
- package/package.json +1 -1
- package/sohl/default-item-art.mjs +18 -16
- package/sohl/index.mjs +3 -0
- package/sohl/infobox.mjs +499 -0
- package/types/content-config.d.mts +7 -0
- package/types/engine/content-format.d.mts +36 -0
- package/types/engine/content-images.d.mts +281 -0
- package/types/engine/dependency-bump.d.mts +89 -0
- package/types/engine/frontmatter-lint.d.mts +23 -0
- package/types/engine/helpers.d.mts +30 -72
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +36 -0
- package/types/engine/infobox-render.d.mts +87 -0
- package/types/engine/infobox.d.mts +443 -0
- package/types/engine/item-registry.d.mts +5 -5
- package/types/engine/journals.d.mts +9 -1
- package/types/engine/note-vocabulary.d.mts +51 -0
- package/types/engine/pathnames.d.mts +189 -0
- package/types/engine/pdf-build.d.mts +46 -0
- package/types/engine/pdf-render.d.mts +97 -1
- package/types/engine/pdf-toc.d.mts +10 -5
- package/types/engine/site-build.d.mts +11 -3
- package/types/engine/site-index.d.mts +35 -3
- package/types/engine/wikilinks.d.mts +22 -0
- package/types/hm3/default-item-art.d.mts +5 -6
- package/types/hm3/index.d.mts +1 -0
- package/types/hm3/infobox.d.mts +22 -0
- package/types/sohl/index.d.mts +1 -0
- 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
|
+
* {float: top-left}
|
|
20
|
+
* {.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 — `` — 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, "&")
|
|
326
|
+
.replace(/</g, "<")
|
|
327
|
+
.replace(/>/g, ">")
|
|
328
|
+
.replace(/"/g, """)
|
|
329
|
+
.replace(/'/g, "'");
|
|
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
|
+
`\`\` 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
|
+
* `` 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
|
+
}
|