@heroiclands/package-build 22.22.0 → 22.24.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 (140) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +51 -14
  4. package/ci/content-format-reference.mjs +1 -1
  5. package/content-config.mjs +80 -2
  6. package/docs/api.md +85 -72
  7. package/docs/authoring/assets.md +29 -0
  8. package/docs/authoring/dates-and-calendars.md +37 -1
  9. package/docs/authoring/links-and-markup.md +112 -31
  10. package/docs/commands.md +30 -29
  11. package/docs/configuration.md +92 -19
  12. package/docs/content-format.md +1 -1
  13. package/docs/guides/book.md +5 -4
  14. package/docs/guides/module-project.md +2 -2
  15. package/docs/guides/site.md +41 -0
  16. package/docs/project-setup.md +4 -4
  17. package/docs/reference/format-details.md +323 -60
  18. package/docs/reference/note-types.md +9 -9
  19. package/docs/reference/theme-pages.md +496 -0
  20. package/engine/anchors.mjs +16 -10
  21. package/engine/art-fields.mjs +118 -0
  22. package/engine/art-slots.mjs +9 -2
  23. package/engine/asset-index.mjs +160 -10
  24. package/engine/asset-replacement.mjs +93 -0
  25. package/engine/asset-types.mjs +240 -0
  26. package/engine/base-compiler.mjs +54 -2
  27. package/engine/bundles.mjs +13 -1
  28. package/engine/content-blocks.mjs +4 -4
  29. package/engine/content-embeds.mjs +19 -0
  30. package/engine/content-figures.mjs +468 -0
  31. package/engine/content-footnotes.mjs +10 -2
  32. package/engine/content-format.yaml +1 -1
  33. package/engine/content-images.mjs +126 -25
  34. package/engine/content-index.mjs +36 -10
  35. package/engine/field-reference.mjs +31 -10
  36. package/engine/field-spec.mjs +10 -8
  37. package/engine/foreign-catalog.mjs +145 -19
  38. package/engine/frontmatter-lint.mjs +73 -1
  39. package/engine/heading-attributes.mjs +31 -18
  40. package/engine/helpers.mjs +102 -33
  41. package/engine/infobox-registry.mjs +4 -1
  42. package/engine/infobox.mjs +47 -8
  43. package/engine/itinerary-scenes.mjs +4 -10
  44. package/engine/journals.mjs +264 -39
  45. package/engine/lore-events.mjs +197 -0
  46. package/engine/markdown-expressions.mjs +257 -7
  47. package/engine/note-dates.mjs +305 -12
  48. package/engine/note-vocabulary.mjs +11 -5
  49. package/engine/pathnames.mjs +36 -0
  50. package/engine/pdf-build.mjs +357 -34
  51. package/engine/pdf-images.mjs +64 -0
  52. package/engine/pdf-render.mjs +160 -36
  53. package/engine/scenes.mjs +16 -4
  54. package/engine/site-build.mjs +127 -13
  55. package/engine/site-config.mjs +19 -24
  56. package/engine/site-index.mjs +72 -1
  57. package/engine/site-preview.mjs +2 -0
  58. package/engine/system-block.mjs +44 -16
  59. package/hm3/actors.mjs +2 -2
  60. package/hugo-theme/data/banners.yaml +69 -0
  61. package/hugo-theme/layouts/404.html +85 -0
  62. package/hugo-theme/layouts/_default/baseof.html +64 -0
  63. package/hugo-theme/layouts/_default/list.html +139 -0
  64. package/hugo-theme/layouts/_default/single.html +4 -0
  65. package/hugo-theme/layouts/blog/list.html +54 -0
  66. package/hugo-theme/layouts/blog-post/single.html +36 -0
  67. package/hugo-theme/layouts/index.html +75 -0
  68. package/hugo-theme/layouts/partials/banner-url.html +117 -0
  69. package/hugo-theme/layouts/partials/cdn-url.html +36 -0
  70. package/hugo-theme/layouts/partials/draft-notice.html +41 -0
  71. package/hugo-theme/layouts/partials/draft-state.html +26 -0
  72. package/hugo-theme/layouts/partials/footer.html +29 -0
  73. package/hugo-theme/layouts/partials/header.html +35 -0
  74. package/hugo-theme/layouts/partials/hero-banner.html +86 -0
  75. package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
  76. package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
  77. package/hugo-theme/layouts/partials/holdings.html +61 -0
  78. package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
  79. package/hugo-theme/layouts/partials/infobox/items.html +26 -0
  80. package/hugo-theme/layouts/partials/infobox/section.html +56 -0
  81. package/hugo-theme/layouts/partials/infobox/value.html +43 -0
  82. package/hugo-theme/layouts/partials/infobox.html +57 -0
  83. package/hugo-theme/layouts/partials/page-body.html +154 -0
  84. package/hugo-theme/layouts/partials/place-map.html +9 -0
  85. package/hugo-theme/layouts/partials/related.html +121 -0
  86. package/hugo-theme/layouts/partials/search.html +84 -0
  87. package/hugo-theme/layouts/partials/site-url.html +32 -0
  88. package/hugo-theme/layouts/partials/tags.html +30 -0
  89. package/hugo-theme/layouts/partials/toc.html +28 -0
  90. package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
  91. package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
  92. package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
  93. package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
  94. package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
  95. package/hugo-theme/static/css/sohl-icons.css +57 -0
  96. package/hugo-theme/static/css/style.css +2953 -0
  97. package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
  98. package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
  99. package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
  100. package/hugo-theme/theme.toml +11 -0
  101. package/manifest.mjs +30 -24
  102. package/package.json +2 -1
  103. package/packagebuild-metadata.jsonl +13 -13
  104. package/sohl/item-fields.mjs +4 -3
  105. package/types/content-config.d.mts +48 -1
  106. package/types/engine/anchors.d.mts +9 -4
  107. package/types/engine/art-fields.d.mts +64 -0
  108. package/types/engine/art-slots.d.mts +2 -1
  109. package/types/engine/asset-index.d.mts +43 -0
  110. package/types/engine/asset-replacement.d.mts +22 -0
  111. package/types/engine/asset-types.d.mts +18 -0
  112. package/types/engine/base-compiler.d.mts +0 -17
  113. package/types/engine/content-embeds.d.mts +16 -0
  114. package/types/engine/content-figures.d.mts +101 -0
  115. package/types/engine/content-footnotes.d.mts +7 -0
  116. package/types/engine/content-images.d.mts +78 -12
  117. package/types/engine/content-index.d.mts +8 -2
  118. package/types/engine/field-reference.d.mts +21 -0
  119. package/types/engine/field-spec.d.mts +20 -16
  120. package/types/engine/foreign-catalog.d.mts +39 -0
  121. package/types/engine/heading-attributes.d.mts +13 -8
  122. package/types/engine/helpers.d.mts +28 -10
  123. package/types/engine/infobox-registry.d.mts +4 -1
  124. package/types/engine/infobox.d.mts +6 -0
  125. package/types/engine/journals.d.mts +31 -9
  126. package/types/engine/lore-events.d.mts +14 -0
  127. package/types/engine/markdown-expressions.d.mts +23 -2
  128. package/types/engine/note-dates.d.mts +64 -1
  129. package/types/engine/pathnames.d.mts +26 -0
  130. package/types/engine/pdf-build.d.mts +28 -6
  131. package/types/engine/pdf-images.d.mts +52 -0
  132. package/types/engine/pdf-render.d.mts +12 -0
  133. package/types/engine/site-build.d.mts +14 -3
  134. package/types/engine/site-config.d.mts +9 -9
  135. package/types/engine/site-index.d.mts +54 -0
  136. package/types/engine/system-block.d.mts +26 -2
  137. package/types/hm3/actors.d.mts +18 -0
  138. package/types/manifest.d.mts +19 -23
  139. package/engine/content-captions.mjs +0 -230
  140. package/types/engine/content-captions.d.mts +0 -58
