@heroiclands/package-build 21.0.0 → 21.2.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 (112) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/CONTENT.md +109 -7
  3. package/assets/fonts/libertinus/LibertinusMono-Regular.otf +0 -0
  4. package/assets/fonts/libertinus/LibertinusSans-Bold.otf +0 -0
  5. package/assets/fonts/libertinus/LibertinusSans-Italic.otf +0 -0
  6. package/assets/fonts/libertinus/LibertinusSans-Regular.otf +0 -0
  7. package/assets/fonts/libertinus/OFL.txt +94 -0
  8. package/assets/fonts/libertinus/provenance.yaml +11 -0
  9. package/assets/images/banners/afflictionbnr.webp +0 -0
  10. package/assets/images/banners/armorclothingbnr.webp +0 -0
  11. package/assets/images/banners/attributebnr.webp +0 -0
  12. package/assets/images/banners/containerbnr.webp +0 -0
  13. package/assets/images/banners/devdocsbnr.webp +0 -0
  14. package/assets/images/banners/miscgearbnr.webp +0 -0
  15. package/assets/images/banners/mysticalabilitybnr.webp +0 -0
  16. package/assets/images/banners/projectilebnr.webp +0 -0
  17. package/assets/images/banners/rulesbnr.webp +0 -0
  18. package/assets/images/banners/skillbnr.webp +0 -0
  19. package/assets/images/banners/traumabnr.webp +0 -0
  20. package/assets/images/banners/userguidebnr.webp +0 -0
  21. package/assets/images/banners/weapongearbnr.webp +0 -0
  22. package/assets/images/provenance.yaml +4 -0
  23. package/bin/content-build.mjs +19 -5
  24. package/ci/emit-asset-index.mjs +32 -0
  25. package/content-config.mjs +30 -1
  26. package/docs/api.md +100 -26
  27. package/docs/configuration.md +16 -7
  28. package/docs/content-format.md +512 -109
  29. package/engine/actor-compiler.mjs +58 -9
  30. package/engine/art-fields.mjs +292 -0
  31. package/engine/asset-index.mjs +397 -0
  32. package/engine/asset-types.mjs +192 -0
  33. package/engine/base-compiler.mjs +69 -1
  34. package/engine/bundles.mjs +5 -5
  35. package/engine/compile-corpus.mjs +4 -0
  36. package/engine/content-address.mjs +14 -2
  37. package/engine/content-charset.mjs +5 -1
  38. package/engine/content-embeds.mjs +314 -0
  39. package/engine/content-html.mjs +5 -1
  40. package/engine/content-icons.mjs +5 -1
  41. package/engine/content-images.mjs +26 -5
  42. package/engine/content-index.mjs +68 -33
  43. package/engine/content-links.mjs +164 -24
  44. package/engine/frontmatter-lint.mjs +36 -23
  45. package/engine/helpers.mjs +83 -31
  46. package/engine/index-records.mjs +63 -4
  47. package/engine/index.mjs +15 -0
  48. package/engine/infobox-registry.mjs +50 -3
  49. package/engine/infobox-render.mjs +4 -3
  50. package/engine/infobox.mjs +12 -8
  51. package/engine/item-compiler.mjs +10 -10
  52. package/engine/journals.mjs +3 -0
  53. package/engine/macros.mjs +16 -13
  54. package/engine/map-notes.mjs +29 -22
  55. package/engine/metadata-index.mjs +44 -36
  56. package/engine/note-vocabulary.mjs +71 -29
  57. package/engine/pack-router.mjs +14 -0
  58. package/engine/packagebuild-index.mjs +124 -0
  59. package/engine/packages.mjs +83 -0
  60. package/engine/pathnames.mjs +74 -0
  61. package/engine/pdf-build.mjs +133 -10
  62. package/engine/pdf-render.mjs +41 -41
  63. package/engine/scenes.mjs +12 -9
  64. package/engine/site-build.mjs +43 -16
  65. package/engine/site-index.mjs +10 -1
  66. package/engine/subtype-registry.mjs +21 -0
  67. package/engine/web-wikilinks.mjs +46 -12
  68. package/engine/wikilink-syntax.mjs +20 -2
  69. package/engine/wikilinks.mjs +15 -2
  70. package/hm3/actors.mjs +11 -38
  71. package/hm3/infobox.mjs +1 -1
  72. package/package.json +5 -2
  73. package/packagebuild-metadata.jsonl +13 -0
  74. package/sohl/actors.mjs +12 -86
  75. package/sohl/infobox.mjs +1 -1
  76. package/sohl/note-schemas.mjs +4 -16
  77. package/types/content-config.d.mts +8 -0
  78. package/types/engine/actor-compiler.d.mts +29 -0
  79. package/types/engine/art-fields.d.mts +192 -0
  80. package/types/engine/asset-index.d.mts +92 -0
  81. package/types/engine/asset-types.d.mts +110 -0
  82. package/types/engine/base-compiler.d.mts +42 -1
  83. package/types/engine/compile-corpus.d.mts +4 -1
  84. package/types/engine/content-charset.d.mts +4 -0
  85. package/types/engine/content-embeds.d.mts +218 -0
  86. package/types/engine/content-html.d.mts +4 -0
  87. package/types/engine/content-icons.d.mts +4 -0
  88. package/types/engine/content-images.d.mts +15 -2
  89. package/types/engine/content-index.d.mts +27 -15
  90. package/types/engine/frontmatter-lint.d.mts +11 -14
  91. package/types/engine/helpers.d.mts +7 -2
  92. package/types/engine/index-records.d.mts +44 -3
  93. package/types/engine/index.d.mts +5 -0
  94. package/types/engine/infobox-registry.d.mts +36 -1
  95. package/types/engine/infobox.d.mts +6 -4
  96. package/types/engine/macros.d.mts +9 -5
  97. package/types/engine/map-notes.d.mts +10 -6
  98. package/types/engine/metadata-index.d.mts +2 -17
  99. package/types/engine/note-vocabulary.d.mts +30 -0
  100. package/types/engine/packagebuild-index.d.mts +56 -0
  101. package/types/engine/packages.d.mts +62 -0
  102. package/types/engine/pathnames.d.mts +41 -81
  103. package/types/engine/pdf-build.d.mts +58 -1
  104. package/types/engine/pdf-render.d.mts +3 -3
  105. package/types/engine/scenes.d.mts +2 -1
  106. package/types/engine/site-build.d.mts +5 -1
  107. package/types/engine/site-index.d.mts +4 -1
  108. package/types/engine/subtype-registry.d.mts +20 -0
  109. package/types/engine/web-wikilinks.d.mts +14 -4
  110. package/types/engine/wikilink-syntax.d.mts +13 -1
  111. package/types/engine/wikilinks.d.mts +7 -1
  112. package/types/sohl/actors.d.mts +0 -1
