@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
@@ -67,7 +67,14 @@ import deflistPlugin from "markdown-it-deflist";
67
67
 
68
68
  import { bookDraftNoticePreamble } from "./draft-notice.mjs";
69
69
  import { iconPlugin, ICON_PATTERN, ICON_SIZES } from "./content-icons.mjs";
70
- import { IMAGE_CLASSES, IMAGE_FLOATS, imagePlugin } from "./content-images.mjs";
70
+ import {
71
+ IMAGE_CLASSES,
72
+ IMAGE_FLOATS,
73
+ IMAGE_PATTERN,
74
+ checkImages,
75
+ imagePlugin,
76
+ } from "./content-images.mjs";
77
+ import { bookImageWidthIn } from "./pdf-images.mjs";
71
78
 
72
79
  /** Requested print width for every named image size. */
73
80
  export const BOOK_IMAGE_WIDTHS = Object.freeze({
@@ -79,7 +86,7 @@ export const BOOK_IMAGE_WIDTHS = Object.freeze({
79
86
  "full-width": '"full-width"',
80
87
  });
81
88
  import { slugify } from "./content-slug.mjs";
82
- import { scanCaptions } from "./content-captions.mjs";
89
+ import { scanFigures } from "./content-figures.mjs";
83
90
  import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
84
91
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
85
92
  import { HTML_TAG, htmlMessage } from "./content-html.mjs";
@@ -290,7 +297,7 @@ function offsetPosition(source, offset, opts = {}) {
290
297
  * those passes did not run — `opts.prepared` — because each of them leaves the
291
298
  * markup as written when it fails and has already said so in its own words.
292
299
  *
293
- * A `:::` block and a `:::caption` are the pair this pass does handle, and their
300
+ * A `:::` block and a `:::figure` are the pair this pass does handle, and their
294
301
  * own scanners decide what is well formed; their findings are reported here so
295
302
  * that a front-matter file, a prose file and a note body all get them.
296
303
  *
@@ -341,8 +348,15 @@ function reportUnrenderable(source, definitions, opts, original) {
341
348
  );
342
349
  }
343
350
  }
344
- for (const error of scanCaptions(source).errors)
351
+ for (const error of scanFigures(source).errors)
345
352
  report(linePosition(error.line, error.column, opts), "error", error.message);
353
+ // An image sharing its paragraph with other text, or an address or title
354
+ // the website's own lint already refuses — asked here too, so the book
355
+ // fails on the same input rather than typesetting the directive as
356
+ // though it were absent. `checkImages` resolves `opts.bodyLine` and
357
+ // `opts.bodyColumn` itself.
358
+ for (const error of checkImages(source, opts.file ?? "", opts))
359
+ report({ line: error.line, column: error.column }, error.severity, error.message);
346
360
  for (const error of scanHeadingAttributes(source).errors)
347
361
  report(linePosition(error.line, error.column, opts), "error", error.message);
348
362
  for (const error of withheldSections(source).errors)
@@ -408,6 +422,12 @@ function reportUnrenderable(source, definitions, opts, original) {
408
422
  * the staged file's path, relative to the `.typ`. An address this does not
409
423
  * carry has no file the compiler can open, so the figure prints its caption
410
424
  * alone — see {@link renderImage}.
425
+ * @param {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
426
+ * [opts.assets] - An image's address as authored → what
427
+ * {@link module:engine/art-fields.assetImageInfoByPathname} records for it,
428
+ * which is what sizes a picture carrying no named `size=` — see
429
+ * {@link module:engine/pdf-images.bookImageWidthIn}. An address missing from
430
+ * this map draws at the medium's ordinary size, the same as today.
411
431
  * @param {number} [opts.headingOffset] - Added to every heading level, so a
412
432
  * note's own `##` nests beneath the entry heading the book gave it.
413
433
  * @param {string} [opts.anchorPrefix] - The entry's anchor, which namespaces
@@ -440,6 +460,7 @@ export function markdownToTypst(markdown, opts = {}) {
440
460
  links = new Map(),
441
461
  glyphs = new Map(),
442
462
  images = new Map(),
463
+ assets = new Map(),
443
464
  headingOffset = 0,
444
465
  anchorPrefix = "",
445
466
  } = opts;
@@ -466,6 +487,7 @@ export function markdownToTypst(markdown, opts = {}) {
466
487
  links,
467
488
  glyphs,
468
489
  images,
490
+ assets,
469
491
  headingOffset,
470
492
  anchorPrefix,
471
493
  url: opts.url,
@@ -544,18 +566,18 @@ export function markdownToTypst(markdown, opts = {}) {
544
566
  }
545
567
  }
546
568
  const lines = source.split("\n");
547
- const localCaptions = scanCaptions(source).captions;
569
+ const localFigures = scanFigures(source, { resolveRole: opts.resolveRole }).figures;
548
570
  // Numbered by the caller, which counts across the whole book, and placed by
549
571
  // the scan above, which is the only reading of *this* body. Paired by
550
- // position: two captions may share an id — a finding, and the book is built
551
- // anyway — and keying on one would give the second caption's lines to the
552
- // first, printing its block twice and losing its label.
553
- const provided = opts.captions ?? localCaptions;
554
- const numbered = new Map(provided.map((caption) => [caption.id, caption]));
555
- const captions = localCaptions.map((caption, at) => {
572
+ // position: two figures may share an id — a finding, and the book is built
573
+ // anyway — and keying on one would give the second figure's lines to the
574
+ // first, printing its contents twice and losing its label.
575
+ const provided = opts.captions ?? localFigures;
576
+ const numbered = new Map(provided.map((figure) => [figure.id, figure]));
577
+ const captions = localFigures.map((figure, at) => {
556
578
  const counted =
557
- provided[at]?.id === caption.id ? provided[at] : (numbered.get(caption.id) ?? caption);
558
- return { ...caption, label: counted.label, number: counted.number };
579
+ provided[at]?.id === figure.id ? provided[at] : (numbered.get(figure.id) ?? figure);
580
+ return { ...figure, label: counted.label, number: counted.number };
559
581
  });
560
582
  if (!captions.length) return renderMarkdownSegment(source, md, ctx, definitions);
561
583
  const out = [];
@@ -569,14 +591,29 @@ export function markdownToTypst(markdown, opts = {}) {
569
591
  definitions,
570
592
  ),
571
593
  );
572
- const block = lines.slice(caption.blockStart, caption.blockEnd).join("\n");
573
- out.push(renderMarkdownSegment(block, md, { ...ctx, caption }, definitions));
574
- if (caption.kind !== "table" && caption.kind !== "figure") {
575
- out.push(
576
- `\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}]] <${sectionLabel(anchorPrefix, slugify(caption.id))}>\n\n`,
594
+ const block = lines.slice(caption.bodyStart, caption.bodyEnd).join("\n");
595
+ // A grouped figure's images are rendered through the same call that
596
+ // reads `ctx.caption`, so `renderImage` needs to know which one is
597
+ // last — see the counter it decrements, set only for a `figure`-kind
598
+ // fence, where more than one image can share the one caption.
599
+ const captioned =
600
+ caption.kind === "figure" || caption.kind === "map" ?
601
+ { ...caption, imagesRemaining: { n: countImages(block) } }
602
+ : caption;
603
+ const segments = [
604
+ renderMarkdownSegment(block, md, { ...ctx, caption: captioned }, definitions),
605
+ ];
606
+ if (caption.kind !== "table" && caption.kind !== "figure" && caption.kind !== "map") {
607
+ segments.push(
608
+ `\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}]]${typstAnchor(anchorPrefix, caption)}\n\n`,
577
609
  );
578
610
  }
579
- cursor = caption.blockEnd;
611
+ out.push(
612
+ caption.classes.includes("border") ?
613
+ figureBorder(segments.join(""))
614
+ : segments.join(""),
615
+ );
616
+ cursor = caption.close + 1;
580
617
  }
581
618
  out.push(renderMarkdownSegment(lines.slice(cursor).join("\n"), md, ctx, definitions));
582
619
  return out.filter(Boolean).join("\n\n");
@@ -885,9 +922,63 @@ function inlineMarkup(text, ctx) {
885
922
  return inline ? renderInline(inline, ctx) : "";
886
923
  }
887
924
 
888
- /** Caption text with the same inline Markdown handling as the surrounding prose. */
925
+ /**
926
+ * A figure's label, with its caption when it carries one.
927
+ *
928
+ * The caption's inline Markdown is handled the way the surrounding prose is. A
929
+ * figure with no caption draws its label alone.
930
+ *
931
+ * @param {object} caption - A figure record.
932
+ * @param {object} ctx - Render context.
933
+ * @returns {string} Typst markup.
934
+ */
889
935
  function captionMarkup(caption, ctx) {
890
- return `${escapeTypst(caption.label)}: ${inlineMarkup(caption.text, ctx)}`;
936
+ const label = escapeTypst(caption.label);
937
+ return caption.hasCaption ? `${label}: ${inlineMarkup(caption.caption, ctx)}` : label;
938
+ }
939
+
940
+ /**
941
+ * How many markdown images a figure's contents carry.
942
+ *
943
+ * A grouped figure's `///` caption describes the whole plate, not any one
944
+ * picture in it, so {@link renderImage} needs to know which image is the
945
+ * last — the one that carries the group's single label and anchor — and this
946
+ * is the count it counts down from.
947
+ *
948
+ * @param {string} contents - The figure's captioned contents, as authored.
949
+ * @returns {number} How many images the contents hold.
950
+ */
951
+ function countImages(contents) {
952
+ return (String(contents ?? "").match(IMAGE_PATTERN) ?? []).length;
953
+ }
954
+
955
+ /**
956
+ * `.border` draws the whole figure — its content and its label together —
957
+ * inside a hairline box. A thin stroke is print's equivalent of a border and
958
+ * padding on the web and in a Foundry journal: a figure already sits inside
959
+ * its own column or float, so the book's version of a border is the lightest
960
+ * mark that still reads as one rather than a second, heavier frame around a
961
+ * frame.
962
+ *
963
+ * @param {string} body - The figure's own rendered Typst, content and label.
964
+ * @returns {string} Typst markup.
965
+ */
966
+ function figureBorder(body) {
967
+ return `\n#block(width: 100%, stroke: 0.4pt + luma(60%), inset: 8pt, above: 0.6em, below: 0.6em)[\n${body}\n]\n\n`;
968
+ }
969
+
970
+ /**
971
+ * The Typst label a figure is referenced by, or `""` when it declares no id.
972
+ *
973
+ * A figure with no id is numbered and drawn and nothing addresses it, so it is
974
+ * given no label: an empty one would collide with every other idless figure.
975
+ *
976
+ * @param {string} prefix - The entry's anchor prefix.
977
+ * @param {object} caption - A figure record.
978
+ * @returns {string} A ` <label>` to append, or `""`.
979
+ */
980
+ function typstAnchor(prefix, caption) {
981
+ return caption.id ? ` <${sectionLabel(prefix, slugify(caption.id))}>` : "";
891
982
  }
892
983
 
893
984
  /**
@@ -966,7 +1057,7 @@ function renderTable(tokens, ctx) {
966
1057
  const caption = ctx.caption;
967
1058
  const labelled =
968
1059
  caption ?
969
- `#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}] <${sectionLabel(ctx.anchorPrefix, slugify(caption.id))}>\n${drawn}`
1060
+ `#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}]${typstAnchor(ctx.anchorPrefix, caption)}\n${drawn}`
970
1061
  : drawn;
971
1062
  // Wide content is given an explicit span rather than left to overflow the
972
1063
  // measure: past three columns a table is set across the page, and
@@ -1186,6 +1277,16 @@ function renderLink(href, inner, ctx) {
1186
1277
  return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
1187
1278
  }
1188
1279
 
1280
+ /**
1281
+ * A width in inches, as a Typst length literal.
1282
+ *
1283
+ * @param {number} inches - The width.
1284
+ * @returns {string} A Typst length, rounded to a thousandth of an inch.
1285
+ */
1286
+ function typstInches(inches) {
1287
+ return `${Math.round(inches * 1000) / 1000}in`;
1288
+ }
1289
+
1189
1290
  /**
1190
1291
  * One image, as the figure the book prints.
1191
1292
  *
@@ -1221,32 +1322,55 @@ function renderLink(href, inner, ctx) {
1221
1322
  * `#image` on a path Typst cannot open is a compile error, and a compile error
1222
1323
  * in a 2,500-entry book is fatal at the very end of a run that otherwise
1223
1324
  * succeeded — over an illustration, which is the least important thing on the
1224
- * page. An address the build could not stage prints its caption alone instead,
1225
- * and the build reports the address it could not find.
1325
+ * page. An address the build could not stage prints its authored caption alone
1326
+ * instead, if it has one, and nothing at all if it does not; the build reports
1327
+ * the address it could not find either way.
1328
+ *
1329
+ * ## A grouped figure's one label belongs to the last picture
1330
+ *
1331
+ * Only a figure-kind fence labels an image inline — a fence holding anything
1332
+ * besides pictures is prose, and gets its label from the generic block-level
1333
+ * print in {@link markdownToTypst} instead, the same as a code or a table
1334
+ * fence. Within a figure-kind fence, `ctx.caption.imagesRemaining` counts down
1335
+ * one picture at a time; the picture that brings it to zero is the last one in
1336
+ * document order, and only that one carries the caption and the anchor. Every
1337
+ * other picture in the group draws with no caption at all. Attaching the label
1338
+ * to each of them would print the same text under every picture and emit the
1339
+ * same Typst label more than once, which Typst refuses to compile.
1226
1340
  *
1227
1341
  * @param {object} token - An `image` token.
1228
1342
  * @param {object} ctx - Render context.
1229
1343
  * @returns {string} Typst markup.
1230
1344
  */
1231
1345
  function renderImage(token, ctx) {
1232
- const alt = token.content || token.attrGet?.("alt") || "";
1233
- // Only a genuine figure caption labels the image inline — a caption
1234
- // reclassified as prose, because something besides the image shares its
1235
- // block, gets its label from the generic block-level caption print in
1236
- // {@link markdownToTypst} instead, the same as a code or prose caption
1237
- // always has. Reading it here regardless of kind printed the label twice.
1238
- const figureCaption = ctx.caption?.kind === "figure" ? ctx.caption : null;
1239
- const captionText = figureCaption ? captionMarkup(figureCaption, ctx) : escapeTypst(alt);
1346
+ const figureCaption =
1347
+ ctx.caption?.kind === "figure" || ctx.caption?.kind === "map" ? ctx.caption : null;
1348
+ const remaining = figureCaption?.imagesRemaining;
1349
+ const isLastImage = !remaining || --remaining.n <= 0;
1350
+ const labelled = Boolean(figureCaption) && isLastImage;
1351
+ const captionText = labelled ? captionMarkup(figureCaption, ctx) : "";
1240
1352
  const caption = captionText ? `[${captionText}]` : "none";
1241
- const anchor =
1242
- figureCaption ? ` <${sectionLabel(ctx.anchorPrefix, slugify(figureCaption.id))}>` : "";
1243
- const staged = ctx.images.get(token.attrGet?.("src") ?? "");
1353
+ const anchor = labelled ? typstAnchor(ctx.anchorPrefix, figureCaption) : "";
1354
+ const src = token.attrGet?.("src") ?? "";
1355
+ const staged = ctx.images.get(src);
1244
1356
  if (!staged)
1245
1357
  return captionText ?
1246
1358
  `\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic", fill: luma(45%))${caption}]${anchor}\n\n`
1247
1359
  : "";
1248
1360
 
1249
- const size = BOOK_IMAGE_WIDTHS[token.meta?.size] ?? BOOK_IMAGE_WIDTHS.auto;
1361
+ // A named `size=` is the author's own statement and wins outright. Left
1362
+ // unstated — `auto`, written or implied — the picture's role or its icon
1363
+ // type decides instead, and only a picture declaring neither falls back to
1364
+ // the natural-pixel `auto` Typst resolves for itself.
1365
+ const requestedSize = token.meta?.size;
1366
+ const roleWidthIn =
1367
+ requestedSize && requestedSize !== "auto" ?
1368
+ undefined
1369
+ : bookImageWidthIn(ctx.assets?.get(src));
1370
+ const size =
1371
+ roleWidthIn !== undefined ?
1372
+ typstInches(roleWidthIn)
1373
+ : (BOOK_IMAGE_WIDTHS[requestedSize] ?? BOOK_IMAGE_WIDTHS.auto);
1250
1374
  const figure = `#book-image("${escapeTypstString(staged)}", requested: ${size}, caption: ${caption})${anchor}`;
1251
1375
 
1252
1376
  const width = token.meta?.classes?.[0];
package/engine/scenes.mjs CHANGED
@@ -80,7 +80,7 @@ import { behaviorDocId, buildScene, isMapType, regionDocId } from "./map-notes.m
80
80
  import { buildItineraryScenes } from "./itinerary-scenes.mjs";
81
81
  import { emitDiagnostic } from "./diagnostics.mjs";
82
82
  import { rasterizeMapSvg } from "./map-raster.mjs";
83
- import { artPathname, artSlot } from "./art-fields.mjs";
83
+ import { artPathname, artSlot, pathnameRoles } from "./art-fields.mjs";
84
84
  import { buildExportedScene } from "./exported-scene.mjs";
85
85
 
86
86
  /**
@@ -395,11 +395,15 @@ export class Scenes extends BasePackCompiler {
395
395
  * @param {string} markdown - The converted body.
396
396
  * @param {string} entryId - The JournalEntry's id.
397
397
  * @param {string} name - The map's name (its lead page).
398
+ * @param {(address: string) => string|undefined} resolveRole - See
399
+ * {@link module:engine/journals.splitPages}; threaded through so an
400
+ * unanchored map figure's page key agrees with the journal this pass
401
+ * derives it against.
398
402
  * @returns {Map<string, string>} heading key → page id.
399
403
  */
400
- #pageIds(markdown, entryId, name) {
404
+ #pageIds(markdown, entryId, name, resolveRole) {
401
405
  const pageIds = new Map();
402
- splitPages(markdown, name).forEach((page) => {
406
+ splitPages(markdown, name, resolveRole).forEach((page) => {
403
407
  const id = journalPageId(entryId, page);
404
408
  if (page.anchorSlug) pageIds.set(page.anchorSlug, id);
405
409
  const slug = slugify(page.name);
@@ -439,6 +443,12 @@ export class Scenes extends BasePackCompiler {
439
443
  compileNote(fm, markdown) {
440
444
  const name = resolveName(fm);
441
445
  const hasBody = Boolean(String(markdown).trim());
446
+ // The markdown this pass reads has already had its embeds rewritten
447
+ // into ordinary images, each `src` the pathname `pathnameRoles` keys
448
+ // its roles by — see {@link module:engine/journals.Journals#buildEntry},
449
+ // which derives this same journal's pages the same way.
450
+ const roles = pathnameRoles(this.linkIndex);
451
+ const resolveRole = (pathname) => roles.get(pathname);
442
452
  // The same doc-entry id the journals pass derives, from the
443
453
  // shared `docEntryTypes` arrangement — so neither
444
454
  // pass has to read the other's output.
@@ -511,7 +521,8 @@ export class Scenes extends BasePackCompiler {
511
521
  hasBody ?
512
522
  this.#packRouter.resolve(fm, "JournalEntry")
513
523
  : this.#packRouter.defaultOf("JournalEntry"),
514
- pageIds: hasBody ? this.#pageIds(markdown, entryId, name) : new Map(),
524
+ pageIds:
525
+ hasBody ? this.#pageIds(markdown, entryId, name, resolveRole) : new Map(),
515
526
  knownActions: this.knownActions,
516
527
  warnings,
517
528
  ...this.#resolvers(this.index, this.effectsByAddress, fm.shortcode),
@@ -535,6 +546,7 @@ export class Scenes extends BasePackCompiler {
535
546
  name,
536
547
  markdown,
537
548
  leadName: name,
549
+ resolveRole,
538
550
  // As in the journals pass: an address resolves in the
539
551
  // pack that emits it, which is what makes the folder
540
552
  // materialise there too. The id spelling that used
@@ -64,12 +64,18 @@ import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
64
64
  import { expandContentTables } from "./content-tables.mjs";
65
65
  import { renderBlocks, renderWithheldSections, scanBlocks } from "./content-blocks.mjs";
66
66
  import { scanHeadingAttributes, withheldSections } from "./heading-attributes.mjs";
67
- import { renderCaptionBlocks, scanCaptions } from "./content-captions.mjs";
67
+ import { renderFigureBlocks, scanFigures } from "./content-figures.mjs";
68
68
  import { footnoteFindings } from "./content-footnotes.mjs";
69
69
  import { collectAnchors } from "./anchors.mjs";
70
- import { renderImageFigures } from "./content-images.mjs";
71
- import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
72
- import { buildSiteIndex, resolveInfoboxRef, wikiContext } from "./site-index.mjs";
70
+ import { checkImages, renderImageFigures } from "./content-images.mjs";
71
+ import { assetPathnameKey, pathnameProblem, resolvePathname } from "./pathnames.mjs";
72
+ import {
73
+ buildSiteIndex,
74
+ figureIndexKeys,
75
+ resolveCrossNoteFigures,
76
+ resolveInfoboxRef,
77
+ wikiContext,
78
+ } from "./site-index.mjs";
73
79
  import { frontmatterWikilinks, resolveWebWikilinks } from "./web-wikilinks.mjs";
74
80
  import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
75
81
  import { noteInfoboxes } from "./infobox-registry.mjs";
@@ -88,7 +94,8 @@ import { isNoteRecord, noteFile } from "./index-records.mjs";
88
94
  // The one statement of what an empty body means, shared with the index.
89
95
  import { isStubNote } from "./note-state.mjs";
90
96
  import { isGmNote } from "./note-vocabulary.mjs";
91
- import { ART_SLOTS, artPathname, assetAddressIndex } from "./art-fields.mjs";
97
+ import { ART_SLOTS, artPathname, assetAddressIndex, pathnameRoles } from "./art-fields.mjs";
98
+ import { embedRole } from "./content-embeds.mjs";
92
99
  import {
93
100
  HOMEPAGE_DESTINATION,
94
101
  checkHomepageCount,
@@ -699,6 +706,7 @@ export function renderSitePage(
699
706
  sqlTables,
700
707
  config,
701
708
  artIndex,
709
+ figuresByAddress = new Map(),
702
710
  },
703
711
  ) {
704
712
  const tableErrors = [];
@@ -709,6 +717,7 @@ export function renderSitePage(
709
717
  const footnoteErrors = [];
710
718
  const wikiErrors = [];
711
719
  const imageErrors = [];
720
+ const embedErrors = [];
712
721
  const resolved = [];
713
722
  const src = page.relPath ?? page.base;
714
723
  const ctx = wikiContext(index, {
@@ -745,10 +754,36 @@ export function renderSitePage(
745
754
  return src;
746
755
  };
747
756
  const artSrc = (value, type, accepts) => artPathname(artIndex, value, type, accepts).pathname;
757
+ // The role lookup a `:::figure` fence's `map` counter reaches through —
758
+ // see `engine/content-figures.mjs`'s `resolveRole`. Addressed form, for
759
+ // the page's own scan below, which runs before an embed is rewritten into
760
+ // an ordinary image; `roleByWebSrc` is the same lookup keyed by the web
761
+ // address each picture that reaches `renderImageFigures` resolves to,
762
+ // recorded as it is resolved rather than built across the whole corpus —
763
+ // `webSrc` itself reports an image's own problems as a side effect, which
764
+ // a speculative call over every asset would misfire for one this page
765
+ // never names.
766
+ const resolveRole = (address) => embedRole(artIndex, address);
767
+ const pathRoles = pathnameRoles(artIndex);
768
+ const roleByWebSrc = new Map();
769
+ const webSrcWithRole = (src) => {
770
+ const result = webSrc(src);
771
+ const role = pathRoles.get(src);
772
+ if (role) roleByWebSrc.set(result, role);
773
+ return result;
774
+ };
775
+ // The picture's role and pixel size, by the address it resolved to — read
776
+ // before `webSrc` translates it to the page's own host. A body image is
777
+ // free to write the bare, own-package form, so the address is normalized
778
+ // to the pathname the asset index keys by first.
779
+ const lookupAsset = (src) => {
780
+ const key = assetPathnameKey(src, config);
781
+ return key ? artIndex?.byPath?.get(key) : undefined;
782
+ };
748
783
  const resolve = (text) => {
749
784
  let transformed = pass.beforeLinks ? pass.beforeLinks(text, page) : text;
750
785
  transformed = resolveWebWikilinks(transformed, ctx);
751
- return renderImageFigures(transformed, webSrc);
786
+ return renderImageFigures(transformed, webSrcWithRole, lookupAsset);
752
787
  };
753
788
 
754
789
  const { markdown, errors, lineMap } = expandContentTables(page.body, {
@@ -760,8 +795,21 @@ export function renderSitePage(
760
795
  self: { fm: searchableFrontmatter(page.fm, page.pkg), path: page.relPath },
761
796
  });
762
797
  tableErrors.push(...errors);
763
- const captionScan = scanCaptions(markdown);
764
- ctx.captionLabels = new Map(captionScan.captions.map((caption) => [caption.id, caption.label]));
798
+ const figureScan = scanFigures(markdown, { resolveRole });
799
+ ctx.captionLabels = new Map(
800
+ figureScan.figures.filter((figure) => figure.id).map((figure) => [figure.id, figure.label]),
801
+ );
802
+ // What the `ref` expression helper needs beyond the label: the caption
803
+ // text and whether one was authored, by the same id `ctx.captionLabels`
804
+ // keys on.
805
+ const figuresById = new Map(
806
+ figureScan.figures
807
+ .filter((figure) => figure.id)
808
+ .map((figure) => [
809
+ figure.id,
810
+ { label: figure.label, caption: figure.caption, hasCaption: figure.hasCaption },
811
+ ]),
812
+ );
765
813
  // This page's own anchors, so a `[[#slug]]` self-link is checked against
766
814
  // what the page actually declares rather than trusted unconditionally.
767
815
  ctx.anchors = new Set(collectAnchors(markdown).map((anchor) => anchor.slug));
@@ -771,12 +819,29 @@ export function renderSitePage(
771
819
  file: page.file,
772
820
  bodyLine: page.bodyLine,
773
821
  sqlResults: sqlTables?.inline?.get(page.file),
822
+ figures: {
823
+ get: (id) => figuresById.get(id),
824
+ // A cross-note `ref` resolves the written address exactly as a
825
+ // wikilink does, against every page's figures scanned before any
826
+ // page's expressions render — see `renderPages`.
827
+ note: (target) =>
828
+ resolveCrossNoteFigures(target, {
829
+ contentTypes: index.contentTypes,
830
+ packages: index.packages,
831
+ noIndexPackages: index.noIndexPackages,
832
+ contentPackage: index.contentPackage,
833
+ siteIndexMap: index.index,
834
+ figuresByAddress,
835
+ }),
836
+ },
774
837
  });
775
838
  expressionErrors.push(...expressions.findings);
776
839
  const data = pageFrontmatter(page, { decorate, webSrc, artSrc });
777
840
  const blocks = renderBlocks(protectCode(expressions.markdown, resolve), "web");
778
- const captioned = renderCaptionBlocks(blocks.markdown);
779
- for (const error of captionScan.errors)
841
+ const figured = renderFigureBlocks(blocks.markdown, undefined, undefined, {
842
+ resolveRole: (address) => roleByWebSrc.get(address),
843
+ });
844
+ for (const error of figureScan.errors)
780
845
  captionErrors.push({
781
846
  file: page.file,
782
847
  line: (page.bodyLine ?? 1) + (lineMap[error.line - 1]?.line ?? error.line - 1),
@@ -801,12 +866,18 @@ export function renderSitePage(
801
866
  column: error.column,
802
867
  message: error.message,
803
868
  });
869
+ // An image sharing its paragraph with other text, or an address or title
870
+ // the lint already refuses — asked here too, so a site build run on its
871
+ // own fails on the same input `renderImageFigures` otherwise renders as
872
+ // though the directive were absent.
873
+ for (const error of checkImages(page.body, page.file, { bodyLine: page.bodyLine ?? 1 }))
874
+ embedErrors.push(error);
804
875
  return {
805
876
  page,
806
877
  // The disclosure is written last, over the Markdown the page ships: the
807
878
  // passes before this one carry line positions into their findings, and a
808
879
  // line inserted ahead of them would move every one of them.
809
- body: renderWithheldSections(captioned.markdown),
880
+ body: renderWithheldSections(figured.markdown),
810
881
  data,
811
882
  resolved,
812
883
  tableErrors,
@@ -817,6 +888,7 @@ export function renderSitePage(
817
888
  footnoteErrors,
818
889
  wikiErrors,
819
890
  imageErrors,
891
+ embedErrors,
820
892
  };
821
893
  }
822
894
 
@@ -855,7 +927,7 @@ export function renderSitePage(
855
927
  * it by wikilink, and its own markdown links name content pages. `maps` is
856
928
  * each drawing by the URL of the page that carries it.
857
929
  * @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
858
- * wikiErrors: object[], imageErrors: object[],
930
+ * wikiErrors: object[], imageErrors: object[], embedErrors: object[],
859
931
  * related: Map<string, import("./related-pages.mjs").Related>,
860
932
  * maps: number}} `related`
861
933
  * is keyed by page URL, and holds the homepage's block beside every content
@@ -888,6 +960,38 @@ export function renderPages(pages, options) {
888
960
  types: index?.contentTypes ?? [],
889
961
  });
890
962
 
963
+ // Every page's own figures, by the address a `ref` crossing into it would
964
+ // write — scanned once, before any page's own expressions render, so a
965
+ // page citing another's figure finds it already numbered. Numbering stays
966
+ // per page: each `scanFigures` call here starts its own counters, exactly
967
+ // as the page's own render does, so the number a cross-note `ref` reports
968
+ // is the one the target's own page carries.
969
+ const figuresByAddress = new Map();
970
+ for (const page of pages) {
971
+ const shortcode = page.fm.shortcode;
972
+ if (typeof shortcode !== "string" || !shortcode) continue;
973
+ const src = page.relPath ?? page.base;
974
+ const { markdown } = expandContentTables(page.body, {
975
+ docs: universe.get(page.pkg) ?? [],
976
+ linkable,
977
+ source: src,
978
+ sqlTables: sqlTables?.get(page.file),
979
+ pageLists: sqlTables?.pageLists?.get(page.file),
980
+ self: { fm: searchableFrontmatter(page.fm, page.pkg), path: page.relPath },
981
+ });
982
+ const byId = new Map(
983
+ scanFigures(markdown)
984
+ .figures.filter((figure) => figure.id)
985
+ .map((figure) => [
986
+ figure.id,
987
+ { label: figure.label, caption: figure.caption, hasCaption: figure.hasCaption },
988
+ ]),
989
+ );
990
+ const type = String(page.fm.type ?? "").toLowerCase();
991
+ for (const key of figureIndexKeys(page.pkg, type, shortcode))
992
+ figuresByAddress.set(key, byId);
993
+ }
994
+
891
995
  const tableErrors = [];
892
996
  const expressionErrors = [];
893
997
  const secretErrors = [];
@@ -896,6 +1000,7 @@ export function renderPages(pages, options) {
896
1000
  const footnoteErrors = [];
897
1001
  const wikiErrors = [];
898
1002
  const imageErrors = [];
1003
+ const embedErrors = [];
899
1004
  const byKind = {};
900
1005
  // The link graph, as `(source URL, target URL)` — read off each page's
901
1006
  // resolution below, and off the homepage's markdown links.
@@ -932,6 +1037,7 @@ export function renderPages(pages, options) {
932
1037
  sqlTables,
933
1038
  config,
934
1039
  artIndex,
1040
+ figuresByAddress,
935
1041
  });
936
1042
  tableErrors.push(...result.tableErrors);
937
1043
  expressionErrors.push(...result.expressionErrors);
@@ -941,6 +1047,7 @@ export function renderPages(pages, options) {
941
1047
  footnoteErrors.push(...result.footnoteErrors);
942
1048
  wikiErrors.push(...result.wikiErrors);
943
1049
  imageErrors.push(...result.imageErrors);
1050
+ embedErrors.push(...result.embedErrors);
944
1051
  rendered.push({ page, body: result.body, data: result.data });
945
1052
  for (const hit of result.resolved) if (hit.url) edges.push([page.url, hit.url]);
946
1053
  }
@@ -986,6 +1093,7 @@ export function renderPages(pages, options) {
986
1093
  footnoteErrors,
987
1094
  wikiErrors,
988
1095
  imageErrors,
1096
+ embedErrors,
989
1097
  related,
990
1098
  maps: withMap,
991
1099
  ...(capture ? { ...(outputs ? { outputs } : {}), edges, entries } : {}),
@@ -1036,6 +1144,7 @@ export function sitePageDecorator(config, index) {
1036
1144
  resolve: (ref, hint) => resolveInfoboxRef(index, ref, hint),
1037
1145
  router,
1038
1146
  dates: index.dateContext,
1147
+ contentPackage: index.contentPackage,
1039
1148
  });
1040
1149
  };
1041
1150
  }
@@ -1057,7 +1166,8 @@ export function sitePageDecorator(config, index) {
1057
1166
  * `sql` directive with none prepared is a table error: nothing here runs a
1058
1167
  * query.
1059
1168
  * @returns {{gates: object, stats: object|null, tableErrors: object[],
1060
- * wikiErrors: object[], imageErrors: object[], mapFindings: object[],
1169
+ * wikiErrors: object[], imageErrors: object[], embedErrors: object[],
1170
+ * mapFindings: object[],
1061
1171
  * manifests: object|null}} `mapFindings` is what drawing the maps found —
1062
1172
  * warnings, never a reason to fail the build.
1063
1173
  */
@@ -1156,6 +1266,7 @@ export function buildSite({ config, sqlTables } = {}) {
1156
1266
  footnoteErrors: [],
1157
1267
  wikiErrors: [],
1158
1268
  imageErrors: [],
1269
+ embedErrors: [],
1159
1270
  mapFindings: [],
1160
1271
  stats: null,
1161
1272
  };
@@ -1180,6 +1291,7 @@ export function buildSite({ config, sqlTables } = {}) {
1180
1291
  footnoteErrors: [],
1181
1292
  wikiErrors: [],
1182
1293
  imageErrors: [],
1294
+ embedErrors: [],
1183
1295
  mapFindings: [],
1184
1296
  stats: {
1185
1297
  homepages: writeHomepages(homeRoot, homepages, resolved),
@@ -1230,6 +1342,7 @@ export function buildSite({ config, sqlTables } = {}) {
1230
1342
  footnoteErrors: [],
1231
1343
  wikiErrors: [],
1232
1344
  imageErrors: [],
1345
+ embedErrors: [],
1233
1346
  mapFindings: [],
1234
1347
  };
1235
1348
  }
@@ -1290,6 +1403,7 @@ export function buildSite({ config, sqlTables } = {}) {
1290
1403
  footnoteErrors: rendered.footnoteErrors,
1291
1404
  wikiErrors: rendered.wikiErrors,
1292
1405
  imageErrors: rendered.imageErrors,
1406
+ embedErrors: rendered.embedErrors,
1293
1407
  mapFindings: drawn.findings,
1294
1408
  stats: {
1295
1409
  ...rendered.byKind,