@@ -58,10 +58,12 @@
58
58
  * agree: a figure is a block on all three, and nothing has to decide what a
59
59
  * floated run of text inside a paragraph would mean.
60
60
  *
61
- * **The alt text is the caption.** Print has no `alt` attribute and has to put
62
- * those words somewhere visible; rather than one surface showing them and two
63
- * hiding them, every surface draws them under the picture, and the HTML
64
- * surfaces carry them as `alt` as well.
61
+ * **Alt text renders as a caption on no surface.** A caption is drawn only for
62
+ * an image inside a `:::figure` fence that carries an authored caption, and the
63
+ * text drawn is always that caption. An image with no fence, or a fence with no
64
+ * caption, draws no line under the picture anywhere. The HTML surfaces still
65
+ * carry the alt text as the `img` element's `alt` attribute, for the reader a
66
+ * screen reader serves.
65
67
  *
66
68
  * ## Where the address resolves
67
69
  *
@@ -79,7 +81,9 @@ import path from "node:path";
79
81
  import { matchAllOutsideCode } from "./code-fences.mjs";
80
82
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
81
83
  import { positionInBody } from "./diagnostics.mjs";
84
+ import { FOOTNOTE_REFERENCE } from "./content-footnotes.mjs";
82
85
  import { foundryAddressProblem, pathnameProblem, servesFoundry } from "./pathnames.mjs";