@@ -93,6 +93,8 @@
93
93
  * @module
94
94
  */
95
95
 
96
+ import { PACKAGEBUILD_PACKAGE } from "./packages.mjs";
97
+
96
98
  /**
97
99
  * The directory a package ships its files in, and the segment that marks a
98
100
  * pathname's first segment as a package name.
@@ -220,6 +222,78 @@ export function pathnameProblem(raw) {
220
222
  );
221
223
  }
222
224
 
225
+ /**
226
+ * Whether this build installs anything into a Foundry data directory.
227
+ *
228
+ * A `documentation` package compiles no packs and installs nowhere, so no note
229
+ * of its reaches a Foundry document and no pathname of its can be dead on a
230
+ * surface it never touches. A check that reports a missing Foundry address asks
231
+ * this first, so the one kind with no Foundry surface is not told about it.
232
+ *
233
+ * Read from {@link packageAddresses} rather than from a key, so "does this
234
+ * package have a Foundry root?" is answered in one place by the map that
235
+ * derives every other package's.
236
+ *
237
+ * @param {object} config - The resolved build configuration.
238
+ * @returns {boolean} Whether the package being built has a Foundry root.
239
+ */
240
+ export function servesFoundry(config) {
241
+ const own = config?.contentPackage ? packageAddresses(config).get(config.contentPackage) : null;
242
+ return Boolean(own?.root);
243
+ }
244
+
245
+ /**
246
+ * Why a pathname has no Foundry address, or `""` when it has one.
247
+ *
248
+ * The sibling of {@link pathnameProblem} one step further on: that one asks
249
+ * whether a pathname is written correctly at all, and this one asks whether
250
+ * *this* build can turn a correctly written pathname into a path a Foundry
251
+ * install serves. Both answer in a sentence rather than a code, so the caller
252
+ * that holds a line and a column and the caller that holds only a file say the
253
+ * same thing about the same value.
254
+ *
255
+ * `""` for every pathname that resolves — and for the three shapes that
256
+ * legitimately reach Foundry untranslated: a URL, a protocol-relative
257
+ * `//host/…`, and a `/`-rooted path Foundry serves from its data root. A blank
258
+ * pathname is a deliberate blank and resolves to a blank on every surface, so
259
+ * it is not a problem either.
260
+ *
261
+ * `""` as well for a pathname {@link pathnameProblem} already refuses. That
262
+ * defect has its own finding naming its own replacement, and reporting a second
263
+ * one about the same characters would send the author to one mistake twice.
264
+ *
265
+ * @param {string|null|undefined} raw - The pathname, as authored.
266
+ * @param {object} config - The resolved build configuration.
267
+ * @returns {string} The problem, as a finding's sentence, or `""`.
268
+ */
269
+ export function foundryAddressProblem(raw, config) {
270
+ if (pathnameProblem(raw)) return "";
271
+ const forms = resolvePathname(raw, config);
272
+ if (forms === null || forms.foundry !== null) return "";
273
+ if (forms.package === PACKAGEBUILD_PACKAGE) {
274
+ return (
275
+ `\`${forms.authored}\` names a file the \`${PACKAGEBUILD_PACKAGE}\` package ` +
276
+ "ships, and Foundry installs no package of that name — package-build is an " +
277
+ "npm dependency of a package rather than a package of its own, so its files " +
278
+ "reach the website and the book and never a Foundry document. Address a " +
279
+ "`/`-rooted path or a URL"
280
+ );
281
+ }
282
+ if (forms.own) {
283
+ return (
284
+ `\`${forms.authored}\` names a file this package serves, and a ` +
285
+ "`documentation` package has no asset root to serve it from — Foundry " +
286
+ "installs no such package. Address a `/`-rooted path or a URL"
287
+ );
288
+ }
289
+ return (
290
+ `\`${forms.authored}\` names a file the \`${forms.package}\` package ships, and ` +
291
+ "this build declares no relationship with a package of that name, so there is " +
292
+ "no Foundry address to derive. Declare it under `relationships`, or address " +
293
+ "the file by a `/`-rooted path"
294
+ );
295
+ }
296
+
223
297
  /**
224
298
  * Every game system this build compiles content for.
225
299
  *
@@ -64,6 +64,20 @@
64
64
  * `.typ` source on disk, which is both the diagnostic and the thing a consumer
65
65
  * can compile by hand.
66
66
  *
67
+ * ## The faces are the toolchain's and the compiler's, never the machine's
68
+ *
69
+ * Every face the book sets resolves from one of two places that travel with the
70
+ * build: the faces this package ships — see {@link BOOK_FONTS_PATH} — and the
71
+ * ones the compiler embeds. The compile passes the shipped directory as
72
+ * `--font-path` and `--ignore-system-fonts` alongside it, so a machine carrying
73
+ * its own copy of a named family cannot quietly set a different book from the
74
+ * same source.
75
+ *
76
+ * `pdf.fonts.path` is searched as well, so a consumer naming a face of its own
77
+ * in `pdf.fonts` still resolves it — and a name nothing resolves is reported,
78
+ * because the compiler says so and a compile that says it still exits 0 with a
79
+ * book set in the fallback.
80
+ *
67
81
  * @module
68
82
  */
