@heroiclands/package-build 20.4.0 → 20.6.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 (78) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/CONTENT.md +213 -20
  3. package/README.md +19 -1
  4. package/bin/content-build.mjs +135 -32
  5. package/bin/package-build.mjs +46 -13
  6. package/content-config.mjs +345 -101
  7. package/docs/api.md +1352 -0
  8. package/docs/commands.md +1609 -0
  9. package/docs/configuration.md +1432 -0
  10. package/docs/content-format.md +16 -6
  11. package/docs/diagnostics.md +356 -0
  12. package/docs/getting-started.md +813 -0
  13. package/docs/project-setup.md +469 -0
  14. package/engine/actor-compiler.mjs +30 -27
  15. package/engine/address-diff.mjs +45 -41
  16. package/engine/base-compiler.mjs +6 -0
  17. package/engine/bundles.mjs +9 -0
  18. package/engine/content-address.mjs +9 -9
  19. package/engine/content-index.mjs +44 -23
  20. package/engine/content-links.mjs +44 -11
  21. package/engine/content-lint.mjs +44 -10
  22. package/engine/content-tables.mjs +32 -27
  23. package/engine/folder-notes.mjs +4 -2
  24. package/engine/frontmatter-lint.mjs +35 -38
  25. package/engine/generate.mjs +5 -0
  26. package/engine/helpers.mjs +86 -32
  27. package/engine/index.mjs +12 -2
  28. package/engine/journals.mjs +9 -0
  29. package/engine/note-claims.mjs +18 -10
  30. package/engine/note-schemas.mjs +0 -5
  31. package/engine/note-vocabulary.mjs +32 -31
  32. package/engine/pack-config.mjs +26 -12
  33. package/engine/pack-router.mjs +0 -0
  34. package/engine/pdf-build.mjs +464 -0
  35. package/engine/pdf-fonts.mjs +420 -0
  36. package/engine/pdf-render.mjs +876 -0
  37. package/engine/pdf-toc.mjs +525 -0
  38. package/engine/scenes.mjs +14 -5
  39. package/engine/schema-check.mjs +1 -1
  40. package/engine/site-build.mjs +21 -3
  41. package/engine/web-wikilinks.mjs +6 -3
  42. package/engine/wikilinks.mjs +2 -4
  43. package/hm3/actors.mjs +8 -0
  44. package/hm3/items.mjs +8 -0
  45. package/package.json +1 -1
  46. package/release.mjs +63 -3
  47. package/sohl/actors.mjs +8 -0
  48. package/sohl/items.mjs +8 -0
  49. package/sohl/note-schemas.mjs +5 -5
  50. package/types/content-config.d.mts +66 -15
  51. package/types/engine/actor-compiler.d.mts +34 -30
  52. package/types/engine/address-diff.d.mts +57 -3
  53. package/types/engine/base-compiler.d.mts +10 -2
  54. package/types/engine/bundles.d.mts +9 -0
  55. package/types/engine/content-address.d.mts +9 -9
  56. package/types/engine/content-index.d.mts +57 -13
  57. package/types/engine/content-lint.d.mts +6 -4
  58. package/types/engine/content-tables.d.mts +49 -18
  59. package/types/engine/frontmatter-lint.d.mts +3 -2
  60. package/types/engine/helpers.d.mts +105 -31
  61. package/types/engine/index.d.mts +4 -0
  62. package/types/engine/journals.d.mts +9 -0
  63. package/types/engine/note-claims.d.mts +17 -10
  64. package/types/engine/note-vocabulary.d.mts +23 -196
  65. package/types/engine/pack-config.d.mts +4 -4
  66. package/types/engine/pdf-build.d.mts +42 -0
  67. package/types/engine/pdf-fonts.d.mts +30 -0
  68. package/types/engine/pdf-render.d.mts +156 -0
  69. package/types/engine/pdf-toc.d.mts +114 -0
  70. package/types/engine/scenes.d.mts +10 -1
  71. package/types/engine/schema-check.d.mts +2 -2
  72. package/types/engine/site-build.d.mts +34 -6
  73. package/types/engine/wikilinks.d.mts +2 -3
  74. package/types/hm3/actors.d.mts +8 -0
  75. package/types/hm3/items.d.mts +8 -0
  76. package/types/release.d.mts +15 -4
  77. package/types/sohl/actors.d.mts +10 -2
  78. package/types/sohl/items.d.mts +8 -0
