@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
@@ -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
  *
@@ -61,6 +61,7 @@ import { slugify } from "./content-slug.mjs";
61
61
  /** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
62
62
  export { slugify };
63
63
  import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
64
+ import { resolveEmbeds } from "./content-embeds.mjs";
64
65
 
65
66
  /**
66
67
  * The index key a **piped** target resolves to, or `null` when it does not
@@ -300,15 +301,25 @@ function isPlainMap(value) {
300
301
  * web pages, so the author wrote a real address and there is simply nothing to
301
302
  * link to.
302
303
  *
304
+ * ## Embeds first, and they are not links
305
+ *
306
+ * `![[address|label]]` names a file rather than a note, so it is resolved into
307
+ * an ordinary markdown image before anything looks for a link — which is what
308
+ * stops the link pass reading an embed's interior as a link to a note nobody
309
+ * wrote. The image the rewrite leaves behind is what
310
+ * {@link module:engine/content-images.renderImageFigures} turns into a figure
311
+ * for the website, and what the book reads its staging list out of.
312
+ *
303
313
  * @param {string} body - The markdown body.
304
- * @param {object} ctx - `{ index, collide, sections, contentTypes, packages,
305
- * foreign, type, errors, src, file }`.
314
+ * @param {object} ctx - `{ index, assets, collide, sections, contentTypes,
315
+ * packages, foreign, type, errors, src, file }`.
306
316
  * `packages` is every package an address may name, without which the leading
307
317
  * package segment of a canonical address reads as an unknown type; `foreign`
308
- * is the cross-package manifest index. `src` is the page's display
318
+ * is the cross-package manifest index; `assets` is the address space an embed
319
+ * resolves against. `src` is the page's display
309
320
  * path and `file` the source file a diagnostic should name — absent, `src`
310
321
  * stands in.
311
- * @returns {string} The body with wikilinks rewritten.
322
+ * @returns {string} The body with embeds and wikilinks rewritten.
312
323
  */