69
83
 
@@ -71,10 +85,12 @@ import fs from "node:fs";
71
85
  import os from "node:os";
72
86
  import path from "node:path";
73
87
  import { spawnSync } from "node:child_process";
88
+ import { fileURLToPath } from "node:url";
74
89
 
75
90
  import YAML from "yaml";
76
91
 
77
92
  import { loadPackConfig } from "./pack-config.mjs";
93
+ import { routerFor } from "./pack-router.mjs";
78
94
  import { publishesContentPages } from "../content-config.mjs";
79
95
  import { indexRecordsFor } from "./content-index.mjs";
80
96
  import { isNoteRecord, noteFile } from "./index-records.mjs";
@@ -83,6 +99,8 @@ import { parseDocumentTree, runTreeFilters, planDocument } from "./pdf-toc.mjs";
83
99
  import { collectContentPages, siteGates, tableUniverse, gatesFailed } from "./site-build.mjs";
84
100
  import { resolveInfoboxRef, wikiContext } from "./site-index.mjs";
85
101
  import { resolveWebWikilinks } from "./web-wikilinks.mjs";
102
+ import { linkFindingMessage } from "./wikilink-syntax.mjs";
103
+ import { assetAddressIndex } from "./art-fields.mjs";
86
104
  import { expandContentTables } from "./content-tables.mjs";
