@heroiclands/package-build 21.1.0 → 22.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 (115) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/CONTENT.md +107 -3
  3. package/assets/fonts/libertinus/LibertinusMono-Regular.otf +0 -0
  4. package/assets/fonts/libertinus/LibertinusSans-Bold.otf +0 -0
  5. package/assets/fonts/libertinus/LibertinusSans-Italic.otf +0 -0
  6. package/assets/fonts/libertinus/LibertinusSans-Regular.otf +0 -0
  7. package/assets/fonts/libertinus/OFL.txt +94 -0
  8. package/assets/fonts/libertinus/provenance.yaml +11 -0
  9. package/assets/images/banners/afflictionbnr.webp +0 -0
  10. package/assets/images/banners/armorclothingbnr.webp +0 -0
  11. package/assets/images/banners/attributebnr.webp +0 -0
  12. package/assets/images/banners/containerbnr.webp +0 -0
  13. package/assets/images/banners/devdocsbnr.webp +0 -0
  14. package/assets/images/banners/miscgearbnr.webp +0 -0
  15. package/assets/images/banners/mysticalabilitybnr.webp +0 -0
  16. package/assets/images/banners/projectilebnr.webp +0 -0
  17. package/assets/images/banners/rulesbnr.webp +0 -0
  18. package/assets/images/banners/skillbnr.webp +0 -0
  19. package/assets/images/banners/traumabnr.webp +0 -0
  20. package/assets/images/banners/userguidebnr.webp +0 -0
  21. package/assets/images/banners/weapongearbnr.webp +0 -0
  22. package/assets/images/provenance.yaml +4 -0
  23. package/bin/content-build.mjs +19 -5
  24. package/bin/package-build.mjs +6 -5
  25. package/ci/emit-asset-index.mjs +32 -0
  26. package/content-config.mjs +30 -1
  27. package/docs/api.md +103 -27
  28. package/docs/commands.md +13 -11
  29. package/docs/configuration.md +16 -7
  30. package/docs/content-format.md +523 -111
  31. package/engine/actor-compiler.mjs +58 -9
  32. package/engine/art-fields.mjs +292 -0
  33. package/engine/asset-index.mjs +397 -0
  34. package/engine/asset-types.mjs +192 -0
  35. package/engine/base-compiler.mjs +69 -1
  36. package/engine/bundles.mjs +5 -5
  37. package/engine/compile-corpus.mjs +4 -0
  38. package/engine/content-address.mjs +14 -2
  39. package/engine/content-charset.mjs +5 -1
  40. package/engine/content-embeds.mjs +314 -0
  41. package/engine/content-html.mjs +5 -1
  42. package/engine/content-icons.mjs +5 -1
  43. package/engine/content-images.mjs +26 -5
  44. package/engine/content-index.mjs +68 -33
  45. package/engine/content-links.mjs +164 -24
  46. package/engine/dependency-bump.mjs +14 -16
  47. package/engine/frontmatter-lint.mjs +36 -23
  48. package/engine/helpers.mjs +83 -31
  49. package/engine/index-records.mjs +63 -4
  50. package/engine/index.mjs +15 -0
  51. package/engine/infobox-registry.mjs +50 -3
  52. package/engine/infobox.mjs +12 -8
  53. package/engine/item-compiler.mjs +10 -10
  54. package/engine/journals.mjs +3 -0
  55. package/engine/macros.mjs +16 -13
  56. package/engine/map-notes.mjs +29 -22
  57. package/engine/metadata-index.mjs +44 -36
  58. package/engine/note-vocabulary.mjs +71 -29
  59. package/engine/pack-router.mjs +14 -0
  60. package/engine/packagebuild-index.mjs +124 -0
  61. package/engine/packages.mjs +83 -0
  62. package/engine/pathnames.mjs +74 -0
  63. package/engine/pdf-build.mjs +133 -8
  64. package/engine/pdf-render.mjs +49 -12
  65. package/engine/scenes.mjs +12 -9
  66. package/engine/site-build.mjs +43 -16
  67. package/engine/site-index.mjs +10 -1
  68. package/engine/subtype-registry.mjs +21 -0
  69. package/engine/web-wikilinks.mjs +46 -12
  70. package/engine/wikilink-syntax.mjs +20 -2
  71. package/engine/wikilinks.mjs +15 -2
  72. package/hm3/actors.mjs +11 -38
  73. package/manifest.mjs +67 -13
  74. package/package.json +5 -2
  75. package/packagebuild-metadata.jsonl +13 -0
  76. package/sohl/actors.mjs +12 -86
  77. package/sohl/note-schemas.mjs +4 -16
  78. package/types/content-config.d.mts +8 -0
  79. package/types/engine/actor-compiler.d.mts +29 -0
  80. package/types/engine/art-fields.d.mts +192 -0
  81. package/types/engine/asset-index.d.mts +92 -0
  82. package/types/engine/asset-types.d.mts +110 -0
  83. package/types/engine/base-compiler.d.mts +42 -1
  84. package/types/engine/compile-corpus.d.mts +4 -1
  85. package/types/engine/content-charset.d.mts +4 -0
  86. package/types/engine/content-embeds.d.mts +218 -0
  87. package/types/engine/content-html.d.mts +4 -0
  88. package/types/engine/content-icons.d.mts +4 -0
  89. package/types/engine/content-images.d.mts +15 -2
  90. package/types/engine/content-index.d.mts +27 -15
  91. package/types/engine/dependency-bump.d.mts +2 -2
  92. package/types/engine/frontmatter-lint.d.mts +11 -14
  93. package/types/engine/helpers.d.mts +7 -2
  94. package/types/engine/index-records.d.mts +44 -3
  95. package/types/engine/index.d.mts +5 -0
  96. package/types/engine/infobox-registry.d.mts +36 -1
  97. package/types/engine/infobox.d.mts +6 -4
  98. package/types/engine/macros.d.mts +9 -5
  99. package/types/engine/map-notes.d.mts +10 -6
  100. package/types/engine/metadata-index.d.mts +2 -17
  101. package/types/engine/note-vocabulary.d.mts +30 -0
  102. package/types/engine/packagebuild-index.d.mts +56 -0
  103. package/types/engine/packages.d.mts +62 -0
  104. package/types/engine/pathnames.d.mts +41 -81
  105. package/types/engine/pdf-build.d.mts +58 -1
  106. package/types/engine/pdf-render.d.mts +3 -1
  107. package/types/engine/scenes.d.mts +2 -1
  108. package/types/engine/site-build.d.mts +5 -1
  109. package/types/engine/site-index.d.mts +4 -1
  110. package/types/engine/subtype-registry.d.mts +20 -0
  111. package/types/engine/web-wikilinks.d.mts +14 -4
  112. package/types/engine/wikilink-syntax.d.mts +13 -1
  113. package/types/engine/wikilinks.d.mts +7 -1
  114. package/types/manifest.d.mts +42 -1
  115. package/types/sohl/actors.d.mts +0 -1
