@heroiclands/package-build 20.7.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 (63) hide show
  1. package/CHANGELOG.md +159 -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 +106 -7
  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 +1 -1
  36. package/sohl/default-item-art.mjs +18 -16
  37. package/sohl/index.mjs +3 -0
  38. package/sohl/infobox.mjs +499 -0
  39. package/types/content-config.d.mts +7 -0
  40. package/types/engine/content-format.d.mts +36 -0
  41. package/types/engine/content-images.d.mts +281 -0
  42. package/types/engine/dependency-bump.d.mts +89 -0
  43. package/types/engine/frontmatter-lint.d.mts +23 -0
  44. package/types/engine/helpers.d.mts +30 -72
  45. package/types/engine/index.d.mts +5 -0
  46. package/types/engine/infobox-registry.d.mts +36 -0
  47. package/types/engine/infobox-render.d.mts +87 -0
  48. package/types/engine/infobox.d.mts +443 -0
  49. package/types/engine/item-registry.d.mts +5 -5
  50. package/types/engine/journals.d.mts +9 -1
  51. package/types/engine/note-vocabulary.d.mts +51 -0
  52. package/types/engine/pathnames.d.mts +189 -0
  53. package/types/engine/pdf-build.d.mts +46 -0
  54. package/types/engine/pdf-render.d.mts +99 -1
  55. package/types/engine/pdf-toc.d.mts +10 -5
  56. package/types/engine/site-build.d.mts +11 -3
  57. package/types/engine/site-index.d.mts +35 -3
  58. package/types/engine/wikilinks.d.mts +22 -0
  59. package/types/hm3/default-item-art.d.mts +5 -6
  60. package/types/hm3/index.d.mts +1 -0
  61. package/types/hm3/infobox.d.mts +22 -0
  62. package/types/sohl/index.d.mts +1 -0
  63. package/types/sohl/infobox.d.mts +145 -0