87
105
  import { protectCode } from "./code-fences.mjs";
88
106
  import { imageSourcesIn } from "./content-images.mjs";
@@ -379,6 +397,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
379
397
  skipDirectories: resolved.skipDirectories,
380
398
  records,
381
399
  });
400
+ // The address space an `![[…]]` embed resolves against — the same one the
401
+ // site builds, so one authored picture reaches both surfaces or neither.
402
+ const assets = assetAddressIndex(records, {
403
+ config: resolved,
404
+ foreign: gates.foreign,
405
+ types: gates.index?.contentTypes ?? [],
406
+ });
382
407
  const md = createParser(resolved.icons);
383
408
  const glyphs = resolveIconGlyphs(resolved.icons, resolved.pdf.iconFonts, findings);
384
409
 
@@ -478,6 +503,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
478
503
  type: page.fm.type ?? null,
479
504
  errors: wikiErrors,
480
505
  foreignIndex: gates.foreign.index,
506
+ assets,
481
507
  });
482
508
  // Code fences are protected for the same reason every other pass
483
509
  // protects them: a wikilink shown as an example is prose about a
@@ -487,7 +513,10 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
487
513
  findings.push({
488
514
  file: page.file,
489
515
  severity: "warning",
490
- message: String(err.message ?? err),
516
+ // A link finding names a `reason` from the shared table and no
517
+ // sentence of its own; an embed's directive complaint carries
518
+ // the sentence instead.
519
+ message: err.message ?? (err.reason ? linkFindingMessage(err) : String(err)),
491
520
  });
492
521
  }
493
522
  stageImages(resolvedBody, page.file);
@@ -498,14 +527,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
498
527
  images,
499
528
  headingOffset,
500
529
  anchorPrefix,
501
- // An entry opens a page, so its first paragraph opens the page too.
502
- dropCap: true,
503
530
  });
504
531
  // The infobox is generated content in document order — prepended,
505
532
  // before the prose. An image the note authored ahead of it still comes
506
533
  // first, because the image lives in the body and the body follows.
507
534
  const boxes = noteInfoboxes(page.fm, {
508
535
  resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
536
+ router: routerFor(resolved),
509
537
  });