86
+ import { ASSET_ROLES } from "./asset-index.mjs";
83
87
 
84
88
  /**
85
89
  * The width classes an image may carry, and what each means to a renderer.
@@ -273,13 +277,27 @@ export function parseImageDirective(raw) {
273
277
  }
274
278
 
275
279
  /**
276
- * The classes a figure carries, from a parsed directive.
280
+ * The class a picture's role names, or `""` for no role or one outside the
281
+ * closed set — the same silent-degrade rule every other unrecognised value
282
+ * here gets, since a role this far downstream has already been validated or
283
+ * dropped by the asset record that carries it.
277
284
  *
278
- * @param {{classes?: string[], size?: string, float?: string}} [directive] - As parsed.
285
+ * @param {string} [role] - The role, from the resolved asset record.
286
+ * @returns {string} The class, or `""`.
287
+ */
288
+ export function roleClass(role) {
289
+ return role && ASSET_ROLES.includes(role) ? `note-image-role-${role}` : "";
290
+ }
291
+
292
+ /**
293
+ * The classes a figure carries, from a parsed directive and its asset's role.
294
+ *
295
+ * @param {{classes?: string[], size?: string, float?: string, role?: string}} [directive] -
296
+ * As parsed, plus the role the resolved asset record carries, if any.
279
297
  * @returns {string} A space-separated class list, always naming
280
298
  * {@link IMAGE_FIGURE_CLASS} first.
281
299
  */
