@heroiclands/package-build 20.7.0 → 21.0.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 (63) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/CONTENT.md +134 -44
  3. package/bin/content-build.mjs +37 -5
  4. package/bin/package-build.mjs +77 -0
  5. package/content-config.mjs +59 -1
  6. package/docs/api.md +149 -19
  7. package/docs/commands.md +75 -0
  8. package/docs/configuration.md +37 -10
  9. package/docs/content-format.md +450 -49
  10. package/engine/content-format.mjs +52 -3
  11. package/engine/content-images.mjs +699 -0
  12. package/engine/dependency-bump.mjs +218 -0
  13. package/engine/frontmatter-lint.mjs +89 -2
  14. package/engine/helpers.mjs +81 -142
  15. package/engine/index.mjs +15 -0
  16. package/engine/infobox-registry.mjs +81 -0
  17. package/engine/infobox-render.mjs +381 -0
  18. package/engine/infobox.mjs +963 -0
  19. package/engine/item-registry.mjs +5 -5
  20. package/engine/journals.mjs +22 -1
  21. package/engine/map-notes.mjs +11 -5
  22. package/engine/metadata-index.mjs +5 -0
  23. package/engine/note-vocabulary.mjs +57 -2
  24. package/engine/pathnames.mjs +374 -0
  25. package/engine/pdf-build.mjs +208 -9
  26. package/engine/pdf-render.mjs +461 -21
  27. package/engine/pdf-toc.mjs +77 -5
  28. package/engine/scenes.mjs +2 -1
  29. package/engine/site-build.mjs +106 -7
  30. package/engine/site-index.mjs +93 -4
  31. package/engine/wikilinks.mjs +93 -0
  32. package/hm3/default-item-art.mjs +14 -15
  33. package/hm3/index.mjs +3 -0
  34. package/hm3/infobox.mjs +64 -0
  35. package/package.json +1 -1
  36. package/sohl/default-item-art.mjs +18 -16
  37. package/sohl/index.mjs +3 -0
  38. package/sohl/infobox.mjs +499 -0
  39. package/types/content-config.d.mts +7 -0
  40. package/types/engine/content-format.d.mts +36 -0
  41. package/types/engine/content-images.d.mts +281 -0
  42. package/types/engine/dependency-bump.d.mts +89 -0
  43. package/types/engine/frontmatter-lint.d.mts +23 -0
  44. package/types/engine/helpers.d.mts +30 -72
  45. package/types/engine/index.d.mts +5 -0
  46. package/types/engine/infobox-registry.d.mts +36 -0
  47. package/types/engine/infobox-render.d.mts +87 -0
  48. package/types/engine/infobox.d.mts +443 -0
  49. package/types/engine/item-registry.d.mts +5 -5
  50. package/types/engine/journals.d.mts +9 -1
  51. package/types/engine/note-vocabulary.d.mts +51 -0
  52. package/types/engine/pathnames.d.mts +189 -0
  53. package/types/engine/pdf-build.d.mts +46 -0
  54. package/types/engine/pdf-render.d.mts +99 -1
  55. package/types/engine/pdf-toc.d.mts +10 -5
  56. package/types/engine/site-build.d.mts +11 -3
  57. package/types/engine/site-index.d.mts +35 -3
  58. package/types/engine/wikilinks.d.mts +22 -0
  59. package/types/hm3/default-item-art.d.mts +5 -6
  60. package/types/hm3/index.d.mts +1 -0
  61. package/types/hm3/infobox.d.mts +22 -0
  62. package/types/sohl/index.d.mts +1 -0
  63. package/types/sohl/infobox.d.mts +145 -0