510
538
  const panel = infoboxesToTypst(boxes, {
511
539
  link: (value) => linkToTypst(value, plan.links, labelFor),
@@ -615,6 +643,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
615
643
  }
616
644
 
617
645
  const compiled = compileTypst(typPath, pdfPath, resolved.pdf);
646
+ findings.push(...compiled.findings);
618
647
  if (!compiled.ok) {
619
648
  findings.push({ file: typPath, severity: "error", message: compiled.message });
620
649
  return { built: false, reason: null, findings, typ: typPath, pdf: null, stats };
@@ -622,24 +651,113 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
622
651
  return { built: true, reason: null, findings, typ: typPath, pdf: pdfPath, stats };
623
652
  }
624
653
 
654
+ /**
655
+ * The faces the book is set in that the compiler does not carry itself.
656
+ *
657
+ * Resolved from this module rather than from the working directory, on the same
658
+ * rule the specification and `--version` follow: a consumer runs the build
659
+ * inside its own repository, and the faces it sets the book in are the ones
660
+ * that came with the toolchain version it resolved.
661
+ *
662
+ * What is here is the **sans**, in the three styles a heading can ask for, the
663
+ * superfamily's **mono** for a package that names it, and the licence they
664
+ * travel under. The serif is not: the compiler embeds one, and a second copy of
665
+ * a face it already carries is a file nothing selects.
666
+ *
667
+ * It is **not** an addressable asset root: the compiler matches a face by
668
+ * family name, so nothing addresses these files and nothing needs to.
669
+ *
670
+ * @type {string}
671
+ */
672
+ export const BOOK_FONTS_PATH = path.join(
673
+ path.dirname(fileURLToPath(import.meta.url)),
674
+ "..",
675
+ "assets",
676
+ "fonts",
677
+ );
678
+
679
+ /**
680
+ * The command line the compile runs, as data.
681
+ *
682
+ * Separate from running it so the flags that decide which faces are in play
683
+ * can be asserted without a compiler installed — which is the half of the
684
+ * invocation that changes what the book looks like.
685
+ *
686
+ * @param {string} typPath - The `.typ` file.
687
+ * @param {string} pdfPath - Where the PDF goes.
688
+ * @param {object} [pdf] - The resolved `pdf:` block.
689
+ * @returns {string[]} The arguments, in order.
690
+ */
691
+ export function typstArgs(typPath, pdfPath, pdf = {}) {
692
+ // One flag, the paths joined by the platform's separator: the compiler
693
+ // takes a list, and the shipped faces come first so a consumer's own
694
+ // directory extends the set rather than standing in for it.
695
+ const fontPaths = [BOOK_FONTS_PATH];
696
+ if (pdf.fonts?.path) fontPaths.push(pdf.fonts.path);
697
+ return [
698
+ "compile",
699
+ "--ignore-system-fonts",
700
+ "--font-path",
701
+ fontPaths.join(path.delimiter),
702
+ typPath,
703
+ pdfPath,
704
+ ];
705
+ }
706
+
707
+ /**
708
+ * The compiler's own warnings, as findings.
709
+ *
710
+ * A compile that says `unknown font family` still exits 0 and still writes a
711
+ * book — one set in whatever face the fallback reached. That is the failure
712
+ * this surface is least able to see, so the compiler's warnings are read back
713
+ * and reported on the same terms as everything else the build finds.
714
+ *
715
+ * Typst writes a warning as a `warning:` line followed by a `┌─ file:line:col`
716
+ * locator over a source excerpt. The message and the position are taken; the
717
+ * excerpt is not, since the reader has the file.
718
+ *
719
+ * @param {string} output - What the compiler wrote to stderr.
720
+ * @returns {Array<{file: string, line?: number, column?: number,
721
+ * severity: string, message: string}>} One finding per warning.
722
+ */
723
+ export function typstWarnings(output) {
724
+ const lines = String(output || "").split("\n");
725
+ const findings = [];
726
+ for (let i = 0; i < lines.length; i += 1) {
727
+ const warned = /^warning: (.+)$/.exec(lines[i]);
728
+ if (!warned) continue;
729
+ const finding = { file: "", severity: "warning", message: warned[1].trim() };
730
+ const at = /^\s*┌─ (.+):(\d+):(\d+)\s*$/.exec(lines[i + 1] ?? "");
731
+ if (at) {
732
+ // The compiler writes the path relative to its own working
733
+ // directory, which is this process's, so resolving it there is what
734
+ // recovers the file a reader can open.
735
+ finding.file = path.resolve(at[1]);
736
+ finding.line = Number(at[2]);
737
+ finding.column = Number(at[3]);
738
+ }
739
+ findings.push(finding);
740
+ }
741
+ return findings;
742
+ }
743
+
625
744
  /**
626
745
  * Run Typst over the emitted source.
627
746
  *
628
747
  * @param {string} typPath - The `.typ` file.
629
748
  * @param {string} pdfPath - Where the PDF goes.
630
749
  * @param {object} pdf - The resolved `pdf:` block.
631
- * @returns {{ok: boolean, message: string}} What happened.
750
+ * @returns {{ok: boolean, message: string, findings: object[]}} What happened,
751
+ * and what the compiler warned about on the way.
632
752
  */
633
753
  export function compileTypst(typPath, pdfPath, pdf = {}) {
634
754
  const binary = pdf.binary || "typst";
635
- const args = ["compile"];
636
- if (pdf.fonts?.path) args.push("--font-path", pdf.fonts.path);
637
- args.push(typPath, pdfPath);
755
+ const args = typstArgs(typPath, pdfPath, pdf);
638
756
  let result;
639
757
  try {
640
758
  result = spawnSync(binary, args, { encoding: "utf8" });
641
759
  } catch (err) {
642
- return { ok: false, message: `could not run \`${binary}\`: ${err.message}` };
760
+ return { ok: false, message: `could not run \`${binary}\`: ${err.message}`, findings: [] };
643
761
  }
644
762
  if (result.error) {
645
763
  const missing = /** @type {any} */ (result.error).code === "ENOENT";
@@ -651,13 +769,18 @@ export function compileTypst(typPath, pdfPath, pdf = {}) {
651
769
  "`typst compile` over it produces the book. Name another binary with " +
652
770
  "`pdf.binary`."
653
771
  : `could not run \`${binary}\`: ${result.error.message}`,
772
+ findings: [],
654
773
  };
655
774
  }
656
775
  if (result.status !== 0) {
657
776
  const detail = String(result.stderr || result.stdout || "")
658
777
  .trim()
659
778
  .split("\n")[0];
660
- return { ok: false, message: `\`${binary} compile\` failed: ${detail}` };
779
+ return {
780
+ ok: false,
781
+ message: `\`${binary} compile\` failed: ${detail}`,
782
+ findings: [],
783
+ };
661
784
  }
662
- return { ok: true, message: "" };
785
+ return { ok: true, message: "", findings: typstWarnings(result.stderr) };
663
786
  }
@@ -168,7 +168,6 @@ export function createParser(registry) {
168
168
  * note's own `##` nests beneath the entry heading the book gave it.
169
169
  * @param {string} [opts.anchorPrefix] - The entry's anchor, which namespaces
170
170
  * every `{#slug}` the body declares.
171
- * @param {boolean} [opts.dropCap] - Whether to open the body with a raised
172
171
  * capital. Set for an entry, which begins a page; not for front matter or a
173
172
  * prose file, which carry headings of their own.
174
173
  * @returns {string} Typst markup.
@@ -181,7 +180,6 @@ export function markdownToTypst(markdown, opts = {}) {
181
180
  images = new Map(),
182
181
  headingOffset = 0,
183
182
  anchorPrefix = "",
184
- dropCap = false,
185
183
  } = opts;
186
184
  const tokens = md.parse(String(markdown ?? ""), {});
187
185
  // One map for the whole body, not one per block: a heading inside a
@@ -194,7 +192,6 @@ export function markdownToTypst(markdown, opts = {}) {
194
192
  images,
195
193
  headingOffset,
196
194
  anchorPrefix,
197
- dropCap,
198
195
  seen: new Map(),
199
196
  });
200
197
  }
