@heroiclands/package-build 21.1.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 (109) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTENT.md +107 -3
  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.mjs +12 -8
  50. package/engine/item-compiler.mjs +10 -10
  51. package/engine/journals.mjs +3 -0
  52. package/engine/macros.mjs +16 -13
  53. package/engine/map-notes.mjs +29 -22
  54. package/engine/metadata-index.mjs +44 -36
  55. package/engine/note-vocabulary.mjs +71 -29
  56. package/engine/pack-router.mjs +14 -0
  57. package/engine/packagebuild-index.mjs +124 -0
  58. package/engine/packages.mjs +83 -0
  59. package/engine/pathnames.mjs +74 -0
  60. package/engine/pdf-build.mjs +133 -8
  61. package/engine/pdf-render.mjs +9 -2
  62. package/engine/scenes.mjs +12 -9
  63. package/engine/site-build.mjs +43 -16
  64. package/engine/site-index.mjs +10 -1
  65. package/engine/subtype-registry.mjs +21 -0
  66. package/engine/web-wikilinks.mjs +46 -12
  67. package/engine/wikilink-syntax.mjs +20 -2
  68. package/engine/wikilinks.mjs +15 -2
  69. package/hm3/actors.mjs +11 -38
  70. package/package.json +5 -2
  71. package/packagebuild-metadata.jsonl +13 -0
  72. package/sohl/actors.mjs +12 -86
  73. package/sohl/note-schemas.mjs +4 -16
  74. package/types/content-config.d.mts +8 -0
  75. package/types/engine/actor-compiler.d.mts +29 -0
  76. package/types/engine/art-fields.d.mts +192 -0
  77. package/types/engine/asset-index.d.mts +92 -0
  78. package/types/engine/asset-types.d.mts +110 -0
  79. package/types/engine/base-compiler.d.mts +42 -1
  80. package/types/engine/compile-corpus.d.mts +4 -1
  81. package/types/engine/content-charset.d.mts +4 -0
  82. package/types/engine/content-embeds.d.mts +218 -0
  83. package/types/engine/content-html.d.mts +4 -0
  84. package/types/engine/content-icons.d.mts +4 -0
  85. package/types/engine/content-images.d.mts +15 -2
  86. package/types/engine/content-index.d.mts +27 -15
  87. package/types/engine/frontmatter-lint.d.mts +11 -14
  88. package/types/engine/helpers.d.mts +7 -2
  89. package/types/engine/index-records.d.mts +44 -3
  90. package/types/engine/index.d.mts +5 -0
  91. package/types/engine/infobox-registry.d.mts +36 -1
  92. package/types/engine/infobox.d.mts +6 -4
  93. package/types/engine/macros.d.mts +9 -5
  94. package/types/engine/map-notes.d.mts +10 -6
  95. package/types/engine/metadata-index.d.mts +2 -17
  96. package/types/engine/note-vocabulary.d.mts +30 -0
  97. package/types/engine/packagebuild-index.d.mts +56 -0
  98. package/types/engine/packages.d.mts +62 -0
  99. package/types/engine/pathnames.d.mts +41 -81
  100. package/types/engine/pdf-build.d.mts +58 -1
  101. package/types/engine/pdf-render.d.mts +3 -1
  102. package/types/engine/scenes.d.mts +2 -1
  103. package/types/engine/site-build.d.mts +5 -1
  104. package/types/engine/site-index.d.mts +4 -1
  105. package/types/engine/subtype-registry.d.mts +20 -0
  106. package/types/engine/web-wikilinks.d.mts +14 -4
  107. package/types/engine/wikilink-syntax.d.mts +13 -1
  108. package/types/engine/wikilinks.d.mts +7 -1
  109. 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);
@@ -504,6 +533,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
504
533
  // first, because the image lives in the body and the body follows.
505
534
  const boxes = noteInfoboxes(page.fm, {
506
535
  resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
536
+ router: routerFor(resolved),
507
537
  });
