@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
@@ -46,6 +46,8 @@
46
46
  * @module
47
47
  */
48
48
 
49
+ import fs from "node:fs";
50
+
49
51
  import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
50
52
 
51
53
  /**
@@ -190,3 +192,241 @@ export function assetTypeOfRoot(root) {
190
192
  export function isAssetShortcode(shortcode) {
191
193
  return typeof shortcode === "string" && ADDRESS_SEGMENT_PATTERN.test(shortcode);
192
194
  }
195
+
196
+ /**
197
+ * The PNG (and APNG, which shares the signature) header.
198
+ *
199
+ * @param {Buffer} bytes
200
+ * @returns {{width: number, height: number}|undefined}
201
+ */
202
+ function pngDimensions(bytes) {
203
+ if (bytes.length < 24) return undefined;
204
+ if (bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") return undefined;
205
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
206
+ }
207
+
208
+ /**
209
+ * The GIF header's logical screen size.
210
+ *
211
+ * @param {Buffer} bytes
212
+ * @returns {{width: number, height: number}|undefined}
213
+ */
214
+ function gifDimensions(bytes) {
215
+ if (bytes.length < 10) return undefined;
216
+ const signature = bytes.toString("ascii", 0, 6);
217
+ if (signature !== "GIF87a" && signature !== "GIF89a") return undefined;
218
+ return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
219
+ }
220
+
221
+ /**
222
+ * The BMP header's size, carried as a signed value so a bottom-up bitmap's
223
+ * negative height still reads as a pixel count.
224
+ *
225
+ * @param {Buffer} bytes
226
+ * @returns {{width: number, height: number}|undefined}
227
+ */
228
+ function bmpDimensions(bytes) {
229
+ if (bytes.length < 26) return undefined;
230
+ if (bytes.toString("ascii", 0, 2) !== "BM") return undefined;
231
+ return {
232
+ width: Math.abs(bytes.readInt32LE(18)),
233
+ height: Math.abs(bytes.readInt32LE(22)),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * The size a JPEG's first SOF (start-of-frame) segment states, found by
239
+ * walking its markers rather than assuming a fixed offset — a JPEG carries an
240
+ * arbitrary run of metadata segments before the frame header.
241
+ *
242
+ * @param {Buffer} bytes
243
+ * @returns {{width: number, height: number}|undefined}
244
+ */
245
+ function jpegDimensions(bytes) {
246
+ if (bytes.length < 4 || bytes.readUInt16BE(0) !== 0xffd8) return undefined;
247
+ let pos = 2;
248
+ while (pos + 1 < bytes.length) {
249
+ if (bytes[pos] !== 0xff) return undefined;
250
+ const marker = bytes[pos + 1];
251
+ pos += 2;
252
+ // TEM and the restart markers carry no segment length; EOI ends the
253
+ // stream with no frame header found.
254
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
255
+ if (marker === 0xd9) return undefined;
256
+ if (pos + 2 > bytes.length) return undefined;
257
+ const length = bytes.readUInt16BE(pos);
258
+ const isStartOfFrame =
259
+ marker >= 0xc0 &&
260
+ marker <= 0xcf &&
261
+ marker !== 0xc4 &&
262
+ marker !== 0xc8 &&
263
+ marker !== 0xcc;
264
+ if (isStartOfFrame) {
265
+ if (pos + 7 > bytes.length) return undefined;
266
+ return { height: bytes.readUInt16BE(pos + 3), width: bytes.readUInt16BE(pos + 5) };
267
+ }
268
+ pos += length;
269
+ }
270
+ return undefined;
271
+ }
272
+
273
+ /**
274
+ * A WebP file's size, read from whichever of the three chunk layouts the
275
+ * format defines — the plain lossy frame, the lossless bitstream, or the
276
+ * extended header a file with alpha, animation or metadata carries instead.
277
+ *
278
+ * @param {Buffer} bytes
279
+ * @returns {{width: number, height: number}|undefined}
280
+ */
281
+ function webpDimensions(bytes) {
282
+ if (bytes.length < 30) return undefined;
283
+ if (bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") {
284
+ return undefined;
285
+ }
286
+ const chunk = bytes.toString("ascii", 12, 16);
287
+ if (chunk === "VP8 ") {
288
+ return {
289
+ width: bytes.readUInt16LE(26) & 0x3fff,
290
+ height: bytes.readUInt16LE(28) & 0x3fff,
291
+ };
292
+ }
293
+ if (chunk === "VP8L") {
294
+ const bits = bytes.readUInt32LE(21);
295
+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
296
+ }
297
+ if (chunk === "VP8X") {
298
+ return {
299
+ width: (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1,
300
+ height: (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)) + 1,
301
+ };
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /**
307
+ * A TIFF's `ImageWidth`/`ImageLength` tags, read from its first IFD in
308
+ * whichever byte order the file's own header declares.
309
+ *
310
+ * @param {Buffer} bytes
311
+ * @returns {{width: number, height: number}|undefined}
312
+ */
313
+ function tiffDimensions(bytes) {
314
+ if (bytes.length < 8) return undefined;
315
+ const order = bytes.toString("ascii", 0, 2);
316
+ let little;
317
+ if (order === "II") little = true;
318
+ else if (order === "MM") little = false;
319
+ else return undefined;
320
+ const u16 = (offset) => (little ? bytes.readUInt16LE(offset) : bytes.readUInt16BE(offset));
321
+ const u32 = (offset) => (little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset));
322
+ if (u16(2) !== 42) return undefined;
323
+ const ifd = u32(4);
324
+ if (ifd + 2 > bytes.length) return undefined;
325
+ const entryCount = u16(ifd);
326
+ let width;
327
+ let height;
328
+ for (let i = 0; i < entryCount && (width === undefined || height === undefined); i++) {
329
+ const entry = ifd + 2 + i * 12;
330
+ if (entry + 12 > bytes.length) break;
331
+ const tag = u16(entry);
332
+ if (tag !== 256 && tag !== 257) continue;
333
+ // SHORT (type 3) values sit in the first two bytes of the value
334
+ // field; LONG (type 4) occupies all four, so only the width in that
335
+ // field differs by type.
336
+ const type = u16(entry + 2);
337
+ const value = type === 3 ? u16(entry + 8) : u32(entry + 8);
338
+ if (tag === 256) width = value;
339
+ else height = value;
340
+ }
341
+ return width !== undefined && height !== undefined ? { width, height } : undefined;
342
+ }
343
+
344
+ /**
345
+ * An AVIF's declared size, read from the first `ispe` (Image Spatial
346
+ * Extents) property its ISOBMFF container carries, by walking box headers
347
+ * down through `meta` → `iprp` → `ipco` rather than assuming a fixed layout.
348
+ *
349
+ * @param {Buffer} bytes
350
+ * @returns {{width: number, height: number}|undefined}
351
+ */
352
+ function avifDimensions(bytes) {
353
+ /** @type {{width: number, height: number}|undefined} */
354
+ let found;
355
+ const walk = (start, end) => {
356
+ let pos = start;
357
+ while (!found && pos + 8 <= end) {
358
+ const size = bytes.readUInt32BE(pos);
359
+ if (size < 8) return;
360
+ const type = bytes.toString("ascii", pos + 4, pos + 8);
361
+ const dataStart = pos + 8;
362
+ const dataEnd = Math.min(pos + size, end);
363
+ if (type === "meta") {
364
+ // `meta` is a full box: four bytes of version and flags
365
+ // precede its own nested boxes.
366
+ walk(dataStart + 4, dataEnd);
367
+ } else if (type === "iprp" || type === "ipco") {
368
+ walk(dataStart, dataEnd);
369
+ } else if (type === "ispe" && dataEnd - dataStart >= 12) {
370
+ found = {
371
+ width: bytes.readUInt32BE(dataStart + 4),
372
+ height: bytes.readUInt32BE(dataStart + 8),
373
+ };
374
+ }
375
+ pos += size;
376
+ }
377
+ };
378
+ walk(0, bytes.length);
379
+ return found;
380
+ }
381
+
382
+ /**
383
+ * The pixel dimensions a raster image's own header states.
384
+ *
385
+ * Read from the file's bytes directly rather than through a decoder, so a
386
+ * corrupt or partial file fails closed — the same `undefined` answer as a
387
+ * format this carries no reader for — instead of throwing out of the asset
388
+ * walk. An SVG has no pixel dimensions to read; stating one would be a
389
+ * fabricated viewBox reading rather than a fact about the file.
390
+ *
391
+ * @param {string} absPath - The file's absolute path.
392
+ * @param {string} ext - Its extension, lowercase and dot-led.
393
+ * @returns {{width: number, height: number}|undefined} The declared size, or
394
+ * `undefined` when the format carries none, or the header cannot be read.
395
+ */
396
+ export function imageDimensions(absPath, ext) {
397
+ if (ext === ".svg") return undefined;
398
+ const reader = IMAGE_DIMENSION_READERS[ext];
399
+ if (!reader) return undefined;
400
+ let bytes;
401
+ try {
402
+ bytes = fs.readFileSync(absPath);
403
+ } catch {
404
+ return undefined;
405
+ }
406
+ try {
407
+ return reader(bytes);
408
+ } catch {
409
+ // A header this short or this malformed to parse is the same answer
410
+ // as one this reads no format for: the dimensions stay blank.
411
+ return undefined;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * The dimension reader for each raster extension {@link IMAGE_EXTENSIONS}
417
+ * declares, `.svg` excepted — it has no pixel dimensions, handled above
418
+ * rather than entered here.
419
+ *
420
+ * @type {Readonly<Record<string, (bytes: Buffer) => ({width: number, height: number}|undefined)>>}
421
+ */
422
+ const IMAGE_DIMENSION_READERS = Object.freeze({
423
+ ".png": pngDimensions,
424
+ ".apng": pngDimensions,
425
+ ".gif": gifDimensions,
426
+ ".bmp": bmpDimensions,
427
+ ".jpg": jpegDimensions,
428
+ ".jpeg": jpegDimensions,
429
+ ".webp": webpDimensions,
430
+ ".tiff": tiffDimensions,
431
+ ".avif": avifDimensions,
432
+ });
@@ -64,8 +64,9 @@
64
64
  import { authoredFrontmatter } from "./index-records.mjs";
65
65
  import { scanBlocks } from "./content-blocks.mjs";
66
66
  import { scanHeadingAttributes, withheldSections } from "./heading-attributes.mjs";
67
- import { scanCaptions } from "./content-captions.mjs";
67
+ import { scanFigures } from "./content-figures.mjs";
68
68
  import { footnoteFindings } from "./content-footnotes.mjs";
69
+ import { checkImages } from "./content-images.mjs";
69
70
  import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
70
71
  import { isGmNote } from "./note-vocabulary.mjs";
71
72
  import { cloneAddressState } from "./address-values.mjs";
@@ -125,6 +126,25 @@ import { reckoningContext } from "./reckoning-markers.mjs";
125
126
  * says nothing about. Counted as errors, never as skips.
126
127
  */
127
128
 
129
+ /**
130
+ * A `ref` call's Foundry rendering: a same-page wikilink rather than a
131
+ * Markdown link, so the wikilink pass that runs after expressions render
132
+ * addresses it exactly as an authored `[[#anchor|Text]]` is — by the
133
+ * JournalEntryPage the figure's own fence became, whether that page holds
134
+ * only the figure or carries it alongside other prose. A Markdown link's
135
+ * `#anchor` fragment resolves nowhere in Foundry, which addresses a page by
136
+ * UUID and an anchor within a page by that page's own anchor; this is why the
137
+ * two surfaces do not share a renderer.
138
+ *
139
+ * @param {{anchor: string}} target - The figure's own anchor.
140
+ * @param {string} label - The link's text, which may itself carry Markdown.
141
+ * @returns {string} `[[#anchor|label]]`, with a literal `|` in the label
142
+ * escaped so it cannot be read as the wikilink's own separator.
143
+ */
144
+ function foundryRefLink({ anchor }, label) {
145
+ return `[[#${anchor}|${label.replace(/\|/g, "\\|")}]]`;
146
+ }
147
+
128
148
  /**
129
149
  * The shared walk → filter → expand → convert → build → write → count loop.
130
150
  *
@@ -699,6 +719,22 @@ export class BasePackCompiler {
699
719
  : footnoteError.column,
700
720
  });
701
721
  }
722
+ // An image sharing its paragraph with other text, a bad address or a
723
+ // title with nowhere to draw it — the same findings `lint` reports,
724
+ // asked here too so a build run on its own refuses what the lint
725
+ // already names rather than rendering the directive as though it were
726
+ // absent. `checkImages` already resolves `bodyLine` and `bodyColumn`
727
+ // internally, so its findings need no further adjustment.
728
+ for (const imageError of checkImages(body, "", {
729
+ bodyLine: this.currentNote?.bodyLine ?? 1,
730
+ bodyColumn: this.currentNote?.bodyColumn ?? 1,
731
+ })) {
732
+ findings.push({
733
+ message: imageError.message,
734
+ line: imageError.line,
735
+ column: imageError.column,
736
+ });
737
+ }
702
738
  if (!this.constructor.convertsWikilinks) {
703
739
  this.reportConvertBodyFindings(findings);
704
740
  return body;
@@ -711,7 +747,8 @@ export class BasePackCompiler {
711
747
  sqlTables: absPath ? this.sqlTables?.get(absPath) : undefined,
712
748
  pageLists: absPath ? this.sqlTables?.pageLists?.get(absPath) : undefined,
713
749
  });
714
- for (const captionError of scanCaptions(tabulated).errors) {
750
+ const figureScan = scanFigures(tabulated);
751
+ for (const captionError of figureScan.errors) {
715
752
  findings.push({
716
753
  message: captionError.message,
717
754
  line:
@@ -720,12 +757,27 @@ export class BasePackCompiler {
720
757
  column: captionError.column,
721
758
  });
722
759
  }
760
+ // What the `ref` expression helper needs: this note's own figures, by
761
+ // id, and how a reference renders — a same-page wikilink rather than
762
+ // a Markdown link, so the wikilink pass below addresses it exactly as
763
+ // an authored `[[#anchor|Text]]` is, by the page the figure's own
764
+ // fence became. Cross-note resolution is omitted: a journal addresses
765
+ // a page by UUID, which this pass has none of for another note.
766
+ const figuresById = new Map(
767
+ figureScan.figures
768
+ .filter((figure) => figure.id)
769
+ .map((figure) => [
770
+ figure.id,
771
+ { label: figure.label, caption: figure.caption, hasCaption: figure.hasCaption },
772
+ ]),
773
+ );
723
774
  const expressions = renderMarkdownExpressions(tabulated, {
724
775
  fm,
725
776
  dates: reckoningContext(this.linkIndex),
726
777
  sqlResults: absPath ? this.sqlTables?.inline?.get(absPath) : undefined,
727
778
  file: absPath,
728
779
  bodyLine,
780
+ figures: { get: (id) => figuresById.get(id), link: foundryRefLink },
729
781
  });
730
782
  for (const finding of expressions.findings) {
731
783
  findings.push({ message: finding.message, line: finding.line, column: finding.column });
@@ -75,6 +75,7 @@ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
75
75
  import { folderField, renderFoundryMarkdown, resolveName } from "./helpers.mjs";
76
76
  import { packForType } from "./ids.mjs";
77
77
  import { readQualifier } from "./wikilinks.mjs";
78
+ import { pathnameRoles } from "./art-fields.mjs";
78
79
 
79
80
  /**
80
81
  * Load every compiled document a bundle may hold, keyed `<docType>/<id>`.
@@ -311,6 +312,12 @@ export class Bundles extends BasePackCompiler {
311
312
  contents.push({ docType: member.docType, document });
312
313
  }
313
314
 
315
+ // The markdown this pass reads has already had its embeds rewritten
316
+ // into ordinary images, each `src` the pathname `pathnameRoles` keys
317
+ // its roles by — so a `:::figure` of a map names itself `Map 1` here
318
+ // exactly as the website and the book name the same picture.
319
+ const roles = pathnameRoles(this.linkIndex);
320
+
314
321
  return buildAdventure({
315
322
  id: fm.id,
316
323
  name,
@@ -323,7 +330,12 @@ export class Bundles extends BasePackCompiler {
323
330
  // Foundry renders on the import card. That is why a bundle earns no
324
331
  // separate documentation journal the way an item does: the document
325
332
  // it compiles into already has somewhere to put the prose.
326
- description: markdown.trim() ? renderFoundryMarkdown(markdown) : "",
333
+ description:
334
+ markdown.trim() ?
335
+ renderFoundryMarkdown(markdown, undefined, undefined, undefined, (pathname) =>
336
+ roles.get(pathname),
337
+ )
338
+ : "",
327
339
  folder: this.folderResolver(authoredFolder, { isAddress: folderIsAddress }),
328
340
  stats: this.stats,
329
341
  contents,
@@ -27,7 +27,7 @@
27
27
 
28
28
  * **One pass reads all three names.** Reading them in two passes made the first
29
29
  * one meet the second's closers with nothing open, and report a block the author
30
- * had not written. A construct this pass does not own — `:::caption` — is
30
+ * had not written. A construct this pass does not own — `:::figure` — is
31
31
  * counted rather than claimed, so its closer is attributed to it and the pass
32
32
  * that owns it still finds it in the markdown this one passes through.
33
33
  *
@@ -87,13 +87,13 @@ export const BLOCK_NAMES = Object.freeze({
87
87
  export const BLOCK_CONTAINERS = Object.freeze(["secret"]);
88
88
 
89
89
  /**
90
- * Constructs another pass owns. A `:::caption` is read by the caption pass, so
90
+ * Constructs another pass owns. A `:::figure` is read by the figure pass, so
91
91
  * this one **counts it and does not claim it**: the opener and its closer pass
92
92
  * through untouched, and the count is what lets a closer be attributed to the
93
93
  * innermost block actually open. Claiming one would report a block that does not
94
- * exist and take the caption out of the note.
94
+ * exist and take the figure out of the note.
95
95
  */
96
- const FOREIGN = Object.freeze(["caption"]);
96
+ const FOREIGN = Object.freeze(["figure"]);
97
97
 
98
98
  /** `title` is the heading. Everything else an author writes becomes an attribute. */
99
99
  const TITLE = "title";
@@ -173,6 +173,25 @@ export function resolveEmbed(index, embed) {
173
173
  return { reason: read.reason, target: embed.written, type: read.type };
174
174
  }
175
175
 
176
+ /**
177
+ * The role an address's asset declares, read through the same resolution an
178
+ * embed's address takes.
179
+ *
180
+ * This is the lookup a `:::figure` fence's `map` counter reaches through —
181
+ * see {@link module:engine/content-figures.scanFigures}'s `resolveRole` — for
182
+ * an address still in its authored form, before {@link resolveEmbeds} has
183
+ * rewritten it into the ordinary image every surface renders.
184
+ *
185
+ * @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex},
186
+ * or the equivalent the site and the book build.
187
+ * @param {string} address - The address as authored, inside `![[...]]`.
188
+ * @returns {string|undefined} The role, or `undefined` for a note's address,
189
+ * one that resolves nowhere, or a picture that declares none.
190
+ */
191
+ export function embedRole(index, address) {
192
+ return readAssetAddress(index, address, EMBED_DEFAULT_TYPE).record?.asset?.role || undefined;
193
+ }
194
+
176
195
  /**
177
196
  * What is wrong with one embed's directive or placement, if anything.
178
197
  *