@@ -258,7 +255,7 @@ function renderBlock(tokens, i, out, ctx) {
258
255
  return 3;
259
256
  }
260
257
  case "paragraph_open": {
261
- out.push(`\n${openingParagraph(renderInline(tokens[i + 1], ctx), ctx)}\n\n`);
258
+ out.push(`\n${renderInline(tokens[i + 1], ctx)}\n\n`);
262
259
  return 3;
263
260
  }
264
261
  case "fence":
@@ -271,7 +268,7 @@ function renderBlock(tokens, i, out, ctx) {
271
268
  return 1;
272
269
  case "blockquote_open": {
273
270
  const end = matching(tokens, i, "blockquote_open", "blockquote_close");
274
- const inner = renderTokens(tokens.slice(i + 1, end), { ...ctx, dropCap: false });
271
+ const inner = renderTokens(tokens.slice(i + 1, end), { ...ctx });
275
272
  out.push(`\n#quote(block: true)[${inner}]\n\n`);
276
273
  return end - i + 1;
277
274
  }
@@ -298,29 +295,6 @@ function renderBlock(tokens, i, out, ctx) {
298
295
  }
299
296
  }
300
297
 
301
- /**
302
- * The first paragraph of an entry, opened with a raised capital.
303
- *
304
- * Only the first, and only when it begins with a letter: a paragraph opening
305
- * on a link, a bold run or a number has no character to raise, and raising
306
- * whatever happened to be first would put a 26pt accent on a bracket. The
307
- * chance is spent either way — a body opens once — so a paragraph that cannot
308
- * take the capital simply sets as itself.
309
- *
310
- * @param {string} rendered - The paragraph's Typst markup.
311
- * @param {object} ctx - Render context.
312
- * @returns {string} The same markup, or it with a raised capital.
313
- */
314
- function openingParagraph(rendered, ctx) {
315
- if (!ctx.dropCap) return rendered;
316
- ctx.dropCap = false;
317
- // A letter is never escaped, so the first character of the markup is the
318
- // first character of the prose whenever the prose starts with one.
319
- const match = /^(\p{L})([\s\S]*)$/u.exec(rendered);
320
- if (!match) return rendered;
321
- return `#book-dropcap[${match[1]}]#h(1pt)${match[2]}`;
322
- }
323
-
324
298
  /**
325
299
  * A heading's text, and the `{#slug}` it may end with.
326
300
  *
@@ -438,7 +412,7 @@ function listItems(tokens, start, end, ctx) {
438
412
  continue;
439
413
  }
440
414
  const close = matching(tokens, i, "list_item_open", "list_item_close");
441
- items.push(renderTokens(tokens.slice(i + 1, close), { ...ctx, dropCap: false }));
415
+ items.push(renderTokens(tokens.slice(i + 1, close), { ...ctx }));
442
416
  i = close + 1;
443
417
  }
444
418
  return items;
@@ -825,6 +799,7 @@ export function bookTypstPreamble() {
825
799
  '#let book-ink = rgb("#241f1a")',
826
800
  '#let book-paper = rgb("#f4efe4")',
827
801
  '#let book-accent = rgb("#7c3b1e")',
802
+ '#let book-head = rgb("#5e2b14")',
828
803
  '#let book-faint = rgb("#6b6357")',
829
804
  "#let book-ornament = box(baseline: 1pt, " +
830
805
  "rotate(45deg, rect(width: 3pt, height: 3pt, fill: book-accent)))",
@@ -835,16 +810,34 @@ export function bookTypstPreamble() {
835
810
  "}",
836
811
  // A heading justifies and hyphenates like body text unless told
837
812
  // otherwise, and both make a display line look broken.
838
- "#let book-sechead(it) = block(width: 100%, above: 0.9em, below: 0.5em, " +
839
- "breakable: false)[\n" +
840
- " #set par(justify: false, first-line-indent: 0em)\n" +
841
- " #set text(hyphenate: false)\n" +
842
- ' #text(size: 11pt, weight: "bold", tracking: 1.6pt, fill: book-accent)[#upper(it.body)]\n' +
843
- " #v(-0.35em)\n" +
844
- " #line(length: 100%, stroke: 0.5pt + book-accent)\n" +
845
- "]",
846
- '#let book-dropcap(letter) = text(size: 26pt, weight: "bold", fill: book-accent, ' +
847
- "baseline: 5pt)[#letter]",
813
+ //
814
+ // Size, weight and the space above follow the level, so a parent reads
815
+ // as one without the words being read. The rule belongs to the note's own
816
+ // top level alone; every level under it is set apart by space. Headings set
817
+ // in the sans face and in the case they were authored in, so neither
818
+ // capitals nor tracking is carrying the distinction. The scale starts
819
+ // at 3: `book-plate` shadows this rule while it draws the title, so
820
+ // the section landing and the entry title never arrive here, and the
821
+ // headings that do are the note's own.
822
+ "#let book-sechead(it) = {\n" +
823
+ " let lv = it.level\n" +
824
+ " let size = if lv <= 3 { 26pt } else if lv == 4 { 19pt }\n" +
825
+ " else if lv == 5 { 14pt } else { 11pt }\n" +
826
+ " let above = if lv <= 3 { 2.5em } else if lv == 4 { 2.0em }\n" +
827
+ " else { 1.0em }\n" +
828
+ " let below = if lv <= 3 { 0.5em } else if lv == 4 { 0.36em }\n" +
829
+ " else { 0.28em }\n" +
830
+ ' let weight = if lv <= 5 { "bold" } else { "regular" }\n' +
831
+ " block(width: 100%, above: above, below: below, breakable: false)[\n" +
832
+ " #set par(justify: false, first-line-indent: 0em)\n" +
833
+ " #set text(hyphenate: false)\n" +
834
+ " #text(size: size, weight: weight, fill: book-head)[#it.body]\n" +
835
+ " #if lv <= 3 {\n" +
836
+ " v(-0.30em)\n" +
837
+ " line(length: 100%, stroke: 0.5pt + book-accent)\n" +
838
+ " }\n" +
839
+ " ]\n" +
840
+ "}",
848
841
  // The plate bleeds off the paper: the placed panel is the full width of
849
842
  // the sheet and starts a margin above and to the left of wherever the
850
843
  // flow has reached, which on an entry's first page is the top corner.
@@ -1014,7 +1007,9 @@ export function bookTypstPreamble() {
1014
1007
  * @param {string} opts.title - The document's title.
1015
1008
  * @param {string} [opts.subtitle] - Shown under it on the title page.
1016
1009
  * @param {string[]} [opts.front] - Rendered Typst for each front-matter file.
1017
- * @param {object} [opts.fonts] - `{ serif, sans, mono }` family names.
1010
+ * @param {object} [opts.fonts] - `{ serif, sans, mono }` family names. Each
1011
+ * falls back to the face the toolchain ships or the compiler embeds, so a
1012
+ * caller that names none still sets the book in all three.
1018
1013
  * @param {string} [opts.version] - Stamped on the title page when given.
1019
1014
  * @param {string} [opts.preamble] - Definitions the bodies call, emitted once
1020
1015
  * above the title page. A panel every entry draws is a set of rules stated
@@ -1036,8 +1031,13 @@ export function renderBook({
1036
1031
  preamble = "",
1037
1032
  banners = new Map(),
1038
1033
  } = {}) {
1034
+ // The two halves of one superfamily, chosen together: matched metrics are
1035
+ // most of why the sans can carry every heading over a serif body without
1036
+ // the page reading as two books. The mono is a separate claim — the
1037
+ // superfamily's own is missing the Latin Extended Additional letters this
1038
+ // corpus spells names with, where the compiler's embedded face carries them.
1039
1039
  const serif = fonts.serif || "Libertinus Serif";
1040
- const sans = fonts.sans || serif;
1040
+ const sans = fonts.sans || "Libertinus Sans";
1041
1041
  const mono = fonts.mono || "DejaVu Sans Mono";
1042
1042
  const out = [];
1043
1043
 
package/engine/scenes.mjs CHANGED
@@ -53,7 +53,6 @@ import log from "loglevel";
53
53
  import {
54
54
  parseMarkdownFile,
55
55
  sohlField,
56
- resolveImg,
57
56
  resolveName,
58
57
  slugify,
59
58
  defaultStats,
@@ -130,15 +129,15 @@ export class Scenes extends BasePackCompiler {
130
129
  static label = "map";
131
130
 
132
131
  /**
133
- * A map note's `img` is its background art, and it is **required**: the map
134
- * compiler refuses a note without one. It lands on the scene's level rather
135
- * than on a property spelled `img`, which makes no difference to the
136
- * question this declaration answers — the authored path reaches the output.
137
- * The place Adventure this pass bundles carries it too.
132
+ * A map note's `bgImage` is its background art, and it is **required**: the
133
+ * map compiler refuses a note without one. It lands on the scene's level
134
+ * rather than on a property spelled `img`, which makes no difference to the
135
+ * question this declaration answers — the authored address reaches the
136
+ * output. The place Adventure this pass bundles carries it too.
138
137
  *
139
138
  * @type {readonly string[]}
140
139
  */