508
538
  const panel = infoboxesToTypst(boxes, {
509
539
  link: (value) => linkToTypst(value, plan.links, labelFor),
@@ -613,6 +643,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
613
643
  }
614
644
 
615
645
  const compiled = compileTypst(typPath, pdfPath, resolved.pdf);
646
+ findings.push(...compiled.findings);
616
647
  if (!compiled.ok) {
617
648
  findings.push({ file: typPath, severity: "error", message: compiled.message });
618
649
  return { built: false, reason: null, findings, typ: typPath, pdf: null, stats };
@@ -620,24 +651,113 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
620
651
  return { built: true, reason: null, findings, typ: typPath, pdf: pdfPath, stats };
621
652
  }
622
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
+
623
744
  /**
624
745
  * Run Typst over the emitted source.
625
746
  *
626
747
  * @param {string} typPath - The `.typ` file.
627
748
  * @param {string} pdfPath - Where the PDF goes.
628
749
  * @param {object} pdf - The resolved `pdf:` block.
629
- * @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.
630
752
  */
631
753
  export function compileTypst(typPath, pdfPath, pdf = {}) {
632
754
  const binary = pdf.binary || "typst";
633
- const args = ["compile"];
634
- if (pdf.fonts?.path) args.push("--font-path", pdf.fonts.path);
635
- args.push(typPath, pdfPath);
755
+ const args = typstArgs(typPath, pdfPath, pdf);
636
756
  let result;
637
757
  try {
638
758
  result = spawnSync(binary, args, { encoding: "utf8" });
639
759
  } catch (err) {
640
- return { ok: false, message: `could not run \`${binary}\`: ${err.message}` };
760
+ return { ok: false, message: `could not run \`${binary}\`: ${err.message}`, findings: [] };
641
761
  }
642
762
  if (result.error) {
643
763
  const missing = /** @type {any} */ (result.error).code === "ENOENT";
@@ -649,13 +769,18 @@ export function compileTypst(typPath, pdfPath, pdf = {}) {
649
769
  "`typst compile` over it produces the book. Name another binary with " +
650
770
  "`pdf.binary`."
651
771
  : `could not run \`${binary}\`: ${result.error.message}`,
772
+ findings: [],
652
773
  };
653
774
  }
654
775
  if (result.status !== 0) {
655
776
  const detail = String(result.stderr || result.stdout || "")
656
777
  .trim()
657
778
  .split("\n")[0];
658
- return { ok: false, message: `\`${binary} compile\` failed: ${detail}` };
779
+ return {
780
+ ok: false,
781
+ message: `\`${binary} compile\` failed: ${detail}`,
782
+ findings: [],
783
+ };
659
784
  }
660
- return { ok: true, message: "" };
785
+ return { ok: true, message: "", findings: typstWarnings(result.stderr) };
661
786
  }
@@ -1007,7 +1007,9 @@ export function bookTypstPreamble() {
1007
1007
  * @param {string} opts.title - The document's title.
1008
1008
  * @param {string} [opts.subtitle] - Shown under it on the title page.
1009
1009
  * @param {string[]} [opts.front] - Rendered Typst for each front-matter file.
1010
- * @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.
1011
1013
  * @param {string} [opts.version] - Stamped on the title page when given.
1012
1014
  * @param {string} [opts.preamble] - Definitions the bodies call, emitted once
1013
1015
  * above the title page. A panel every entry draws is a set of rules stated
@@ -1029,8 +1031,13 @@ export function renderBook({
1029
1031
  preamble = "",
1030
1032
  banners = new Map(),
1031
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.
1032
1039
  const serif = fonts.serif || "Libertinus Serif";
1033
- const sans = fonts.sans || serif;
1040
+ const sans = fonts.sans || "Libertinus Sans";
1034
1041
  const mono = fonts.mono || "DejaVu Sans Mono";
1035
1042
  const out = [];
1036
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: [],
@@ -55,7 +55,6 @@ import { protectCode } from "./code-fences.mjs";
55
55
  import { expandContentTables } from "./content-tables.mjs";
56
56
  import { renderImageFigures } from "./content-images.mjs";
57
57
  import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
58
- import { ART_FIELDS } from "./frontmatter-lint.mjs";
59
58
  import { buildSiteIndex, resolveInfoboxRef, wikiContext } from "./site-index.mjs";
60
59
  import { frontmatterWikilinks, resolveWebWikilinks } from "./web-wikilinks.mjs";
61
60
  import { loadForeignIndexes } from "./metadata-index.mjs";
@@ -63,10 +62,12 @@ import { noteInfoboxes } from "./infobox-registry.mjs";
63
62
  import { formatUnaddressableFinding, unaddressableForeignPackages } from "./metadata-index.mjs";
64
63
  import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
65
64
  import { loadPackConfig } from "./pack-config.mjs";
65
+ import { routerFor } from "./pack-router.mjs";
66
66
  import { searchableFrontmatter } from "./note-package.mjs";
67
67
  // The corpus, from the one pass that derives it.
68
68
  import { indexRecordsFor } from "./content-index.mjs";
69
69
  import { isNoteRecord, noteFile } from "./index-records.mjs";
70
+ import { ART_SLOTS, artPathname, assetAddressIndex } from "./art-fields.mjs";
70
71
  import {
71
72
  checkHomepageCount,
72
73
  homepageDestination,
@@ -627,13 +628,16 @@ export function sectionFrontmatter(meta) {
627
628
  * for.
628
629
  * @param {(data: object, page: object) => void} [options.decorate] - Called
629
630
  * with each page's frontmatter, for whatever a consumer's own pass adds.
631
+ * @param {(value: unknown, type: string) => string|null} [options.artSrc] -
632
+ * Translates an art address into the pathname the website resolver takes, or
633
+ * `null` where nothing answers it.
630
634
  * @param {(src: string) => string} [options.webSrc] - Translates an authored
631
635
  * pathname into the address the website serves. Every artwork field goes
632
636
  * through it, so a page's `img:` and its body images name the same file the
633
637
  * same way.
634
638
  * @returns {object} The frontmatter to write.
635
639
  */
636
- export function pageFrontmatter(page, { readmeSections = {}, decorate, webSrc }) {
640
+ export function pageFrontmatter(page, { readmeSections = {}, decorate, webSrc, artSrc }) {
637
641
  const { fm, name, slug, sec, isReadme } = page;
638
642
  let data;
639
643
  if (page.kind === "content") {
@@ -665,29 +669,39 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate, webSrc })
665
669
  if (meta) Object.assign(data, sectionFrontmatter(meta));
666
670
  }
667
671
  delete data.aliases;
668
- if (webSrc) resolveArtFields(data, webSrc);
672
+ if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
669
673
  return data;
670
674
  }
671
675
 
672
676
  /**
673
- * Rewrite a page's artwork fields into the addresses the website serves.
677
+ * Rewrite a page's artwork addresses into the URLs the website serves.
674
678
  *
675
- * The same fields the linter holds to the pathname rule, read from the same
676
- * list, so a third art field added to the vocabulary reaches the page without
677
- * anyone remembering this function exists. Only an authored **string** is
678
- * touched: `null` is a note naming no art and `""` is one naming none on
679
- * purpose, and neither is a pathname to resolve.
679
+ * The slots are read from {@link module:engine/art-fields.ART_SLOTS}, so a slot
680
+ * added to the vocabulary reaches the page without anyone remembering this
681
+ * function exists. Only an authored **string** is touched: `null` is a note
682
+ * naming no art and `""` is one naming none on purpose, and neither is an
683
+ * address to resolve.
684
+ *
685
+ * **An address nothing answers is dropped**, not emitted as written. The theme
686
+ * renders nothing where a value is absent — its silent-disappear convention —
687
+ * and a raw address left in place would reach the reader as a broken image
688
+ * source with nothing saying why.
680
689
  *
681
690
  * @param {object} data - The frontmatter being emitted, rewritten in place.
682
- * @param {(src: string) => string} webSrc - The website's resolver.
691
+ * @param {(src: string) => string} webSrc - The website's pathname resolver.
692
+ * @param {(value: unknown, type: string) => string|null} artSrc - The address
693
+ * resolver, yielding the pathname `webSrc` takes.
683
694
  * @returns {void}
684
695
  */
685
- function resolveArtFields(data, webSrc) {
686
- for (const { key, inData } of ART_FIELDS) {
687
- const holder = inData && isPlainObject(data.data) ? data.data : data;
688
- const value = holder?.[key];
696
+ function resolveArtFields(data, webSrc, artSrc) {
697
+ for (const { key, type } of ART_SLOTS) {
698
+ const holder = isPlainObject(data.data) ? data.data : null;
699
+ if (!holder) continue;
700
+ const value = holder[key];
689
701
  if (typeof value !== "string" || value === "") continue;
690
- holder[key] = webSrc(value);
702
+ const pathname = artSrc(value, type);
703
+ if (pathname === null) delete holder[key];
704
+ else holder[key] = webSrc(pathname);
691
705
  }
692
706
  }
693
707
 
@@ -758,8 +772,17 @@ export function renderPages(pages, options) {
758
772
  linkable = (d) => Boolean(d.fm.shortcode),
759
773
  sqlTables,
760
774
  config,
775
+ records = [],
761
776
  } = options;
762
777
 
778
+ // The address space the art slots and the body's embeds resolve against:
779
+ // the files this package ships, and the ones a dependency published.
780
+ const artIndex = assetAddressIndex(records, {
781
+ config,
782
+ foreign,
783
+ types: index?.contentTypes ?? [],
784
+ });
785
+
763
786
  const tableErrors = [];
764
787
  const wikiErrors = [];
765
788
  const imageErrors = [];
@@ -817,9 +840,11 @@ export function renderPages(pages, options) {
817
840
  type: page.fm.type ?? null,
818
841
  errors: wikiErrors,
819
842
  foreignIndex: foreign.index,
843
+ assets: artIndex,
820
844
  });
821
845
 
822
846
  const webSrc = webAddresses(page.file);
847
+ const artSrc = (value, type) => artPathname(artIndex, value, type).pathname;
823
848
  const resolve = (text) => {
824
849
  let t = text;
825
850
  if (pass.beforeLinks) t = pass.beforeLinks(t, page);
@@ -851,7 +876,7 @@ export function renderPages(pages, options) {
851
876
  body = markdown;
852
877
  }
853
878
 
854
- const data = pageFrontmatter(page, { readmeSections, decorate, webSrc });
879
+ const data = pageFrontmatter(page, { readmeSections, decorate, webSrc, artSrc });
855
880
  const dest = path.join(outRoot, pageDestination(page));
856
881
  fs.mkdirSync(path.dirname(dest), { recursive: true });
857
882
  fs.writeFileSync(dest, matter.stringify(protectCode(body, resolve), data));
@@ -1235,6 +1260,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1235
1260
  outRoot: out,
1236
1261
  sqlTables,
1237
1262
  config: resolved,
1263
+ records: ctx.records,
1238
1264
  index: gates.index,
1239
1265
  foreign: gates.foreign,
1240
1266
  universe: tableUniverse(pages),
@@ -1254,6 +1280,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1254
1280
  // book from one definition.
1255
1281
  data.infoboxes = noteInfoboxes(page.fm, {
1256
1282
  resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
1283
+ router: routerFor(resolved),
1257
1284
  });
1258
1285
  },
1259
1286
  });
@@ -322,6 +322,8 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
322
322
  * diagnostic names. Absent, `src` stands in.
323
323
  * @param {Map<string, object>} [options.foreignIndex] - The foreign index, for
324
324
  * resolvers that distinguish a foreign hit from a local one.
325
+ * @param {object} [options.assets] - The address space an `![[…]]` embed
326
+ * resolves against, shaped as every asset resolver reads one.
325
327
  * @returns {object} The resolver context.
326
328
  *
327
329
  * There is deliberately **no `manifestsComplete`**. It used to let a resolver
@@ -330,10 +332,17 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
330
332
  * checker never had it and one authored link must not get two verdicts. A
331
333
  * caller still passing it is ignored rather than obeyed.
332
334
  */
333
- export function wikiContext(built, { src, file, type = null, errors, foreignIndex = new Map() }) {
335
+ export function wikiContext(
336
+ built,
337
+ { src, file, type = null, errors, foreignIndex = new Map(), assets },
338
+ ) {
334
339
  return {
335
340
  index: built.index,
336
341
  foreign: foreignIndex,
342
+ // The address space an embed resolves against. Separate from `index`
343
+ // because the two hold different record shapes: a note's entry carries
344
+ // a URL and a name, an asset's carries the path to a file.
345
+ assets,
337
346
  collide: built.ambiguous,
338
347
  sections: built.sections,
339
348
  contentTypes: built.contentTypes,
@@ -65,6 +65,27 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
65
65
  HM3_DOCUMENT_SUBTYPES,
66
66
  ]);
67
67
 
68
+ /**
69
+ * The map a pack declaring no `system:` compiles its documents against.
70
+ *
71
+ * A pack names the system its documents are shaped for, and one declaring none
72
+ * is compiled by the fallback pass for its document type — a single-system
73
+ * package's ordinary arrangement, and the one `sohl-thalorna` ships. That pass
74
+ * follows one map whatever a note carries, which makes this the answer to "a
75
+ * document compiled here belongs to *which* system?" wherever a pack has not
76
+ * said.
77
+ *
78
+ * Stated here rather than read off the compiler classes because those are not
79
+ * reachable from a leaf: `engine/item-compiler.mjs` imports the journals pass,
80
+ * which draws infoboxes, so anything the infobox reads cannot import a pass.
81
+ * `tests/default-document-subtypes.test.ts` holds this to `compilerFor`, which
82
+ * is where the fallback is actually decided — so the two cannot drift apart in
83
+ * silence.
84
+ *
85
+ * @type {import("./document-subtypes.mjs").DocumentSubtypeMap}
86
+ */
87
+ export const DEFAULT_DOCUMENT_SUBTYPES = SOHL_DOCUMENT_SUBTYPES;
88
+
68
89
  /**
69
90
  * Every note type any shipped map compiles into an **Actor**.
70
91
  *