@heroiclands/package-build 22.22.0 → 22.23.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 (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/content-commands.mjs +51 -14
  3. package/ci/content-format-reference.mjs +1 -1
  4. package/content-config.mjs +80 -2
  5. package/docs/api.md +85 -72
  6. package/docs/authoring/assets.md +14 -0
  7. package/docs/authoring/dates-and-calendars.md +37 -1
  8. package/docs/authoring/links-and-markup.md +112 -31
  9. package/docs/commands.md +12 -10
  10. package/docs/configuration.md +90 -13
  11. package/docs/content-format.md +1 -1
  12. package/docs/guides/book.md +5 -4
  13. package/docs/reference/format-details.md +323 -60
  14. package/docs/reference/note-types.md +9 -9
  15. package/engine/anchors.mjs +16 -10
  16. package/engine/art-fields.mjs +118 -0
  17. package/engine/art-slots.mjs +7 -1
  18. package/engine/asset-index.mjs +160 -10
  19. package/engine/asset-replacement.mjs +93 -0
  20. package/engine/asset-types.mjs +240 -0
  21. package/engine/base-compiler.mjs +54 -2
  22. package/engine/bundles.mjs +13 -1
  23. package/engine/content-blocks.mjs +4 -4
  24. package/engine/content-embeds.mjs +19 -0
  25. package/engine/content-figures.mjs +468 -0
  26. package/engine/content-footnotes.mjs +10 -2
  27. package/engine/content-format.yaml +1 -1
  28. package/engine/content-images.mjs +126 -25
  29. package/engine/content-index.mjs +36 -10
  30. package/engine/field-reference.mjs +31 -10
  31. package/engine/field-spec.mjs +10 -8
  32. package/engine/foreign-catalog.mjs +143 -18
  33. package/engine/frontmatter-lint.mjs +73 -1
  34. package/engine/heading-attributes.mjs +31 -18
  35. package/engine/helpers.mjs +97 -30
  36. package/engine/infobox-registry.mjs +4 -1
  37. package/engine/infobox.mjs +47 -8
  38. package/engine/itinerary-scenes.mjs +4 -10
  39. package/engine/journals.mjs +264 -39
  40. package/engine/lore-events.mjs +197 -0
  41. package/engine/markdown-expressions.mjs +257 -7
  42. package/engine/note-dates.mjs +305 -12
  43. package/engine/note-vocabulary.mjs +11 -5
  44. package/engine/pathnames.mjs +36 -0
  45. package/engine/pdf-build.mjs +357 -34
  46. package/engine/pdf-images.mjs +64 -0
  47. package/engine/pdf-render.mjs +160 -36
  48. package/engine/scenes.mjs +16 -4
  49. package/engine/site-build.mjs +127 -13
  50. package/engine/site-index.mjs +72 -1
  51. package/engine/site-preview.mjs +2 -0
  52. package/engine/system-block.mjs +44 -16
  53. package/hm3/actors.mjs +2 -2
  54. package/manifest.mjs +30 -24
  55. package/package.json +1 -1
  56. package/packagebuild-metadata.jsonl +13 -13
  57. package/sohl/item-fields.mjs +4 -3
  58. package/types/content-config.d.mts +48 -1
  59. package/types/engine/anchors.d.mts +9 -4
  60. package/types/engine/art-fields.d.mts +64 -0
  61. package/types/engine/asset-index.d.mts +43 -0
  62. package/types/engine/asset-replacement.d.mts +22 -0
  63. package/types/engine/asset-types.d.mts +18 -0
  64. package/types/engine/base-compiler.d.mts +0 -17
  65. package/types/engine/content-embeds.d.mts +16 -0
  66. package/types/engine/content-figures.d.mts +101 -0
  67. package/types/engine/content-footnotes.d.mts +7 -0
  68. package/types/engine/content-images.d.mts +78 -12
  69. package/types/engine/content-index.d.mts +8 -2
  70. package/types/engine/field-reference.d.mts +21 -0
  71. package/types/engine/field-spec.d.mts +20 -16
  72. package/types/engine/foreign-catalog.d.mts +39 -0
  73. package/types/engine/heading-attributes.d.mts +13 -8
  74. package/types/engine/helpers.d.mts +23 -7
  75. package/types/engine/infobox-registry.d.mts +4 -1
  76. package/types/engine/infobox.d.mts +6 -0
  77. package/types/engine/journals.d.mts +31 -9
  78. package/types/engine/lore-events.d.mts +14 -0
  79. package/types/engine/markdown-expressions.d.mts +23 -2
  80. package/types/engine/note-dates.d.mts +64 -1
  81. package/types/engine/pathnames.d.mts +26 -0
  82. package/types/engine/pdf-build.d.mts +28 -6
  83. package/types/engine/pdf-images.d.mts +52 -0
  84. package/types/engine/pdf-render.d.mts +12 -0
  85. package/types/engine/site-build.d.mts +14 -3
  86. package/types/engine/site-index.d.mts +54 -0
  87. package/types/engine/system-block.d.mts +26 -2
  88. package/types/hm3/actors.d.mts +18 -0
  89. package/types/manifest.d.mts +19 -23
  90. package/engine/content-captions.mjs +0 -230
  91. package/types/engine/content-captions.d.mts +0 -58
@@ -44,6 +44,8 @@
44
44
  * @module
45
45
  */
46
46
 
47
+ import path from "node:path";
48
+
47
49
  import { encodeAddresses } from "./address-values.mjs";
48
50
  import { ASSET_SYSTEM, isAssetType } from "./asset-types.mjs";
49
51
  import { ASSETS_SEGMENT } from "./pathnames.mjs";
@@ -129,9 +131,49 @@ export function assetAddressIndex(records = [], { config, foreign, types = [] }
129
131
  .filter(([key]) => key),
130
132
  ),
131
133
  foreign: foreign?.index ?? new Map(),
134
+ // Foreign first, local last: a renderer sizing a picture looks it up
135
+ // by the pathname the record resolved to, never by address, and a
136
+ // local record is entered after a foreign one so it wins the lookup on
137
+ // the collision neither should ever cause.
138
+ byPath: assetImageInfoByPathname([
139
+ ...(foreign?.index?.values() ?? []),
140
+ ...records.filter(isAssetRecord),
141
+ ]),
132
142
  };