282
- export function figureClasses({ classes = [], size = "auto", float = "" } = {}) {
300
+ export function figureClasses({ classes = [], size = "auto", float = "", role = "" } = {}) {
283
301
  const names = [IMAGE_FIGURE_CLASS];
284
302
  for (const name of classes) {
285
303
  const spec = IMAGE_CLASSES[/** @type {keyof typeof IMAGE_CLASSES} */ (name)];
@@ -288,6 +306,8 @@ export function figureClasses({ classes = [], size = "auto", float = "" } = {})
288
306
  if (size !== "auto" && IMAGE_SIZES.includes(size)) names.push(`note-image-size-${size}`);
289
307
  const position = IMAGE_FLOATS[/** @type {keyof typeof IMAGE_FLOATS} */ (float)];
290
308
  if (position) names.push(position.class);
309
+ const roleName = roleClass(role);
310
+ if (roleName) names.push(roleName);
291
311
  return names.join(" ");
292
312
  }
293
313
 
@@ -313,30 +333,85 @@ export function escapeHtml(text) {
313
333
  * function, so the two cannot drift into styling the same directive through
314
334
  * different class names.
315
335
  *
336
+ * **No `<figcaption>` is emitted here.** A `:::figure` fence draws its own
337
+ * label and authored caption around whatever this returns; the alt text stays
338
+ * on the `img` element alone.
339
+ *
340
+ * **The role class and the `width`/`height` attributes are what a stylesheet
341
+ * elsewhere keys a role's size on.** This module states none — the measure a
342
+ * `portrait` fills on a page is a Hugo theme's or a Foundry system's own
343
+ * decision — and emits only what the resolved asset record states: a role
344
+ * class when the picture declares one, and the attributes whenever the
345
+ * picture resolved to an asset record at all, blank for a vector. The
346
+ * attributes reserve the picture's own layout space before it loads, which is
347
+ * what lets a page avoid reflowing as pictures load in.
348
+ *
316
349
  * @param {object} image - The image.
317
350
  * @param {string} image.src - The address, resolved for the surface.
318
- * @param {string} [image.alt] - The alt text, which is also the caption.
351
+ * @param {string} [image.alt] - The alt text, for a reader who cannot see the
352
+ * picture.
319
353
  * @param {string[]} [image.classes] - Width classes, from the directive.
320
354
  * @param {string} [image.size] - Named display size, from the directive.
321
355
  * @param {string} [image.float] - The float position, from the directive.
356
+ * @param {string} [image.role] - The picture's declared role, from the
357
+ * resolved asset record.
358
+ * @param {number|""} [image.width] - The file's own pixel width, from the
359
+ * resolved asset record; `""` for a vector. Omitted entirely when the
360
+ * address resolved to no asset record at all.
361
+ * @param {number|""} [image.height] - As `width`.
322
362
  * @returns {string} The figure, as one HTML block.
323
363
  */
324
- export function imageFigureHtml({ src, alt = "", classes = [], size = "auto", float = "" }) {
325
- const caption = alt ? `\n<figcaption>${escapeHtml(alt)}</figcaption>` : "";
364
+ export function imageFigureHtml({
365
+ src,
366
+ alt = "",
367
+ classes = [],
368
+ size = "auto",
369
+ float = "",
370
+ role = "",
371
+ width,
372
+ height,
373
+ }) {
374
+ const dims =
375
+ width !== undefined && height !== undefined ?
376
+ ` width="${escapeHtml(String(width))}" height="${escapeHtml(String(height))}"`
377
+ : "";
326
378
  return (
327
- `<figure class="${figureClasses({ classes, size, float })}">\n` +
328
- `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}">${caption}\n` +
379
+ `<figure class="${figureClasses({ classes, size, float, role })}">\n` +
380
+ `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}"${dims}>\n` +
329
381
  `</figure>`
330
382
  );
331
383
  }
332
384
 
385
+ /**
386
+ * A body extension's own delimiter — a `:::` opener or closer, or a figure's
387
+ * `///`.
388
+ *
389
+ * It bounds a paragraph exactly as a blank line does: the line belongs to the
390
+ * construct rather than to the prose, and the pass that owns the construct
391
+ * hands its body on by itself. So a picture written tight against one is a
392
+ * block of its own, which is how `:::figure` is written.
393
+ */
394
+ const DELIMITER = /^(?::{3,}[^\n]*|\/{3,}[ \t]*)$/;
395
+
396
+ /**
397
+ * A footnote reference immediately trailing a match, anchored so it only
398
+ * consumes one sitting right against the end of it.
399
+ */
400
+ const TRAILING_FOOTNOTE_REFERENCE = new RegExp(`^${FOOTNOTE_REFERENCE.source}`);
401
+
333
402
  /**
334
403
  * Whether a match sits alone in its own paragraph.
335
404
  *
336
- * "Alone" is the whole of a block: nothing else on its line, and a blank line
337
- * or the end of the body either side of it. A leading `>` or list marker
338
- * disqualifies it for the same reason a word does — the paragraph it belongs to
339
- * holds something the figure would have to be lifted out of.
405
+ * "Alone" is the whole of a block: nothing else on its line, and a blank line,
406
+ * a body-extension delimiter or the end of the body either side of it. A
407
+ * leading `>` or list marker disqualifies it for the same reason a word does —
408
+ * the paragraph it belongs to holds something the figure would have to be
409
+ * lifted out of.
410
+ *
411
+ * **A footnote reference trailing the match is not "other text."** `[^note]`
412
+ * annotates the picture rather than sitting beside it in prose, the same
413
+ * standing its own directive already has, so `![A ranger](r.webp)[^note]` is
414
+ * still alone.
340
415
  *
341
416
  * @param {string} text - The body the match indexes into.
342
417
  * @param {number} start - Where the match begins.
@@ -350,12 +425,18 @@ export function standsAlone(text, start, end) {
350
425
  // makes it an indented code block, which `matchAllOutsideCode` skips.
351
426
  if (!/^[ \t]*$/.test(src.slice(lineStart, start))) return false;
352
427
  const lineEnd = src.indexOf("\n", end);
353
- if (!/^[ \t]*$/.test(src.slice(end, lineEnd === -1 ? src.length : lineEnd))) return false;
428
+ const afterMatch = src.slice(end, lineEnd === -1 ? src.length : lineEnd);
429
+ const reference = TRAILING_FOOTNOTE_REFERENCE.exec(afterMatch);
430
+ if (!/^[ \t]*$/.test(reference ? afterMatch.slice(reference[0].length) : afterMatch))
431
+ return false;
354
432
 
355
433
  const before = src.slice(0, lineStart);
356
- if (before.trim() && !/\n[ \t]*\n[ \t]*$/.test(before)) return false;
434
+ const previous = before.replace(/\n$/, "").split("\n").pop() ?? "";
435
+ if (before.trim() && !/\n[ \t]*\n[ \t]*$/.test(before) && !DELIMITER.test(previous))
436
+ return false;
357
437
  const after = lineEnd === -1 ? "" : src.slice(lineEnd);
358
- if (after.trim() && !/^\n[ \t]*\n/.test(after)) return false;
438
+ const next = after.replace(/^\n/, "").split("\n")[0] ?? "";
439
+ if (after.trim() && !/^\n[ \t]*\n/.test(after) && !DELIMITER.test(next)) return false;
359
440
  return true;
360
441
  }
361
442
 
@@ -414,6 +495,9 @@ export function imageSourcesIn(body) {
414
495
  * @param {object} [opts]
415
496
  * @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
416
497
  * @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
498
+ * @param {Array<{line: number, generated: boolean}>} [opts.lineMap] - From
499
+ * table expansion, mapping each line of `body` back to the line an author
500
+ * wrote, for a caller handing in a body whose tables already expanded.
417
501
  * @param {object} [opts.config] - The resolved build configuration. Supplied,
418
502
  * an address is also held to the one surface a pathname can be dead on
419
503
  * without any other pass noticing — see the Foundry address below. Omitted,
@@ -423,7 +507,7 @@ export function imageSourcesIn(body) {
423
507
  * severity: "error", message: string}>} One finding per defect, in source
424
508
  * order.
425
509
  */
426
- export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, config } = {}) {
510
+ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, lineMap, config } = {}) {
427
511
  const text = String(body ?? "");
428
512
  if (!text) return [];
429
513
 
@@ -434,7 +518,7 @@ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, config }
434
518
  * @param {string} message - What is wrong.
435
519
  */
436
520
  const report = (offset, message) => {
437
- const { line, column } = positionInBody(text, offset, { bodyLine, bodyColumn });
521
+ const { line, column } = positionInBody(text, offset, { bodyLine, bodyColumn, lineMap });
438
522
  findings.push({ file, line, column, severity: /** @type {"error"} */ ("error"), message });
439
523
  };
440
524
 
@@ -454,7 +538,7 @@ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, config }
454
538
  report(
455
539
  image.index,
456
540
  `\`"${image.title}"\` is a title on an image, and no surface here draws ` +
457
- "one — an image's alt text is its caption",
541
+ "one — a caption is written in a `:::figure` fence's /// section",
458
542
  );
