@heroiclands/package-build 20.6.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 (65) hide show
  1. package/CHANGELOG.md +181 -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 +115 -8
  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 +3 -2
  36. package/sohl/being-info.mjs +9 -3
  37. package/sohl/default-item-art.mjs +18 -16
  38. package/sohl/index.mjs +3 -0
  39. package/sohl/infobox.mjs +499 -0
  40. package/types/content-config.d.mts +7 -0
  41. package/types/engine/content-format.d.mts +36 -0
  42. package/types/engine/content-images.d.mts +281 -0
  43. package/types/engine/dependency-bump.d.mts +89 -0
  44. package/types/engine/frontmatter-lint.d.mts +23 -0
  45. package/types/engine/helpers.d.mts +30 -72
  46. package/types/engine/index.d.mts +5 -0
  47. package/types/engine/infobox-registry.d.mts +36 -0
  48. package/types/engine/infobox-render.d.mts +87 -0
  49. package/types/engine/infobox.d.mts +443 -0
  50. package/types/engine/item-registry.d.mts +5 -5
  51. package/types/engine/journals.d.mts +9 -1
  52. package/types/engine/note-vocabulary.d.mts +51 -0
  53. package/types/engine/pathnames.d.mts +189 -0
  54. package/types/engine/pdf-build.d.mts +46 -0
  55. package/types/engine/pdf-render.d.mts +99 -1
  56. package/types/engine/pdf-toc.d.mts +10 -5
  57. package/types/engine/site-build.d.mts +19 -3
  58. package/types/engine/site-index.d.mts +35 -3
  59. package/types/engine/wikilinks.d.mts +22 -0
  60. package/types/hm3/default-item-art.d.mts +5 -6
  61. package/types/hm3/index.d.mts +1 -0
  62. package/types/hm3/infobox.d.mts +22 -0
  63. package/types/sohl/being-info.d.mts +4 -3
  64. package/types/sohl/index.d.mts +1 -0
  65. 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";
@@ -267,6 +271,14 @@ export function collectContentPages(contentBase, ctx) {
267
271
  * addressed by type and slug: they are a book with chapters, and a reader
268
272
  * follows their paths. A `README` is its directory's landing.
269
273
  *
274
+ * **The section is the tree's, never the note's.** `tree.section` is the
275
+ * mount point a `trees` entry configures — fixed, physical, and the same
276
+ * value `site-index.mjs` indexes a tree page's address under. A note's own
277
+ * `subType` is a genre and reaches no address, the same contract
278
+ * `packageAddress()` holds for a content page: reading it here would move a
279
+ * page's URL, its file destination (`pageDestination`) and the address a
280
+ * wikilink cites it by, every time an author classified it.
281
+ *
270
282
  * @param {object} tree - `{ from, rel, section, route }`.
271
283
  * @param {object} ctx - `{ mount }`.
272
284
  * @returns {{pages: object[], fmLinkFindings: object[]}}
@@ -287,7 +299,7 @@ export function collectTreePages(tree, ctx) {
287
299
  const rel = path.relative(tree.from, file).replace(/\\/g, "/");
288
300
  const base = path.basename(rel);
289
301
  const isReadme = base.toLowerCase() === "readme.md";
290
- const sec = fm.subType ?? tree.section;
302
+ const sec = tree.section;
291
303
  const h1 = /^#\s+(.+?)\s*$/m.exec(body);
292
304
  const h1Title = h1 ? h1[1].replace(/\{@link\s+[^}]*\}/g, "").trim() : null;
293
305
  const name = fm.name?.full ?? fm.title ?? h1Title ?? path.basename(base, ".md");
@@ -615,9 +627,13 @@ export function sectionFrontmatter(meta) {
615
627
  * for.
616
628
  * @param {(data: object, page: object) => void} [options.decorate] - Called
617
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.
618
634
  * @returns {object} The frontmatter to write.
619
635
  */
620
- export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
636
+ export function pageFrontmatter(page, { readmeSections = {}, decorate, webSrc }) {
621
637
  const { fm, name, slug, sec, isReadme } = page;
622
638
  let data;
623
639
  if (page.kind === "content") {
@@ -649,9 +665,40 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
649
665
  if (meta) Object.assign(data, sectionFrontmatter(meta));
650
666
  }
651
667
  delete data.aliases;
668
+ if (webSrc) resolveArtFields(data, webSrc);
652
669
  return data;
653
670
  }
654
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
+
655
702
  /**
656
703
  * Where a page is written, relative to the output root.
657
704
  *
@@ -696,7 +743,8 @@ export function pageDestination(page) {
696
743
  *
697
744
  * @param {object[]} pages - Every page.
698
745
  * @param {object} options - Everything the render needs.
699
- * @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[]}}
700
748
  */
701
749
  export function renderPages(pages, options) {
702
750
  const {
@@ -709,12 +757,54 @@ export function renderPages(pages, options) {
709
757
  decorate,
710
758
  linkable = (d) => Boolean(d.fm.shortcode),
711
759
  sqlTables,
760
+ config,
712
761
  } = options;
713
762
 
714
763
  const tableErrors = [];
715
764
  const wikiErrors = [];
765
+ const imageErrors = [];
716
766
  const byKind = {};
717
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
+
718
808
  for (const page of pages) {
719
809
  // The page's path in the tree an author edits: below the content root
720
810
  // for a content note, below the tree's own root for a `trees` page. It
@@ -729,12 +819,17 @@ export function renderPages(pages, options) {
729
819
  foreignIndex: foreign.index,
730
820
  });
731
821
 
822
+ const webSrc = webAddresses(page.file);
732
823
  const resolve = (text) => {
733
824
  let t = text;
734
825
  if (pass.beforeLinks) t = pass.beforeLinks(t, page);
735
826
  t = resolveWebWikilinks(t, ctx);
736
827
  if (pass.afterLinks) t = pass.afterLinks(t, page);
737
- 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);
738
833
  };
739
834
 
740
835
  let body = page.body;
@@ -756,14 +851,14 @@ export function renderPages(pages, options) {
756
851
  body = markdown;
757
852
  }
758
853
 
759
- const data = pageFrontmatter(page, { readmeSections, decorate });
854
+ const data = pageFrontmatter(page, { readmeSections, decorate, webSrc });
760
855
  const dest = path.join(outRoot, pageDestination(page));
761
856
  fs.mkdirSync(path.dirname(dest), { recursive: true });
762
857
  fs.writeFileSync(dest, matter.stringify(protectCode(body, resolve), data));
763
858
  byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
764
859
  }
765
860
 
766
- return { written: pages.length, byKind, tableErrors, wikiErrors };
861
+ return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors };
767
862
  }