@@ -0,0 +1,81 @@
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
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The infobox declarations this toolchain ships, one per system.
16
+ *
17
+ * Separate from `engine/infobox.mjs` for the reason
18
+ * `engine/subtype-registry.mjs` is separate from
19
+ * `engine/document-subtypes.mjs`: the mechanism is the engine's and the
20
+ * declarations are the systems', and a mechanism that imported its own
21
+ * consumers could not be imported by them.
22
+ *
23
+ * @module
24
+ */
25
+
26
+ import { assertInfoboxSet, buildInfoboxes } from "./infobox.mjs";
27
+ import { carriesSystemBlock, resolveFieldValue } from "./system-block.mjs";
28
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
29
+ import { SOHL_INFOBOX } from "../sohl/infobox.mjs";
30
+ import { HM3_INFOBOX } from "../hm3/infobox.mjs";
31
+
32
+ /**
33
+ * Every system's infobox declaration, in the order a page shows them.
34
+ *
35
+ * The order matches {@link module:engine/subtype-registry.KNOWN_DOCUMENT_SUBTYPE_MAPS},
36
+ * because the box set is derived from those maps and a reader meeting two
37
+ * pages of one type should meet the boxes in one order.
38
+ *
39
+ * @type {readonly object[]}
40
+ */
41
+ export const KNOWN_INFOBOXES = Object.freeze([SOHL_INFOBOX, HM3_INFOBOX]);
42
+
43
+ /**
44
+ * One system's declaration, by its id.
45
+ *
46
+ * @param {string|undefined} system - The system id.
47
+ * @returns {object|undefined} Its declaration, or `undefined` where this
48
+ * toolchain ships none for it.
49
+ */
50
+ export function infoboxFor(system) {
51
+ if (!system) return undefined;
52
+ return KNOWN_INFOBOXES.find((entry) => entry.system === system);
53
+ }
54
+
55
+ /**
56
+ * Every box one note carries, wired to the registries this toolchain ships.
57
+ *
58
+ * The one call each medium makes. What varies between them is the resolver —
59
+ * a website has URLs, a compendium has UUIDs, the book has its own labels —
60
+ * and nothing else, which is what keeps the three showing the same panel.
61
+ *
62
+ * @param {object} fm - The note's frontmatter.
63
+ * @param {object} [options] - Options.
64
+ * @param {(ref: unknown, hint?: object) => object|undefined} [options.resolve] -
65
+ * Resolves a reference to `{name, url?, uuid?, address?, subType?}`.
66
+ * @returns {object[]} The boxes, in the order every medium renders them.
67
+ * @throws {Error} When the built set disagrees with what the note's type maps
68
+ * to — see {@link module:engine/infobox.assertInfoboxSet}.
69
+ */
70
+ export function noteInfoboxes(fm, { resolve } = {}) {
71
+ const boxes = buildInfoboxes(fm, {
72
+ maps: KNOWN_DOCUMENT_SUBTYPE_MAPS,
73
+ providers: KNOWN_INFOBOXES,
74
+ carriesBlock: carriesSystemBlock,
75
+ resolveField: resolveFieldValue,
76
+ resolve,
77
+ });
78
+ return /** @type {object[]} */ (
79
+ assertInfoboxSet(boxes, fm, { maps: KNOWN_DOCUMENT_SUBTYPE_MAPS })
80
+ );
81
+ }
@@ -0,0 +1,381 @@
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
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **Rendering the declared infobox** — the two surfaces this package draws
16
+ * itself, HTML for a Foundry Journal Page and Typst for the book.
17
+ *
18
+ * The website is the third surface and is not here: its boxes travel in the
19
+ * page's front matter and the site theme draws them, which is what makes the
20
+ * theme one generic renderer instead of a partial per note type.
21
+ *
22
+ * **Neither renderer knows a field name.** Both switch on a section's `layout`
23
+ * and a row's `kind`, which is the whole of what keeps the field list from
24
+ * leaking back into a template.
25
+ *
26
+ * **A section is the unit that flows.** In Typst each section is a
27
+ * `block(breakable: false)` inside a `block(breakable: true)` panel, so a long
28
+ * box crosses a column or page boundary between its sections and never through
29
+ * a stat grid. In HTML the box is a `<details>` disclosure, default **open** —
30
+ * native, accessible, no JavaScript, and what lets a being's note box and its
31
+ * system boxes stack as three summary lines rather than three panels burying
32
+ * the prose.
33
+ *
34
+ * @module
35
+ */
36
+
37
+ import { sectionHolds } from "./infobox.mjs";
38
+ import { escapeTypst, escapeTypstString } from "./pdf-render.mjs";
39
+
40
+ /**
41
+ * How many cells an attribute grid packs into a row.
42
+ *
43
+ * Six is what the book prototype settled on: a full set of fourteen scores
44
+ * fills two rows and two cells of a third at the column measure the book sets,
45
+ * and a wider row crowds the labels into their values.
46
+ *
47
+ * @type {number}
48
+ */
49
+ const GRID_COLUMNS = 6;
50
+
51
+ /** HTML text, with the five characters that would otherwise be markup escaped. */
52
+ function escapeHtml(text) {
53
+ return String(text ?? "")
54
+ .replace(/&/g, "&amp;")
55
+ .replace(/</g, "&lt;")
56
+ .replace(/>/g, "&gt;")
57
+ .replace(/"/g, "&quot;")
58
+ .replace(/'/g, "&#39;");
59
+ }
60
+
61
+ /**
62
+ * The default HTML for one link value: an anchor when the medium supplied a
63
+ * URL, and the text alone otherwise.
64
+ *
65
+ * An unresolved reference keeps its words. A page that names something the
66
+ * index has not heard of is better than a page silently missing a row.
67
+ *
68
+ * @param {object} value - A `link` value.
69
+ * @returns {string} HTML.
70
+ */
71
+ export function linkToHtml(value) {
72
+ const text = escapeHtml(value?.text);
73
+ return value?.url ? `<a href="${escapeHtml(value.url)}">${text}</a>` : text;
74
+ }
75
+
76
+ /**
77
+ * One link value as a Foundry document reference.
78
+ *
79
+ * A compendium journal links **inside** Foundry: a website URL on a panel a
80
+ * player reads at the table sends them out of the game, and a UUID resolves to
81
+ * the document whether or not the world has imported it. Foundry's enricher
82
+ * reads the reference out of the page's own HTML, so no markup is needed
83
+ * around it.
84
+ *
85
+ * @param {object} value - A `link` value.
86
+ * @returns {string} A `@UUID` reference, or the text where there is none.
87
+ */
88
+ export function linkToUuid(value) {
89
+ const text = escapeHtml(value?.text);
90
+ return value?.uuid ? `@UUID[${escapeHtml(value.uuid)}]{${text}}` : text;
91
+ }
92
+
93
+ /**
94
+ * One row's value as HTML.
95
+ *
96
+ * @param {object} row - The row.
97
+ * @param {(value: object) => string} link - How this medium draws a link.
98
+ * @returns {string} HTML.
99
+ */
100
+ function valueToHtml(row, link) {
101
+ switch (row.kind) {
102
+ case "link":
103
+ return link(row.value);
104
+ case "links":
105
+ return (row.value ?? []).map((value) => link(value)).join(", ");
106
+ case "list":
107
+ return (row.value ?? []).map((entry) => escapeHtml(entry)).join(", ");
108
+ case "number":
109
+ return escapeHtml(row.value);
110
+ default:
111
+ return escapeHtml(row.value);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * One entry of a `runin` group or a `list` section as HTML.
117
+ *
118
+ * @param {object} entry - The entry.
119
+ * @param {(value: object) => string} link - How this medium draws a link.
120
+ * @returns {string} HTML.
121
+ */
122
+ function entryToHtml(entry, link) {
123
+ return entry?.url || entry?.address ? link(entry) : escapeHtml(entry?.text);
124
+ }
125
+
126
+ /**
127
+ * One section as HTML.
128
+ *
129
+ * @param {object} section - The section.
130
+ * @param {(value: object) => string} link - How this medium draws a link.
131
+ * @returns {string} HTML.
132
+ */
133
+ function sectionToHtml(section, link) {
134
+ const out = [`<section class="infobox-section infobox-${section.layout}">`];
135
+ if (section.label)
136
+ out.push(`<h4 class="infobox-section-title">${escapeHtml(section.label)}</h4>`);
137
+
138
+ if (section.layout === "rows") {
139
+ out.push('<dl class="infobox-rows">');
140
+ for (const row of section.rows ?? []) {
141
+ out.push(`<dt>${escapeHtml(row.label)}</dt><dd>${valueToHtml(row, link)}</dd>`);
142
+ }
143
+ out.push("</dl>");
144
+ } else if (section.layout === "grid") {
145
+ out.push('<ul class="infobox-cells">');
146
+ for (const cell of section.cells ?? []) {
147
+ out.push(
148
+ `<li><span class="infobox-cell-label">${escapeHtml(cell.label)}</span>` +
149
+ `<span class="infobox-cell-value">${escapeHtml(cell.value)}</span></li>`,
150
+ );
151
+ }
152
+ out.push("</ul>");
153
+ } else if (section.layout === "runin") {
154
+ for (const group of section.groups ?? []) {
155
+ const entries = (group.entries ?? [])
156
+ .map((entry) => entryToHtml(entry, link))
157
+ .join(", ");
158
+ const label = group.label ? `<strong>${escapeHtml(group.label)}:</strong> ` : "";
159
+ out.push(`<p class="infobox-runin">${label}${entries}</p>`);
160
+ }
161
+ } else {
162
+ out.push('<ul class="infobox-list">');
163
+ for (const entry of section.entries ?? []) {
164
+ out.push(`<li>${entryToHtml(entry, link)}</li>`);
165
+ }
166
+ out.push("</ul>");
167
+ }
168
+
169
+ out.push("</section>");
170
+ return out.join("");
171
+ }
172
+
173
+ /**
174
+ * Every box as HTML, in the order given.
175
+ *
176
+ * @param {readonly object[]} boxes - From `buildInfoboxes`.
177
+ * @param {object} [options] - Options.
178
+ * @param {(value: object) => string} [options.link] - How this medium draws a
179
+ * link. Defaults to an anchor on the value's `url`.
180
+ * @returns {string} HTML, empty when there is nothing to draw.
181
+ */
182
+ export function infoboxesToHtml(boxes, { link = linkToHtml } = {}) {
183
+ const out = [];
184
+ for (const box of boxes ?? []) {
185
+ if (box.kind !== "system" && !box.sections?.some((s) => sectionHasContent(s))) continue;
186
+ out.push(`<details class="infobox infobox-${escapeHtml(box.id)}" open>`);
187
+ out.push(`<summary>${escapeHtml(box.title)}</summary>`);
188
+ if (box.statement) {
189
+ out.push(`<p class="infobox-statement">${escapeHtml(box.statement)}</p>`);
190
+ } else {
191
+ for (const section of box.sections ?? []) {
192
+ if (sectionHasContent(section)) out.push(sectionToHtml(section, link));
193
+ }
194
+ }
195
+ out.push("</details>");
196
+ }
197
+ return out.join("\n");
198
+ }
199
+
200
+ /**
201
+ * Whether a section holds anything at all.
202
+ *
203
+ * A section with nothing in it is not drawn: rule 4 says an absent field is
204
+ * absent, and a heading over nothing is the em-dash placeholder in another
205
+ * form. The declaration answers it — {@link module:engine/infobox.sectionHolds}
206
+ * — because the same question decides whether a system box carries a statement
207
+ * instead of sections, and two answers to it would come apart.
208
+ *
209
+ * @param {object} section - The section.
210
+ * @returns {boolean} Whether to draw it.
211
+ */
212
+ export function sectionHasContent(section) {
213
+ return sectionHolds(section);
214
+ }
215
+
216
+ /**
217
+ * One link value as Typst.
218
+ *
219
+ * An internal destination — a note the book also prints — is a label
220
+ * reference, so the link works on paper as a cross-reference and in a PDF
221
+ * viewer as a jump. Everything else is set as its own words.
222
+ *
223
+ * @param {object} value - A `link` value.
224
+ * @param {Map<string, string>} links - Address slug → the book's anchor.
225
+ * @param {(anchor: string) => string} labelFor - The anchor's Typst label.
226
+ * @returns {string} Typst markup.
227
+ */
228
+ export function linkToTypst(value, links, labelFor) {
229
+ const text = escapeTypst(value?.text ?? "");
230
+ const anchor = value?.address ? links?.get(value.address) : undefined;
231
+ if (anchor) return `#link(<${labelFor(anchor)}>)[${text}]`;
232
+ if (value?.url) return `#link("${escapeTypstString(value.url)}")[${text}]`;
233
+ return text;
234
+ }
235
+
236
+ /**
237
+ * One row's value as Typst.
238
+ *
239
+ * @param {object} row - The row.
240
+ * @param {(value: object) => string} link - How the book draws a link.
241
+ * @returns {string} Typst markup.
242
+ */
243
+ function valueToTypst(row, link) {
244
+ switch (row.kind) {
245
+ case "link":
246
+ return link(row.value);
247
+ case "links":
248
+ return (row.value ?? []).map((value) => link(value)).join(", ");
249
+ case "list":
250
+ return (row.value ?? []).map((entry) => escapeTypst(entry)).join(", ");
251
+ default:
252
+ return escapeTypst(row.value);
253
+ }
254
+ }
255
+
256
+ /**
257
+ * One section as Typst, whole and unbreakable.
258
+ *
259
+ * `lead` rides **inside** the block rather than above it. A panel breaks
260
+ * between its sections, and a title sitting outside the first one is a break
261
+ * the panel is allowed to take — which leaves the heading and a rule stranded
262
+ * at the foot of a page with nothing under them.
263
+ *
264
+ * @param {object} section - The section.
265
+ * @param {(value: object) => string} link - How the book draws a link.
266
+ * @param {string} [lead] - Markup to carry at the head of this section.
267
+ * @returns {string} Typst markup.
268
+ */
269
+ function sectionToTypst(section, link, lead = "") {
270
+ const body = lead ? [lead] : [];
271
+ if (section.label) body.push(`#infobox-head[${escapeTypst(section.label)}]`);
272
+
273
+ if (section.layout === "rows") {
274
+ for (const row of section.rows ?? []) {
275
+ body.push(`#infobox-row[${escapeTypst(row.label)}][${valueToTypst(row, link)}]`);
276
+ }
277
+ } else if (section.layout === "grid") {
278
+ const cells = section.cells ?? [];
279
+ const drawn = cells
280
+ .map((cell) => `infobox-cell[${escapeTypst(cell.label)}][${escapeTypst(cell.value)}]`)
281
+ .join(", ");
282
+ // A `grid` draws its strokes through cells that hold nothing, so a
283
+ // part-filled last row would trail rules across empty space. The
284
+ // separator is therefore drawn only up to the last cell that holds
285
+ // something, and the row is padded out so the columns stay even.
286
+ const padding = (GRID_COLUMNS - (cells.length % GRID_COLUMNS)) % GRID_COLUMNS;
287
+ const filler = Array.from({ length: padding }, () => "[]").join(", ");
288
+ body.push(
289
+ `#grid(columns: (1fr,)*${GRID_COLUMNS}, row-gutter: 0.34em, inset: (x: 1.5pt), ` +
290
+ `stroke: (x, y) => if x > 0 and (y*${GRID_COLUMNS} + x) < ${cells.length} ` +
291
+ `{ (left: 0.4pt + infobox-hairline) }, ` +
292
+ `${drawn}${padding ? `, ${filler}` : ""})`,
293
+ );
294
+ } else if (section.layout === "runin") {
295
+ for (const group of section.groups ?? []) {
296
+ const entries = (group.entries ?? [])
297
+ .map((entry) =>
298
+ entry?.url || entry?.address ? link(entry) : escapeTypst(entry?.text),
299
+ )
300
+ .join(", ");
301
+ body.push(`#infobox-runin("${escapeTypstString(group.label ?? "")}")[${entries}]`);
302
+ }
303
+ } else {
304
+ for (const entry of section.entries ?? []) {
305
+ const text = entry?.url || entry?.address ? link(entry) : escapeTypst(entry?.text);
306
+ body.push(`#infobox-runin("")[${text}]`);
307
+ }
308
+ }
309
+
310
+ return `#block(breakable: false)[\n${body.join("\n")}\n]`;
311
+ }
312
+
313
+ /**
314
+ * Every box as one Typst panel each, breaking between sections.
315
+ *
316
+ * The panel is `breakable: true` and each section inside it is
317
+ * `breakable: false`, which is what puts a break between `ATTRIBUTES` and
318
+ * `SKILLS` on a richly-statted character and never inside either.
319
+ *
320
+ * @param {readonly object[]} boxes - From `buildInfoboxes`.
321
+ * @param {object} [options] - Options.
322
+ * @param {(value: object) => string} [options.link] - How the book draws a link.
323
+ * @returns {string} Typst markup, empty when there is nothing to draw.
324
+ */
325
+ export function infoboxesToTypst(boxes, { link = (value) => escapeTypst(value?.text ?? "") } = {}) {
326
+ const out = [];
327
+ for (const box of boxes ?? []) {
328
+ const drawn = (box.sections ?? []).filter((section) => sectionHasContent(section));
329
+ if (box.kind !== "system" && !drawn.length) continue;
330
+ const title = `#infobox-title[${escapeTypst(box.title)}]`;
331
+ const body = [];
332
+ if (box.statement) {
333
+ body.push(
334
+ `#block(breakable: false)[\n${title}\n` +
335
+ `#infobox-statement[${escapeTypst(box.statement)}]\n]`,
336
+ );
337
+ } else if (!drawn.length) {
338
+ body.push(`#block(breakable: false)[\n${title}\n]`);
339
+ } else {
340
+ drawn.forEach((section, at) => {
341
+ body.push(sectionToTypst(section, link, at === 0 ? title : ""));
342
+ });
343
+ }
344
+ out.push(`#infobox-panel[\n${body.join("\n")}\n]`);
345
+ }
346
+ return out.join("\n");
347
+ }
348
+
349
+ /**
350
+ * The Typst definitions an infobox panel is drawn with.
351
+ *
352
+ * Emitted once at the head of the book rather than inlined per entry: 2,500
353
+ * entries each carrying their own rules is a megabyte of repetition, and the
354
+ * one place a reader changes the panel's appearance should be one place.
355
+ *
356
+ * @returns {string} Typst markup.
357
+ */
358
+ export function infoboxTypstPreamble() {
359
+ return [
360
+ '#let infobox-rule = rgb("#7c3b1e")',
361
+ '#let infobox-faint = rgb("#6b6357")',
362
+ '#let infobox-hairline = rgb("#c9bfa8")',
363
+ "#let infobox-panel(body) = block(width: 100%, breakable: true, " +
364
+ "inset: (x: 9pt, y: 8pt), below: 0.8em, " +
365
+ "stroke: (top: 1.4pt + infobox-rule, bottom: 1.4pt + infobox-rule))[" +
366
+ "#set par(first-line-indent: 0em, justify: false, leading: 0.40em)\n#body]",
367
+ '#let infobox-title(t) = { text(size: 9pt, weight: "bold", tracking: 1.3pt, ' +
368
+ "fill: infobox-rule)[#upper(t)]; v(0.20em) }",
369
+ '#let infobox-head(t) = { v(0.40em); text(size: 7.6pt, weight: "bold", ' +
370
+ "tracking: 1.3pt, fill: infobox-rule)[#upper(t)]; v(0.16em) }",
371
+ "#let infobox-row(k, v) = grid(columns: (2.05cm, 1fr), column-gutter: 4pt, " +
372
+ "text(size: 7pt, tracking: 0.5pt, fill: infobox-faint)[#upper(k)], text(size: 8pt)[#v])",
373
+ "#let infobox-cell(lab, val) = align(center)[" +
374
+ "#text(size: 7pt, tracking: 0.6pt, fill: infobox-faint)[#lab]#h(2.5pt)" +
375
+ '#text(size: 8.6pt, weight: "bold")[#val]]',
376
+ "#let infobox-runin(lab, body) = block(below: 0.28em, breakable: false)[" +
377
+ "#set par(justify: false, first-line-indent: 0em, hanging-indent: 0.5cm, leading: 0.40em)\n" +
378
+ '#text(size: 7.8pt)[#if lab != "" [ #text(weight: "bold")[#lab: ] ]#body]]',
379
+ '#let infobox-statement(t) = text(size: 8pt, style: "italic", fill: infobox-faint)[#t]',
380
+ ].join("\n");
381
+ }