@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,464 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The content tree, built into a book.
|
|
16
|
+
*
|
|
17
|
+
* The I/O half of the PDF surface: it reads the configuration, the document
|
|
18
|
+
* tree and the notes, drives the passes the site build already owns, hands the
|
|
19
|
+
* result to {@link module:engine/pdf-render} and runs Typst over what comes
|
|
20
|
+
* back. Everything about *what the book says* is decided in the pure half; this
|
|
21
|
+
* module is where the filesystem and the compiler live.
|
|
22
|
+
*
|
|
23
|
+
* ## The same passes, deliberately
|
|
24
|
+
*
|
|
25
|
+
* A reader who finds the book and the website describing the tree differently
|
|
26
|
+
* has found a bug in one of them, so the book does not get its own walk, its
|
|
27
|
+
* own frontmatter reader or its own link resolver. It collects pages with
|
|
28
|
+
* {@link module:engine/site-build.collectContentPages}, indexes them with
|
|
29
|
+
* `siteGates`, expands tables with `expandContentTables` and resolves links
|
|
30
|
+
* with `resolveWebWikilinks` — the same four calls, in the same order, that
|
|
31
|
+
* `renderPages` makes. What differs is only the last step and the *selection*:
|
|
32
|
+
* the site publishes every page, and the book publishes what its document tree
|
|
33
|
+
* asked for.
|
|
34
|
+
*
|
|
35
|
+
* ## `publish.site` is the switch, and it is the only switch
|
|
36
|
+
*
|
|
37
|
+
* `homepage` mode fences the content surfaces off: the tree is not walked and
|
|
38
|
+
* `sections`, `trees` and `landing` emit nothing however they are declared. A
|
|
39
|
+
* PDF of the content tree is a content surface by any reading — arguably the
|
|
40
|
+
* most portable one there is — so it is fenced on exactly the same terms, by
|
|
41
|
+
* asking the same {@link module:content-config.publishesContentPages} the
|
|
42
|
+
* site build asks. Four of the six packages that would adopt this run
|
|
43
|
+
* `homepage`, and a PDF that appeared there would breach the fence silently:
|
|
44
|
+
* nothing in those packages' configuration would say so. So the fence is
|
|
45
|
+
* checked **before** the tree is read, and the command says why it built
|
|
46
|
+
* nothing rather than emitting an empty document.
|
|
47
|
+
*
|
|
48
|
+
* ## Every gate reports; none exits
|
|
49
|
+
*
|
|
50
|
+
* A filter that selects nothing, a `file:` that resolves nowhere, a table that
|
|
51
|
+
* will not run and a missing Typst binary are all findings handed back to the
|
|
52
|
+
* caller. `bin/content-build.mjs` decides what to do about them — which is the
|
|
53
|
+
* rule the whole engine is built on, and what lets one pass serve a lint, a
|
|
54
|
+
* build step and a unit test.
|
|
55
|
+
*
|
|
56
|
+
* ## Typst is a binary, not a dependency
|
|
57
|
+
*
|
|
58
|
+
* The compiler is invoked as an external program, found on `PATH` or named by
|
|
59
|
+
* `pdf.binary`. The alternative — a native npm package — would put a
|
|
60
|
+
* platform-specific compiled artefact into the dependency tree of three
|
|
61
|
+
* repositories, only one of which is mostly a book, and would have to resolve
|
|
62
|
+
* on every consumer's CI runner before any of them could install the
|
|
63
|
+
* toolchain at all. A missing binary is reported as a finding and leaves the
|
|
64
|
+
* `.typ` source on disk, which is both the diagnostic and the thing a consumer
|
|
65
|
+
* can compile by hand.
|
|
66
|
+
*
|
|
67
|
+
* @module
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import fs from "node:fs";
|
|
71
|
+
import os from "node:os";
|
|
72
|
+
import path from "node:path";
|
|
73
|
+
import { spawnSync } from "node:child_process";
|
|
74
|
+
|
|
75
|
+
import YAML from "yaml";
|
|
76
|
+
|
|
77
|
+
import { loadPackConfig } from "./pack-config.mjs";
|
|
78
|
+
import { publishesContentPages } from "../content-config.mjs";
|
|
79
|
+
import { indexRecordsFor } from "./content-index.mjs";
|
|
80
|
+
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
81
|
+
import { openNotesDatabase, prepareTreeSqlTables } from "./sql-tables.mjs";
|
|
82
|
+
import { parseDocumentTree, runTreeFilters, planDocument } from "./pdf-toc.mjs";
|
|
83
|
+
import { collectContentPages, siteGates, tableUniverse, gatesFailed } from "./site-build.mjs";
|
|
84
|
+
import { wikiContext } from "./site-index.mjs";
|
|
85
|
+
import { resolveWebWikilinks } from "./web-wikilinks.mjs";
|
|
86
|
+
import { expandContentTables } from "./content-tables.mjs";
|
|
87
|
+
import { protectCode } from "./code-fences.mjs";
|
|
88
|
+
import { createParser, markdownToTypst, renderBook, resolveDanglingLabels } from "./pdf-render.mjs";
|
|
89
|
+
import { resolveIconGlyphs } from "./pdf-fonts.mjs";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The file name a downloaded book identifies itself by.
|
|
93
|
+
*
|
|
94
|
+
* The zip and the manifest take their names from the manifest, so an asset's
|
|
95
|
+
* name and its advertised URL cannot disagree. A PDF has no advertised URL, so
|
|
96
|
+
* its name is a free choice — which is exactly why it is fixed here rather than
|
|
97
|
+
* left for each of six consumers to invent.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} artifact - The package's artifact name.
|
|
100
|
+
* @param {string} version - The version being released.
|
|
101
|
+
* @returns {string} `<artifact>-<version>.pdf`, or `<artifact>.pdf` unversioned.
|
|
102
|
+
*/
|
|
103
|
+
export function pdfFileName(artifact, version) {
|
|
104
|
+
const safe = String(artifact || "content").replace(/[^\w.-]+/g, "-");
|
|
105
|
+
return version ? `${safe}-${version}.pdf` : `${safe}.pdf`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read and parse the declared document tree.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} file - Absolute path to the document tree.
|
|
112
|
+
* @returns {{raw: unknown, text: string, findings: object[]}} What it held.
|
|
113
|
+
*/
|
|
114
|
+
function readDocumentTree(file) {
|
|
115
|
+
let text;
|
|
116
|
+
try {
|
|
117
|
+
text = fs.readFileSync(file, "utf8");
|
|
118
|
+
} catch {
|
|
119
|
+
return {
|
|
120
|
+
raw: null,
|
|
121
|
+
text: "",
|
|
122
|
+
findings: [
|
|
123
|
+
{
|
|
124
|
+
file,
|
|
125
|
+
severity: "error",
|
|
126
|
+
message: "the document tree named by `pdf.document` cannot be read",
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return { raw: YAML.parse(text), text, findings: [] };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return {
|
|
135
|
+
raw: null,
|
|
136
|
+
text,
|
|
137
|
+
findings: [
|
|
138
|
+
{
|
|
139
|
+
file,
|
|
140
|
+
severity: "error",
|
|
141
|
+
message: `the document tree is not readable YAML: ${err.message}`,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build the book.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} [opts] - Options.
|
|
152
|
+
* @param {object} [opts.config] - A resolved configuration; loaded when absent.
|
|
153
|
+
* @param {string} [opts.out] - Where to write, overriding `pdf.out`.
|
|
154
|
+
* @param {string} [opts.version] - Stamped on the title page and the file name.
|
|
155
|
+
* @param {boolean} [opts.compile] - Whether to run Typst. False leaves the
|
|
156
|
+
* `.typ` source, which is what the unit tests read.
|
|
157
|
+
* @returns {Promise<object>} `{ built, reason, findings, typ, pdf, stats }`.
|
|
158
|
+
*/
|
|
159
|
+
export async function buildPdf({ config, out, version = "", compile = true } = {}) {
|
|
160
|
+
const resolved = config ?? loadPackConfig();
|
|
161
|
+
const findings = [];
|
|
162
|
+
|
|
163
|
+
// The fence, first — before the tree is read, before anything is walked.
|
|
164
|
+
if (!publishesContentPages(resolved)) {
|
|
165
|
+
return {
|
|
166
|
+
built: false,
|
|
167
|
+
reason:
|
|
168
|
+
"`publish.site` is `homepage`, which fences the content surfaces off — " +
|
|
169
|
+
"the tree is not walked and no book is built. Publish content to build one.",
|
|
170
|
+
findings,
|
|
171
|
+
typ: null,
|
|
172
|
+
pdf: null,
|
|
173
|
+
stats: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (!resolved.pdf) {
|
|
177
|
+
return {
|
|
178
|
+
built: false,
|
|
179
|
+
reason: "no `pdf:` block is configured, so this package publishes no book",
|
|
180
|
+
findings,
|
|
181
|
+
typ: null,
|
|
182
|
+
pdf: null,
|
|
183
|
+
stats: null,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const contentBase = resolved.paths.content;
|
|
188
|
+
if (!fs.existsSync(contentBase)) {
|
|
189
|
+
return {
|
|
190
|
+
built: false,
|
|
191
|
+
reason: "this package has no content tree, so there is nothing to print",
|
|
192
|
+
findings,
|
|
193
|
+
typ: null,
|
|
194
|
+
pdf: null,
|
|
195
|
+
stats: null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const tree = readDocumentTree(resolved.pdf.document);
|
|
200
|
+
findings.push(...tree.findings);
|
|
201
|
+
if (!tree.raw) {
|
|
202
|
+
return { built: false, reason: null, findings, typ: null, pdf: null, stats: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const parsed = parseDocumentTree(tree.raw, { text: tree.text });
|
|
206
|
+
findings.push(...parsed.findings.map((f) => ({ file: resolved.pdf.document, ...f })));
|
|
207
|
+
|
|
208
|
+
// The corpus, once — the same records the index, the site and the SQL
|
|
209
|
+
// tables read, so the book cannot select a note the other surfaces do not
|
|
210
|
+
// have.
|
|
211
|
+
const records = indexRecordsFor({
|
|
212
|
+
contentBase,
|
|
213
|
+
config: resolved,
|
|
214
|
+
skipDirectories: resolved.skipDirectories,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "content-pdf-"));
|
|
218
|
+
const db = await openNotesDatabase(records, { dir });
|
|
219
|
+
const ran = await runTreeFilters(parsed.nodes, db, {
|
|
220
|
+
keep: isNoteRecord,
|
|
221
|
+
text: tree.text,
|
|
222
|
+
});
|
|
223
|
+
findings.push(...ran.findings.map((f) => ({ file: resolved.pdf.document, ...f })));
|
|
224
|
+
|
|
225
|
+
const plan = planDocument(parsed.nodes, { selections: ran.selections });
|
|
226
|
+
|
|
227
|
+
// Pages, indexed and gated exactly as the site does. A book of a tree whose
|
|
228
|
+
// addresses collide would print the wrong entry under the right name.
|
|
229
|
+
const scheme = resolved.publish.address;
|
|
230
|
+
const base = resolved.site.base || `/${resolved.contentPackage}/`;
|
|
231
|
+
const ctx = {
|
|
232
|
+
packages: new Set(
|
|
233
|
+
resolved.site.packages.length ? resolved.site.packages : [resolved.contentPackage],
|
|
234
|
+
),
|
|
235
|
+
contentPackage: resolved.contentPackage,
|
|
236
|
+
skipDirectories: resolved.skipDirectories,
|
|
237
|
+
config: resolved,
|
|
238
|
+
records,
|
|
239
|
+
base,
|
|
240
|
+
mount: `${base}${scheme.prefix}`,
|
|
241
|
+
scheme,
|
|
242
|
+
};
|
|
243
|
+
const collected = collectContentPages(contentBase, ctx);
|
|
244
|
+
const gates = siteGates(collected.pages, collected, { config: resolved });
|
|
245
|
+
if (gatesFailed(gates)) {
|
|
246
|
+
for (const [name, list] of Object.entries(gates)) {
|
|
247
|
+
if (!Array.isArray(list)) continue;
|
|
248
|
+
for (const f of list) {
|
|
249
|
+
findings.push({
|
|
250
|
+
file: f.file ?? contentBase,
|
|
251
|
+
severity: "error",
|
|
252
|
+
message: f.message ?? `${name}: ${JSON.stringify(f)}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { built: false, reason: null, findings, typ: null, pdf: null, stats: null };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const byFile = new Map(collected.pages.map((page) => [page.file, page]));
|
|
260
|
+
const universe = tableUniverse(collected.pages);
|
|
261
|
+
const sqlTables = await prepareTreeSqlTables(contentBase, {
|
|
262
|
+
config: resolved,
|
|
263
|
+
skipDirectories: resolved.skipDirectories,
|
|
264
|
+
records,
|
|
265
|
+
});
|
|
266
|
+
const md = createParser(resolved.icons);
|
|
267
|
+
const glyphs = resolveIconGlyphs(resolved.icons, resolved.pdf.iconFonts, findings);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* One note's markdown, through the same passes the site runs.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} page - A collected page.
|
|
273
|
+
* @param {number} headingOffset - Where the book put this entry.
|
|
274
|
+
* @param {string} anchorPrefix - The entry's anchor, namespacing its sections.
|
|
275
|
+
* @returns {string} Typst markup.
|
|
276
|
+
*/
|
|
277
|
+
const renderPage = (page, headingOffset, anchorPrefix) => {
|
|
278
|
+
const src = page.relPath ?? page.rel ?? page.base;
|
|
279
|
+
const wikiErrors = [];
|
|
280
|
+
const { markdown, errors } = expandContentTables(page.body, {
|
|
281
|
+
docs: universe.get(page.pkg) ?? [],
|
|
282
|
+
linkable: (d) => Boolean(d.fm.shortcode),
|
|
283
|
+
source: src,
|
|
284
|
+
sqlTables: sqlTables?.get(page.file),
|
|
285
|
+
self: { fm: page.fm, path: page.relPath },
|
|
286
|
+
});
|
|
287
|
+
for (const err of errors) {
|
|
288
|
+
findings.push({
|
|
289
|
+
file: page.file,
|
|
290
|
+
severity: "error",
|
|
291
|
+
message: String(err.message ?? err),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const linkCtx = wikiContext(gates.index, {
|
|
295
|
+
src,
|
|
296
|
+
file: page.file,
|
|
297
|
+
type: page.fm.type ?? null,
|
|
298
|
+
errors: wikiErrors,
|
|
299
|
+
foreignIndex: gates.foreign.index,
|
|
300
|
+
});
|
|
301
|
+
// Code fences are protected for the same reason every other pass
|
|
302
|
+
// protects them: a wikilink shown as an example is prose about a
|
|
303
|
+
// wikilink, and resolving it would make the example impossible to write.
|
|
304
|
+
const resolvedBody = protectCode(markdown, (text) => resolveWebWikilinks(text, linkCtx));
|
|
305
|
+
for (const err of wikiErrors) {
|
|
306
|
+
findings.push({
|
|
307
|
+
file: page.file,
|
|
308
|
+
severity: "warning",
|
|
309
|
+
message: String(err.message ?? err),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return markdownToTypst(resolvedBody, {
|
|
313
|
+
md,
|
|
314
|
+
links: plan.links,
|
|
315
|
+
glyphs,
|
|
316
|
+
headingOffset,
|
|
317
|
+
anchorPrefix,
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const bodies = new Map();
|
|
322
|
+
let missing = 0;
|
|
323
|
+
for (const entry of plan.entries) {
|
|
324
|
+
if (entry.kind === "note") {
|
|
325
|
+
const file = noteFile(contentBase, entry.record);
|
|
326
|
+
const page = byFile.get(file);
|
|
327
|
+
if (!page) {
|
|
328
|
+
missing += 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
// The note's own headings nest *under* the heading the book gave its
|
|
332
|
+
// entry. A note's body starts at `##` — its name is frontmatter, not
|
|
333
|
+
// an H1 — so an offset of the entry's depth puts that `##` one level
|
|
334
|
+
// below the entry heading at `depth + 1`, which is where it belongs.
|
|
335
|
+
bodies.set(entry.anchor, renderPage(page, entry.depth, entry.anchor));
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (entry.kind === "prose") {
|
|
339
|
+
const file = path.resolve(resolved.rootDir, entry.file);
|
|
340
|
+
let text;
|
|
341
|
+
try {
|
|
342
|
+
text = fs.readFileSync(file, "utf8");
|
|
343
|
+
} catch {
|
|
344
|
+
findings.push({
|
|
345
|
+
file: resolved.pdf.document,
|
|
346
|
+
severity: "error",
|
|
347
|
+
message: `the prose file \`${entry.file}\` cannot be read`,
|
|
348
|
+
});
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
bodies.set(
|
|
352
|
+
entry.anchor,
|
|
353
|
+
markdownToTypst(text, {
|
|
354
|
+
md,
|
|
355
|
+
links: plan.links,
|
|
356
|
+
glyphs,
|
|
357
|
+
headingOffset: entry.depth,
|
|
358
|
+
anchorPrefix: entry.anchor,
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (missing) {
|
|
364
|
+
findings.push({
|
|
365
|
+
file: contentBase,
|
|
366
|
+
severity: "warning",
|
|
367
|
+
message:
|
|
368
|
+
`${missing} selected note(s) have an index record but no collected page, ` +
|
|
369
|
+
"so they print as a heading with no body",
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const front = resolved.pdf.front.map((file) => {
|
|
374
|
+
try {
|
|
375
|
+
return markdownToTypst(fs.readFileSync(file, "utf8"), {
|
|
376
|
+
md,
|
|
377
|
+
links: plan.links,
|
|
378
|
+
glyphs,
|
|
379
|
+
});
|
|
380
|
+
} catch {
|
|
381
|
+
findings.push({
|
|
382
|
+
file,
|
|
383
|
+
severity: "error",
|
|
384
|
+
message: "the front-matter file named by `pdf.front` cannot be read",
|
|
385
|
+
});
|
|
386
|
+
return "";
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const assembled = renderBook({
|
|
391
|
+
plan,
|
|
392
|
+
bodies,
|
|
393
|
+
title: resolved.pdf.title,
|
|
394
|
+
subtitle: resolved.pdf.subtitle,
|
|
395
|
+
front,
|
|
396
|
+
fonts: resolved.pdf.fonts,
|
|
397
|
+
version,
|
|
398
|
+
});
|
|
399
|
+
// Last, over the whole document: a reference can only be checked once every
|
|
400
|
+
// declaration is in one string, and Typst treats a dangling one as fatal.
|
|
401
|
+
const source = resolveDanglingLabels(assembled, findings);
|
|
402
|
+
|
|
403
|
+
const outDir = path.resolve(
|
|
404
|
+
resolved.rootDir,
|
|
405
|
+
out || resolved.pdf.out || path.join("build", "dist"),
|
|
406
|
+
);
|
|
407
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
408
|
+
const stem = pdfFileName(resolved.foundryPackage?.id ?? resolved.contentPackage, version);
|
|
409
|
+
const typPath = path.join(outDir, stem.replace(/\.pdf$/, ".typ"));
|
|
410
|
+
const pdfPath = path.join(outDir, stem);
|
|
411
|
+
fs.writeFileSync(typPath, source);
|
|
412
|
+
|
|
413
|
+
const stats = { ...plan.stats, entries: plan.entries.length, bytes: source.length };
|
|
414
|
+
if (!compile) {
|
|
415
|
+
return { built: true, reason: null, findings, typ: typPath, pdf: null, stats };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const compiled = compileTypst(typPath, pdfPath, resolved.pdf);
|
|
419
|
+
if (!compiled.ok) {
|
|
420
|
+
findings.push({ file: typPath, severity: "error", message: compiled.message });
|
|
421
|
+
return { built: false, reason: null, findings, typ: typPath, pdf: null, stats };
|
|
422
|
+
}
|
|
423
|
+
return { built: true, reason: null, findings, typ: typPath, pdf: pdfPath, stats };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Run Typst over the emitted source.
|
|
428
|
+
*
|
|
429
|
+
* @param {string} typPath - The `.typ` file.
|
|
430
|
+
* @param {string} pdfPath - Where the PDF goes.
|
|
431
|
+
* @param {object} pdf - The resolved `pdf:` block.
|
|
432
|
+
* @returns {{ok: boolean, message: string}} What happened.
|
|
433
|
+
*/
|
|
434
|
+
export function compileTypst(typPath, pdfPath, pdf = {}) {
|
|
435
|
+
const binary = pdf.binary || "typst";
|
|
436
|
+
const args = ["compile"];
|
|
437
|
+
if (pdf.fonts?.path) args.push("--font-path", pdf.fonts.path);
|
|
438
|
+
args.push(typPath, pdfPath);
|
|
439
|
+
let result;
|
|
440
|
+
try {
|
|
441
|
+
result = spawnSync(binary, args, { encoding: "utf8" });
|
|
442
|
+
} catch (err) {
|
|
443
|
+
return { ok: false, message: `could not run \`${binary}\`: ${err.message}` };
|
|
444
|
+
}
|
|
445
|
+
if (result.error) {
|
|
446
|
+
const missing = /** @type {any} */ (result.error).code === "ENOENT";
|
|
447
|
+
return {
|
|
448
|
+
ok: false,
|
|
449
|
+
message:
|
|
450
|
+
missing ?
|
|
451
|
+
`\`${binary}\` is not installed — the Typst source was written, and ` +
|
|
452
|
+
"`typst compile` over it produces the book. Name another binary with " +
|
|
453
|
+
"`pdf.binary`."
|
|
454
|
+
: `could not run \`${binary}\`: ${result.error.message}`,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
if (result.status !== 0) {
|
|
458
|
+
const detail = String(result.stderr || result.stdout || "")
|
|
459
|
+
.trim()
|
|
460
|
+
.split("\n")[0];
|
|
461
|
+
return { ok: false, message: `\`${binary} compile\` failed: ${detail}` };
|
|
462
|
+
}
|
|
463
|
+
return { ok: true, message: "" };
|
|
464
|
+
}
|