768
863
 
769
864
  /**
@@ -963,7 +1058,7 @@ export function resolveOutputRoot(rootDir, out) {
963
1058
  * `sql` directive with none prepared is a table error: nothing here runs a
964
1059
  * query.
965
1060
  * @returns {{gates: object, stats: object|null, tableErrors: object[],
966
- * wikiErrors: object[], manifests: object|null}}
1061
+ * wikiErrors: object[], imageErrors: object[], manifests: object|null}}
967
1062
  */
968
1063
  export function buildSite({ config, outRoot, sqlTables } = {}) {
969
1064
  const resolved = config ?? loadPackConfig();
@@ -1057,6 +1152,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1057
1152
  manifests: null,
1058
1153
  tableErrors: [],
1059
1154
  wikiErrors: [],
1155
+ imageErrors: [],
1060
1156
  stats: null,
1061
1157
  };
1062
1158
  }
@@ -1074,6 +1170,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1074
1170
  manifests: null,
1075
1171
  tableErrors: [],
1076
1172
  wikiErrors: [],
1173
+ imageErrors: [],
1077
1174
  stats: {
1078
1175
  homepages: writeHomepages(homeRoot, homepages, resolved),
1079
1176
  landings: 0,
@@ -1125,6 +1222,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1125
1222
  stats: null,
1126
1223
  tableErrors: [],
1127
1224
  wikiErrors: [],
1225
+ imageErrors: [],
1128
1226
  };
1129
1227
  }
1130
1228
 
@@ -1136,6 +1234,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1136
1234
  const rendered = renderPages(pages, {
1137
1235
  outRoot: out,
1138
1236
  sqlTables,
1237
+ config: resolved,
1139
1238
  index: gates.index,
1140
1239
  foreign: gates.foreign,
1141
1240
  universe: tableUniverse(pages),
@@ -1149,6 +1248,13 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1149
1248
  if (isBeing(page.fm)) {
1150
1249
  data.sohl = deriveBeingInfo(page.fm.sohl, gates.index.refIndex);
1151
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
+ });
1152
1258
  },
1153
1259
  });
1154
1260
 
@@ -1166,6 +1272,7 @@ export function buildSite({ config, outRoot, sqlTables } = {}) {
1166
1272
  gates,
1167
1273
  tableErrors: rendered.tableErrors,
1168
1274
  wikiErrors: rendered.wikiErrors,
1275
+ imageErrors: rendered.imageErrors,
1169
1276
  stats: {
1170
1277
  ...rendered.byKind,
1171
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";