313
324
  export function resolveWebWikilinks(body, ctx) {
314
325
  // How many times each authored link has been seen, so two identical links
@@ -317,15 +328,13 @@ export function resolveWebWikilinks(body, ctx) {
317
328
  // `file:line:column:` diagnostic rather than a note-wide one.
318
329
  const seen = new Map();
319
330
  /**
320
- * Records a finding, and returns the marked-up link it renders as.
331
+ * Records a finding against one authored literal.
321
332
  *
322
- * @param {string} all - The authored link, brackets and all.
323
- * @param {object} finding - `{ target, reason }`, plus any extras the class
324
- * carries.
325
- * @param {string} text - What the link renders as.
326
- * @returns {string} The marked span.
333
+ * @param {string} all - The literal, exactly as authored.
334
+ * @param {object} finding - The finding's own fields.
335
+ * @returns {void}
327
336
  */
328
- const report = (all, finding, text) => {
337
+ const record = (all, finding) => {
329
338
  const occurrence = (seen.get(all) ?? 0) + 1;
330
339
  seen.set(all, occurrence);
331
340
  ctx.errors.push({
@@ -337,12 +346,37 @@ export function resolveWebWikilinks(body, ctx) {
337
346
  occurrence,
338
347
  ...finding,
339
348
  });
349
+ };
350
+
351
+ /**
352
+ * Records a finding, and returns the marked-up link it renders as.
353
+ *
354
+ * @param {string} all - The authored link, brackets and all.
355
+ * @param {object} finding - `{ target, reason }`, plus any extras the class
356
+ * carries.
357
+ * @param {string} text - What the link renders as.
358
+ * @returns {string} The marked span.
359
+ */
360
+ const report = (all, finding, text) => {
361
+ record(all, finding);
340
362
  return unresolvedLink(text, finding.target);
341
363
  };
342
364
 
365
+ // An embed names a file, so it resolves before anything looks for a link.
366
+ // Its findings are recorded rather than rendered: the rewrite already
367
+ // leaves an embed it cannot resolve exactly as authored, which is the
368
+ // visible degradation an unresolved link gets from `unresolvedLink`.
369
+ const embedded = resolveEmbeds(body, { index: ctx.assets });
370
+ for (const u of embedded.unresolved) {
371
+ record(u.link, { target: u.target, reason: u.reason, ...(u.type ? { type: u.type } : {}) });
372
+ }
373
+ for (const problem of embedded.problems) {
374
+ record(problem.link, { target: problem.link, message: problem.message });
375
+ }
376
+
343
377
  // Code is verbatim: a `[[…]]` inside a code fence, an indented block or an
344
378
  // inline span is source text, not a link.
345
- return replaceOutsideCode(body, WIKILINK, (all, rawInner) => {
379
+ return replaceOutsideCode(embedded.markdown, WIKILINK, (all, rawInner) => {
346
380
  const parsed = parseWikilink(rawInner);
347
381
  const { target, anchor, display } = parsed;
348
382
  // An empty label is not a label: `[[x|]]` addresses the target and
@@ -57,8 +57,15 @@
57
57
  * unclosed `[[` is a typo, and the alternative is letting it swallow arbitrary
58
58
  * prose in search of a closer. Erring towards "not a link" leaves the author's
59
59
  * text as written, which is the safe direction for a rewriter.
60
+ *
61
+ * **A leading `!` makes it an embed, not a link**, and the lookbehind is what
62
+ * keeps every reader from seeing one as the other. Stated here rather than in
63
+ * each reader for the reason the rest of this module exists: the checker, the
64
+ * pack compilers and the web resolver all match on this pattern, and three
65
+ * copies of the exclusion is three places for it to be forgotten. An embed's
66
+ * own grammar is {@link module:engine/content-embeds}.
60
67
  */
61
- export const WIKILINK = /\[\[([^\]\n]+)\]\]/g;
68
+ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]/g;
62
69
 
63
70
  /**
64
71
  * The parts of a wikilink's interior.
@@ -171,7 +178,9 @@ export function unlabelledLinkMessage(target) {
171
178
  *
172
179
  * - `unlabelled` — no `|`, so the link addresses nothing.
173
180
  * - `not-an-address` — labelled, but the target does not parse as an address.
181
+ * - `not-lowercase` — a package, system or type segment carries a capital.
174
182
  * - `unknown-type` — definitely qualified, but names no type this build knows.
183
+ * - `not-an-asset` — a real address, embedded where only a file can be drawn.
175
184
  * - `unresolved` — parses as an address, and nothing publishes it.
176
185
  * - `ambiguous` — more than one package publishes the short address.
177
186
  * - `unknown-anchor` — the address resolved, the `#section` it names did not.
@@ -184,6 +193,7 @@ export const LINK_FINDING_REASONS = Object.freeze(
184
193
  "not-an-address",
185
194
  "not-lowercase",
186
195
  "unknown-type",
196
+ "not-an-asset",
187
197
  "unresolved",
188
198
  "ambiguous",
189
199
  "unknown-anchor",
@@ -262,11 +272,13 @@ export function ambiguousAddressMessage(target, packages = []) {
262
272
  * @param {Iterable<string>} [finding.packages] - For `ambiguous`, the
263
273
  * claimants.
264
274
  * @param {string} [finding.anchor] - For `unknown-anchor`, the section named.
275
+ * @param {string} [finding.type] - For `not-an-asset`, the type the address
276
+ * named.
265
277
  * @returns {string} The message.
266
278
  * @throws {Error} On a reason outside the closed set — a resolver inventing one
267
279
  * would otherwise report a link with no explanation at all.
268
280
  */
269
- export function linkFindingMessage({ reason, target, packages, anchor }) {
281
+ export function linkFindingMessage({ reason, target, packages, anchor, type }) {
270
282
  switch (reason) {
271
283
  case "unlabelled":
272
284
  return unlabelledLinkMessage(target);
@@ -285,6 +297,12 @@ export function linkFindingMessage({ reason, target, packages, anchor }) {
285
297
  );
286
298
  case "unknown-type":
287
299
  return `address [[${target}]] names no known content type`;
300
+ case "not-an-asset":
301
+ return (
302
+ `![[${target}]] embeds a \`${type ?? "note"}\`, and an embed draws a ` +
303
+ `file — only an \`icon\`, an \`image\` or an \`audio\` address can be ` +
304
+ `embedded. Write [[${target}|Text]] to link to it instead`
305
+ );
288
306
  case "ambiguous":
289
307
  return ambiguousAddressMessage(target, packages ?? []);
290
308
  case "unknown-anchor":
@@ -86,6 +86,7 @@ import crypto from "crypto";
86
86
 
87
87
  import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
88
88
  import { readCanonicalKey } from "./content-address.mjs";
89
+ import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
89
90
  import { isSystemSegment, NO_SYSTEM } from "./systems.mjs";
90
91
  import { systemOf } from "./document-subtypes.mjs";
91
92
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
@@ -354,11 +355,15 @@ export function anchorPageId(noteId, anchorSlug) {
354
355
  * vendored manifests of packages this build links into but does not publish.
355
356
  * @param {string} [contentPackage] - This build's *content* package, which an
356
357
  * authored address may name explicitly. Defaults to `packageId`.
358
+ * @param {object} [opts] - Options.
359
+ * @param {Map<string, object>} [opts.assets] - The files this package ships, by
360
+ * canonical address. They resolve no link — an asset is not a document — and
361
+ * answer only the art fields, which name a file and never a document.
357
362
  * @returns {{byShortcode: Map<string, object>, types: Set<string>}} `types` is
358
363
  * every type the tree actually contains, so a qualifier naming no real type
359
364
  * can be told apart from a missing target.
360
365
  */
361
- export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
366
+ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage, { assets } = {}) {
362
367
  if (!packageId) {
363
368
  throw new Error(
364
369
  "buildWikilinkIndex: packageId is required — it is the first " +
@@ -368,7 +373,11 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
368
373
  }
369
374
 
370
375
  const byShortcode = new Map();
371
- const types = new Set();
376
+ // The **asset** types join unconditionally, whether or not this tree holds
377
+ // a file of each: they are a closed vocabulary rather than a census of what
378
+ // was found, and an art field hands the resolver `icon-<shortcode>` whose
379
+ // type has to parse before anything can be looked up.
380
+ const types = new Set(ASSET_TYPE_NAMES);
372
381
 
373
382
  // Each note's address is computed once, here, and every reference to it is
374
383
  // that stored value. Nothing downstream assembles a UUID from parts, so a
@@ -439,8 +448,12 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
439
448
  types,
440
449
  uuidByDoc,
441
450
  packageId,
451
+ /** The content package this build publishes, which an art address defaults to. */
452
+ contentPackage: contentPackage ?? packageId,
442
453
  packages,
443
454
  foreign: foreignByKey,
455
+ /** The files this package ships, by canonical address. */
456
+ assets: assets ?? new Map(),
444
457
  };
445
458
  }
446
459
 
package/hm3/actors.mjs CHANGED
@@ -49,8 +49,8 @@
49
49
  * @module
50
50
  */
51
51
 
52
- import { resolveName, resolveImg } from "../engine/helpers.mjs";
53
- import { buildFromFields, readField, retiredTopLevelKey, STRING } from "../engine/field-spec.mjs";
52
+ import { resolveName } from "../engine/helpers.mjs";
53
+ import { buildFromFields, retiredTopLevelKey, STRING } from "../engine/field-spec.mjs";
54
54
  import { SystemActorCompiler } from "../engine/actor-compiler.mjs";
55
55
  import { renderSection } from "../engine/anchored-sections.mjs";
56
56
  import { documentSubtype } from "../engine/document-subtypes.mjs";
@@ -99,33 +99,6 @@ const ACTOR_FIELDS = Object.freeze([
99
99
  },
100
100
  ]);
101
101
 
102
- /**
103
- * The actor's bio image — the fourth row of the specification's `being` table,
104
- * and the last one that was still read by hand.
105
- *
106
- * Declared for the reason {@link ACTOR_FIELDS} is, and fixed for the reason
107
- * `data.species` was: read with `blockProperty(fm, block, "portrait")` it saw
108
- * the block and the note's top level and nothing else, so the `data.portrait`
109
- * the specification names never reached the document and the `?? defaultImg`
110
- * beside it dressed the miss up as "this note names no art".
111
- *
112
- * It is **not** in `ACTOR_FIELDS`, because `buildFromFields` has no seam for
113
- * the subtype default that has to follow it — the `?? defaultImg` is the whole
114
- * of what distinguishes an unnamed portrait from a deliberately blank one,
115
- * and it needs a subtype the coercion is not handed.
116
- *
117
- * @type {import("../engine/field-spec.mjs").FieldSpec}
118
- */
119
- const BIO_IMAGE_FIELD = Object.freeze({
120
- name: "data.portrait",
121
- legacyKey: "portrait",
122
- to: "bioImage",
123
- shape: "path",
124
- read: (raw) => resolveImg(raw),
125
- default: null,
126
- describe: "Path to the portrait image.",
127
- });
128
-
129
102
  /**
130
103
  * The two `data:` facts HM3 declares on a `character` and not on a `creature`.
131
104
  *
@@ -285,7 +258,10 @@ export class Hm3Actors extends SystemActorCompiler {
285
258
  absPath: this.currentNote?.absPath,
286
259
  })
287
260
  );
288
- const defaultImg = defaultActorImg(subType);
261
+ // The being's own default sits above the subtype's: only the note's
262
+ // tags say whether it is a person or a creature, and only this pass
263
+ // reads them.
264
+ const art = this.actorArt(fm, defaultActorImg(subType));
289
265
 
290
266
  const items = this.buildEmbeddedItems(itemsMap, id, fm, ctx);
291
267
 
@@ -326,11 +302,9 @@ export class Hm3Actors extends SystemActorCompiler {
326
302
  });
327
303
 
328
304
  const system = {
329
- // Nullish, not `||`: a note that names no portrait gets the
330
- // subtype's default, one that writes `""` ships blank on purpose.
331
- // Resolved through the declaration so `data.portrait` is reached at
332
- // all — see {@link BIO_IMAGE_FIELD}.
333
- bioImage: readField(BIO_IMAGE_FIELD, fm, reports) ?? defaultImg,
305
+ // `system.bioImage` is **not** written here, for the reason SoHL's
306
+ // `system.portrait` is not: a being's portrait is the lead image of
307
+ // its `{#appearance}` section, which is the markup below.
334
308
  description: renderSection(body || "", "appearance"),
335
309
  biography: renderSection(body || "", "dossier"),
336
310
  ...buildFromFields(ACTOR_FIELDS, reports)(fm),
@@ -356,12 +330,11 @@ export class Hm3Actors extends SystemActorCompiler {
356
330
  });
357
331
 
358
332
  const effects = blockProperty(fm, block, "effects");
359
- const img = resolveImg(blockProperty(fm, block, "img")) ?? defaultImg;
360
333
 
361
334
  return {
362
335
  name,
363
336
  type: subType,
364
- img,
337
+ img: art.img,
365
338
  _id: id,
366
339
  system,
367
340
  items,
@@ -369,7 +342,7 @@ export class Hm3Actors extends SystemActorCompiler {
369
342
  name,
370
343
  displayName: 0,
371
344
  actorLink: false,
372
- texture: { src: img },
345
+ texture: { src: art.token },
373
346
  width: 1,
374
347
  height: 1,
375
348
  sight: { enabled: false },
package/hm3/infobox.mjs CHANGED
@@ -33,7 +33,7 @@ import { GEAR_UNITS, defineInfobox } from "../engine/infobox.mjs";
33
33
  import { HM3_ITEM_FIELDS } from "./item-fields.mjs";
34
34
 
35
35
  /** What this system's box is called. @type {string} */
36
- export const HM3_INFOBOX_TITLE = "HM3";
36
+ export const HM3_INFOBOX_TITLE = "HârnMaster 3";
37
37
 
38
38
  /**
39
39
  * HM3's presentation overlay: what one of this system's fields is called where
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "21.0.0",
3
+ "version": "21.2.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -102,6 +102,8 @@
102
102
  "CHANGELOG.md",
103
103
  "CONTENT.md",
104
104
  "README.md",
105
+ "assets",
106
+ "packagebuild-metadata.jsonl",
105
107
  "bin",
106
108
  "bundle.mjs",
107
109
  "ci",
@@ -160,8 +162,9 @@
160
162
  "scripts": {
161
163
  "test": "vitest run",
162
164
  "test:watch": "vitest",
163
- "prepack": "npm run build:types",
165
+ "prepack": "run-s build:types build:asset-index",
164
166
  "build:types": "tsc -p tsconfig.dts.json",
167
+ "build:asset-index": "node ci/emit-asset-index.mjs",
165
168
  "format": "prettier --write .",
166
169
  "format:check": "prettier --check .",
167
170
  "lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:content-format",
@@ -0,0 +1,13 @@
1
+ {"address":{"canonical":"packagebuild-none-image-afflictionbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/afflictionbnr.webp","source":""},"package":"packagebuild","shortcode":"afflictionbnr","type":"image"}
2
+ {"address":{"canonical":"packagebuild-none-image-armorclothingbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/armorclothingbnr.webp","source":""},"package":"packagebuild","shortcode":"armorclothingbnr","type":"image"}
3
+ {"address":{"canonical":"packagebuild-none-image-attributebnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/attributebnr.webp","source":""},"package":"packagebuild","shortcode":"attributebnr","type":"image"}
4
+ {"address":{"canonical":"packagebuild-none-image-containerbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/containerbnr.webp","source":""},"package":"packagebuild","shortcode":"containerbnr","type":"image"}
5
+ {"address":{"canonical":"packagebuild-none-image-devdocsbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/devdocsbnr.webp","source":""},"package":"packagebuild","shortcode":"devdocsbnr","type":"image"}
6
+ {"address":{"canonical":"packagebuild-none-image-miscgearbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/miscgearbnr.webp","source":""},"package":"packagebuild","shortcode":"miscgearbnr","type":"image"}
7
+ {"address":{"canonical":"packagebuild-none-image-mysticalabilitybnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/mysticalabilitybnr.webp","source":""},"package":"packagebuild","shortcode":"mysticalabilitybnr","type":"image"}
8
+ {"address":{"canonical":"packagebuild-none-image-projectilebnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/projectilebnr.webp","source":""},"package":"packagebuild","shortcode":"projectilebnr","type":"image"}
9
+ {"address":{"canonical":"packagebuild-none-image-rulesbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/rulesbnr.webp","source":""},"package":"packagebuild","shortcode":"rulesbnr","type":"image"}
10
+ {"address":{"canonical":"packagebuild-none-image-skillbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/skillbnr.webp","source":""},"package":"packagebuild","shortcode":"skillbnr","type":"image"}
11
+ {"address":{"canonical":"packagebuild-none-image-traumabnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/traumabnr.webp","source":""},"package":"packagebuild","shortcode":"traumabnr","type":"image"}
12
+ {"address":{"canonical":"packagebuild-none-image-userguidebnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/userguidebnr.webp","source":""},"package":"packagebuild","shortcode":"userguidebnr","type":"image"}
13
+ {"address":{"canonical":"packagebuild-none-image-weapongearbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/weapongearbnr.webp","source":""},"package":"packagebuild","shortcode":"weapongearbnr","type":"image"}