133
143
  }
134
144
 
145
+ /**
146
+ * What a renderer needs to size one picture, keyed by the pathname its
147
+ * address resolves to.
148
+ *
149
+ * An image reaches a renderer as the resolved pathname an embed or an authored
150
+ * body image already carries — see {@link readAssetAddress} — never as the
151
+ * address that produced it, so the lookup a renderer wants is by pathname, not
152
+ * by {@link assetAddressIndex}'s own `assets` map.
153
+ *
154
+ * @param {readonly object[]} records - Asset records, local or foreign.
155
+ * @returns {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
156
+ * One entry per addressable file, carrying only what a renderer sizes a
157
+ * picture from. `role` is omitted when the record states none; `width` and
158
+ * `height` are always present, blank (`""`) for a vector — the record's own
159
+ * convention, carried through rather than collapsed into an absence a
160
+ * renderer could not tell apart from "no asset resolved at all".
161
+ */
162
+ export function assetImageInfoByPathname(records = []) {
163
+ const map = new Map();
164
+ for (const record of records) {
165
+ if (!record?.asset?.path || !record.package) continue;
166
+ const key = `${record.package}/${ASSETS_SEGMENT}/${record.asset.path}`;
167
+ map.set(key, {
168
+ type: record.type,
169
+ role: record.asset.role || undefined,
170
+ width: typeof record.asset.width === "number" ? record.asset.width : "",
171
+ height: typeof record.asset.height === "number" ? record.asset.height : "",
172
+ });
173
+ }
174
+ return map;
175
+ }
176
+
135
177
  /**
136
178
  * The asset one authored value names, or why it names none.
137
179
  *
@@ -200,6 +242,82 @@ export function resolveArtRecord(index, value, defaultType, accepts) {
200
242
  return readAssetAddress(index, value, defaultType, accepts).record;
201
243
  }
202
244
 
245
+ /**
246
+ * Every asset's role, keyed by the pathname it resolves to — the form a body
247
+ * carries once an embed's address has been rewritten into the ordinary image
248
+ * every surface renders ({@link module:engine/content-embeds.resolveEmbeds}).
249
+ *
250
+ * This is the lookup a `:::figure` fence's `map` counter reaches through —
251
+ * see {@link module:engine/content-figures.scanFigures}'s `resolveRole` —
252
+ * for a caller that reads a body after that rewrite: a Foundry journal, an
253
+ * item or actor's documentation, and the book.
254
+ *
255
+ * @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex},
256
+ * or the equivalent the site and the book build.
257
+ * @returns {Map<string, string>} Pathname → role, one entry per asset that
258
+ * declares one.
259
+ */
260
+ export function pathnameRoles(index) {
261
+ const roles = new Map();
262
+ for (const record of [
263
+ ...(index?.assets?.values() ?? []),
264
+ ...(index?.foreign?.values() ?? []),
265
+ ]) {
266
+ const role = record?.asset?.role;
267
+ if (!role || !record.asset?.path || !record.package) continue;
268
+ roles.set(`${record.package}/${ASSETS_SEGMENT}/${record.asset.path}`, role);
269
+ }
270
+ return roles;
271
+ }
272
+
273
+ /**
274
+ * Flag a note whose art slot names a picture that is not the size that slot
275
+ * is cut to.
276
+ *
277
+ * The size is stated by the slot itself, in
278
+ * {@link module:engine/art-slots.ART_SLOTS}, so it is written once and a slot
279
+ * that states none asks nothing of the picture it names. Today the hero image
280
+ * is the one slot with a fixed strip to fill; a picture written in prose is
281
+ * fitted to the room it has and is never measured here.
282
+ *
283
+ * A vector carries no pixel size to compare, and neither does a raster whose
284
+ * header cannot be read; neither is flagged.
285
+ *
286
+ * @param {readonly object[]} records - The corpus, notes and assets together.
287
+ * @param {object} [opts]
288
+ * @param {object} [opts.config] - The resolved build configuration.
289
+ * @param {string} [opts.assetsBase] - Where the asset roots sit, so a finding
290
+ * names the file from the working directory as every other finding does.
291
+ * @returns {object[]} A finding per picture off its slot's size,
292
+ * `severity: "error"`.
293
+ */
294
+ export function checkArtSlotSizes(records = [], { config, assetsBase = "" } = {}) {
295
+ const sized = ART_SLOTS.filter((slot) => slot.size);
296
+ if (!sized.length) return [];
297
+ const index = assetAddressIndex(records, { config });
298
+ const findings = [];
299
+ for (const record of records) {
300
+ const data = record?.data;
301
+ if (!data) continue;
302
+ for (const slot of sized) {
303
+ const value = data[slot.key];
304
+ if (!value) continue;
305
+ const asset = resolveArtRecord(index, value, slot.type, slot.accepts);
306
+ const { path: file, width, height } = asset?.asset ?? {};
307
+ if (!file || !width || !height) continue;
308
+ if (Number(width) === slot.size.width && Number(height) === slot.size.height) continue;
309
+ findings.push({
310
+ file: path.join(assetsBase, file),
311
+ severity: "error",
312
+ message:
313
+ `${width}×${height} is not the ${slot.size.width}×${slot.size.height} ` +
314
+ `a \`${slot.key}\` is cut to`,
315
+ });
316
+ }
317
+ }
318
+ return findings;
319
+ }
320
+
203
321
  /**
204
322
  * The pathname an art value names, in the form an authored one takes.
205
323
  *
@@ -76,6 +76,12 @@ export const ART_SLOTS = Object.freeze([
76
76
  type: "image",
77
77
  accepts: ART_TYPES,
78
78
  document: false,
79
- describe: "The page's hero image. Reaches no compiled document.",
79
+ // A hero image fills one fixed strip wherever it is drawn, so every
80
+ // banner is cut to one size and a picture of another size is resized
81
+ // to it. A picture written in prose fills no fixed strip and is
82
+ // fitted to whatever room it has, so no slot but this one states a
83
+ // size.
84
+ size: Object.freeze({ width: 1792, height: 768 }),
85
+ describe: "The page's hero image, cut to 1792×768. Reaches no compiled document.",
80
86
  }),
81
87
  ]);
@@ -50,6 +50,13 @@
50
50
  * drop: `licence` beside `license` is otherwise an attribution record that looks
51
51
  * complete and carries nothing.
52
52
  *
53
+ * **`role` names what the picture is for**, from a closed set, and belongs to
54
+ * the `image` type only — an `icon` address carries one nominal size per medium
55
+ * whatever the file holds, so a `role` declared there is a finding and is
56
+ * dropped rather than carried through. **`width` and `height` come from the
57
+ * walk itself**, read from the file's own header rather than declared, and are
58
+ * blank for an SVG, which has no pixel dimensions to state.
59
+ *
53
60
  * @module
54
61
  */