@@ -32,8 +32,9 @@ import yaml from "yaml";
32
32
  import unidecode from "unidecode";
33
33
  import markdownit from "markdown-it";
34
34
  import { iconPlugin } from "./content-icons.mjs";
35
- import { imagePlugin } from "./content-images.mjs";
36
- import { resolvePathname } from "./pathnames.mjs";
35
+ import { imagePlugin, imagesIn } from "./content-images.mjs";
36
+ import { resolveEmbeds } from "./content-embeds.mjs";
37
+ import { foundryAddressProblem, pathnameProblem, resolvePathname } from "./pathnames.mjs";
37
38
  import log from "loglevel";
38
39
 
39
40
  import { loadPackConfig } from "./pack-config.mjs";
@@ -46,7 +47,7 @@ import { loadForeignIndexes } from "./metadata-index.mjs";
46
47
  // The record accessors only — deriving records reaches the pack router and the
47
48
  // manifest emitter, which reach the compilers, which load this module. Reading
48
49
  // a record needs none of that.
49
- import { authoredFrontmatter, isNoteRecord, noteFile } from "./index-records.mjs";
50
+ import { authoredFrontmatter, isAssetRecord, isNoteRecord, noteFile } from "./index-records.mjs";
50
51
  import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
51
52
  // One vocabulary of link findings, and one message per class, so the three
52
53
  // resolvers cannot word the same defect differently.
@@ -100,13 +101,16 @@ export const md = markdownit({ html: true })
100
101
  // translated by the same rule `img:` follows.