@@ -54,16 +54,86 @@ import { positionOfYamlPath } from "./diagnostics.mjs";
54
54
  /**
55
55
  * Presentation a node may declare, and that its descendants inherit.
56
56
  *
57
- * Reserved now although the first release renders none of them, because the
58
- * shape of the file is the thing consumers commit to: a book that has to be
59
- * restructured to gain a running head has the wrong format, not the wrong
60
- * renderer. Inheritance is what makes them worth declaring at all — `Gear` says
61
- * once which infobox its entries use, and nine sections beneath it agree.
57
+ * Inheritance is what makes them worth declaring at all `Gear` says once
58
+ * which banner its entries are plated over, and nine sections beneath it
59
+ * agree.
60
+ *
61
+ * **`page` and `footer` are read by the renderer**, and their shapes are
62
+ * checked here so that a key nothing can act on is an error rather than a
63
+ * silence. `header` and `infobox` are reserved and read by nothing: the
64
+ * running head is a foot in this design, and which infobox a note draws is
65
+ * decided by the note's type. They keep their place in the format because the
66
+ * shape of the file is the thing consumers commit to.
62
67
  *
63
68
  * @type {readonly string[]}
64
69
  */
65
70
  export const PRESENTATION_KEYS = Object.freeze(["header", "footer", "infobox", "page"]);
66
71
 
72
+ /** Keys the `page:` presentation may carry. @type {readonly string[]} */
73
+ const PAGE_KEYS = Object.freeze(["banner", "columns", "kicker"]);
74
+
75
+ /**
76
+ * How many columns a section's pages may be set in.
77
+ *
78
+ * One or two is the real choice; the ceiling is there so a typo that reads as
79
+ * a number cannot produce a page of unreadable slivers.
80
+ *
81
+ * @type {number}
82
+ */
83
+ const MAX_COLUMNS = 4;
84
+
85
+ /**
86
+ * Check the presentation a section declares, in the two keys anything reads.
87
+ *
88
+ * @param {Record<string, unknown>} entry - The section.
89
+ * @param {Array<string|number>} at - Where it sits in the document.
90
+ * @param {object} ctx - `{ findings, text }`.
91
+ * @returns {void}
92
+ */
93
+ function checkPresentation(entry, at, ctx) {
94
+ if (entry.footer !== undefined && typeof entry.footer !== "string") {
95
+ ctx.findings.push(
96
+ finding(ctx, [...at, "footer"], "`footer:` must be the name the running foot carries"),
97
+ );
98
+ }
99
+ if (entry.page === undefined) return;
100
+ if (!entry.page || typeof entry.page !== "object" || Array.isArray(entry.page)) {
101
+ ctx.findings.push(finding(ctx, [...at, "page"], "`page:` must be a mapping"));
102
+ return;
103
+ }
104
+ const page = /** @type {Record<string, unknown>} */ (entry.page);
105
+ for (const key of Object.keys(page)) {
106
+ if (!PAGE_KEYS.includes(key)) {
107
+ ctx.findings.push(
108
+ finding(ctx, [...at, "page", key], `unknown key \`${key}:\` under \`page:\``, {
109
+ key: true,
110
+ }),
111
+ );
112
+ }
113
+ }
114
+ for (const key of ["banner", "kicker"]) {
115
+ if (page[key] !== undefined && (typeof page[key] !== "string" || !page[key].trim())) {
116
+ ctx.findings.push(
117
+ finding(ctx, [...at, "page", key], `\`page.${key}:\` must be a non-empty string`),
118
+ );
119
+ }
120
+ }
121
+ if (
122
+ page.columns !== undefined &&
123
+ (!Number.isInteger(page.columns) ||
124
+ /** @type {number} */ (page.columns) < 1 ||
125
+ /** @type {number} */ (page.columns) > MAX_COLUMNS)
126
+ ) {
127
+ ctx.findings.push(
128
+ finding(
129
+ ctx,
130
+ [...at, "page", "columns"],
131
+ `\`page.columns:\` must be a whole number from 1 to ${MAX_COLUMNS}`,
132
+ ),
133
+ );
134
+ }
135
+ }
136
+
67
137
  /** Keys a section node may carry. @type {readonly string[]} */
68
138
  const SECTION_KEYS = Object.freeze(["sectionName", "contents", ...PRESENTATION_KEYS]);
69
139
 
@@ -164,6 +234,8 @@ function walkSections(contents, keyPath, trail, inherited, ctx) {
164
234
  }
165
235
  }
166
236
 
237
+ checkPresentation(entry, at, ctx);
238
+
167
239
  const presentation = { ...inherited };