55
62
 
@@ -60,7 +67,18 @@ import YAML from "yaml";
60
67
 
61
68
  import { canonicalKey } from "./content-address.mjs";
62
69
  import { positionOfYamlPath } from "./diagnostics.mjs";
63
- import { ASSET_SYSTEM, ASSET_TYPES, isAssetShortcode } from "./asset-types.mjs";
70
+ import { ASSET_SYSTEM, ASSET_TYPES, imageDimensions, isAssetShortcode } from "./asset-types.mjs";
71
+
72
+ /**
73
+ * The closed set of roles a picture may declare.
74
+ *
75
+ * Shared with the field declaration below and with every medium that sizes a
76
+ * picture by its role, so the vocabulary cannot drift between the record that
77
+ * carries it and the renderer that reads it.
78
+ *
79
+ * @type {readonly string[]}
80
+ */
81
+ export const ASSET_ROLES = Object.freeze(["portrait", "emblem", "banner", "plate", "map"]);
64
82
 
65
83
  /**
66
84
  * The file a directory records provenance for its subtree in.
@@ -92,6 +110,18 @@ export const PROVENANCE_SIDECAR_SUFFIX = ".yaml";
92
110
  * state this key. Omitting one is a finding rather than a blank, because a
93
111
  * record resolves wholesale: the nearest one is the whole answer, so a key it
94
112
  * leaves out is not inherited from above but simply absent.
113
+ * @property {"boolean"|"integer"} [type] - The value's own type, for a field
114
+ * whose answer is not a string. Omitted for every string field; `ai` carries
115
+ * the YAML boolean itself rather than a stringified copy, and `width` and
116
+ * `height` carry the pixel count the walk measured. Each is refused rather
117
+ * than coerced when the value does not match.
118
+ * @property {readonly string[]} [values] - The closed set a provenance
119
+ * declaration must choose one of; a value outside it is a finding and the
120
+ * key is left blank, the same treatment an `ai` value of the wrong shape
121
+ * gets. `role` is the one field that carries it.
122
+ * @property {string} [onlyType] - The one asset type this field may be
123
+ * declared for. A provenance record stating it for another type is a
124
+ * finding, and the value is dropped rather than carried through.
95
125
  * @property {string} describe - One line, for the author-facing reference.
96
126
  */