101
102
  .use(
102
103
  imagePlugin((src) => {
103
- // **Reported elsewhere, never here.** A renderer has no channel to
104
+ // **Reported upstream, never here.** A renderer has no channel to
104
105
  // report through, and this one runs inside the very passes whose
105
106
  // job is to collect findings — a throw would take the whole lint
106
- // down and lose every other finding in the tree. A pathname this
107
- // rule refuses is a Foundry address, so emitting it unchanged is
108
- // right on the one surface this renderer serves, and the passes
109
- // that own the other three refuse it with a line and a column.
107
+ // down and lose every other finding in the tree. So nothing reaches
108
+ // this point unreported: {@link convertNoteWikilinks} refuses a body
109
+ // image with no address inside the install before a compiler renders
110
+ // one, and the address passes report the same pathname with a line
111
+ // and a column. What is left here is a fallback for a caller with no
112
+ // configuration to resolve against, where the authored pathname is
113
+ // the most honest thing to emit.
110
114
  try {
111
115
  return resolveImg(src, loadPackConfig()) ?? src;
112
116
  } catch {
@@ -494,19 +498,9 @@ export function resolveImg(raw, config = loadPackConfig()) {
494
498
  const forms = resolvePathname(raw, config);
495
499
  if (forms === null) return null;
496
500
  if (forms.foundry !== null) return forms.foundry;
497
- if (forms.own) {
498
- throw new Error(
499
- `package-build: \`${forms.authored}\` names a file this package serves, ` +
500
- `and a \`documentation\` package has no asset root to serve it from — ` +
501
- `Foundry installs no such package. Address a \`/\`-rooted path or a URL.`,
502
- );
503
- }
504
- throw new Error(
505
- `package-build: \`${forms.authored}\` names a file the \`${forms.package}\` ` +
506
- `package ships, and this build declares no relationship with a package of ` +
507
- `that name, so there is no Foundry address to derive. Declare it under ` +
508
- `\`relationships\`, or address the file by a \`/\`-rooted path.`,
509
- );
501
+ // One sentence per class, stated where the rule is, so an art field that
502
+ // refuses and a body image that refuses tell an author the same thing.
503
+ throw new Error(`package-build: ${foundryAddressProblem(raw, config)}.`);
510
504
  }
511
505
 
512
506
  /**
@@ -704,9 +698,18 @@ export function buildContentLinkIndex(
704
698
  { skipDirectories, config, records, problems } = {},
705
699
  ) {
706
700
  const docs = [];
701
+ /** The files this package ships, by canonical address. */
702
+ const assets = new Map();
707
703
  const resolved = config ?? loadPackConfig();
708
704
  assertSuppliedCorpus(records, "buildContentLinkIndex");
709
705
  for (const record of records) {
706
+ // An asset's record addresses a file rather than a note, so it becomes
707
+ // no `doc` and takes no part in link resolution — it is keyed for the
708
+ // art fields, which name a file and never a document.
709
+ if (isAssetRecord(record)) {
710
+ if (record.address?.canonical) assets.set(record.address.canonical, record);
711
+ continue;
712
+ }
710
713
  // A documentation journal is a document this tree emits, not a note in
711
714
  // it; the note it documents is indexed here and carries its address.
712
715
  if (!isNoteRecord(record)) continue;
@@ -782,7 +785,9 @@ export function buildContentLinkIndex(
782
785
  `Wikilink index: ${docs.length} local document(s), ` +
783
786
  `${foreign.size} foreign address(es)`,
784
787
  );
785
- return buildWikilinkIndex(docs, resolved.foundryPackage, foreign, resolved.contentPackage);
788
+ return buildWikilinkIndex(docs, resolved.foundryPackage, foreign, resolved.contentPackage, {
789
+ assets,
790
+ });
786
791
  }
787
792
 
788
793
  /**
@@ -807,6 +812,9 @@ export function buildContentLinkIndex(
807
812
  * in, addressing a self-link the same way.
808
813
  * @param {object} ctx.index - The address index every link resolves through.
809
814
  * @param {string} ctx.name - The note, for the message.
815
+ * @param {object} [ctx.config] - The resolved build configuration, which is
816
+ * what says which packages this build can address a file in. Loaded when
817
+ * omitted, exactly as an art field's resolution loads it.
810
818
  * @param {string} [ctx.file] - The note's file, so a report names it.
811
819
  * @param {number} [ctx.bodyLine] - 1-based file line of the body's first line.
812
820
  * @param {number} [ctx.bodyColumn] - 1-based file column of the same character.
@@ -814,15 +822,24 @@ export function buildContentLinkIndex(
814
822
  * authored line each body line came from, from {@link expandNoteTables}.
815
823
  * @returns {{markdown: string, unresolved: Array<object>}}
816
824
  * @throws {Error} On any link that does not resolve — an unlabelled one, a
817
- * target that is not an address, or an address nothing publishes. The error
825
+ * target that is not an address, or an address nothing publishes and on a
826
+ * body image whose pathname names no file a Foundry install serves. The error
818
827
  * carries `file` and `position`, so a caller reports it in the same form
819
828
  * rather than re-deriving one.
820
829
  */
821
830
  export function convertNoteWikilinks(
822
831
  body,
823
- { type, id, pack, docPack, index, name, file, bodyLine, bodyColumn, lineMap },
832
+ { type, id, pack, docPack, index, name, file, bodyLine, bodyColumn, lineMap, config },
824
833
  ) {
825
- const result = convertWikilinks(body ?? "", {
834
+ const source = body ?? "";
835
+ // **Embeds first, and on the authored text.** An embed's interior is a
836
+ // wikilink, so resolving it here is what stops the link pass reading
837
+ // `![[wildboar|Wild Boar]]` as a link to a note called `wildboar`. The
838
+ // rewrite it produces carries no newline, so the line every later offset
839
+ // lands on — and the `lineMap` a generated table supplied — survive it, and
840
+ // only the embed's own findings need the authored text to locate against.
841
+ const embedded = resolveEmbeds(source, { index });
842
+ const result = convertWikilinks(embedded.markdown, {
826
843
  type,
827
844
  id,
828
845
  pack,
@@ -832,14 +849,15 @@ export function convertNoteWikilinks(
832
849
  /**
833
850
  * Where one unresolved link sits, in file coordinates.
834
851
  *
835
- * @param {object} u - An entry of `result.unresolved`.
852
+ * @param {object} u - An entry of an `unresolved` list.
853
+ * @param {string} text - The body its `offset` indexes into.
836
854
  * @returns {{line?: number, column?: number, generated?: boolean}} Empty
837
855
  * when the caller supplied no position to resolve against.
838
856
  */
839
- const locate = (u) =>
857
+ const locate = (u, text) =>
840
858
  bodyLine === undefined || u.offset === undefined ?
841
859
  {}
842
- : positionInBody(body ?? "", u.offset, {
860
+ : positionInBody(text, u.offset, {
843
861
  bodyLine,
844
862
  bodyColumn,
845
863
  lineMap,
@@ -850,10 +868,11 @@ export function convertNoteWikilinks(
850
868
  *
851
869
  * @param {object} u - The offending link.
852
870
  * @param {string} message - What is wrong.
871
+ * @param {string} text - The body its `offset` indexes into.
853
872
  * @returns {never}
854
873
  */
855
- const fail = (u, message) => {
856
- const at = locate(u);
874
+ const fail = (u, message, text) => {
875
+ const at = locate(u, text);
857
876
  // A link this build wrote is not at any authored position, so say
858
877
  // where it came from rather than implying an edit site.
859
878
  const err = new Error(
@@ -864,6 +883,39 @@ export function convertNoteWikilinks(
864
883
  throw err;
865
884
  };
866
885
 
886
+ for (const u of embedded.unresolved) {
887
+ fail(u, `${linkFindingMessage(u)} — in "${name}".`, source);
888
+ }
889
+ // A directive that cannot be honoured is refused for the reason an image's
890
+ // is: rendering as the ordinary width looks exactly like a directive that
891
+ // worked, so it fails here rather than publishing a page nobody asked for.
892
+ for (const problem of embedded.problems) {
893
+ fail(problem, `${problem.message} — in "${name}".`, source);
894
+ }
895
+
896
+ // **One scan, both spellings.** By here an `![[…]]` embed has been
897
+ // rewritten into the ordinary image every surface renders, so a single walk
898
+ // of the rewritten body holds an authored `![alt](…)` and an embed to the
899
+ // same rule — there is no second image path to keep in step.
900
+ //
901
+ // A pathname the rule refuses is **refused here too, not emitted**. The
902
+ // renderer below has no channel to report through and hands the journal the
903
+ // pathname as authored — which for one class resolves against nothing
904
+ // Foundry serves, and for the other resolves here and on neither of the
905
+ // remaining two surfaces. Every surface refuses such a value rather than
906
+ // deriving an address from it, and this is that refusal on the Foundry one:
907
+ // the same one an art field already makes through {@link resolveImg}.
908
+ const images = imagesIn(embedded.markdown);
909
+ if (images.length) {
910
+ const resolved = config ?? loadPackConfig();
911
+ for (const image of images) {
912
+ const problem =
913
+ pathnameProblem(image.src) || foundryAddressProblem(image.src, resolved);
914
+ if (!problem) continue;
915
+ fail({ offset: image.index }, `${problem} — in "${name}".`, embedded.markdown);
916
+ }
917
+ }
918
+
867
919
  for (const u of result.unresolved) {
868
920
  // Every class fails, and every class is worded by the shared table.
869
921
  // The three resolvers read one authored link, so an author who
@@ -873,7 +925,7 @@ export function convertNoteWikilinks(
873
925
  //
874
926
  // The note's name is appended rather than woven in: the message is the
875
927
  // defect, the name is the context this build can add.
876
- fail(u, `${linkFindingMessage(u)} — in "${name}".`);
928
+ fail(u, `${linkFindingMessage(u)} — in "${name}".`, embedded.markdown);
877
929
  }
878
930
  return result;
879
931
  }
@@ -59,8 +59,30 @@ export const DERIVED_KEYS = Object.freeze([
59
59
  "foundry",
60
60
  "documentation",
61
61
  "documents",
62
+ "asset",
62
63
  ]);
63
64
 
65
+ /**
66
+ * Recursively sort an object's keys, so serialization is order-independent.
67
+ *
68
+ * Arrays keep their order — it is authored — but every object inside one is
69
+ * sorted too. Anything that is not a plain object is returned as it is.
70
+ *
71
+ * @param {unknown} value - The value to normalize.
72
+ * @returns {unknown} The value with every plain object's keys in sorted order.
73
+ */
74
+ export function sortKeysDeep(value) {
75
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
76
+ if (value === null || typeof value !== "object") return value;
77
+ // A Date or any other exotic object would lose itself in a rebuild from
78
+ // entries, and YAML frontmatter can produce one.
79
+ if (Object.getPrototypeOf(value) !== Object.prototype) return value;
80
+ /** @type {Record<string, unknown>} */
81
+ const out = {};
82
+ for (const key of Object.keys(value).sort()) out[key] = sortKeysDeep(value[key]);
83
+ return out;
84
+ }
85
+
64
86
  /**
65
87
  * The file a record was read from, as an absolute path.
66
88
  *
@@ -109,18 +131,55 @@ export function authoredFrontmatter(record) {
109
131
  }
110
132
 
111
133
  /**
112
- * Whether a record is a note's, rather than a documentation journal's.
134
+ * Whether a record addresses a **file** rather than a note.
135
+ *
136
+ * The index holds two record shapes in one file, and this is how a reader tells
137
+ * them apart. An asset record carries no frontmatter, no anchors and no
138
+ * `foundry` block — a `.webp` declares nothing about itself — so every pass that
139
+ * reads a note's fields has to skip it, and the `asset` block is what marks it.
140
+ *
141
+ * Asked of the block rather than of `type`, so a fourth asset type needs no
142
+ * edit here: what makes a record an asset's is that it describes a file, and the
143
+ * block is the description.
144
+ *
145
+ * @param {Record<string, any>} record - An index record.
146
+ * @returns {boolean} True for an asset's record.
147
+ */
148
+ export function isAssetRecord(record) {
149
+ return Boolean(record?.asset);
150
+ }
151
+
152
+ /**
153
+ * Whether a record is a note's, rather than a documentation journal's or an
154
+ * asset's.
113
155
  *
114
156
  * An item note yields two records — itself and the JournalEntry its prose
115
157
  * compiles into — and the second is a document, not a note: it has no file of
116
158
  * its own to read, no frontmatter an author wrote, and its `type` is the
117
159
  * virtual `doc<type>` that `readQualifier` resolves rather than a type any tree
118
- * declares. A reader enumerating the corpus wants the notes; one resolving an
119
- * address wants both.
160
+ * declares. An asset's record is not a note either, for the stronger reason that
161
+ * nobody authored it at all. A reader enumerating the corpus wants the notes;
162
+ * one resolving an address wants all three.
120
163
  *
121
164
  * @param {Record<string, any>} record - An index record.
122
165
  * @returns {boolean} True for a note's own record.
123
166
  */
124
167
  export function isNoteRecord(record) {
125
- return !record?.documents;
168
+ return !record?.documents && !isAssetRecord(record);
169
+ }
170
+
171
+ /**
172
+ * The path a record names inside its package, whichever shape it is.
173
+ *
174
+ * The two shapes state it differently and honestly: a note names the `.md` it
175
+ * was parsed from, relative to the content root, while an asset names the file
176
+ * it *is*, relative to the package's asset directory. Both are paths within one
177
+ * package, so one total order covers the whole index — which is what keeps the
178
+ * artifact byte-stable across a rebuild.
179
+ *
180
+ * @param {Record<string, any>} record - An index record.
181
+ * @returns {string} The path, or `""` for a record naming neither.
182
+ */
183
+ export function recordPath(record) {
184
+ return String(record?.file?.path ?? record?.asset?.path ?? "");
126
185
  }
package/engine/index.mjs CHANGED
@@ -120,6 +120,18 @@ export * as foundryEntries from "./foundry-entries.mjs";
120
120
  /** Publishing the note tree as a queryable JSON Lines index. */
121
121
  export * as contentIndex from "./content-index.mjs";
122
122
 
123
+ /** The asset types — `icon`, `image`, `audio` — and the roots they are walked from. */
124
+ export * as assetTypes from "./asset-types.mjs";
125
+
126
+ /** The asset record: one index line per addressable file, and its provenance. */
127
+ export * as assetIndex from "./asset-index.mjs";
128
+
129
+ /** The `<package>` segment's registry: the names no repository may claim. */
130
+ export * as packages from "./packages.mjs";
131
+
132
+ /** The toolchain's own content index — the files it ships, addressed. */
133
+ export * as packagebuildIndex from "./packagebuild-index.mjs";
134
+
123
135
  /** Publishing a content tree as a website: the pass, and its integrity gates. */
124
136
  export * as siteBuild from "./site-build.mjs";
125
137
 
@@ -138,6 +150,9 @@ export * as contentHtml from "./content-html.mjs";
138
150
  /** An image saying how wide it is and where it sits, in two closed vocabularies. */
139
151
  export * as contentImages from "./content-images.mjs";
140
152
 
153
+ /** `![[…]]` — the wikilink that renders a picture where it stands. */
154
+ export * as contentEmbeds from "./content-embeds.mjs";
155
+
141
156
  /** One authored pathname, and the four addresses the surfaces derive from it. */
142
157
  export * as pathnames from "./pathnames.mjs";
143
158
 
@@ -23,9 +23,11 @@
23
23
  * @module
24
24
  */
25
25
 
26
+ import { subtypeRow } from "./document-subtypes.mjs";
26
27
  import { assertInfoboxSet, buildInfoboxes } from "./infobox.mjs";
28
+ import { packRouter } from "./pack-router.mjs";
27
29
  import { carriesSystemBlock, resolveFieldValue } from "./system-block.mjs";
28
- import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
30
+ import { DEFAULT_DOCUMENT_SUBTYPES, KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
29
31
  import { SOHL_INFOBOX } from "../sohl/infobox.mjs";
30
32
  import { HM3_INFOBOX } from "../hm3/infobox.mjs";
31
33
 
@@ -52,6 +54,49 @@ export function infoboxFor(system) {
52
54
  return KNOWN_INFOBOXES.find((entry) => entry.system === system);
53
55
  }
54
56
 
57
+ /**
58
+ * Whether one system compiles a document for one note.
59
+ *
60
+ * This is what a system box's _available_ asserts, and it is the compile's own
61
+ * question rather than a reading of the frontmatter. A note carrying no block
62
+ * is not a note a system has nothing for: where the pack compiling its document
63
+ * declares no `system:`, the document is built from `data:` and the field
64
+ * defaults and ships exactly like any other.
65
+ *
66
+ * Three statements answer it, and they are the three the compile itself
67
+ * follows:
68
+ *
69
+ * 1. **The map** says which document class this system makes of the note's
70
+ * type. No row, no document — and no box either, which is why a caller
71
+ * reaching here already has one.
72
+ * 2. **The router** says which pack that document goes to, read from the pack
73
+ * list this build is driven by.
74
+ * 3. **That pack's `system:`** decides the rest. Declaring one, it writes that
75
+ * system's data and takes only notes that say something about it — the rule
76
+ * {@link module:engine/base-compiler.BasePackCompiler#eligibleFor} applies,
77
+ * asked here from outside. Declaring none, it is compiled by the fallback
78
+ * pass, which needs no block and answers for
79
+ * {@link module:engine/subtype-registry.DEFAULT_DOCUMENT_SUBTYPES} alone —
80
+ * so a tree with no HM3 pack ships no HM3 document however a note is
81
+ * written.
82
+ *
83
+ * @param {object} fm - The note's frontmatter.
84
+ * @param {object} map - The system's note-type → document-subtype map.
85
+ * @param {object} router - The pack router this build is driven by.
86
+ * @returns {boolean} True when this system compiles a document for this note.
87
+ */
88
+ export function compilesSystemDocument(fm, map, router) {
89
+ const row = subtypeRow(map, fm?.type);
90
+ if (!row?.document) return false;
91
+
92
+ const packName = router.resolveOrNull(fm, row.document, map.system);
93
+ if (!packName) return false;
94
+
95
+ const packSystem = router.systemOf(packName);
96
+ if (!packSystem) return map.system === DEFAULT_DOCUMENT_SUBTYPES.system;
97
+ return packSystem === map.system && carriesSystemBlock(fm, map.block);
98
+ }
99
+
55
100
  /**
56
101
  * Every box one note carries, wired to the registries this toolchain ships.
57
102
  *
@@ -63,15 +108,17 @@ export function infoboxFor(system) {
63
108
  * @param {object} [options] - Options.
64
109
  * @param {(ref: unknown, hint?: object) => object|undefined} [options.resolve] -
65
110
  * Resolves a reference to `{name, url?, uuid?, address?, subType?}`.
111
+ * @param {object} [options.router] - The pack router deciding which system
112
+ * compiles a document for this note. Defaults to the consuming repository's.
66
113
  * @returns {object[]} The boxes, in the order every medium renders them.
67
114
  * @throws {Error} When the built set disagrees with what the note's type maps
68
115
  * to — see {@link module:engine/infobox.assertInfoboxSet}.
69
116
  */
70
- export function noteInfoboxes(fm, { resolve } = {}) {
117
+ export function noteInfoboxes(fm, { resolve, router = packRouter() } = {}) {
71
118
  const boxes = buildInfoboxes(fm, {
72
119
  maps: KNOWN_DOCUMENT_SUBTYPE_MAPS,
73
120
  providers: KNOWN_INFOBOXES,
74
- carriesBlock: carriesSystemBlock,
121
+ compilesDocument: (note, map) => compilesSystemDocument(note, map, router),
75
122
  resolveField: resolveFieldValue,
76
123
  resolve,
77
124
  });
@@ -56,7 +56,7 @@
56
56
  * appears before it.
57
57
  * 2. **It contains no image.** A picture is authored in the text with its own
58
58
  * directive, and its position governs. {@link NOTE_FIELD_PRESENTATION}
59
- * withholds `portrait`, `img` and `overlay` for that reason and no other.
59
+ * withholds the art slots and `overlay` for that reason and no other.
60
60
  * 3. **A section is the unit that flows.** Sections are whole and unbreakable;
61
61
  * the panel breaks between them. This is what lets a long box cross a
62
62
  * column or page boundary without splitting a stat grid.
@@ -266,8 +266,10 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
266
266
  color: Object.freeze({
267
267
  withheld: "sidebar machinery, not a fact about the subject",
268
268
  }),
269
- portrait: Object.freeze({ withheld: "an image, which the box never carries" }),
270
- img: Object.freeze({ withheld: "an image, which the box never carries" }),
269
+ icon: Object.freeze({ withheld: "an image, which the box never carries" }),
270
+ tokenIcon: Object.freeze({ withheld: "an image, which the box never carries" }),
271
+ bgImage: Object.freeze({ withheld: "an image, which the box never carries" }),
272
+ banner: Object.freeze({ withheld: "an image, which the box never carries" }),
271
273
  overlay: Object.freeze({ withheld: "an image, which the box never carries" }),
272
274
 
273
275
  birthday: Object.freeze({ label: "Born" }),
@@ -834,9 +836,11 @@ export function systemRowsSection(
834
836
  * build ships, which decide the box set.
835
837
  * @param {readonly object[]} [options.providers] - The systems' infobox
836
838
  * declarations, keyed by `system`.
837
- * @param {(fm: object, block: string) => boolean} options.carriesBlock -
838
- * Whether the note says anything about a system, which decides
839
- * {@link NOT_AVAILABLE}.
839
+ * @param {(fm: object, map: object) => boolean} options.compilesDocument -
840
+ * Whether that system compiles a document for this note, which decides
841
+ * {@link NOT_AVAILABLE}. Asked of the routing and the passes rather than of
842
+ * the frontmatter: a pack declaring no `system:` compiles a note from `data:`
843
+ * and the field defaults, so the block's presence is not the question.
840
844
  * @param {(field: object, fm: object, opts: object) => object} options.resolveField -
841
845
  * Resolves one declared field against the note.
842
846
  * @param {(ref: unknown) => object|undefined} [options.resolve] - Resolves a
@@ -848,7 +852,7 @@ export function buildInfoboxes(fm, options) {
848
852
  const {
849
853
  maps,
850
854
  providers = [],
851
- carriesBlock,
855
+ compilesDocument,
852
856
  resolveField,
853
857
  resolve,
854
858
  vocabulary = NOTE_VOCABULARY,
@@ -860,7 +864,7 @@ export function buildInfoboxes(fm, options) {
860
864
  for (const map of maps ?? []) {
861
865
  if (!subtypeRow(map, fm?.type)) continue;
862
866
  const provider = providers.find((entry) => entry.system === map.system);
863
- const available = Boolean(carriesBlock(fm, map.block));
867
+ const available = Boolean(compilesDocument(fm, map));
864
868
  const box = {
865
869
  id: map.system,
866
870
  kind: "system",
@@ -48,7 +48,7 @@
48
48
 
49
49
  import log from "loglevel";
50
50
 
51
- import { resolveName, resolveImg } from "./helpers.mjs";
51
+ import { resolveName } from "./helpers.mjs";
52
52
  import { BasePackCompiler } from "./base-compiler.mjs";
53
53
  import { journalPageId, splitPages } from "./journals.mjs";
54
54
  import { foundryPackageId } from "./content-package.mjs";
@@ -117,15 +117,15 @@ export class SystemItemCompiler extends BasePackCompiler {
117
117
  static requiresSystemBlock = true;
118
118
 
119
119
  /**
120
- * An Item carries an `img` — its icon — which this pass writes from the
121
- * note's own path, falling back to the type's default art.
120
+ * An Item carries one piece of art — its icon — which this pass writes from
121
+ * the address the note names, falling back to the type's default art.
122
122
  *
123
- * `portrait` is **not** among them: a portrait is a being's sheet picture,
124
- * and an item has nowhere to put one.
123
+ * `tokenIcon` is **not** among them: an Item places no token, so it has
124
+ * nowhere to put a second picture.
125
125
  *
126
126
  * @type {readonly string[]}
127
127
  */
128
- static emitsArt = Object.freeze(["img"]);
128
+ static emitsArt = Object.freeze(["icon"]);
129
129
 
130
130
  /**
131
131
  * The note-type → document-subtype map this pass compiles against.
@@ -372,10 +372,10 @@ export class SystemItemCompiler extends BasePackCompiler {
372
372
  // both registries are keyed by content type — while the document's
373
373
  // own subtype comes from the system's map.
374
374
  type: subType,
375
- // Nullish, not `||`: `resolveImg` returns `null` for a
376
- // note that names no art and `""` for one that wants none, and only
377
- // the first may be replaced by the type's default.
378
- img: resolveImg(blockProperty(fm, system, "img")) ?? itemArt(type, system),
375
+ // Nullish, not `||`: an art address resolves to `null` for a note
376
+ // that names none and `""` for one that wants none, and only the
377
+ // first may be replaced by the type's default.
378
+ img: this.artPath(fm, "icon") ?? itemArt(type, system),
379
379
  _id: id,
380
380
  system: built,
381
381
  effects: Array.isArray(effects) ? [...effects] : [],
@@ -440,6 +440,9 @@ export class Journals extends BasePackCompiler {
440
440
  // URLs: a player reading this at the table stays in Foundry.
441
441
  const boxes = noteInfoboxes(fm, {
442
442
  resolve: (ref, hint) => resolveReference(this.linkIndex, ref, hint),
443
+ // This compile's own router, so the panel's `available` is decided
444
+ // by the pack list this build is being driven by.
445
+ router: this.router,
443
446
  });
444
447
 
445
448
  return buildJournalEntry({
package/engine/macros.mjs CHANGED
@@ -51,7 +51,7 @@
51
51
 
52
52
  import log from "loglevel";
53
53
 
54
- import { sohlField, resolveName, resolveImg, defaultStats, folderField } from "./helpers.mjs";
54
+ import { sohlField, resolveName, defaultStats, folderField } from "./helpers.mjs";
55
55
  import { BasePackCompiler } from "./base-compiler.mjs";
56
56
  import { splitPages } from "./journals.mjs";
57
57
 
@@ -79,11 +79,11 @@ export const MACRO_TYPES = Object.freeze(["script", "chat"]);
79
79
  export const MACRO_SCOPES = Object.freeze(["global", "actors", "actor"]);
80
80
 
81
81
  /**
82
- * Foundry's own default macro artwork, used when a note authors no `img`.
82
+ * Foundry's own default macro artwork, used when a note names no `icon`.
83
83
  *
84
- * A core path, deliberately: it is not translated by {@link resolveImg} (which
85
- * roots `icons/…` under this system's assets), so it must be stated after that
86
- * translation rather than as authored frontmatter.
84
+ * A core path, deliberately: it names a file Foundry itself ships rather than an
85
+ * address in any package, so it is stated after the art resolution rather than
86
+ * as something a note could author.
87
87
  */
88
88
  export const DEFAULT_MACRO_IMG = "icons/svg/dice-target.svg";
89
89
 
@@ -248,10 +248,13 @@ export function resolveMacroScope(fm, label) {
248
248
  * @param {string} opts.command - The command, from {@link macroCommand}.
249
249
  * @param {string|null} [opts.folder] - The resolved folder id.
250
250
  * @param {object} [opts.stats] - The `_stats` block.
251
+ * @param {string|null} [opts.img] - The resolved art, from the note's `icon`
252
+ * address. Passed in rather than resolved here, because an address is
253
+ * answered by the compile's index and this function takes no index.
251
254
  * @returns {MacroDocument} The Macro document.
252
255
  * @throws {Error} When the frontmatter's macro type or scope is unusable.
253
256
  */
254
- export function buildMacroEntry(fm, { command, folder = null, stats = defaultStats() }) {
257
+ export function buildMacroEntry(fm, { command, folder = null, stats = defaultStats(), img }) {
255
258
  const name = resolveName(fm);
256
259
  const id = fm.id;
257
260
  return {
@@ -262,8 +265,8 @@ export function buildMacroEntry(fm, { command, folder = null, stats = defaultSta
262
265
  type: resolveMacroType(fm, name),
263
266
  author: null,
264
267
  // Nullish, not `||`: a macro note that names no art gets the
265
- // shared default, one that writes `img: ""` ships blank on purpose.
266
- img: resolveImg(fm.img) ?? DEFAULT_MACRO_IMG,
268
+ // shared default, one that writes `icon: ""` ships blank on purpose.
269
+ img: (img === undefined ? null : img) ?? DEFAULT_MACRO_IMG,
267
270
  scope: resolveMacroScope(fm, name),
268
271
  command,
269
272
  folder,
@@ -289,19 +292,18 @@ export class Macros extends BasePackCompiler {
289
292
 
290
293
  /**
291
294
  * The command must be exactly what the author typed, so this pass reads the
292
- * note as authored: no table expansion, no wikilink conversion, and no
293
- * content-wide link index it would never consult. The journals pass
294
- * compiles the converted copy of the same body independently.
295
+ * note as authored: no table expansion and no wikilink conversion. The
296
+ * journals pass compiles the converted copy of the same body independently.
295
297
  */
296
298
  static convertsWikilinks = false;
297
299
 
298
300
  /**
299
- * A Macro carries an `img` — the tile art Foundry shows on the hotbar —
301
+ * A Macro carries one piece of art — the tile Foundry shows on the hotbar —
300
302
  * defaulting to {@link DEFAULT_MACRO_IMG} where the note names none.
301
303
  *
302
304
  * @type {readonly string[]}
303
305
  */
304
- static emitsArt = Object.freeze(["img"]);
306
+ static emitsArt = Object.freeze(["icon"]);
305
307
 
306
308
  /**
307
309
  * @param {object} fm - The note's frontmatter.
@@ -323,6 +325,7 @@ export class Macros extends BasePackCompiler {
323
325
  const name = resolveName(fm);
324
326
  return buildMacroEntry(fm, {
325
327
  command: macroCommand(body, name),
328
+ img: this.artPath(fm, "icon"),
326
329
  folder: this.folderResolver(folderField(fm).value, {
327
330
  isAddress: folderField(fm).isAddress,
328
331
  }),