168
240
  for (const key of PRESENTATION_KEYS) {
169
241
  if (entry[key] !== undefined) presentation[key] = entry[key];
package/engine/scenes.mjs CHANGED
@@ -53,6 +53,7 @@ import log from "loglevel";
53
53
  import {
54
54
  parseMarkdownFile,
55
55
  sohlField,
56
+ resolveImg,
56
57
  resolveName,
57
58
  slugify,
58
59
  defaultStats,
@@ -472,7 +473,7 @@ export class Scenes extends BasePackCompiler {
472
473
  this.places.set(placeKey, {
473
474
  key: placeKey,
474
475
  name: sohlField(fm, "placeName", null) || name,
475
- img: sohlField(fm, "img", null),
476
+ img: resolveImg(sohlField(fm, "img", null)),
476
477
  pinned: false,
477
478
  scenes: [],
478
479
  journal: [],
@@ -53,9 +53,13 @@ import { slugify } from "./content-slug.mjs";
53
53
  import { addressSlug } from "./content-address.mjs";
54
54
  import { protectCode } from "./code-fences.mjs";
55
55
  import { expandContentTables } from "./content-tables.mjs";
56
- import { buildSiteIndex, wikiContext } from "./site-index.mjs";
56
+ import { renderImageFigures } from "./content-images.mjs";
57
+ import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
58
+ import { ART_FIELDS } from "./frontmatter-lint.mjs";
59
+ import { buildSiteIndex, resolveInfoboxRef, wikiContext } from "./site-index.mjs";
57
60
  import { frontmatterWikilinks, resolveWebWikilinks } from "./web-wikilinks.mjs";
58
61
  import { loadForeignIndexes } from "./metadata-index.mjs";
62
+ import { noteInfoboxes } from "./infobox-registry.mjs";
59
63
  import { formatUnaddressableFinding, unaddressableForeignPackages } from "./metadata-index.mjs";
60
64
  import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
61
65
  import { loadPackConfig } from "./pack-config.mjs";
@@ -623,9 +627,13 @@ export function sectionFrontmatter(meta) {
623
627
  * for.
624
628
  * @param {(data: object, page: object) => void} [options.decorate] - Called
625
629
  * with each page's frontmatter, for whatever a consumer's own pass adds.
630
+ * @param {(src: string) => string} [options.webSrc] - Translates an authored
631
+ * pathname into the address the website serves. Every artwork field goes
632
+ * through it, so a page's `img:` and its body images name the same file the
633
+ * same way.
626
634
  * @returns {object} The frontmatter to write.
627
635
  */
628
- export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
636
+ export function pageFrontmatter(page, { readmeSections = {}, decorate, webSrc }) {
629
637
  const { fm, name, slug, sec, isReadme } = page;
630
638
  let data;
631
639
  if (page.kind === "content") {
@@ -657,9 +665,40 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
657
665
  if (meta) Object.assign(data, sectionFrontmatter(meta));
658
666
  }
659
667
  delete data.aliases;
668
+ if (webSrc) resolveArtFields(data, webSrc);
660
669
  return data;
661
670
  }
662
671
 
672
+ /**
673
+ * Rewrite a page's artwork fields into the addresses the website serves.
674
+ *
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.
680
+ *
681
+ * @param {object} data - The frontmatter being emitted, rewritten in place.
682
+ * @param {(src: string) => string} webSrc - The website's resolver.
683
+ * @returns {void}
684
+ */
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];
689
+ if (typeof value !== "string" || value === "") continue;
690
+ holder[key] = webSrc(value);
691
+ }
692
+ }
693
+
694
+ /**
695
+ * @param {unknown} value - Anything.
696
+ * @returns {boolean} Whether it is a mapping a field may be read out of.
697
+ */
698
+ function isPlainObject(value) {
699
+ return typeof value === "object" && value !== null && !Array.isArray(value);
700
+ }
701
+
663
702
  /**
664
703
  * Where a page is written, relative to the output root.
665
704
  *
@@ -704,7 +743,8 @@ export function pageDestination(page) {
704
743
  *
705
744
  * @param {object[]} pages - Every page.
706
745
  * @param {object} options - Everything the render needs.
707
- * @returns {{written: number, byKind: Record<string, number>, tableErrors: object[], wikiErrors: object[]}}
746
+ * @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
747
+ * wikiErrors: object[], imageErrors: object[]}}
708
748
  */
709
749
  export function renderPages(pages, options) {
710
750
  const {
@@ -717,12 +757,54 @@ export function renderPages(pages, options) {
717
757
  decorate,
718
758
  linkable = (d) => Boolean(d.fm.shortcode),
719
759
  sqlTables,
760
+ config,
720
761
  } = options;
721
762
 
722
763
  const tableErrors = [];
723
764
  const wikiErrors = [];
765
+ const imageErrors = [];
724
766
  const byKind = {};
725
767
 
768
+ /**
769
+ * The address the website serves for one authored pathname.
770
+ *
771
+ * A pathname the rule refuses, and a package-owned one with no asset host
772
+ * to resolve against, are both reported and emitted as authored: a page
773
+ * still publishes, with a picture the reader can see is missing, and the
774
+ * build exits non-zero. The occurrence count is what lets the command find
775
+ * the literal in the note and report a line and a column, the way a
776
+ * wikilink finding is located.
777
+ *
778
+ * @param {string} file - The note, for the finding.
779
+ * @returns {(src: string) => string} The resolver for that note's images.
780
+ */
781
+ const webAddresses = (file) => {
782
+ /** @type {Map<string, number>} */
783
+ const seen = new Map();
784
+ return (src) => {
785
+ const occurrence = (seen.get(src) ?? 0) + 1;
786
+ seen.set(src, occurrence);
787
+ const problem = pathnameProblem(src);
788
+ if (problem) {
789
+ imageErrors.push({ file, src, occurrence, message: problem });
790
+ return src;
791
+ }
792
+ const forms = resolvePathname(src, config);
793
+ if (!forms || forms.web !== null) return forms?.web ?? src;
794
+ imageErrors.push({
795
+ file,
796
+ src,
797
+ occurrence,
798
+ message:
799
+ `\`${src}\` names a file the \`${forms.package}\` package ships, and ` +
800
+ "no asset host is configured to serve it from — set `site.assets` in " +
801
+ "package-build.config.yaml. Emitted as authored, the address resolves " +
802
+ "against the page's own URL, which is nowhere",
803
+ });
804
+ return src;
805
+ };
806
+ };
807
+
726
808
  for (const page of pages) {
727
809
  // The page's path in the tree an author edits: below the content root
728
810
  // for a content note, below the tree's own root for a `trees` page. It
@@ -737,12 +819,17 @@ export function renderPages(pages, options) {
737
819
  foreignIndex: foreign.index,
738
820
  });
739
821
 
822
+ const webSrc = webAddresses(page.file);
740
823
  const resolve = (text) => {
741
824
  let t = text;
742
825
  if (pass.beforeLinks) t = pass.beforeLinks(t, page);
743
826
  t = resolveWebWikilinks(t, ctx);
744
827
  if (pass.afterLinks) t = pass.afterLinks(t, page);
745
- return t;
828
+ // Last, so a consumer's own rewrites see the image as the note
829
+ // wrote it rather than as a figure. Hugo is handed markdown, not a
830
+ // rendered page, so a `{…}` directive left in the body would reach
831
+ // the reader as its own literal braces.
832
+ return renderImageFigures(t, webSrc);
746
833
  };
747
834
 
748
835
  let body = page.body;
@@ -764,14 +851,14 @@ export function renderPages(pages, options) {
764
851
  body = markdown;
765
852
  }
766
853
 
767
- const data = pageFrontmatter(page, { readmeSections, decorate });
854
+ const data = pageFrontmatter(page, { readmeSections, decorate, webSrc });
768
855
  const dest = path.join(outRoot, pageDestination(page));
769
856
  fs.mkdirSync(path.dirname(dest), { recursive: true });
770
857
  fs.writeFileSync(dest, matter.stringify(protectCode(body, resolve), data));
771
858
  byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
772
859
  }
773
860
 
774
- return { written: pages.length, byKind, tableErrors, wikiErrors };
861
+ return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors };
775
862
  }
776
863
 
777
864
  /**
@@ -971,7 +1058,7 @@ export function resolveOutputRoot(rootDir, out) {
971
1058
  * `sql` directive with none prepared is a table error: nothing here runs a
972
1059
  * query.
973
1060
  * @returns {{gates: object, stats: object|null, tableErrors: object[],
974
- * wikiErrors: object[], manifests: object|null}}
1061
+ * wikiErrors: object[], imageErrors: object[], manifests: object|null}}
975
1062
  */
976
1063
  export function buildSite({ config, outRoot, sqlTables } = {}) {
977
1064
  const resolved = config ?? loadPackConfig();
@@ -1065,6 +1152,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1065
1152
  manifests: null,
1066
1153
  tableErrors: [],
1067
1154
  wikiErrors: [],
1155
+ imageErrors: [],
1068
1156
  stats: null,
1069
1157
  };
1070
1158
  }
@@ -1082,6 +1170,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1082
1170
  manifests: null,
1083
1171
  tableErrors: [],
1084
1172
  wikiErrors: [],
1173
+ imageErrors: [],
1085
1174
  stats: {
1086
1175
  homepages: writeHomepages(homeRoot, homepages, resolved),
1087
1176
  landings: 0,
@@ -1133,6 +1222,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1133
1222
  stats: null,
1134
1223
  tableErrors: [],
1135
1224
  wikiErrors: [],
1225
+ imageErrors: [],
1136
1226
  };
1137
1227
  }