97
127
 
@@ -134,7 +164,10 @@ export const ASSET_RECORD_FIELDS = Object.freeze([
134
164
  Object.freeze({
135
165
  name: "ai",
136
166
  from: "provenance",
137
- describe: "Whether the file is machine-generated — `true` or `false`.",
167
+ type: "boolean",
168
+ describe:
169
+ "Whether the file is machine-generated — the YAML boolean `true` " +
170
+ "or `false`, never a string.",
138
171
  }),
139
172
  Object.freeze({
140
173
  name: "license",
@@ -147,6 +180,32 @@ export const ASSET_RECORD_FIELDS = Object.freeze([
147
180
  from: "provenance",
148
181
  describe: "Anything else a person reading the attribution needs.",
149
182
  }),
183
+ Object.freeze({
184
+ name: "role",
185
+ from: "provenance",
186
+ onlyType: "image",
187
+ values: ASSET_ROLES,
188
+ describe:
189
+ "What the picture is for — portrait, emblem, banner, plate or map. " +
190
+ "Absent for an ordinary picture, and refused outside that set. " +
191
+ "The `image` type only; a value on an `icon` address is a finding.",
192
+ }),
193
+ Object.freeze({
194
+ name: "width",
195
+ from: "walk",
196
+ type: "integer",
197
+ describe:
198
+ "The file's pixel width, read from its own header during the " +
199
+ "asset walk. Blank for an SVG, which has no pixel dimensions.",
200
+ }),
201
+ Object.freeze({
202
+ name: "height",
203
+ from: "walk",
204
+ type: "integer",
205
+ describe:
206
+ "The file's pixel height, read from its own header during the " +
207
+ "asset walk. Blank for an SVG, which has no pixel dimensions.",
208
+ }),
150
209
  ]);
151
210
 
152
211
  /**
@@ -181,12 +240,30 @@ export const REQUIRED_PROVENANCE_KEYS = Object.freeze(
181
240
  ),
182
241
  );
183
242
 
243
+ /**
244
+ * The provenance field declaring each key, by name.
245
+ *
246
+ * Read in the parsing loop below to ask whether a key has its own `type`
247
+ * rather than the ordinary string one — derived from {@link ASSET_RECORD_FIELDS}
248
+ * for the same reason {@link PROVENANCE_KEYS} is, so the field declaration
249
+ * stays the one place a key's shape is stated.
250
+ *
251
+ * @type {ReadonlyMap<string, AssetRecordField>}
252
+ */
253
+ const PROVENANCE_FIELD_BY_KEY = new Map(
254
+ ASSET_RECORD_FIELDS.filter((field) => field.from === "provenance").map((field) => [
255
+ field.name,
256
+ field,
257
+ ]),
258
+ );
259
+
184
260
  /**
185
261
  * Read one provenance file, reporting every key that is not a provenance key.
186
262
  *
187
263
  * @param {string} file - The provenance file.
188
264
  * @param {object[]} findings - Collects a diagnostic per unknown key.
189
- * @returns {Record<string, string>} The recognised keys, as strings.
265
+ * @returns {Record<string, string|boolean>} The recognised keys — a string for
266
+ * every field but `ai`, which carries the YAML boolean itself.
190
267
  */
191
268
  function readProvenanceFile(file, findings) {
192
269
  let text;
@@ -238,6 +315,44 @@ function readProvenanceFile(file, findings) {
238
315
  });
239
316
  continue;
240
317
  }
318
+ const field = PROVENANCE_FIELD_BY_KEY.get(key);
319
+ if (field?.type === "boolean") {
320
+ if (typeof value === "boolean") {
321
+ out[key] = value;
322
+ } else {
323
+ // Distinct from the unknown-key finding above: this key is
324
+ // recognised, and the problem is the shape of its value — a
325
+ // quoted `"false"` or a YAML `yes`/`1` would otherwise be
326
+ // stringified and read as truthy regardless of which it was.
327
+ findings.push({
328
+ file,
329
+ ...positionOfYamlPath(text, [key], { key: true }),
330
+ severity: "error",
331
+ message:
332
+ `\`${key}\` must be the YAML boolean \`true\` or \`false\`, ` +
333
+ `not ${JSON.stringify(value)}`,
334
+ });
335
+ }
336
+ continue;
337
+ }
338
+ if (field?.values) {
339
+ if (field.values.includes(value)) {
340
+ out[key] = value;
341
+ } else {
342
+ // Distinct from the unknown-key finding above: this key is
343
+ // recognised, and the problem is that its value is not one of
344
+ // the closed set it accepts.
345
+ findings.push({
346
+ file,
347
+ ...positionOfYamlPath(text, [key], { key: true }),
348
+ severity: "error",
349
+ message:
350
+ `\`${key}\` must be one of ${field.values.join(", ")}, ` +
351
+ `not ${JSON.stringify(value)}`,
352
+ });
353
+ }
354
+ continue;
355
+ }
241
356
  out[key] = value == null ? "" : String(value);
242
357
  }