459
543
  }
460
544
  if (!image.block) {
@@ -587,9 +671,13 @@ export function lintContentImages(contentBase, { skipDirectories = [], config }
587
671
  * @param {(src: string) => string} [resolveSrc] - Translates an authored
588
672
  * pathname into the address this surface serves. The default is the identity,
589
673
  * for a caller rendering the format rather than publishing it.
674
+ * @param {(src: string) => {type?: string, role?: string, width?: number|"", height?: number|""}|undefined}
675
+ * [lookupAsset] - What the resolved asset record says about the address, as
676
+ * authored — before `resolveSrc` translates it. The default answers nothing,
677
+ * which is what an address resolving to no asset record gets anyway.
590
678
  * @returns {string} The same body, with each block image as a `<figure>`.
591
679
  */
592
- export function renderImageFigures(body, resolveSrc = (src) => src) {
680
+ export function renderImageFigures(body, resolveSrc = (src) => src, lookupAsset = () => undefined) {
593
681
  const text = String(body ?? "");
594
682
  let out = "";
595
683
  let last = 0;
@@ -598,12 +686,16 @@ export function renderImageFigures(body, resolveSrc = (src) => src) {
598
686
  const { classes, size, float, problems } = parseImageDirective(image.directive);
599
687
  if (problems.length) continue;
600
688
  out += text.slice(last, image.index);
689
+ const asset = lookupAsset(image.src);
601
690
  out += imageFigureHtml({
602
691
  src: resolveSrc(image.src),
603
692
  alt: image.alt,
604
693
  classes,
605
694
  size,
606
695
  float,
696
+ role: asset?.role,
697
+ width: asset?.width,
698
+ height: asset?.height,
607
699
  });
608
700
  last = image.index + image.length;
609
701
  }
@@ -632,9 +724,13 @@ export function renderImageFigures(body, resolveSrc = (src) => src) {
632
724
  * address into the one this surface serves. Foundry is handed the path inside
633
725
  * the install; a renderer that resolves the address itself — the book stages
634
726
  * its own copy — passes nothing and gets the address as authored.
727
+ * @param {(src: string) => {type?: string, role?: string, width?: number|"", height?: number|""}|undefined}
728
+ * [lookupAsset] - What the resolved asset record says about the address, as
729
+ * authored — before `resolveSrc` runs. The default answers nothing, which is
730
+ * what an address resolving to no asset record gets anyway.
635
731
  * @returns {(md: object) => void} A markdown-it plugin.
636
732
  */
637
- export function imagePlugin(resolveSrc = (src) => src) {
733
+ export function imagePlugin(resolveSrc = (src) => src, lookupAsset = () => undefined) {
638
734
  return (md) => {
639
735
  /** @type {any} */ (md).core.ruler.push("heroiclands_image", (state) => {
640
736
  attachImageDirectives(state.tokens);
@@ -649,12 +745,17 @@ export function imagePlugin(resolveSrc = (src) => src) {
649
745
  ) => {
650
746
  const token = tokens[idx];
651
747
  if (!token.meta?.block) return base(tokens, idx, options, env, self);
748
+ const src = token.attrGet("src") ?? "";
749
+ const asset = lookupAsset(src);
652
750
  return `${imageFigureHtml({
653
- src: resolveSrc(token.attrGet("src") ?? ""),
751
+ src: resolveSrc(src),
654
752
  alt: token.content ?? "",
655
753
  classes: token.meta.classes,
656
754
  size: token.meta.size,
657
755
  float: token.meta.float,
756
+ role: asset?.role,
757
+ width: asset?.width,
758
+ height: asset?.height,
658
759
  })}\n`;
659
760
  };
660
761
  };
@@ -104,6 +104,8 @@ import { checkForeignAssetBindings } from "./asset-bindings.mjs";
104
104
  import { addressSlug, canonicalKey } from "./content-address.mjs";
105
105
  import { ownDocumentSystem } from "./address.mjs";
106
106
  import { NOTE_SYSTEM } from "./systems.mjs";
107
+ import { assetAddressIndex } from "./art-fields.mjs";
108
+ import { embedRole } from "./content-embeds.mjs";
107
109
  // One reader for a note's anchors, shared with the link checker and with the
108
110
  // builds that emit a link. Re-exported because this is where callers
109
111
  // have always addressed it.
@@ -443,6 +445,10 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
443
445
  * entries are derived against.
444
446
  * @param {object} [options.addressContext] - Address resolution context.
445
447
  * @param {object} [options.dateContext] - Calendar conversion context.
448
+ * @param {(address: string) => string|undefined} [options.resolveRole] - From
449
+ * a picture's address to the role its asset declares, so a figure anchor's
450
+ * `name` reads `Map 1` rather than `Figure 1` where it is due — see
451
+ * {@link module:engine/content-figures.scanFigures}.
446
452
  * @returns {Record<string, any>} The record, keys sorted at every depth. A
447
453
  * **stub** — a note with an empty body, on a type an empty body suppresses —
448
454
  * carries `address` and `anchors` as `null`: it publishes no page, so it
@@ -464,6 +470,7 @@ export function buildIndexRecord({
464
470
  manifest,
465
471
  addressContext,
466
472
  dateContext,
473
+ resolveRole,
467
474
  }) {
468
475
  assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
469
476
 
@@ -514,7 +521,7 @@ export function buildIndexRecord({
514
521
  // there rather than search for the heading.
515
522
  anchors:
516
523
  stub ? null : (
517
- collectAnchors(body, bodyLine).map((a) => ({
524
+ collectAnchors(body, bodyLine, resolveRole).map((a) => ({
518
525
  ...a,
519
526
  link: address ? `${address.slug}#${a.slug}` : null,
520
527
  }))
@@ -615,6 +622,7 @@ export function indexRecordsForNote({
615
622
  manifest,
616
623
  addressContext,
617
624
  dateContext,
625
+ resolveRole,
618
626
  }) {
619
627
  resolveNoteId(frontmatter, { pkg: contentPackage });
620
628
  const record = buildIndexRecord({
@@ -627,6 +635,7 @@ export function indexRecordsForNote({
627
635
  manifest,
628
636
  addressContext,
629
637
  dateContext,
638
+ resolveRole,
630
639
  });
631
640
  const records = [decodeIndexAddresses(record, addressContext ?? { package: contentPackage })];
632
641
  const address = noteAddress(frontmatter, contentPackage);
@@ -726,7 +735,24 @@ export function collectContentIndex(
726
735
  // this same walk states one — the whole tree is already in memory, so no
727
736
  // second read is needed to answer a question about all of it.
728
737
  const present = presentAmongFrontmatters(parsedNotes.map((n) => n.frontmatter));
729
- const dates = reckoningContext({ notes: parsedNotes.map((n) => n.frontmatter) });
738
+ // Carried on the same context `resolvedDateFields` reads: a recurring
739
+ // event's `next` is computed against this present exactly as a being's
740
+ // `age` already is, above.
741
+ const dates = {
742
+ ...reckoningContext({ notes: parsedNotes.map((n) => n.frontmatter) }),
743
+ present,
744
+ };
745
+
746
+ // Walked ahead of the notes, rather than after them as the records
747
+ // themselves are emitted: a note's anchors are collected below, and a
748
+ // figure fence's `map` counter needs this package's own asset roles
749
+ // before the first note is read, the same precedent the book's own
750
+ // cross-file numbering sets. A package with no `assetsBase` resolves no
751
+ // role, which is the one it would have resolved anyway.
752
+ const assetRecords =
753
+ assetsBase ? collectAssetRecords(assetsBase, { contentPackage, problems }) : [];
754
+ const assetIndex = assetAddressIndex(assetRecords, { config: { contentPackage } });
755
+ const resolveRole = (address) => embedRole(assetIndex, address);
730
756
 
731
757
  for (const { frontmatter, body, bodyLine, absPath } of parsedNotes) {
732
758
  const fm = frontmatter ?? {};
@@ -744,6 +770,7 @@ export function collectContentIndex(
744
770
  manifest,
745
771
  addressContext,
746
772
  dateContext: dates,
773
+ resolveRole,
747
774
  }),
748
775
  );
749
776
  } catch (err) {
@@ -776,14 +803,13 @@ export function collectContentIndex(
776
803
  }
777
804
  }
778
805
 
779
- if (assetsBase) {
780
- // Sorted at every depth like a note's record, and for the same reason:
781
- // the declaration order of the `asset` fields is a fact about the
782
- // emitter, not about the content, and the artifact is meant to be
783
- // byte-identical across two runs over an unchanged tree.
784
- for (const record of collectAssetRecords(assetsBase, { contentPackage, problems })) {
785
- records.push(/** @type {Record<string, any>} */ (sortKeysDeep(record)));
786
- }
806
+ // Sorted at every depth like a note's record, and for the same reason: the
807
+ // declaration order of the `asset` fields is a fact about the emitter, not
808
+ // about the content, and the artifact is meant to be byte-identical across
809
+ // two runs over an unchanged tree. Collected above, ahead of the notes —
810
+ // see `resolveRole` — rather than walked a second time here.
811
+ for (const record of assetRecords) {
812
+ records.push(/** @type {Record<string, any>} */ (sortKeysDeep(record)));
787
813
  }
788
814
 
789
815
  // Source path, then the canonical address, then the note id. The walk
@@ -123,16 +123,41 @@ function fieldTable(fields) {
123
123
  return padTable(rows);
124
124
  }
125
125
 
126
+ /**
127
+ * The one line explaining why a single field's shared top-level fallback is
128
+ * off.
129
+ *
130
+ * **The field's reason stands on its own** — a complete sentence, or more
131
+ * than one — rather than completing a clause the frame starts. A field that
132
+ * collides with a top-level spelling meaning something else can say so
133
+ * ("`weight` is the item's own, independent of the note's."); a field whose
134
+ * top-level spelling is refused outright by the closed vocabulary can say
135
+ * that instead, honestly, which "there it means …" cannot: a refused key
136
+ * carries no meaning to report.
137
+ *
138
+ * Exported so a guard can walk every field declaring `topLevelMeans` across
139
+ * every registry and check that each one composes into a grammatical
140
+ * sentence, without reimplementing the frame it is checking.
141
+ *
142
+ * @param {import("./field-spec.mjs").FieldSpec} field - A field declaring
143
+ * `topLevelMeans`.
144
+ * @returns {string} The composed markdown line.
145
+ */
146
+ export function topLevelExemptionLine(field) {
147
+ return `**\`${field.name}\` is not read from the note's top level.** ${field.topLevelMeans}`;
148
+ }
149
+
126
150
  /**
127
151
  * The fields of one type that are **not** filled from the note's top level.
128
152
  *
129
153
  * A field ordinarily falls back to the top-level property spelled like its
130
154
  * name, so an author who writes `weight: 3` at the top of a note reasonably
131
- * expects it to reach the document. Where that spelling means something else at
132
- * the note level the fallback is off, and an author has no way to tell from the
133
- * table — the field is there, the value is written, and the document ships the
134
- * default. So the reason each such field declares is rendered beside its table
135
- * rather than left in the source.
155
+ * expects it to reach the document. Where that spelling is wrong at the note
156
+ * level — it means something else there, or the closed top-level vocabulary
157
+ * refuses it outright — the fallback is off, and an author has no way to tell
158
+ * from the table — the field is there, the value is written, and the document
159
+ * ships the default. So the reason each such field declares is rendered
160
+ * beside its table rather than left in the source.
136
161
  *
137
162
  * Below the table, not inside it: the reason is a sentence or two, and
138
163
  * {@link padTable} pads every column to its widest cell, so a cell holding it
@@ -144,11 +169,7 @@ function fieldTable(fields) {
144
169
  function sharedExemptions(fields) {
145
170
  const exempt = authoredFields(fields).filter((field) => field.topLevelMeans);
146
171
  if (!exempt.length) return [];
147
- return exempt.flatMap((field) => [
148
- `**\`${field.name}\` is not read from the note's top level.** There it means ` +
149
- `${field.topLevelMeans}`,
150
- "",
151
- ]);
172
+ return exempt.flatMap((field) => [topLevelExemptionLine(field), ""]);
152
173
  }
153
174
 
154
175
  /**
@@ -84,19 +84,21 @@ export { legacyKeyOf, retiredTopLevelKey, setPath };
84
84
  * retiring one — so a field can move into `data:` while the corpus catches
85
85
  * up, instead of on a flag day across four repositories. A declaration that
86
86
  * names one is mid-sweep by construction; see {@link readsLegacyKey}.
87
- * @property {string} [topLevelMeans] - **What the note's top-level key of this
88
- * name means instead** — declared only where it means something else, and
89
- * stating it removes the shared top-level position from this field's
90
- * resolution order.
87
+ * @property {string} [topLevelMeans] - **Why the note's top-level key of this
88
+ * name is not this field's source** — declared wherever that key's spelling
89
+ * means something else at the note level, or the closed top-level
90
+ * vocabulary refuses it outright, and stating either removes the shared
91
+ * top-level position from this field's resolution order.
91
92
  *
92
93
  * A field's `name` doubles as its identity and as the shared property it
93
94
  * draws from, which is right wherever the two levels state the same quantity
94
95
  * — `data.weight` is the weight, whoever reads it. It is wrong wherever a
95
- * spelling collides across the two vocabularies. An `affiliation` item's
96
+ * spelling collides across the two vocabularies, or names a key the closed
97
+ * top-level vocabulary does not admit at all. An `affiliation` item's
96
98
  * `system.title` is the style of address an office carries; a note's
97
- * top-level `title` is the note's own heading. Nothing relates them, and
98
- * before this key one silently fed the other, stringifying an authored
99
- * `title: null` into fifteen documents.
99
+ * top-level `title` is refused outright. Nothing relates them, and before
100
+ * this key one silently fed the other, stringifying an authored `title:
101
+ * null` into fifteen documents.
100
102
  *
101
103
  * **The value is the reason**, not a flag with a comment beside it. A boolean
102
104
  * would record the decision and lose the case for it, and the next person