141
- static emitsArt = Object.freeze(["img"]);
140
+ static emitsArt = Object.freeze(["bgImage"]);
142
141
 
143
142
  /** @type {string} */
144
143
  adventureDir;
@@ -152,13 +151,14 @@ export class Scenes extends BasePackCompiler {
152
151
 
153
152
  constructor({
154
153
  contentBase,
154
+ assetsBase,
155
155
  dest,
156
156
  skipDirectories,
157
157
  companionDests = {},
158
158
  folderResolver = () => null,
159
159
  repoRoot = process.cwd(),
160
160
  }) {
161
- super({ contentBase, dest, folderResolver, skipDirectories });
161
+ super({ contentBase, assetsBase, dest, folderResolver, skipDirectories });
162
162
  if (!companionDests.adventures) {
163
163
  throw new Error("Scenes compiler requires an `adventures` companion destination");
164
164
  }
@@ -426,6 +426,9 @@ export class Scenes extends BasePackCompiler {
426
426
  const warnings = [];
427
427
  const scene = buildScene(fm, {
428
428
  packageId: foundryPackageId(),
429
+ // The art resolver, so the map pass turns an address into the path
430
+ // each surface serves without holding an index of its own.
431
+ art: (value, key, type) => this.artPathOf(value, key, type),
429
432
  name,
430
433
  folder,
431
434
  stats: this.stats,
@@ -473,7 +476,7 @@ export class Scenes extends BasePackCompiler {
473
476
  this.places.set(placeKey, {
474
477
  key: placeKey,
475
478
  name: sohlField(fm, "placeName", null) || name,
476
- img: resolveImg(sohlField(fm, "img", null)),
479
+ img: this.artPath(fm, "bgImage"),
477
480
  pinned: false,
478
481
  scenes: [],
479
482
  journal: [],