@heroiclands/package-build 22.21.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 (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. package/types/engine/content-secrets.d.mts +0 -17
@@ -11,18 +11,35 @@ Choose a note's `type` for the subject it describes. Use `subType` when its type
11
11
 
12
12
  The [first-note guide](../authoring/first-note.md) teaches the shape of a note. [Frontmatter](../authoring/frontmatter.md) explains shared and system-specific values. The [detailed reference](format-details.md) gives the full behavior and examples.
13
13
 
14
+ ## Top-level keys
15
+
16
+ These are the only keys a note writes at its top level, in the order the formatter puts them. A key absent from this table is a lint error at its own line: the region is closed, so the key reaches no document and no page. Facts about the subject belong under `data`, and values for one game system belong inside that system's block.
17
+
18
+ | Key | Meaning |
19
+ | ------------- | ----------------------------------------------------------------------------------------- |
20
+ | `shortcode` | The note's own address segment, unique within its type. |
21
+ | `name` | The display names — a required `full`, and any `aliases`. |
22
+ | `type` | What the note is about, which decides its vocabulary and its document. |
23
+ | `subType` | The type's own genre, where it declares one. |
24
+ | `description` | The short page summary. |
25
+ | `tags` | Draft state, GM routing, and the descriptive labels a page list reads. |
26
+ | `data` | The facts about the subject itself, shared by every system. |
27
+ | `dnd5e` | What the `dnd5e` system makes of the subject — its document's mechanics, routing and art. |
28
+ | `hm3` | What the `hm3` system makes of the subject — its document's mechanics, routing and art. |
29
+ | `sohl` | What the `sohl` system makes of the subject — its document's mechanics, routing and art. |
30
+
14
31
  ## Shared `data` fields
15
32
 
16
33
  These fields are accepted by every note type. A field's value can still be irrelevant to a particular output; the build reports that where it can.
17
34
 
18
- | Field | Shape | Meaning |
19
- | ----------------- | ----------------------------------------------- | ------------------------------------------------------------------------- |
20
- | `data.id` | string | A fixed document identity when the derived identity is unsuitable. |
21
- | `data.pack` | string | The shared compendium route, with a system block overriding it. |
22
- | `data.packFolder` | an Address, or a map of Addresses keyed by pack | The shared compendium folder Address, with a system block overriding it. |
23
- | `data.harnworld` | map | HârnWorld source details shared by every system. |
24
- | `data.icon` | an Address | The document's profile art — an `icon` address, resolved into `img`. |
25
- | `data.banner` | an Address | The page's hero image — an `image` address. Reaches no compiled document. |
35
+ | Field | Shape | Meaning |
36
+ | ----------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------ |
37
+ | `data.id` | string | A fixed document identity when the derived identity is unsuitable. |
38
+ | `data.pack` | string | The shared compendium route, with a system block overriding it. |
39
+ | `data.packFolder` | an Address, or a map of Addresses keyed by pack | The shared compendium folder Address, with a system block overriding it. |
40
+ | `data.harnworld` | map | HârnWorld source details shared by every system. |
41
+ | `data.icon` | an Address | The document's profile art — an `icon` address, resolved into `img`. |
42
+ | `data.banner` | an Address | The page's hero image — an `image` address, cut to 1792×768. Reaches no compiled document. |
26
43
 
27
44
  ## Type-specific fields
28
45
 
@@ -41,9 +58,9 @@ These fields are accepted by every note type. A field's value can still be irrel
41
58
  | `data.lore` | list of Addresses | Lore concerning this being — the law it lives under, the customs it is subject to, the traditions it was raised in. |
42
59
  | `data.culture` | an Address | The being's primary culture, as a culture lore note. |
43
60
  | `data.homes` | list of Addresses | Places the being calls home. |
44
- | `data.affiliations` | a standing map keyed by Address, or a list of Addresses | Bodies the being belongs to, keyed by Address, each entry holding the standing it holds there — `rank`, a level on that body's own ladder, and `office`, a post that body names. |
61
+ | `data.affiliations` | a standing map keyed by Address, or a list of Addresses | Bodies the being belongs to, keyed by Address, each entry holding the standing it holds there — `rank`, required, a level on that body's own ladder, and `office`, optional, a post that body names. |
45
62
  | `data.socialTies` | a map keyed by Address | Defining ties directed from this being to others: `patron` (Supports the subject from greater power, wealth, or status.); `friend` (Supports the subject out of goodwill.); `dependent` (Relies on the subject's support or protection.); `acquaintance` (Knows the subject without a strong disposition.); `rival` (Opposes the subject without implacable hostility.); `nemesis` (Opposes the subject personally and implacably.) |
46
- | `data.gender` | string | `male`, `female` or `other`. |
63
+ | `data.gender` | string | One of `male`, `female`, `nonbinary`, `none` or `other`. `none` says the being has no gender; an absent field says its gender is unrecorded. |
47
64
  | `data.species` | an Address | The being's species, as a lore note. |
48
65
  | `data.born` | string | When the being was born — a date, or `unknown` where the birth is unrecorded. Absent, the being was never born. |
49
66
  | `data.calendar` | an Address | Calendar used to display this being's dates. |
@@ -52,11 +69,11 @@ These fields are accepted by every note type. A field's value can still be irrel
52
69
  | `data.ageYears` | number | Written by the compiler beside an `age` estimate — the `~` stripped, the magnitude alone. A note never authors this. |
53
70
  | `data.height` | metres or feet and inches | Height in metres or feet and inches. |
54
71
  | `data.weight` | kilograms or pounds | Body weight in kilograms or pounds. |
55
- | `data.frame` | string | Relative frame — `scant`, `light`, `medium`, `large` or `massive`. |
72
+ | `data.frame` | string | Relative frame — one of `scant`, `light`, `medium`, `heavy` or `massive`. |
56
73
  | `data.appearance.eye_color` | string | Eye colour. |
57
74
  | `data.appearance.hair_color` | string | Hair colour. |
58
75
  | `data.appearance.skin_color` | string | Skin colour. |
59
- | `data.appearance.complexion` | string | Complexion. |
76
+ | `data.appearance.complexion` | string or list | The skin's condition — one value, or several, because a face carries more than one at once. A single value means a list of one. |
60
77
  | `data.appearance.extra_features` | list | Anything else a stranger would notice. |
61
78
 
62
79
  **System mappings**
@@ -415,7 +432,7 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
415
432
  | `data.namedDays` | list of `{ name, abbreviation?, day }` | Names assigned to particular days of the year. |
416
433
  | `data.eras` | list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, label? }` | The year-counts kept in this calendar. A marker names one era and uses these months. |
417
434
  | `data.formats` | map of named Calendaria format strings | Named patterns for reading and writing this calendar's dates. |
418
- | `data.event` | event metadata map | A dated occurrence and its relationships to other events and places. |
435
+ | `data.events` | list of `{ when, until?, recurs? }` | This note's dated occurrences — a founding once, an anniversary that recurs, or a list of recorded happenings. |
419
436
 
420
437
  ### map
421
438
 
@@ -56,6 +56,7 @@ import { emitDiagnostic } from "./diagnostics.mjs";
56
56
  // The `{#appearance}` / `{#dossier}` convention is the note format's, so the
57
57
  // extraction is shared; which field a section lands in stays the system's.
58
58
  export { extractAnchorSection, renderSection } from "./anchored-sections.mjs";
59
+ import { misplacedAnchorSection } from "./anchored-sections.mjs";
59
60
  import { BasePackCompiler } from "./base-compiler.mjs";
60
61
  import { beingDefaultArt } from "./art-fields.mjs";
61
62
  import { contentPackage } from "./content-package.mjs";
@@ -472,6 +473,28 @@ export class SystemActorCompiler extends BasePackCompiler {
472
473
  return { img, token: this.artPath(fm, "tokenIcon") ?? img };
473
474
  }
474
475
 
476
+ /**
477
+ * Report an `{#appearance}` or `{#dossier}` anchor on a heading
478
+ * {@link extractAnchorSection} cannot read, rather than letting the field
479
+ * it feeds compile blank with nothing to say why.
480
+ *
481
+ * @param {string} body - The note body.
482
+ * @param {string} anchorId - `"appearance"` or `"dossier"`.
483
+ * @param {string} ctx - The actor's label, for the message.
484
+ * @returns {void}
485
+ */
486
+ checkAnchoredSection(body, anchorId, ctx) {
487
+ const misplaced = misplacedAnchorSection(body, anchorId, this.currentNote?.bodyLine);
488
+ if (!misplaced) return;
489
+ this.noteError(
490
+ `${ctx}: {#${anchorId}} sits on a heading below the top level, and only ` +
491
+ `an H1 feeds the ${anchorId} field — move the heading to the top level ` +
492
+ "or drop the anchor",
493
+ { line: misplaced.line },
494
+ );
495
+ this.errorCount++;
496
+ }
497
+
475
498
  /**
476
499
  * Which `(actor, subType:identity)` each resolved entry claimed, and the
477
500
  * entry that claimed it first.
@@ -15,8 +15,8 @@
15
15
  * **Anchored body sections** — the `# Heading {#anchor}` convention, and how a
16
16
  * compiler pulls one section out of a note's prose.
17
17
  *
18
- * The content format gives three anchors a document meaning: `{#appearance}`,
19
- * `{#dossier}` and `{#spoilers}`. *Which field* each lands in is a system's
18
+ * The content format gives two anchors a document meaning: `{#appearance}` and
19
+ * `{#dossier}`. *Which field* each lands in is a system's
20
20
  * business — SoHL writes the first to an actor's `appearance`, HM3 to an
21
21
  * actor's and an item's `description` — but *finding* it is not, so the
22
22
  * extraction lives here where every compiler reaches it.
@@ -30,6 +30,8 @@
30
30
  */
31
31
 
32
32
  import { renderFoundryMarkdown } from "./helpers.mjs";
33
+ import { collectAnchors } from "./anchors.mjs";
34
+ import { splitHeadingAttributes } from "./heading-attributes.mjs";
33
35
 
34
36
  /**
35
37
  * Extract the body of an H1 section whose heading carries the explicit
@@ -39,6 +41,11 @@ import { renderFoundryMarkdown } from "./helpers.mjs";
39
41
  * heading exists. Fenced code blocks are respected so `# foo` inside
40
42
  * ``` blocks does not trigger a match.
41
43
  *
44
+ * An anchor declared on a lower heading is found by {@link collectAnchors}
45
+ * but not by the `#\s+` match below, so it extracts as absent rather than as
46
+ * wrong — {@link misplacedAnchorSection} is the check a caller runs first to
47
+ * tell the two apart.
48
+ *
42
49
  * @param {string} body - The note body.
43
50
  * @param {string} anchorId - The anchor to find.
44
51
  * @returns {string} The section's markdown, or "".
@@ -57,8 +64,7 @@ export function extractAnchorSection(body, anchorId) {
57
64
  }
58
65
  const h1Match = !inCodeBlock ? line.match(/^\s*#\s+(.+?)\s*#*\s*$/) : null;
59
66
  if (h1Match) {
60
- const anchor = h1Match[1].match(/\{#([^}]+)\}\s*$/);
61
- const id = anchor?.[1]?.trim().toLowerCase() || null;
67
+ const id = splitHeadingAttributes(h1Match[1]).id.toLowerCase() || null;
62
68
  if (capturing) break;
63
69
  if (id === wanted) {
64
70
  capturing = true;
@@ -81,3 +87,28 @@ export function renderSection(body, anchorId) {
81
87
  const slice = extractAnchorSection(body, anchorId);
82
88
  return slice ? renderFoundryMarkdown(slice) : "";
83
89
  }
90
+
91
+ /**
92
+ * Where an `{#appearance}` or `{#dossier}` anchor sits on a heading below the
93
+ * top level, which {@link extractAnchorSection} cannot read.
94
+ *
95
+ * {@link module:engine/anchors.collectAnchors} finds the anchor at any
96
+ * level, because a lower heading legitimately starts a journal page. It does
97
+ * not feed an actor field: an H1-only extractor reads nothing from one, and a
98
+ * caller that trusts the empty result as "no such section" rather than
99
+ * "found, but unreadable" compiles a blank field with nothing to say why.
100
+ *
101
+ * @param {string} body - The note body.
102
+ * @param {string} anchorId - The anchor to check, case-insensitively.
103
+ * @param {number} [bodyLine=1] - The 1-based file line the body starts on.
104
+ * @returns {{line: number}|null} The misplaced anchor's line, or `null` when
105
+ * it is absent or already on an H1.
106
+ */
107
+ export function misplacedAnchorSection(body, anchorId, bodyLine = 1) {
108
+ const wanted = String(anchorId).toLowerCase();
109
+ const found = collectAnchors(body, bodyLine).find(
110
+ (anchor) => anchor.slug.toLowerCase() === wanted,
111
+ );
112
+ if (!found || found.level === 1) return null;
113
+ return { line: found.line };
114
+ }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The anchors a note declares on headings and captioned blocks.
15
+ * The anchors a note declares on headings and figure fences.
16
16
  *
17
17
  * **A leaf, deliberately.** This is asked by the link checker, by the content
18
18
  * index, and by the builds that emit a link, and they cannot all import one
@@ -22,37 +22,35 @@
22
22
  * anchors does this note declare?" had two answers and they disagreed on
23
23
  * `{#CalendarFormat}`.
24
24
  *
25
+ * The suffix itself is read by {@link module:engine/heading-attributes}, which
26
+ * {@link module:engine/journals.splitPages} also reads it through — so the index
27
+ * cannot name an anchor the pages do not carry.
28
+ *
25
29
  * @module
26
30
  */
27
31
 
28
- import { scanCaptions } from "./content-captions.mjs";
32
+ import { scanFigures } from "./content-figures.mjs";
33
+ import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
29
34
 
30
35
  /**
31
- * A heading, and the `{#slug}` anchor it declares.
36
+ * The `{#slug}` and `:::figure {#slug}` anchors a note declares.
32
37
  *
33
- * Kept identical to the pair {@link splitPages} matches, because the two must
34
- * agree about what an anchor is: that pass decides which sections become
35
- * addressable journal pages, and an index naming an anchor it does not produce
36
- * would advertise a link that resolves nowhere. `tests/content-index.test.ts`
37
- * asserts the two find the same anchors, so drift fails the suite rather than
38
- * shipping.
39
- */
40
- const HEADING = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/;
41
- const ANCHOR = /^(.*?)\s*\{#([^}]+)\}\s*$/;
42
- /**
43
- * The `{#slug}` and `:::caption {#slug}` anchors a note declares.
44
- *
45
- * A bare `#` heading starts a journal page without declaring a slug. Captioned
46
- * blocks declare a slug and start an addressable journal page.
38
+ * A bare `#` heading starts a journal page without declaring a slug, and so
39
+ * does a figure fence that declares no id. A fence that declares one declares a
40
+ * slug and starts an addressable journal page.
47
41
  *
48
42
  * @param {string} body - The note's markdown body, frontmatter already removed.
49
43
  * @param {number} [bodyLine] - The 1-based file line the body starts on, from
50
44
  * `parseMarkdownFile`. Anchors are reported at their position in the **file**,
51
45
  * so an editor can jump straight to one; passing nothing numbers from the body.
46
+ * @param {(address: string) => string|undefined} [resolveRole] - From a
47
+ * picture's address to the role its asset declares, so a figure anchor's
48
+ * `name` reads `Map 1` rather than `Figure 1` where it is due — see
49
+ * {@link module:engine/content-figures.scanFigures}.
52
50
  * @returns {Array<{slug: string, name: string, level: number, line: number}>}
53
51
  * In document order.
54
52
  */
55
- export function collectAnchors(body, bodyLine = 1) {
53
+ export function collectAnchors(body, bodyLine = 1, resolveRole) {
56
54
  const anchors = [];
57
55
  let inCodeBlock = false;
58
56
  const lines = String(body ?? "").split("\n");
@@ -66,26 +64,24 @@ export function collectAnchors(body, bodyLine = 1) {
66
64
  }
67
65
  if (inCodeBlock) continue;
68
66
 
69
- const heading = HEADING.exec(lines[i]);
67
+ const heading = HEADING_LINE.exec(lines[i]);
70
68
  if (!heading) continue;
71
- const anchor = ANCHOR.exec(heading[2].trim());
72
- if (!anchor) continue;
73
-
74
- const slug = anchor[2].trim();
75
- if (!slug) continue;
69
+ const attributes = splitHeadingAttributes(heading[2]);
70
+ if (!attributes.id) continue;
76
71
  anchors.push({
77
- slug,
78
- name: anchor[1].trim(),
72
+ slug: attributes.id,
73
+ name: attributes.text,
79
74
  level: heading[1].length,
80
75
  line: bodyLine + i,
81
76
  });
82
77
  }
83
- for (const caption of scanCaptions(body).captions) {
78
+ for (const figure of scanFigures(body, { resolveRole }).figures) {
79
+ if (!figure.id) continue;
84
80
  anchors.push({
85
- slug: caption.id,
86
- name: caption.label,
81
+ slug: figure.id,
82
+ name: figure.label,
87
83
  level: 1,
88
- line: bodyLine + caption.line - 1,
84
+ line: bodyLine + figure.line - 1,
89
85
  });
90
86
  }
91
87
  return anchors.sort((a, b) => a.line - b.line);
@@ -44,6 +44,8 @@
44
44
  * @module
45
45
  */
46
46
 
47
+ import path from "node:path";
48
+
47
49
  import { encodeAddresses } from "./address-values.mjs";
48
50
  import { ASSET_SYSTEM, isAssetType } from "./asset-types.mjs";
49
51
  import { ASSETS_SEGMENT } from "./pathnames.mjs";
@@ -129,9 +131,49 @@ export function assetAddressIndex(records = [], { config, foreign, types = [] }
129
131
  .filter(([key]) => key),
130
132
  ),
131
133
  foreign: foreign?.index ?? new Map(),
134
+ // Foreign first, local last: a renderer sizing a picture looks it up
135
+ // by the pathname the record resolved to, never by address, and a
136
+ // local record is entered after a foreign one so it wins the lookup on
137
+ // the collision neither should ever cause.
138
+ byPath: assetImageInfoByPathname([
139
+ ...(foreign?.index?.values() ?? []),
140
+ ...records.filter(isAssetRecord),
141
+ ]),
132
142
  };
133
143
  }
134
144
 
145
+ /**
146
+ * What a renderer needs to size one picture, keyed by the pathname its
147
+ * address resolves to.
148
+ *
149
+ * An image reaches a renderer as the resolved pathname an embed or an authored
150
+ * body image already carries — see {@link readAssetAddress} — never as the
151
+ * address that produced it, so the lookup a renderer wants is by pathname, not
152
+ * by {@link assetAddressIndex}'s own `assets` map.
153
+ *
154
+ * @param {readonly object[]} records - Asset records, local or foreign.
155
+ * @returns {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
156
+ * One entry per addressable file, carrying only what a renderer sizes a
157
+ * picture from. `role` is omitted when the record states none; `width` and
158
+ * `height` are always present, blank (`""`) for a vector — the record's own
159
+ * convention, carried through rather than collapsed into an absence a
160
+ * renderer could not tell apart from "no asset resolved at all".
161
+ */
162
+ export function assetImageInfoByPathname(records = []) {
163
+ const map = new Map();
164
+ for (const record of records) {
165
+ if (!record?.asset?.path || !record.package) continue;
166
+ const key = `${record.package}/${ASSETS_SEGMENT}/${record.asset.path}`;
167
+ map.set(key, {
168
+ type: record.type,
169
+ role: record.asset.role || undefined,
170
+ width: typeof record.asset.width === "number" ? record.asset.width : "",
171
+ height: typeof record.asset.height === "number" ? record.asset.height : "",
172
+ });
173
+ }
174
+ return map;
175
+ }
176
+
135
177
  /**
136
178
  * The asset one authored value names, or why it names none.
137
179
  *
@@ -200,6 +242,82 @@ export function resolveArtRecord(index, value, defaultType, accepts) {
200
242
  return readAssetAddress(index, value, defaultType, accepts).record;
201
243
  }
202
244
 
245
+ /**
246
+ * Every asset's role, keyed by the pathname it resolves to — the form a body
247
+ * carries once an embed's address has been rewritten into the ordinary image
248
+ * every surface renders ({@link module:engine/content-embeds.resolveEmbeds}).
249
+ *
250
+ * This is the lookup a `:::figure` fence's `map` counter reaches through —
251
+ * see {@link module:engine/content-figures.scanFigures}'s `resolveRole` —
252
+ * for a caller that reads a body after that rewrite: a Foundry journal, an
253
+ * item or actor's documentation, and the book.
254
+ *
255
+ * @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex},
256
+ * or the equivalent the site and the book build.
257
+ * @returns {Map<string, string>} Pathname → role, one entry per asset that
258
+ * declares one.
259
+ */
260
+ export function pathnameRoles(index) {
261
+ const roles = new Map();
262
+ for (const record of [
263
+ ...(index?.assets?.values() ?? []),
264
+ ...(index?.foreign?.values() ?? []),
265
+ ]) {
266
+ const role = record?.asset?.role;
267
+ if (!role || !record.asset?.path || !record.package) continue;
268
+ roles.set(`${record.package}/${ASSETS_SEGMENT}/${record.asset.path}`, role);
269
+ }
270
+ return roles;
271
+ }
272
+
273
+ /**
274
+ * Flag a note whose art slot names a picture that is not the size that slot
275
+ * is cut to.
276
+ *
277
+ * The size is stated by the slot itself, in
278
+ * {@link module:engine/art-slots.ART_SLOTS}, so it is written once and a slot
279
+ * that states none asks nothing of the picture it names. Today the hero image
280
+ * is the one slot with a fixed strip to fill; a picture written in prose is
281
+ * fitted to the room it has and is never measured here.
282
+ *
283
+ * A vector carries no pixel size to compare, and neither does a raster whose
284
+ * header cannot be read; neither is flagged.
285
+ *
286
+ * @param {readonly object[]} records - The corpus, notes and assets together.
287
+ * @param {object} [opts]
288
+ * @param {object} [opts.config] - The resolved build configuration.
289
+ * @param {string} [opts.assetsBase] - Where the asset roots sit, so a finding
290
+ * names the file from the working directory as every other finding does.
291
+ * @returns {object[]} A finding per picture off its slot's size,
292
+ * `severity: "error"`.
293
+ */
294
+ export function checkArtSlotSizes(records = [], { config, assetsBase = "" } = {}) {
295
+ const sized = ART_SLOTS.filter((slot) => slot.size);
296
+ if (!sized.length) return [];
297
+ const index = assetAddressIndex(records, { config });
298
+ const findings = [];
299
+ for (const record of records) {
300
+ const data = record?.data;
301
+ if (!data) continue;
302
+ for (const slot of sized) {
303
+ const value = data[slot.key];
304
+ if (!value) continue;
305
+ const asset = resolveArtRecord(index, value, slot.type, slot.accepts);
306
+ const { path: file, width, height } = asset?.asset ?? {};
307
+ if (!file || !width || !height) continue;
308
+ if (Number(width) === slot.size.width && Number(height) === slot.size.height) continue;
309
+ findings.push({
310
+ file: path.join(assetsBase, file),
311
+ severity: "error",
312
+ message:
313
+ `${width}×${height} is not the ${slot.size.width}×${slot.size.height} ` +
314
+ `a \`${slot.key}\` is cut to`,
315
+ });
316
+ }
317
+ }
318
+ return findings;
319
+ }
320
+
203
321
  /**
204
322
  * The pathname an art value names, in the form an authored one takes.
205
323
  *
@@ -76,6 +76,12 @@ export const ART_SLOTS = Object.freeze([
76
76
  type: "image",
77
77
  accepts: ART_TYPES,
78
78
  document: false,
79
- describe: "The page's hero image. Reaches no compiled document.",
79
+ // A hero image fills one fixed strip wherever it is drawn, so every
80
+ // banner is cut to one size and a picture of another size is resized
81
+ // to it. A picture written in prose fills no fixed strip and is
82
+ // fitted to whatever room it has, so no slot but this one states a
83
+ // size.
84
+ size: Object.freeze({ width: 1792, height: 768 }),
85
+ describe: "The page's hero image, cut to 1792×768. Reaches no compiled document.",
80
86
  }),
81
87
  ]);