@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.
- package/CHANGELOG.md +288 -0
- package/CONTENT.md +213 -20
- package/README.md +19 -1
- package/bin/content-build.mjs +135 -32
- package/bin/package-build.mjs +46 -13
- package/content-config.mjs +345 -101
- package/docs/api.md +1352 -0
- package/docs/commands.md +1609 -0
- package/docs/configuration.md +1432 -0
- package/docs/content-format.md +16 -6
- package/docs/diagnostics.md +356 -0
- package/docs/getting-started.md +813 -0
- package/docs/project-setup.md +469 -0
- package/engine/actor-compiler.mjs +30 -27
- package/engine/address-diff.mjs +45 -41
- package/engine/base-compiler.mjs +6 -0
- package/engine/bundles.mjs +9 -0
- package/engine/content-address.mjs +9 -9
- package/engine/content-index.mjs +44 -23
- package/engine/content-links.mjs +44 -11
- package/engine/content-lint.mjs +44 -10
- package/engine/content-tables.mjs +32 -27
- package/engine/folder-notes.mjs +4 -2
- package/engine/frontmatter-lint.mjs +35 -38
- package/engine/generate.mjs +5 -0
- package/engine/helpers.mjs +86 -32
- package/engine/index.mjs +12 -2
- package/engine/journals.mjs +9 -0
- package/engine/note-claims.mjs +18 -10
- package/engine/note-schemas.mjs +0 -5
- package/engine/note-vocabulary.mjs +32 -31
- package/engine/pack-config.mjs +26 -12
- package/engine/pack-router.mjs +0 -0
- package/engine/pdf-build.mjs +464 -0
- package/engine/pdf-fonts.mjs +420 -0
- package/engine/pdf-render.mjs +876 -0
- package/engine/pdf-toc.mjs +525 -0
- package/engine/scenes.mjs +14 -5
- package/engine/schema-check.mjs +1 -1
- package/engine/site-build.mjs +21 -3
- package/engine/web-wikilinks.mjs +6 -3
- package/engine/wikilinks.mjs +2 -4
- package/hm3/actors.mjs +8 -0
- package/hm3/items.mjs +8 -0
- package/package.json +1 -1
- package/release.mjs +63 -3
- package/sohl/actors.mjs +8 -0
- package/sohl/items.mjs +8 -0
- package/sohl/note-schemas.mjs +5 -5
- package/types/content-config.d.mts +66 -15
- package/types/engine/actor-compiler.d.mts +34 -30
- package/types/engine/address-diff.d.mts +57 -3
- package/types/engine/base-compiler.d.mts +10 -2
- package/types/engine/bundles.d.mts +9 -0
- package/types/engine/content-address.d.mts +9 -9
- package/types/engine/content-index.d.mts +57 -13
- package/types/engine/content-lint.d.mts +6 -4
- package/types/engine/content-tables.d.mts +49 -18
- package/types/engine/frontmatter-lint.d.mts +3 -2
- package/types/engine/helpers.d.mts +105 -31
- package/types/engine/index.d.mts +4 -0
- package/types/engine/journals.d.mts +9 -0
- package/types/engine/note-claims.d.mts +17 -10
- package/types/engine/note-vocabulary.d.mts +23 -196
- package/types/engine/pack-config.d.mts +4 -4
- package/types/engine/pdf-build.d.mts +42 -0
- package/types/engine/pdf-fonts.d.mts +30 -0
- package/types/engine/pdf-render.d.mts +156 -0
- package/types/engine/pdf-toc.d.mts +114 -0
- package/types/engine/scenes.d.mts +10 -1
- package/types/engine/schema-check.d.mts +2 -2
- package/types/engine/site-build.d.mts +34 -6
- package/types/engine/wikilinks.d.mts +2 -3
- package/types/hm3/actors.d.mts +8 -0
- package/types/hm3/items.d.mts +8 -0
- package/types/release.d.mts +15 -4
- package/types/sohl/actors.d.mts +10 -2
- 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
|
+
}
|