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