1138
1228
 
@@ -1144,6 +1234,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1144
1234
  const rendered = renderPages(pages, {
1145
1235
  outRoot: out,
1146
1236
  sqlTables,
1237
+ config: resolved,
1147
1238
  index: gates.index,
1148
1239
  foreign: gates.foreign,
1149
1240
  universe: tableUniverse(pages),
@@ -1157,6 +1248,13 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1157
1248
  if (isBeing(page.fm)) {
1158
1249
  data.sohl = deriveBeingInfo(page.fm.sohl, gates.index.refIndex);
1159
1250
  }
1251
+ // The declared infobox, travelling in the page's own front matter.
1252
+ // The site theme draws it; what it holds is settled here, so the
1253
+ // same panel reaches the website, a compendium journal and the
1254
+ // book from one definition.
1255
+ data.infoboxes = noteInfoboxes(page.fm, {
1256
+ resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
1257
+ });
1160
1258
  },
1161
1259
  });
1162
1260
 
@@ -1174,6 +1272,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1174
1272
  gates,
1175
1273
  tableErrors: rendered.tableErrors,
1176
1274
  wikiErrors: rendered.wikiErrors,
1275
+ imageErrors: rendered.imageErrors,
1177
1276
  stats: {
1178
1277
  ...rendered.byKind,
1179
1278
  homepages: homepagesWritten,
@@ -95,9 +95,10 @@ import { isDraftNote } from "./note-vocabulary.mjs";
95
95
  * @property {Set<string>} contentTypes Every type the resolver should read as
96
96
  * an address qualifier, local and foreign.
97
97
  * @property {Set<string>} sections Section names, lowercased.
98
- * @property {Map<string, {name: string, url: string}>} refIndex `type:shortcode`
99
- * → page, for callers resolving embedded
100
- * references (a being's items, say).
98
+ * @property {Map<string, {name: string, url: string, subType?: string}>} refIndex
99
+ * `type:shortcode` → page, for callers
100
+ * resolving embedded references (a
101
+ * being's items, say).
101
102
  * @property {{key: string, package: string}[]} conflicts Addresses claimed by
102
103
  * more than one package. Non-empty is a
103
104
  * build failure; the caller reports it.
@@ -230,13 +231,23 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
230
231
  if (e.kind !== "content") continue;
231
232
  const type = String(e.fm.type).toLowerCase();
232
233
  contentTypes.add(type);
233
- const value = { url: e.url, name: e.name, draft: isDraftNote(e.fm) };
234
+ // `subType` rides along because a caller resolving a reference often
235
+ // needs to know what it found, not only where it is: an infobox groups
236
+ // a being's skills by the family each skill note declares, and that
237
+ // fact lives on the target rather than on the reference.
238
+ const value = {
239
+ url: e.url,
240
+ name: e.name,
241
+ draft: isDraftNote(e.fm),
242
+ ...(e.fm.subType ? { subType: e.fm.subType } : {}),
243
+ };
234
244
 
235
245
  const shortcode = e.fm.shortcode;
236
246
  if (typeof shortcode === "string" && shortcode) {
237
247
  refIndex.set(`${e.fm.type}:${shortcode}`, {
238
248
  name: e.name,
239
249
  url: e.url,
250
+ ...(e.fm.subType ? { subType: e.fm.subType } : {}),
240
251
  });
241
252
  index.set(`${type}/${shortcode}`.toLowerCase(), value);
242
253
  // The canonical address alongside the short one. The short form
@@ -338,3 +349,81 @@ export function wikiContext(built, { src, file, type = null, errors, foreignInde
338
349
  file,
339
350
  };
340
351
  }
352
+
353
+ /**
354
+ * Resolve one infobox reference against a site index.
355
+ *
356
+ * A note writes a reference three ways and all three reach here: a bare
357
+ * **shortcode** (`slntlncmpny`), a short **address** (`affiliation-slntlncmpny`)
358
+ * and a **canonical** one (`sohl-sohl-skill-melee`). The first is the ordinary
359
+ * case and the ambiguous one — a shortcode is unique within a type and not
360
+ * across a tree — so a caller that knows what it expects passes `hint.type`
361
+ * and the lookup is narrowed to it.
362
+ *
363
+ * **Without a hint the types are tried in sorted order**, so two trees holding
364
+ * the same note resolve it the same way. A shortcode two types both claim
365
+ * answers with the first alphabetically, which is a stable wrong answer rather
366
+ * than an unstable one; a caller that cares supplies the hint.
367
+ *
368
+ * The `address` on the answer is the slug form — `type-shortcode` — because
369
+ * that is what the book's own link map is keyed by and what a wikilink is
370
+ * written as.
371
+ *
372
+ * @param {SiteIndex} siteIndex - The index.
373
+ * @param {unknown} ref - The reference, as authored.
374
+ * @param {object} [hint] - `{type}`, where the caller knows it.
375
+ * @returns {{name?: string, url?: string, address?: string, subType?: string}|undefined}
376
+ * The page, or `undefined` where nothing answers.
377
+ */
378
+ export function resolveInfoboxRef(siteIndex, ref, hint) {
379
+ if (typeof ref !== "string" || !ref) return undefined;
380
+ const wanted = ref.toLowerCase();
381
+ const keys = [];
382
+ if (hint?.type) keys.push(`${String(hint.type).toLowerCase()}/${wanted}`);
383
+ // An authored address already names its own type, so it is tried whole
384
+ // before the type sweep — `affiliation-slntlncmpny` must not be read as a
385
+ // shortcode of some other type that happens to spell it.
386
+ if (wanted.includes("-")) {
387
+ keys.push(wanted);
388
+ const segments = wanted.split("-");
389
+ if (segments.length >= 2) {
390
+ keys.push(`${segments[segments.length - 2]}/${segments[segments.length - 1]}`);
391
+ }
392
+ }
393
+ if (!hint?.type) {
394
+ for (const type of [...(siteIndex?.contentTypes ?? [])].sort()) {
395
+ keys.push(`${type}/${wanted}`);
396
+ }
397
+ }
398
+ for (const key of keys) {
399
+ const found = siteIndex?.index?.get(key);
400
+ if (!found) continue;
401
+ const slug = key.includes("/") ? key.replace("/", "-") : addressSlugOfKey(key);
402
+ // No `uuid`, although a foreign entry carries one: this resolver
403
+ // answers for the **published** surfaces, which address a page by URL
404
+ // and an entry in the book by its slug. A compendium reference is what
405
+ // `resolveReference` answers with, from the compile's own index.
406
+ return {
407
+ ...(found.name ? { name: found.name } : {}),
408
+ ...(found.url ? { url: found.url } : {}),
409
+ ...(found.subType ? { subType: found.subType } : {}),
410
+ address: slug,
411
+ };
412
+ }
413
+ return undefined;
414
+ }
415
+
416
+ /**
417
+ * The `type-shortcode` slug of a canonical key.
418
+ *
419
+ * A canonical key is `package-system-type-shortcode`, and its slug is the last
420
+ * two segments — the same rule `contentAddress` states, applied to a key
421
+ * rather than to frontmatter.
422
+ *
423
+ * @param {string} key - The canonical key.
424
+ * @returns {string} The slug.
425
+ */
426
+ function addressSlugOfKey(key) {
427
+ const segments = String(key).split("-");
428
+ return segments.length >= 2 ? segments.slice(-2).join("-") : key;
429
+ }
@@ -781,3 +781,96 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
781
781
 
782
782
  return { markdown: out, unresolved };
783
783
  }
784
+
785
+ /**
786
+ * The short `type/shortcode` view of an index's foreign entries.
787
+ *
788
+ * Derived once per index and memoised: a compile resolves references on every
789
+ * one of thousands of notes, and rebuilding the view per note would rescan a
790
+ * dependency's whole published index each time. Keyed on the index object, so
791
+ * it is discarded with it.
792
+ *
793
+ * An address two foreign packages both claim is left out rather than resolved
794
+ * to whichever loaded first — the same rule the wikilink resolver follows.
795
+ *
796
+ * @type {WeakMap<object, Map<string, object>>}
797
+ */
798
+ const FOREIGN_BY_SHORTCODE = new WeakMap();
799
+
800
+ /** The memoised short view of `index.foreign`. */
801
+ function foreignByShortcode(index) {
802
+ const cached = FOREIGN_BY_SHORTCODE.get(index);
803
+ if (cached) return cached;
804
+ const short = new Map();
805
+ const ambiguous = new Set();
806
+ for (const [key, value] of index?.foreign ?? []) {
807
+ const parts = readCanonicalKey(key);
808
+ if (!parts) continue;
809
+ const shortKey = `${norm(parts.type)}/${norm(parts.shortcode)}`;
810
+ if (short.has(shortKey) && short.get(shortKey).package !== value.package) {
811
+ ambiguous.add(shortKey);
812
+ } else {
813
+ short.set(shortKey, value);
814
+ }
815
+ }
816
+ for (const key of ambiguous) short.delete(key);
817
+ FOREIGN_BY_SHORTCODE.set(index, short);
818
+ return short;
819
+ }
820
+
821
+ /**
822
+ * Resolve one reference against a compile's address index.
823
+ *
824
+ * The compile-time counterpart of
825
+ * {@link module:engine/site-index.resolveInfoboxRef}: the same three authored
826
+ * forms — a bare shortcode, a short address, a canonical one — answered with
827
+ * what a **compendium** can use. A local target answers with the UUID its own
828
+ * document was addressed by; a foreign one with the UUID its package
829
+ * published.
830
+ *
831
+ * @param {object} index - From {@link buildWikilinkIndex}.
832
+ * @param {unknown} ref - The reference, as authored.
833
+ * @param {object} [hint] - `{type}`, where the caller knows what it expects.
834
+ * @returns {{name?: string, uuid?: string, address?: string, subType?: string}|undefined}
835
+ * The target, or `undefined` where nothing answers.
836
+ */
837
+ export function resolveReference(index, ref, hint) {
838
+ if (typeof ref !== "string" || !ref) return undefined;
839
+ const wanted = norm(ref);
840
+ const keys = [];
841
+ if (hint?.type) keys.push(`${norm(hint.type)}/${wanted}`);
842
+ if (wanted.includes("-")) {
843
+ const segments = wanted.split("-");
844
+ if (segments.length >= 2) {
845
+ keys.push(`${segments[segments.length - 2]}/${segments[segments.length - 1]}`);
846
+ }
847
+ }
848
+ if (!hint?.type) {
849
+ for (const type of [...(index?.types ?? [])].sort()) keys.push(`${type}/${wanted}`);
850
+ }
851
+
852
+ const foreign = foreignByShortcode(index);
853
+ for (const key of keys) {
854
+ const local = index?.byShortcode?.get(key);
855
+ if (local) {
856
+ return {
857
+ ...(local.name ? { name: local.name } : {}),
858
+ ...(local.subType ? { subType: local.subType } : {}),
859
+ ...(index.uuidByDoc?.get(local)?.uuid ?
860
+ { uuid: index.uuidByDoc.get(local).uuid }
861
+ : {}),
862
+ address: key.replace("/", "-"),
863
+ };
864
+ }
865
+ const away = foreign.get(key);
866
+ if (away) {
867
+ return {
868
+ ...(away.name ? { name: away.name } : {}),
869
+ ...(away.uuid ? { uuid: away.uuid } : {}),
870
+ ...(away.subType ? { subType: away.subType } : {}),
871
+ address: key.replace("/", "-"),
872
+ };
873
+ }
874
+ }
875
+ return undefined;
876
+ }
@@ -19,12 +19,11 @@
19
19
  * no `img:` of its own, rather than shipping a mismatched icon. So every type
20
20
  * `item-builders.mjs` declares has a row here.
21
21
  *
22
- * **Paths are fully resolved.** `resolveImg` rewrites a leading `icons/` or
23
- * `images/` to the *consuming package's* asset root, which is not where these
24
- * live: they are shipped by the HM3 system. Written as `systems/hm3/images/…`
25
- * they pass through untouched and address the icons HM3's own compendiums
26
- * already use, so an item compiled from a note looks like its hand-authored
27
- * neighbours.
22
+ * **Paths name the package that owns the file.** These icons are not the
23
+ * consuming package's they are shipped by the HM3 system — so they are
24
+ * written `hm3/assets/…` and each surface derives its own address from that.
25
+ * That addresses the icons HM3's own compendiums already use, so an item
26
+ * compiled from a note looks like its hand-authored neighbours.
28
27
  *
29
28
  * **A one-to-many type gets one default**, because art is keyed by note type
30
29
  * and a note type is what a registry entry addresses. `weapongear` compiles
@@ -42,15 +41,15 @@
42
41
  * @type {Readonly<Record<string, string>>}
43
42
  */
44
43
  export const HM3_DEFAULT_ITEM_ART = Object.freeze({
45
- armorgear: "systems/hm3/images/icons/svg/armor.svg",
46
- armorlocation: "systems/hm3/images/icons/svg/anatomy.svg",
47
- containergear: "systems/hm3/images/icons/svg/sack.svg",
48
- miscgear: "systems/hm3/images/icons/svg/miscgear.svg",
49
- mysticalability: "systems/hm3/images/icons/svg/psionics.svg",
50
- projectilegear: "systems/hm3/images/icons/svg/arrow.svg",
51
- skill: "systems/hm3/images/icons/svg/skills.svg",
52
- trauma: "systems/hm3/images/icons/svg/injury.svg",
53
- weapongear: "systems/hm3/images/icons/svg/sword.svg",
44
+ armorgear: "hm3/assets/images/icons/svg/armor.svg",
45
+ armorlocation: "hm3/assets/images/icons/svg/anatomy.svg",
46
+ containergear: "hm3/assets/images/icons/svg/sack.svg",
47
+ miscgear: "hm3/assets/images/icons/svg/miscgear.svg",
48
+ mysticalability: "hm3/assets/images/icons/svg/psionics.svg",
49
+ projectilegear: "hm3/assets/images/icons/svg/arrow.svg",
50
+ skill: "hm3/assets/images/icons/svg/skills.svg",
51
+ trauma: "hm3/assets/images/icons/svg/injury.svg",
52
+ weapongear: "hm3/assets/images/icons/svg/sword.svg",
54
53
  });
55
54
 
56
55
  /**
package/hm3/index.mjs CHANGED
@@ -51,6 +51,9 @@ export * as actors from "./actors.mjs";
51
51
  /** Where HM3 records the template priority — one rule, called by both compilers. */
52
52
  export * as templatePriority from "./template-priority.mjs";
53
53
 
54
+ /** Which of HM3's facts a note's summary panel carries. */
55
+ export * as infobox from "./infobox.mjs";
56
+
54
57
  // Flat as well as namespaced, matching the SoHL barrel: the default-art map is
55
58
  // the one export a Foundry runtime would import by name.
56
59
  export { HM3_DEFAULT_ITEM_ART, hm3DefaultItemArt } from "./default-item-art.mjs";