@heroiclands/package-build 20.4.0 → 20.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/CONTENT.md +213 -20
  3. package/README.md +19 -1
  4. package/bin/content-build.mjs +135 -32
  5. package/bin/package-build.mjs +46 -13
  6. package/content-config.mjs +345 -101
  7. package/docs/api.md +1352 -0
  8. package/docs/commands.md +1609 -0
  9. package/docs/configuration.md +1432 -0
  10. package/docs/content-format.md +16 -6
  11. package/docs/diagnostics.md +356 -0
  12. package/docs/getting-started.md +813 -0
  13. package/docs/project-setup.md +469 -0
  14. package/engine/actor-compiler.mjs +30 -27
  15. package/engine/address-diff.mjs +45 -41
  16. package/engine/base-compiler.mjs +6 -0
  17. package/engine/bundles.mjs +9 -0
  18. package/engine/content-address.mjs +9 -9
  19. package/engine/content-index.mjs +44 -23
  20. package/engine/content-links.mjs +44 -11
  21. package/engine/content-lint.mjs +44 -10
  22. package/engine/content-tables.mjs +32 -27
  23. package/engine/folder-notes.mjs +4 -2
  24. package/engine/frontmatter-lint.mjs +35 -38
  25. package/engine/generate.mjs +5 -0
  26. package/engine/helpers.mjs +86 -32
  27. package/engine/index.mjs +12 -2
  28. package/engine/journals.mjs +9 -0
  29. package/engine/note-claims.mjs +18 -10
  30. package/engine/note-schemas.mjs +0 -5
  31. package/engine/note-vocabulary.mjs +32 -31
  32. package/engine/pack-config.mjs +26 -12
  33. package/engine/pack-router.mjs +0 -0
  34. package/engine/pdf-build.mjs +464 -0
  35. package/engine/pdf-fonts.mjs +420 -0
  36. package/engine/pdf-render.mjs +876 -0
  37. package/engine/pdf-toc.mjs +525 -0
  38. package/engine/scenes.mjs +14 -5
  39. package/engine/schema-check.mjs +1 -1
  40. package/engine/site-build.mjs +21 -3
  41. package/engine/web-wikilinks.mjs +6 -3
  42. package/engine/wikilinks.mjs +2 -4
  43. package/hm3/actors.mjs +8 -0
  44. package/hm3/items.mjs +8 -0
  45. package/package.json +1 -1
  46. package/release.mjs +63 -3
  47. package/sohl/actors.mjs +8 -0
  48. package/sohl/items.mjs +8 -0
  49. package/sohl/note-schemas.mjs +5 -5
  50. package/types/content-config.d.mts +66 -15
  51. package/types/engine/actor-compiler.d.mts +34 -30
  52. package/types/engine/address-diff.d.mts +57 -3
  53. package/types/engine/base-compiler.d.mts +10 -2
  54. package/types/engine/bundles.d.mts +9 -0
  55. package/types/engine/content-address.d.mts +9 -9
  56. package/types/engine/content-index.d.mts +57 -13
  57. package/types/engine/content-lint.d.mts +6 -4
  58. package/types/engine/content-tables.d.mts +49 -18
  59. package/types/engine/frontmatter-lint.d.mts +3 -2
  60. package/types/engine/helpers.d.mts +105 -31
  61. package/types/engine/index.d.mts +4 -0
  62. package/types/engine/journals.d.mts +9 -0
  63. package/types/engine/note-claims.d.mts +17 -10
  64. package/types/engine/note-vocabulary.d.mts +23 -196
  65. package/types/engine/pack-config.d.mts +4 -4
  66. package/types/engine/pdf-build.d.mts +42 -0
  67. package/types/engine/pdf-fonts.d.mts +30 -0
  68. package/types/engine/pdf-render.d.mts +156 -0
  69. package/types/engine/pdf-toc.d.mts +114 -0
  70. package/types/engine/scenes.d.mts +10 -1
  71. package/types/engine/schema-check.d.mts +2 -2
  72. package/types/engine/site-build.d.mts +34 -6
  73. package/types/engine/wikilinks.d.mts +2 -3
  74. package/types/hm3/actors.d.mts +8 -0
  75. package/types/hm3/items.d.mts +8 -0
  76. package/types/release.d.mts +15 -4
  77. package/types/sohl/actors.d.mts +10 -2
  78. package/types/sohl/items.d.mts +8 -0
@@ -0,0 +1,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
+ }