243
358
 
@@ -299,15 +414,30 @@ function inheritedProvenance(dir, root, cache, findings) {
299
414
  /**
300
415
  * The `asset` block for one file.
301
416
  *
302
- * @param {string} relPath - The file's path below the package's asset directory.
303
- * @param {Record<string, string>|null} provenance - The resolved record.
304
- * @returns {Record<string, string>} The block, every field present.
417
+ * @param {Record<string, unknown>} walkValues - Every `from: "walk"` field's
418
+ * value, keyed by field name.
419
+ * @param {Record<string, string|boolean>|null} provenance - The resolved record.
420
+ * @param {string} type - The asset type this file was walked as, so a field
421
+ * declaring `onlyType` can be dropped for every other type.
422
+ * @returns {Record<string, string|boolean|number>} The block, every field
423
+ * present — blank (`""`) for a string or integer field nothing states,
424
+ * `false` for `ai`.
305
425
  */
306
- function assetBlock(relPath, provenance) {
426
+ function assetBlock(walkValues, provenance, type) {
307
427
  const block = {};
308
428
  for (const field of ASSET_RECORD_FIELDS) {
309
- const value = field.from === "walk" ? relPath : provenance?.[field.name];
310
- block[field.name] = typeof value === "string" ? value : "";
429
+ if (field.onlyType && field.onlyType !== type) {
430
+ block[field.name] = field.type === "boolean" ? false : "";
431
+ continue;
432
+ }
433
+ const value = field.from === "walk" ? walkValues[field.name] : provenance?.[field.name];
434
+ if (field.type === "boolean") {
435
+ block[field.name] = typeof value === "boolean" ? value : false;
436
+ } else if (field.type === "integer") {
437
+ block[field.name] = typeof value === "number" ? value : "";
438
+ } else {
439
+ block[field.name] = typeof value === "string" ? value : "";
440
+ }
311
441
  }
312
442
  return block;
313
443
  }
@@ -418,6 +548,18 @@ export function collectAssetRecords(assetsBase, { contentPackage, problems }) {
418
548
  // ancestor walk is not consulted when one is present.
419
549
  : inheritedProvenance(path.dirname(absPath), rootDir, cache, findings);
420
550
 
551
+ for (const field of ASSET_RECORD_FIELDS) {
552
+ if (field.onlyType && field.onlyType !== type && provenance?.[field.name]) {
553
+ report(
554
+ absPath,
555
+ `\`${field.name}\` only applies to the \`${field.onlyType}\` type, ` +
556
+ `and this address is \`${type}\` — the declaration is dropped`,
557
+ );
558
+ }
559
+ }
560
+
561
+ const dimensions = imageDimensions(absPath, path.extname(absPath).toLowerCase());
562
+
421
563
  records.push({
422
564
  package: contentPackage,
423
565
  type,
@@ -427,7 +569,15 @@ export function collectAssetRecords(assetsBase, { contentPackage, problems }) {
427
569
  address: {
428
570
  canonical: canonicalKey(contentPackage, ASSET_SYSTEM, type, shortcode),
429
571
  },
430
- asset: assetBlock(`${root}/${relPath}`, provenance),
572
+ asset: assetBlock(
573
+ {
574
+ path: `${root}/${relPath}`,
575
+ width: dimensions?.width,
576
+ height: dimensions?.height,
577
+ },
578
+ provenance,
579
+ type,
580
+ ),
431
581
  });
432
582
  }
433
583
  }
@@ -0,0 +1,93 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * A declared replacement package's asset record, picked over the local one,
16
+ * by address.
17
+ *
18
+ * A package may declare, on a `relationships.<kind>[]` entry, that a
19
+ * dependency's asset tree answers its own asset addresses —
20
+ * `assetReplacement: true` in `package-build.config.yaml`:
21
+ *
22
+ * ```yaml
23
+ * relationships:
24
+ * requires:
25
+ * - id: thalornaaltart
26
+ * manifest: https://…/releases/latest/download/module.json
27
+ * assetReplacement: true
28
+ * contentIndex: true
29
+ * ```
30
+ *
31
+ * `assetReplacement: true` opts that relationship in. `resolveAssetReplacement`
32
+ * is what turns the declaration into an answer, one address at a time, against
33
+ * four rules:
34
+ *
35
+ * 1. **Scoped to asset types.** A non-asset address, or one whose package
36
+ * segment is not the compiling package's own, resolves to `null` at once —
37
+ * tested with {@link module:engine/asset-types.isAssetType} rather than
38
+ * reimplemented. A being, an item or a lore note is never replaced this
39
+ * way.
40
+ * 2. **Ordered, single pass.** Replacements are tried in declaration order —
41
+ * the order their relationships appear in `package-build.config.yaml` —
42
+ * and the first one carrying the address wins.
43
+ * 3. **Never recursive.** A replacement's own output address is not itself
44
+ * looked up against another replacement: resolving
45
+ * `thalornaaltart-none-image-thorn` never asks whether something replaces
46
+ * `thalornaaltart`. This forecloses a cycle by construction — there is
47
+ * nothing to loop.
48
+ * 4. **A miss falls through.** `null` means no declared replacement carries
49
+ * the address, and the caller falls back to the local address exactly as
50
+ * it does today. This function never decides that a missing replacement is
51
+ * an error; a caller that wants that is free to build it on the answer.
52
+ *
53
+ * This resolver answers a different question from
54
+ * {@link module:engine/asset-bindings.checkForeignAssetBindings}: that check
55
+ * validates an art module's own staged files against another package's
56
+ * addresses, at that module's own build time. This one runs at the *replaced*
57
+ * package's build, consulted by its book, and asks whether a declared
58
+ * replacement carries an address this package would otherwise answer itself.
59
+ *
60
+ * @module
61
+ */
62
+
63
+ import { isAssetType } from "./asset-types.mjs";
64
+ import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
65
+
66
+ /**
67
+ * The replacement record for one asset address, or `null`.
68
+ *
69
+ * @param {string} address - A canonical asset address.
70
+ * @param {object} opts
71
+ * @param {string} opts.localPackage - The compiling package's own segment.
72
+ * @param {readonly string[]} opts.replacements - The package segments of
73
+ * every relationship declaring `assetReplacement: true`, in declaration
74
+ * order.
75
+ * @param {Map<string, object>} opts.index - The merged content index, keyed
76
+ * by canonical address.
77
+ * @returns {{record: object, package: string}|null} The first replacement
78
+ * carrying the address, or `null` when none does.
79
+ */
80
+ export function resolveAssetReplacement(address, { localPackage, replacements, index }) {
81
+ const parts = readCanonicalKey(address);
82
+ if (!parts) return null;
83
+ if (!isAssetType(parts.type)) return null;
84
+ if (parts.package !== localPackage) return null;
85
+ for (const replacementPackage of replacements ?? []) {
86
+ const key = canonicalKey(replacementPackage, parts.system, parts.type, parts.shortcode);
87
+ const record = index?.get(key);
88
+ if (record?.asset?.path && record.package) {
89
+ return { record, package: replacementPackage };
90
+ }
91
+ }
92
+ return null;
93
+ }