@@ -0,0 +1,876 @@
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
+ * A note's markdown, and a document plan, rendered as Typst source.
16
+ *
17
+ * **This module emits text and reads nothing.** It takes markdown and a plan and
18
+ * returns a `.typ` document; the filesystem, the note bodies and the compiler
19
+ * that turns the result into a PDF all live in
20
+ * {@link module:engine/pdf-build}. That split is what lets the outline, the
21
+ * table of contents, every anchor and every link destination be asserted in a
22
+ * unit test with no renderer installed — which is most of what a book has to
23
+ * get right, and all of what a test can check without eyes.
24
+ *
25
+ * ## Why a token walk rather than markdown-it's renderer
26
+ *
27
+ * markdown-it renders to HTML by replacing string-producing rules, and two of
28
+ * the constructs a reference book leans on hardest — nested lists and tables —
29
+ * are *indentation*-significant in Typst markup and would have to be rebuilt
30
+ * from a flat stream of `_open`/`_close` strings anyway. Emitting Typst's
31
+ * **function** forms instead (`#list(…)`, `#table(…)`, `#link(…)[…]`) removes
32
+ * indentation from the problem completely: a list nested six deep inside a
33
+ * table cell is a nested call, and nothing about the surrounding whitespace can
34
+ * break it. So the token stream is walked directly.
35
+ *
36
+ * ## Links, and the one rule that decides them
37
+ *
38
+ * A wikilink is already resolved to a URL before this module sees it — by the
39
+ * same {@link module:engine/web-wikilinks} pass the site uses, so the two
40
+ * surfaces cannot disagree about where a link points. What differs is what a
41
+ * *book* does with the answer:
42
+ *
43
+ * - A URL whose address slug this document prints becomes an **internal**
44
+ * destination, `#link(<anchor>)`, because the reader has the page in their
45
+ * hand and sending them to a website for it would be absurd.
46
+ * {@link module:engine/pdf-toc.planDocument} supplies that map, and points
47
+ * every inbound link at the *first* printing of a note that appears twice.
48
+ * - Every other URL stays a URL: a cross-package link resolved through the link
49
+ * manifest, and a same-package note the book did not select, are both genuinely
50
+ * elsewhere.
51
+ *
52
+ * ## Icons
53
+ *
54
+ * `:icon-star-outline:` is parsed by the *same* {@link module:engine/content-icons.iconPlugin}
55
+ * the journals and the website use — one rule, three surfaces — and only the
56
+ * output differs. The glyph is resolved from the font file the consumer named,
57
+ * because the registry deliberately holds no codepoints; when no font is
58
+ * configured for an icon's family the name is set as literal text, which is the
59
+ * visible failure the registry was designed to produce.
60
+ *
61
+ * @module
62
+ */
63
+
64
+ import MarkdownIt from "markdown-it";
65
+
66
+ import { iconPlugin, ICON_PATTERN } from "./content-icons.mjs";
67
+ import { slugify } from "./content-slug.mjs";
68
+
69
+ /**
70
+ * Characters that mean something to Typst's markup parser.
71
+ *
72
+ * Conservative on purpose. Escaping a character that did not need it costs a
73
+ * backslash the reader never sees, where missing one turns a price list into a
74
+ * heading or swallows a paragraph into a function call. `-`, `+` and `/` are
75
+ * handled separately below, because they are only structural at the start of a
76
+ * line and escaping them mid-word would litter every hyphenated name in the
77
+ * corpus.
78
+ *
79
+ * @type {RegExp}
80
+ */
81
+ const TYPST_SPECIAL = /([\\#$*_@<>[\]~`"'])/g;
82
+
83
+ /**
84
+ * Escape literal text for Typst markup.
85
+ *
86
+ * @param {string} text - Text as the author wrote it.
87
+ * @returns {string} The same text, inert.
88
+ */
89
+ export function escapeTypst(text) {
90
+ return (
91
+ String(text ?? "")
92
+ .replace(TYPST_SPECIAL, "\\$1")
93
+ // Structural only at the head of a line: a list marker, a term, or a
94
+ // heading. `10' × 11'` must not become a bullet, and `e-mail` must not
95
+ // grow a backslash.
96
+ .replace(/^(\s*)([-+/=])/gm, "$1\\$2")
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Escape a string going inside Typst string quotes, as a `#link` URL does.
102
+ *
103
+ * @param {string} text - The raw value.
104
+ * @returns {string} The same value, quotable.
105
+ */
106
+ export function escapeTypstString(text) {
107
+ return String(text ?? "")
108
+ .replace(/\\/g, "\\\\")
109
+ .replace(/"/g, '\\"');
110
+ }
111
+
112
+ /**
113
+ * A Typst label, from a plan anchor.
114
+ *
115
+ * Typst labels admit a narrower charset than an anchor does, so anything else
116
+ * folds to a hyphen. The plan already guarantees anchors are unique, and a fold
117
+ * that merged two of them would silently give one destination two meanings —
118
+ * so the fold is injective by construction: only characters Typst rejects move,
119
+ * and they move to a character the slugifier never emits twice in a row.
120
+ *
121
+ * @param {string} anchor - The plan's anchor.
122
+ * @returns {string} A Typst label name.
123
+ */
124
+ export function labelFor(anchor) {
125
+ return (
126
+ String(anchor ?? "")
127
+ .replace(/[^A-Za-z0-9_-]+/g, "-")
128
+ .replace(/^-+|-+$/g, "") || "anchor"
129
+ );
130
+ }
131
+
132
+ /**
133
+ * A markdown-it configured to parse, not to render.
134
+ *
135
+ * `html: false` is the load-bearing setting: raw HTML in a note has no route to
136
+ * Typst at all, which is why {@link module:engine/content-html} reports it. With
137
+ * HTML disabled markdown-it emits the tag as text, so it arrives in the book
138
+ * visibly wrong rather than invisibly missing.
139
+ *
140
+ * @param {object} [registry] - The icon registry.
141
+ * @returns {object} A markdown-it instance.
142
+ */
143
+ export function createParser(registry) {
144
+ const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
145
+ md.use(iconPlugin(registry));
146
+ return md;
147
+ }
148
+
149
+ /**
150
+ * Render markdown as Typst content.
151
+ *
152
+ * @param {string} markdown - The note's body, tables expanded and links resolved.
153
+ * @param {object} [opts] - Options.
154
+ * @param {object} [opts.md] - A parser from {@link createParser}, reused across
155
+ * a whole book rather than rebuilt for each of 2,500 notes.
156
+ * @param {object} [opts.registry] - The icon registry, when no parser is passed.
157
+ * @param {Map<string, string>} [opts.links] - Address slug → plan anchor.
158
+ * @param {Map<string, string>} [opts.glyphs] - Icon name → `{font, char}`.
159
+ * @param {number} [opts.headingOffset] - Added to every heading level, so a
160
+ * note's own `##` nests beneath the entry heading the book gave it.
161
+ * @param {string} [opts.anchorPrefix] - The entry's anchor, which namespaces
162
+ * every `{#slug}` the body declares.
163
+ * @returns {string} Typst markup.
164
+ */
165
+ export function markdownToTypst(markdown, opts = {}) {
166
+ const {
167
+ md = createParser(opts.registry),
168
+ links = new Map(),
169
+ glyphs = new Map(),
170
+ headingOffset = 0,
171
+ anchorPrefix = "",
172
+ } = opts;
173
+ const tokens = md.parse(String(markdown ?? ""), {});
174
+ // One map for the whole body, not one per block: a heading inside a
175
+ // blockquote or a list item shares the entry's anchor namespace with every
176
+ // other heading in the same body, because `sectionLabel` scopes by entry
177
+ // rather than by container.
178
+ return renderTokens(tokens, { links, glyphs, headingOffset, anchorPrefix, seen: new Map() });
179
+ }
180
+
181
+ /**
182
+ * Walk a token stream, emitting Typst.
183
+ *
184
+ * @param {object[]} tokens - markdown-it tokens.
185
+ * @param {object} ctx - `{ links, glyphs, headingOffset }`.
186
+ * @returns {string} Typst markup.
187
+ */
188
+ function renderTokens(tokens, ctx) {
189
+ const out = [];
190
+ let i = 0;
191
+ while (i < tokens.length) {
192
+ const consumed = renderBlock(tokens, i, out, ctx);
193
+ i += consumed > 0 ? consumed : 1;
194
+ }
195
+ return out
196
+ .join("")
197
+ .replace(/\n{3,}/g, "\n\n")
198
+ .trim();
199
+ }
200
+
201
+ /**
202
+ * Render one block-level token and everything it encloses.
203
+ *
204
+ * @param {object[]} tokens - The stream.
205
+ * @param {number} i - Where to start.
206
+ * @param {string[]} out - Output accumulator.
207
+ * @param {object} ctx - Render context.
208
+ * @returns {number} How many tokens were consumed.
209
+ */
210
+ function renderBlock(tokens, i, out, ctx) {
211
+ const token = tokens[i];
212
+ switch (token.type) {
213
+ case "heading_open": {
214
+ // Typst caps headings at a depth no book reaches by accident; going
215
+ // past it would be a compile error in the middle of a 2,500-entry
216
+ // run, so it clamps and keeps setting.
217
+ const level = Math.max(1, Math.min(6, Number(token.tag.slice(1)) + ctx.headingOffset));
218
+ const inline = tokens[i + 1];
219
+ // `## Appearance {#appearance}` declares an addressable section. The
220
+ // journals compiler strips the suffix and surfaces it as an anchor;
221
+ // so does this, because a book that printed the braces would show
222
+ // every reader the markup that makes a link work.
223
+ const { text, anchor } = splitHeadingAnchor(inline, ctx);
224
+ // A heading with no authored anchor still needs a link target, so one
225
+ // is derived from its own text. `anchorFor` keeps it from colliding
226
+ // with an authored anchor, or with another derived one, that lands on
227
+ // the same words later in the same entry.
228
+ const base = anchor || slugify(plainHeadingText(inline)) || "heading";
229
+ const unique = anchorFor(base, ctx.seen);
230
+ const label = ` <${sectionLabel(ctx.anchorPrefix, unique)}>`;
231
+ // A body heading is never printed and never bookmarked — it is
232
+ // structure a reader reaches only by following a link, not a
233
+ // destination either outline offers on its own.
234
+ out.push(
235
+ `\n#heading(level: ${level}, outlined: false, bookmarked: false)[${text}]${label}\n\n`,
236
+ );
237
+ return 3;
238
+ }
239
+ case "paragraph_open": {
240
+ out.push(`\n${renderInline(tokens[i + 1], ctx)}\n\n`);
241
+ return 3;
242
+ }
243
+ case "fence":
244
+ case "code_block": {
245
+ out.push(rawBlock(token.content, token.info?.trim() || ""));
246
+ return 1;
247
+ }
248
+ case "hr":
249
+ out.push("\n#line(length: 100%, stroke: 0.4pt)\n\n");
250
+ return 1;
251
+ case "blockquote_open": {
252
+ const end = matching(tokens, i, "blockquote_open", "blockquote_close");
253
+ const inner = renderTokens(tokens.slice(i + 1, end), ctx);
254
+ out.push(`\n#quote(block: true)[${inner}]\n\n`);
255
+ return end - i + 1;
256
+ }
257
+ case "bullet_list_open":
258
+ case "ordered_list_open": {
259
+ const close =
260
+ token.type === "bullet_list_open" ? "bullet_list_close" : "ordered_list_close";
261
+ const end = matching(tokens, i, token.type, close);
262
+ const fn = token.type === "bullet_list_open" ? "list" : "enum";
263
+ const items = listItems(tokens, i + 1, end, ctx);
264
+ out.push(`\n#${fn}(${items.map((it) => `[${it}]`).join(", ")})\n\n`);
265
+ return end - i + 1;
266
+ }
267
+ case "table_open": {
268
+ const end = matching(tokens, i, "table_open", "table_close");
269
+ out.push(renderTable(tokens.slice(i, end + 1), ctx));
270
+ return end - i + 1;
271
+ }
272
+ case "inline":
273
+ out.push(renderInline(token, ctx));
274
+ return 1;
275
+ default:
276
+ return 1;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * A heading's text, and the `{#slug}` it may end with.
282
+ *
283
+ * The suffix is removed from the *rendered* children rather than from the raw
284
+ * source, so an anchor written inside emphasis or after a link still comes off
285
+ * cleanly and the text either side of it survives.
286
+ *
287
+ * @param {object} inline - The heading's `inline` token.
288
+ * @param {object} ctx - Render context.
289
+ * @returns {{text: string, anchor: string}} The heading, and its anchor or "".
290
+ */
291
+ function splitHeadingAnchor(inline, ctx) {
292
+ const last = inline?.children?.[(inline.children?.length ?? 0) - 1];
293
+ const raw = last?.type === "text" ? String(last.content ?? "") : "";
294
+ const match = /^(.*?)\s*\{#([^}]+)\}\s*$/.exec(raw);
295
+ if (!match) return { text: renderInline(inline, ctx), anchor: "" };
296
+ // Rendered with the suffix removed from a copy, so the token stream the
297
+ // caller owns is not mutated — the same tokens are walked again by the
298
+ // journals and the index.
299
+ const children = [...inline.children];
300
+ children[children.length - 1] = { ...last, content: match[1] };
301
+ return { text: renderInline({ ...inline, children }, ctx), anchor: match[2] };
302
+ }
303
+
304
+ /**
305
+ * A heading's text, unescaped and with any `{#anchor}` suffix still attached.
306
+ *
307
+ * Used only to derive an anchor when the author wrote none, so it wants the
308
+ * words as typed rather than the Typst-escaped, suffix-stripped text
309
+ * {@link splitHeadingAnchor} renders — {@link module:engine/content-slug.slugify}
310
+ * normalises punctuation and case itself and has no use for an escape
311
+ * backslash.
312
+ *
313
+ * @param {object} inline - The heading's `inline` token.
314
+ * @returns {string} The heading's raw text.
315
+ */
316
+ function plainHeadingText(inline) {
317
+ const children = inline?.children ?? [];
318
+ return children.map((child) => child.content ?? "").join("");
319
+ }
320
+
321
+ /**
322
+ * A unique anchor within one render pass, suffixed when the base repeats.
323
+ *
324
+ * A derived anchor is only as good as its uniqueness: two headings reading
325
+ * "Notes" in one entry, or a derived "description" landing on an author's own
326
+ * `{#description}`, would otherwise give one label two meanings. First use of
327
+ * a base anchor keeps it exactly as written or slugified; every later use in
328
+ * the same body is suffixed, in the order headings are walked — which is
329
+ * stable across rebuilds because the body's markdown is.
330
+ *
331
+ * @param {string} base - The preferred anchor.
332
+ * @param {Map<string, number>} seen - How many times each base has been used,
333
+ * scoped to one call to {@link markdownToTypst}.
334
+ * @returns {string} The anchor.
335
+ */
336
+ function anchorFor(base, seen) {
337
+ const n = (seen.get(base) ?? 0) + 1;
338
+ seen.set(base, n);
339
+ return n === 1 ? base : `${base}-${n}`;
340
+ }
341
+
342
+ /**
343
+ * A label for an anchor declared inside a note.
344
+ *
345
+ * Namespaced by the entry that carries it, because `{#appearance}` is written
346
+ * in hundreds of the 2,500 character notes and a bare label would give one
347
+ * destination hundreds of meanings — every inbound link landing on whichever
348
+ * Typst emitted last.
349
+ *
350
+ * @param {string} prefix - The entry's own anchor.
351
+ * @param {string} anchor - The anchor the heading declared.
352
+ * @returns {string} A document-unique label.
353
+ */
354
+ function sectionLabel(prefix, anchor) {
355
+ return labelFor(`${prefix ? `${prefix}--` : ""}${anchor}`);
356
+ }
357
+
358
+ /**
359
+ * The index of the token closing the one at `i`.
360
+ *
361
+ * @param {object[]} tokens - The stream.
362
+ * @param {number} i - The opening token's index.
363
+ * @param {string} open - The opening type.
364
+ * @param {string} close - The closing type.
365
+ * @returns {number} The closing token's index, or the stream's end.
366
+ */
367
+ function matching(tokens, i, open, close) {
368
+ let depth = 0;
369
+ for (let j = i; j < tokens.length; j += 1) {
370
+ if (tokens[j].type === open) depth += 1;
371
+ else if (tokens[j].type === close) {
372
+ depth -= 1;
373
+ if (depth === 0) return j;
374
+ }
375
+ }
376
+ return tokens.length - 1;
377
+ }
378
+
379
+ /**
380
+ * The rendered content of each item in a list.
381
+ *
382
+ * @param {object[]} tokens - The stream.
383
+ * @param {number} start - First token inside the list.
384
+ * @param {number} end - The list's closing token.
385
+ * @param {object} ctx - Render context.
386
+ * @returns {string[]} One rendered item per entry.
387
+ */
388
+ function listItems(tokens, start, end, ctx) {
389
+ const items = [];
390
+ let i = start;
391
+ while (i < end) {
392
+ if (tokens[i].type !== "list_item_open") {
393
+ i += 1;
394
+ continue;
395
+ }
396
+ const close = matching(tokens, i, "list_item_open", "list_item_close");
397
+ items.push(renderTokens(tokens.slice(i + 1, close), ctx));
398
+ i = close + 1;
399
+ }
400
+ return items;
401
+ }
402
+
403
+ /**
404
+ * A markdown table as a Typst `#table`.
405
+ *
406
+ * The header row is emitted through `table.header`, which is what makes it
407
+ * **repeat on every page a long table spills onto** — the property a roster of
408
+ * 2,500 entries needs most and the one a naive HTML-to-PDF pass loses. Column
409
+ * widths are left to Typst rather than computed here: it measures the content,
410
+ * and a width guessed from character counts is wrong the moment a face changes.
411
+ *
412
+ * @param {object[]} tokens - `table_open` through `table_close`.
413
+ * @param {object} ctx - Render context.
414
+ * @returns {string} Typst markup.
415
+ */
416
+ function renderTable(tokens, ctx) {
417
+ const rows = [];
418
+ let current = null;
419
+ let inHeader = false;
420
+ let headerRows = 0;
421
+ const aligns = [];
422
+
423
+ for (const token of tokens) {
424
+ switch (token.type) {
425
+ case "thead_open":
426
+ inHeader = true;
427
+ break;
428
+ case "thead_close":
429
+ inHeader = false;
430
+ break;
431
+ case "tr_open":
432
+ current = [];
433
+ break;
434
+ case "tr_close":
435
+ if (current) {
436
+ rows.push({ cells: current, header: inHeader });
437
+ if (inHeader) headerRows += 1;
438
+ }
439
+ current = null;
440
+ break;
441
+ case "th_open":
442
+ case "td_open": {
443
+ if (token.type === "th_open") {
444
+ const style = String(token.attrGet?.("style") ?? "");
445
+ aligns.push(
446
+ style.includes("right") ? "right"
447
+ : style.includes("center") ? "center"
448
+ : "left",
449
+ );
450
+ }
451
+ break;
452
+ }
453
+ case "inline":
454
+ if (current) current.push(renderInline(token, ctx));
455
+ break;
456
+ default:
457
+ break;
458
+ }
459
+ }
460
+
461
+ if (!rows.length) return "";
462
+ const columns = Math.max(...rows.map((r) => r.cells.length));
463
+ const alignment = aligns.length === columns ? `\n align: (${aligns.join(", ")}),` : "";
464
+ const body = rows
465
+ .filter((r) => !r.header)
466
+ .map((r) => ` ${padCells(r.cells, columns)},`)
467
+ .join("\n");
468
+ const header =
469
+ headerRows ?
470
+ `\n table.header(${padCells(
471
+ rows.filter((r) => r.header).flatMap((r) => r.cells),
472
+ columns,
473
+ )}),`
474
+ : "";
475
+ return `\n#table(\n columns: ${columns},${alignment}${header}\n${body}\n)\n\n`;
476
+ }
477
+
478
+ /**
479
+ * Cells as Typst content blocks, padded to the table's width.
480
+ *
481
+ * A short row is a real thing in authored markdown, and Typst counts cells
482
+ * rather than rows — one missing cell would shift every later row one column
483
+ * left for the rest of the table.
484
+ *
485
+ * @param {string[]} cells - Rendered cell contents.
486
+ * @param {number} columns - The table's column count.
487
+ * @returns {string} A comma-separated list of content blocks.
488
+ */
489
+ function padCells(cells, columns) {
490
+ const padded = [...cells];
491
+ while (padded.length < columns) padded.push("");
492
+ return padded.map((c) => `[${c}]`).join(", ");
493
+ }
494
+
495
+ /**
496
+ * A fenced block as Typst raw text.
497
+ *
498
+ * The fence is opened with more backticks than the content holds, so a note
499
+ * documenting a fenced block cannot terminate its own.
500
+ *
501
+ * @param {string} content - The block's text.
502
+ * @param {string} info - The language, when the fence declared one.
503
+ * @returns {string} Typst markup.
504
+ */
505
+ function rawBlock(content, info) {
506
+ const text = String(content ?? "").replace(/\n$/, "");
507
+ const longest = (text.match(/`+/g) ?? []).reduce((n, run) => Math.max(n, run.length), 0);
508
+ const ticks = "`".repeat(Math.max(3, longest + 1));
509
+ const lang = /^[A-Za-z0-9_+-]+$/.test(info) ? info : "";
510
+ return `\n${ticks}${lang}\n${text}\n${ticks}\n\n`;
511
+ }
512
+
513
+ /**
514
+ * Render an inline token's children.
515
+ *
516
+ * @param {object} token - An `inline` token.
517
+ * @param {object} ctx - Render context.
518
+ * @returns {string} Typst markup.
519
+ */
520
+ function renderInline(token, ctx) {
521
+ const children = token?.children ?? [];
522
+ const out = [];
523
+ for (let i = 0; i < children.length; i += 1) {
524
+ const child = children[i];
525
+ switch (child.type) {
526
+ case "text":
527
+ out.push(escapeTypst(child.content));
528
+ break;
529
+ case "softbreak":
530
+ out.push("\n");
531
+ break;
532
+ case "hardbreak":
533
+ out.push(" \\\n");
534
+ break;
535
+ case "code_inline":
536
+ out.push(inlineRaw(child.content));
537
+ break;
538
+ case "strong_open":
539
+ out.push("#strong[");
540
+ break;
541
+ case "em_open":
542
+ out.push("#emph[");
543
+ break;
544
+ case "s_open":
545
+ out.push("#strike[");
546
+ break;
547
+ case "strong_close":
548
+ case "em_close":
549
+ case "s_close":
550
+ out.push("]");
551
+ break;
552
+ case "heroiclands_icon":
553
+ out.push(renderIcon(child, ctx));
554
+ break;
555
+ case "link_open": {
556
+ const close = childMatching(children, i, "link_open", "link_close");
557
+ const inner = renderInline({ children: children.slice(i + 1, close) }, ctx);
558
+ out.push(renderLink(child.attrGet?.("href") ?? "", inner, ctx));
559
+ i = close;
560
+ break;
561
+ }
562
+ case "image": {
563
+ // An image has no route into a book that does not also carry the
564
+ // file, and the asset tree is not this pass's to resolve. The
565
+ // alt text is what the note said the picture was for.
566
+ const alt = child.content || child.attrGet?.("alt") || "";
567
+ if (alt) out.push(`#emph[${escapeTypst(alt)}]`);
568
+ break;
569
+ }
570
+ default:
571
+ if (child.content) out.push(escapeTypst(child.content));
572
+ break;
573
+ }
574
+ }
575
+ return out.join("");
576
+ }
577
+
578
+ /**
579
+ * The index of the inline token closing the one at `i`.
580
+ *
581
+ * @param {object[]} children - Inline children.
582
+ * @param {number} i - The opening token's index.
583
+ * @param {string} open - The opening type.
584
+ * @param {string} close - The closing type.
585
+ * @returns {number} The closing index, or the last child.
586
+ */
587
+ function childMatching(children, i, open, close) {
588
+ let depth = 0;
589
+ for (let j = i; j < children.length; j += 1) {
590
+ if (children[j].type === open) depth += 1;
591
+ else if (children[j].type === close) {
592
+ depth -= 1;
593
+ if (depth === 0) return j;
594
+ }
595
+ }
596
+ return children.length - 1;
597
+ }
598
+
599
+ /**
600
+ * Inline code as Typst raw.
601
+ *
602
+ * @param {string} content - The code.
603
+ * @returns {string} Typst markup.
604
+ */
605
+ function inlineRaw(content) {
606
+ const text = String(content ?? "");
607
+ const longest = (text.match(/`+/g) ?? []).reduce((n, run) => Math.max(n, run.length), 0);
608
+ const ticks = "`".repeat(Math.max(1, longest + 1));
609
+ return `${ticks}${text}${ticks}`;
610
+ }
611
+
612
+ /**
613
+ * A link, internal when the book prints its destination and external otherwise.
614
+ *
615
+ * The address slug is read from the tail of the URL, which is where every
616
+ * address this toolchain publishes puts it — `…/<type>-<shortcode>/`. A
617
+ * cross-package URL resolved through the link manifest is on another package's
618
+ * base and cannot collide, and a same-package note the book did not select is
619
+ * genuinely on the website rather than in the reader's hand.
620
+ *
621
+ * @param {string} href - The resolved URL.
622
+ * @param {string} inner - The already-rendered link text.
623
+ * @param {object} ctx - Render context.
624
+ * @returns {string} Typst markup.
625
+ */
626
+ function renderLink(href, inner, ctx) {
627
+ const url = String(href ?? "");
628
+ const fragment = /#([^?]*)/.exec(url)?.[1] ?? "";
629
+ const slug =
630
+ url
631
+ .replace(/[#?].*$/, "")
632
+ .replace(/\/+$/, "")
633
+ .split("/")
634
+ .pop() ?? "";
635
+ const anchor = ctx.links.get(slug);
636
+ if (anchor) {
637
+ // `[[note#appearance]]` reaches the section, not just the entry — the
638
+ // same namespaced label the heading declared.
639
+ const target = fragment ? sectionLabel(anchor, fragment) : labelFor(anchor);
640
+ return `#link(<${target}>)[${inner}]`;
641
+ }
642
+ if (!url) return inner;
643
+ return `#link("${escapeTypstString(url)}")[${inner}]`;
644
+ }
645
+
646
+ /**
647
+ * One icon, as its glyph when a font carries it and as its name otherwise.
648
+ *
649
+ * @param {object} token - A `heroiclands_icon` token.
650
+ * @param {object} ctx - Render context.
651
+ * @returns {string} Typst markup.
652
+ */
653
+ function renderIcon(token, ctx) {
654
+ const name = token?.meta?.name ?? "";
655
+ const glyph = ctx.glyphs.get(name);
656
+ if (!glyph) return escapeTypst(`:icon-${name}:`);
657
+ return `#text(font: "${escapeTypstString(glyph.font)}")[\\u{${glyph.codepoint.toString(16)}}]`;
658
+ }
659
+
660
+ /**
661
+ * The whole book, as one Typst document.
662
+ *
663
+ * **Pure, and that is the point.** Everything a reviewer of #316 has to check
664
+ * about structure — the outline's shape, the anchors, which links went inward,
665
+ * the order entries print in — is decided here from a plan and a map of bodies,
666
+ * with no filesystem and no compiler. {@link module:engine/pdf-build} supplies
667
+ * both and runs Typst over the result.
668
+ *
669
+ * ## Three surfaces, one heading tree
670
+ *
671
+ * A roster of 2,500 entries wants every entry reachable from a viewer's
672
+ * sidebar, and emphatically does not want all 2,500 printed in the front
673
+ * matter: that is forty pages of contents before the book starts. It also
674
+ * wants every heading in a note's own body to keep working as a link target,
675
+ * without appearing on either surface — the anchor an author writes for
676
+ * `[[note#appearance]]` is structure, not a destination either outline offers
677
+ * on its own.
678
+ *
679
+ * `heading` carries `outlined` and `bookmarked` independently, so the three
680
+ * wants are three settings rather than three passes:
681
+ *
682
+ * - A **section** — `outlined: true, bookmarked: true` — prints in the paper
683
+ * contents and the PDF sidebar alike.
684
+ * - A **note leaf**, titled from `name.full`, is `outlined: false,
685
+ * bookmarked: true`: reachable from the sidebar, absent from the printed
686
+ * contents.
687
+ * - A **body heading**, inside a note's own markdown, is `outlined: false,
688
+ * bookmarked: false`: a real heading with a label, so it still supplies a
689
+ * link target, a running head and a page break, but neither outline lists
690
+ * it. {@link markdownToTypst} emits these.
691
+ *
692
+ * `#outline()` needs no depth limit under this model: what prints is decided
693
+ * per heading, not by how deep the tree happens to go.
694
+ *
695
+ * ## Headings carry the structure, so nothing else has to
696
+ *
697
+ * Every section, every prose file and every entry is a real Typst heading at
698
+ * its plan depth. That single decision supplies both outlines, the running
699
+ * heads and the page breaks at once — where drawing titles as styled text
700
+ * would have meant building all four by hand and keeping them agreeing with
701
+ * each other.
702
+ *
703
+ * @param {object} opts - Options.
704
+ * @param {object} opts.plan - From {@link module:engine/pdf-toc.planDocument}.
705
+ * @param {Map<string, string>} opts.bodies - Anchor → the entry's rendered
706
+ * Typst body. An entry with no body prints its heading alone.
707
+ * @param {string} opts.title - The document's title.
708
+ * @param {string} [opts.subtitle] - Shown under it on the title page.
709
+ * @param {string[]} [opts.front] - Rendered Typst for each front-matter file.
710
+ * @param {object} [opts.fonts] - `{ serif, sans, mono }` family names.
711
+ * @param {string} [opts.version] - Stamped on the title page when given.
712
+ * @returns {string} A complete `.typ` document.
713
+ */
714
+ export function renderBook({
715
+ plan,
716
+ bodies = new Map(),
717
+ title,
718
+ subtitle = "",
719
+ front = [],
720
+ fonts = {},
721
+ version = "",
722
+ } = {}) {
723
+ const serif = fonts.serif || "Libertinus Serif";
724
+ const sans = fonts.sans || serif;
725
+ const mono = fonts.mono || "DejaVu Sans Mono";
726
+ const out = [];
727
+
728
+ out.push(`#set document(title: "${escapeTypstString(title)}")`);
729
+ out.push('#set page(paper: "us-letter", margin: (x: 2.2cm, y: 2.4cm), numbering: "1")');
730
+ out.push(`#set text(font: "${escapeTypstString(serif)}", size: 10pt, lang: "en")`);
731
+ out.push("#set par(justify: true, leading: 0.65em)");
732
+ // The mono face is a separate claim from the book face: a fenced block is
733
+ // the one place the corpus is allowed box-drawing characters, and the
734
+ // serif that sets the prose is not the font that carries them.
735
+ out.push(`#show raw: set text(font: "${escapeTypstString(mono)}")`);
736
+ out.push(`#show heading: set text(font: "${escapeTypstString(sans)}")`);
737
+ // A link the reader can see is the difference between a cross-reference and
738
+ // a sentence that happens to mention something.
739
+ out.push('#show link: set text(fill: rgb("#1b4d7a"))');
740
+ // Tables are the shape most of this corpus is in, so their defaults are the
741
+ // book's defaults: a header that repeats on every page a long table spills
742
+ // onto, and rules light enough not to fight the text.
743
+ out.push("#set table(stroke: (x, y) => (top: 0.4pt, bottom: 0.4pt), inset: 5pt)");
744
+ out.push("#show table.cell.where(y: 0): strong");
745
+ out.push("");
746
+
747
+ // Title page.
748
+ out.push("#align(center + horizon)[");
749
+ out.push(` #text(size: 30pt, weight: "bold")[${escapeTypst(title)}]`);
750
+ if (subtitle) {
751
+ out.push(" #v(0.6em)");
752
+ out.push(` #text(size: 15pt)[${escapeTypst(subtitle)}]`);
753
+ }
754
+ if (version) {
755
+ out.push(" #v(2em)");
756
+ out.push(` #text(size: 10pt)[${escapeTypst(version)}]`);
757
+ }
758
+ out.push("]");
759
+ out.push("#pagebreak()");
760
+ out.push("");
761
+
762
+ for (const piece of front) {
763
+ if (!piece?.trim()) continue;
764
+ out.push(piece);
765
+ out.push("#pagebreak()");
766
+ out.push("");
767
+ }
768
+
769
+ // No `depth:` limit: what prints is decided per heading by `outlined`,
770
+ // below, not by how deep the plan's tree happens to go.
771
+ out.push("#outline(title: [Contents])");
772
+ out.push("#pagebreak()");
773
+ out.push("");
774
+
775
+ for (const entry of plan?.entries ?? []) {
776
+ const label = labelFor(entry.anchor);
777
+ const depth = Math.min(6, Math.max(1, Number(entry.depth) || 1));
778
+ if (entry.kind === "section") {
779
+ // A declared `sectionName:` — the structure the printed contents
780
+ // shows and the bookmarks panel shows alongside it.
781
+ out.push(
782
+ `#heading(level: ${depth}, outlined: true, bookmarked: true)` +
783
+ `[${escapeTypst(entry.title)}] <${label}>`,
784
+ );
785
+ out.push("");
786
+ continue;
787
+ }
788
+ if (entry.kind === "prose") {
789
+ // Prose carries no title of its own — its headings are its own. The
790
+ // label goes on a zero-width marker so the contents and any inbound
791
+ // link still have somewhere to land.
792
+ out.push(`#metadata(none) <${label}>`);
793
+ out.push(bodies.get(entry.anchor) ?? "");
794
+ out.push("");
795
+ continue;
796
+ }
797
+ // A note leaf: reachable from the bookmarks panel, titled from
798
+ // `name.full`, and never printed in the paper contents.
799
+ const name = entry.record?.name?.full ?? entry.record?.address?.slug ?? "(untitled)";
800
+ out.push(
801
+ `#heading(level: ${Math.min(6, depth + 1)}, outlined: false, bookmarked: true)` +
802
+ `[${escapeTypst(name)}] <${label}>`,
803
+ );
804
+ out.push("");
805
+ const body = bodies.get(entry.anchor);
806
+ if (body) {
807
+ out.push(body);
808
+ out.push("");
809
+ }
810
+ }
811
+
812
+ return `${out.join("\n")}\n`;
813
+ }
814
+
815
+ /**
816
+ * Point every internal link at a label the document actually declares.
817
+ *
818
+ * **Typst refuses to compile a reference to a label that is not there.** That
819
+ * makes one mistyped `[[note#appearance]]`, or an anchor written inside a code
820
+ * fence where no heading is emitted, fatal to a 1,200-page book — and fatal at
821
+ * the very end, after everything else has succeeded. A reference book cannot
822
+ * have that failure mode: the link is the least important thing on the page and
823
+ * would be taking the other two thousand entries down with it.
824
+ *
825
+ * So references are reconciled against declarations before the source is
826
+ * written. A link to a section that does not exist falls back to the **entry**
827
+ * that would have contained it, which is where a reader wants to end up anyway;
828
+ * a link with no entry to fall back to becomes plain text. Both are reported.
829
+ *
830
+ * A declaration is a label not preceded by `#link(` — the only two places a
831
+ * label appears are the heading that declares one and the link that uses one.
832
+ *
833
+ * @param {string} source - The assembled Typst document.
834
+ * @param {object[]} [findings] - Collected here rather than thrown.
835
+ * @returns {string} The same document, with no reference left dangling.
836
+ */
837
+ export function resolveDanglingLabels(source, findings = []) {
838
+ const text = String(source ?? "");
839
+ const declared = new Set();
840
+ for (const match of text.matchAll(/(?<!#link\()<([A-Za-z0-9_-]+)>/g)) declared.add(match[1]);
841
+
842
+ return text.replace(/#link\(<([A-Za-z0-9_-]+)>\)/g, (whole, label) => {
843
+ if (declared.has(label)) return whole;
844
+ // `entry--section` falls back to `entry`: the section is missing, the
845
+ // entry is the page the reader was being sent to.
846
+ const entry = label.includes("--") ? label.slice(0, label.indexOf("--")) : "";
847
+ if (entry && declared.has(entry)) {
848
+ findings.push({
849
+ severity: "warning",
850
+ message:
851
+ `a link to \`${label}\` found no such section in the book, ` +
852
+ `so it points at \`${entry}\` instead`,
853
+ });
854
+ return `#link(<${entry}>)`;
855
+ }
856
+ findings.push({
857
+ severity: "warning",
858
+ message: `a link to \`${label}\` found no such destination in the book, so it is set as plain text`,
859
+ });
860
+ // `#link(…)[text]` becomes `#box[text]`: the words survive, the
861
+ // reference does not, and nothing is silently deleted from the page.
862
+ return "#box";
863
+ });
864
+ }
865
+
866
+ /**
867
+ * Every icon name a body uses, so a build can resolve them once.
868
+ *
869
+ * @param {string} markdown - A note body.
870
+ * @returns {string[]} The names, in order of appearance, with repeats.
871
+ */
872
+ export function iconNamesIn(markdown) {
873
+ const names = [];
874
+ for (const match of String(markdown ?? "").matchAll(ICON_PATTERN)) names.push(match[1]);
875
+ return names;
876
+ }