@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
@@ -94,18 +94,25 @@ import {
94
94
  gatesFailed,
95
95
  resolveSitePass,
96
96
  } from "./site-build.mjs";
97
- import { resolveInfoboxRef, wikiContext } from "./site-index.mjs";
97
+ import {
98
+ figureIndexKeys,
99
+ resolveCrossNoteFigures,
100
+ resolveInfoboxRef,
101
+ wikiContext,
102
+ } from "./site-index.mjs";
98
103
  import { resolveWebWikilinks } from "./web-wikilinks.mjs";
99
104
  import { linkFindingMessage } from "./wikilink-syntax.mjs";
100
- import { artPathname, assetAddressIndex } from "./art-fields.mjs";
105
+ import { positionOfLiteral } from "./diagnostics.mjs";
106
+ import { artPathname, assetAddressIndex, pathnameRoles } from "./art-fields.mjs";
107
+ import { embedRole } from "./content-embeds.mjs";
101
108
  import { expandContentTables } from "./content-tables.mjs";
102
109
  import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
103
- import { renderSecretBlocks } from "./content-secrets.mjs";
104
- import { scanAdmonitions } from "./content-admonitions.mjs";
105
- import { numberCaptions, scanCaptions } from "./content-captions.mjs";
110
+ import { numberFigures, scanFigures } from "./content-figures.mjs";
111
+ import { collectAnchors } from "./anchors.mjs";
106
112
  import { protectCode } from "./code-fences.mjs";
107
113
  import { imagesIn, parseImageDirective } from "./content-images.mjs";
108
114
  import {
115
+ bookImageWidthIn,
109
116
  PDF_IMAGE_INCHES,
110
117
  PDF_PAGE,
111
118
  pdfColumnWidth,
@@ -113,7 +120,15 @@ import {
113
120
  pdfTextWidth,
114
121
  resamplePdfImages,
115
122
  } from "./pdf-images.mjs";
116
- import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
123
+ import {
124
+ ASSETS_SEGMENT,
125
+ assetPathnameKey,
126
+ pathnameProblem,
127
+ resolvePathname,
128
+ } from "./pathnames.mjs";
129
+ import { ASSET_SYSTEM, assetTypeOfRoot, isAssetShortcode } from "./asset-types.mjs";
130
+ import { canonicalKey } from "./address.mjs";
131
+ import { isComplete as isForeignCacheComplete, newestVersionDir } from "./metadata-index.mjs";
117
132
  import {
118
133
  createParser,
119
134
  labelFor,
@@ -128,6 +143,7 @@ import { noteInfoboxes } from "./infobox-registry.mjs";
128
143
  import { resolveIconGlyphs } from "./pdf-fonts.mjs";
129
144
  import { buildMaps, relatedPlaces } from "./map-build.mjs";
130
145
  import { mapWorld } from "./map-places.mjs";
146
+ import { resolveAssetReplacement } from "./asset-replacement.mjs";
131
147
 
132
148
  /**
133
149
  * The file on disk an authored image pathname names, or `null`.
@@ -144,21 +160,165 @@ import { mapWorld } from "./map-places.mjs";
144
160
  * makes the source a consumer can compile by hand with no flags, and what keeps
145
161
  * a build from touching a path outside its own output.
146
162
  *
147
- * Only a file **this** package ships can be staged. A pathname naming another
148
- * package's file, or a URL, names something no build here can open — a build
149
- * reaches no network — and the caller reports it as a picture the book will not
150
- * carry.
163
+ * A file **this** package ships stages directly. A pathname naming another
164
+ * package's file stages instead from a declared replacement's cached archive,
165
+ * when `opts.resolveReplacement` — the pure function
166
+ * {@link module:engine/asset-replacement.resolveAssetReplacement} —
167
+ * answers it; `opts.foreignIndex` is the merged index that answer is read
168
+ * against. A pathname naming a package no replacement carries it for, or a
169
+ * URL, names something no build here can open — a build reaches no network —
170
+ * and the caller reports it as a picture the book will not carry.
151
171
  *
152
172
  * @param {string} src - The pathname, as authored.
153
173
  * @param {object} config - The resolved configuration.
174
+ * @param {object} [opts]
175
+ * @param {Map<string, object>} [opts.foreignIndex] - Every fetched
176
+ * dependency's own records, keyed by canonical address — the index a
177
+ * replacement's record is read out of.
178
+ * @param {Function} [opts.resolveReplacement] - {@link
179
+ * module:engine/asset-replacement.resolveAssetReplacement}, or an
180
+ * equivalent a caller injects for a test. Omitted, a foreign pathname this
181
+ * package does not own stays declined exactly as it always has.
154
182
  * @returns {{from: string, to: string}|null} The file, and where under the
155
183
  * output directory it is staged.
184
+ * @throws {Error} When a declared replacement answers the address but its
185
+ * archive was never fetched.
156
186
  */
157
- export function stagedImagePath(src, config) {
187
+ export function stagedImagePath(src, config, opts = {}) {
158
188
  const forms = resolvePathname(src, config);
159
- if (!forms || forms.state !== "package" || !forms.own) return null;
189
+ if (!forms || forms.state !== "package") return null;
190
+ // A declared replacement answers before the package's own file does, which
191
+ // is the whole of what declaring one buys: the address a note writes is
192
+ // this package's own, and the picture staged for it is the replacement's.
193
+ // A miss falls through, so a package declaring none stages exactly what it
194
+ // ships.
195
+ const replaced = opts.resolveReplacement ? stagedReplacementPath(forms, config, opts) : null;
196
+ if (replaced) return replaced;
197
+ if (forms.own) {
198
+ return {
199
+ from: path.resolve(config.rootDir, forms.local),
200
+ to: forms.pdf,
201
+ };
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Every declared relationship that opted a dependency in as this package's
208
+ * asset replacement, in declaration order.
209
+ *
210
+ * The same shape {@link module:engine/foreign-catalog.itemCatalogRelationships}
211
+ * returns, read directly rather than through that module: this file stays a
212
+ * leaf over the configuration it is handed, and the flag is as small to read
213
+ * back as it was to validate.
214
+ *
215
+ * @param {object} config - The resolved build configuration.
216
+ * @returns {Array<{id: string, package: string}>} The relationships, each
217
+ * with the package segment its own content index publishes under.
218
+ */
219
+ function assetReplacementRelationships(config) {
220
+ const out = [];
221
+ for (const entries of Object.values(config.relationships ?? {})) {
222
+ for (const rel of entries ?? []) {
223
+ if (rel.assetReplacement) {
224
+ out.push({ id: rel.id, package: rel.contentPackage ?? rel.id });
225
+ }
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * The address a pathname this package does not own is read back as, for
233
+ * asking whether a declared replacement answers it instead of the package
234
+ * the pathname names.
235
+ *
236
+ * {@link module:engine/asset-replacement.resolveAssetReplacement} only ever
237
+ * matches an address whose package segment is the compiling package's own,
238
+ * so the pathname's own package is set aside and its suffix — the one piece
239
+ * every surface derives from, and the piece a replacement publishes under
240
+ * its own package instead — is read back as this package's type and
241
+ * shortcode.
242
+ *
243
+ * @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
244
+ * resolved.
245
+ * @param {object} config - The resolved build configuration.
246
+ * @returns {string|null} The canonical address, or `null` when the suffix is
247
+ * not shaped like an asset's.
248
+ */
249
+ function localAssetAddress(forms, config) {
250
+ const segments = String(forms.suffix ?? "").split("/");
251
+ const assetType = assetTypeOfRoot(segments[0]);
252
+ if (!assetType) return null;
253
+ const base = segments[segments.length - 1];
254
+ const ext = path.extname(base);
255
+ const shortcode = (ext ? base.slice(0, -ext.length) : base).toLowerCase();
256
+ if (!isAssetShortcode(shortcode)) return null;
257
+ return canonicalKey(config.contentPackage, ASSET_SYSTEM, assetType.type, shortcode);
258
+ }
259
+
260
+ /**
261
+ * The cache directory one relationship's archive was fetched into, found the
262
+ * way {@link module:engine/foreign-catalog.foreignItemCatalogDirs} finds an
263
+ * item catalogue's: the newest `.complete`-stamped `<id>@<version>`
264
+ * directory under `config.paths.foreignCache`.
265
+ *
266
+ * @param {object} config - The resolved build configuration.
267
+ * @param {string} id - The relationship's dependency id.
268
+ * @returns {string|null} The directory, or `null` when nothing is cached.
269
+ */
270
+ function cachedReplacementDir(config, id) {
271
+ const root = config.paths.foreignCache;
272
+ if (!fs.existsSync(root)) return null;
273
+ const cached = fs
274
+ .readdirSync(root)
275
+ .filter((name) => name.startsWith(`${id}@`))
276
+ .map((name) => path.join(root, name))
277
+ .filter(isForeignCacheComplete);
278
+ return cached.length ? newestVersionDir(cached) : null;
279
+ }
280
+
281
+ /**
282
+ * The file a declared replacement stages in place of a pathname this package
283
+ * does not own, or `null` when no replacement answers it.
284
+ *
285
+ * @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
286
+ * resolved, with `forms.own` already `false`.
287
+ * @param {object} config - The resolved build configuration.
288
+ * @param {object} opts
289
+ * @param {Map<string, object>} [opts.foreignIndex] - Every fetched
290
+ * dependency's own records, keyed by canonical address.
291
+ * @param {Function} opts.resolveReplacement - {@link
292
+ * module:engine/asset-replacement.resolveAssetReplacement}.
293
+ * @returns {{from: string, to: string}|null} The file, staged at the same
294
+ * `pdf`-relative path this address would have taken had this package
295
+ * shipped it.
296
+ * @throws {Error} When the replacement that answers the address has not
297
+ * been fetched.
298
+ */
299
+ function stagedReplacementPath(forms, config, { foreignIndex = new Map(), resolveReplacement }) {
300
+ const address = localAssetAddress(forms, config);
301
+ if (!address) return null;
302
+ const relationships = assetReplacementRelationships(config);
303
+ if (!relationships.length) return null;
304
+ const hit = resolveReplacement(address, {
305
+ localPackage: config.contentPackage,
306
+ replacements: relationships.map((rel) => rel.package),
307
+ index: foreignIndex,
308
+ });
309
+ if (!hit) return null;
310
+ const assetPath = hit.record?.asset?.path;
311
+ if (!assetPath) return null;
312
+ const rel = relationships.find((entry) => entry.package === hit.package);
313
+ const dir = cachedReplacementDir(config, rel?.id ?? hit.package);
314
+ if (!dir) {
315
+ throw new Error(
316
+ `\`${hit.package}\` declares \`assetReplacement: true\` and carries \`${address}\`, ` +
317
+ "but its archive has not been fetched. Run `package-build deps fetch` first.",
318
+ );
319
+ }
160
320
  return {
161
- from: path.resolve(config.rootDir, forms.local),
321
+ from: path.join(dir, ASSETS_SEGMENT, assetPath),
162
322
  to: forms.pdf,
163
323
  };
164
324
  }
@@ -301,9 +461,19 @@ function readDocumentTree(file) {
301
461
  * @param {string} [opts.version] - Stamped on the title page and the file name.
302
462
  * @param {boolean} [opts.compile] - Whether to run Typst. False leaves the
303
463
  * `.typ` source, which is what the unit tests read.
464
+ * @param {Function} [opts.resolveReplacement] - {@link
465
+ * module:engine/asset-replacement.resolveAssetReplacement}, for tests.
466
+ * Loaded from that module otherwise, and only when a relationship declares
467
+ * `assetReplacement: true` — a build with none never imports it.
304
468
  * @returns {Promise<object>} `{ built, reason, findings, typ, pdf, stats }`.
305
469
  */
306
- export async function buildPdf({ config, out, version = "", compile = true } = {}) {
470
+ export async function buildPdf({
471
+ config,
472
+ out,
473
+ version = "",
474
+ compile = true,
475
+ resolveReplacement,
476
+ } = {}) {
307
477
  const resolved = config ?? loadPackConfig();
308
478
  const findings = [];
309
479
 
@@ -378,6 +548,23 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
378
548
  // addresses collide would print the wrong entry under the right name.
379
549
  const scheme = resolved.publish.address;
380
550
  const base = resolved.site.base || `/${resolved.contentPackage}/`;
551
+ // Where the pages this book cites are served. A page's address is written
552
+ // from the root of a site, which is right everywhere on the web and nowhere
553
+ // in a PDF, so the book resolves each one against this — see
554
+ // {@link module:engine/pdf-render.absolutePageUrl}. `package.json`'s
555
+ // `homepage` is the one absolute address a build holds, and the site reads
556
+ // its own `baseURL` from it, so the book and the website cannot disagree
557
+ // about where a page is.
558
+ const site = typeof resolved.homepage === "string" ? resolved.homepage : "";
559
+ if (!site) {
560
+ findings.push({
561
+ file: "package.json",
562
+ severity: "error",
563
+ message:
564
+ "`homepage` is not declared, so a page the book cites is set as a path " +
565
+ `rather than an address a reader can follow — add \`https://www.heroiclands.org/${resolved.contentPackage}/\``,
566
+ });
567
+ }
381
568
  const ctx = {
382
569
  packages: new Set(
383
570
  resolved.site.packages.length ? resolved.site.packages : [resolved.contentPackage],
@@ -424,6 +611,17 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
424
611
  const md = createParser(resolved.icons);
425
612
  const glyphs = resolveIconGlyphs(resolved.icons, resolved.pdf.iconFonts, findings);
426
613
 
614
+ // Asked only where a relationship declares `assetReplacement: true`: with
615
+ // none declared the resolver is never consulted, and staging gives the
616
+ // answer it gives a package that declares no replacement. A caller may
617
+ // supply its own, which is how the staging tests drive it.
618
+ const stagingOpts = {
619
+ foreignIndex: gates.foreign.index,
620
+ resolveReplacement:
621
+ resolveReplacement ??
622
+ (assetReplacementRelationships(resolved).length ? resolveAssetReplacement : null),
623
+ };
624
+
427
625
  const outDir = path.resolve(
428
626
  resolved.rootDir,
429
627
  out || resolved.pdf.out || path.join("build", "dist"),
@@ -432,6 +630,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
432
630
 
433
631
  /** @type {Map<string, string>} Authored address → the staged file's path. */
434
632
  const images = new Map();
633
+ /** @type {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
634
+ * Authored address, exactly as the note wrote it → its asset's role and
635
+ * pixel size. Keyed the same way {@link images} is rather than by the
636
+ * asset index's own canonical pathname, because a body image is free to
637
+ * write the bare, own-package form — see
638
+ * {@link module:engine/pathnames.assetPathnameKey}. */
639
+ const imageAssets = new Map();
435
640
  /** @type {Map<string, {from: string, to: string, relative: string, file: string, uses: Array<{width: number, height: number}>}>} */
436
641
  const imageCandidates = new Map();
437
642
  /** @type {Set<string>} Addresses already looked for, staged or not. */
@@ -456,8 +661,18 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
456
661
  const wide =
457
662
  directive.size === "full-width" || directive.classes.includes("full-width");
458
663
  const availableWidth = wide ? pdfTextWidth : pdfColumnWidth(columns);
664
+ const assetKey = assetPathnameKey(src, resolved);
665
+ const asset = assetKey ? assets.byPath.get(assetKey) : undefined;
666
+ if (asset) imageAssets.set(src, asset);
667
+ // `auto` is the one size a role or an icon's nominal width can
668
+ // narrow — a named `size=` is the author's own statement, honoured
669
+ // as it always was.
670
+ const roleWidth = directive.size === "auto" ? bookImageWidthIn(asset) : undefined;
459
671
  const use = {
460
- width: Math.min(PDF_IMAGE_INCHES[directive.size] ?? availableWidth, availableWidth),
672
+ width: Math.min(
673
+ roleWidth ?? PDF_IMAGE_INCHES[directive.size] ?? availableWidth,
674
+ availableWidth,
675
+ ),
461
676
  height: pdfTextHeight * 0.8,
462
677
  };
463
678
  if (seenImages.has(src)) {
@@ -474,7 +689,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
474
689
  findings.push({ file, severity: "error", message: problem });
475
690
  continue;
476
691
  }
477
- const staged = stagedImagePath(src, resolved);
692
+ let staged;
693
+ try {
694
+ staged = stagedImagePath(src, resolved, stagingOpts);
695
+ } catch (err) {
696
+ findings.push({ file, severity: "error", message: err.message });
697
+ continue;
698
+ }
478
699
  if (!staged) {
479
700
  findings.push({
480
701
  file,
@@ -523,20 +744,24 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
523
744
  * @param {number} columns - The entry's page columns.
524
745
  * @returns {string} Typst markup.
525
746
  */
526
- const captionCounts = { code: 0, table: 0, figure: 0, prose: 0 };
527
- const frontCaptions = new Map();
747
+ const figureCounts = { code: 0, table: 0, figure: 0, map: 0, prose: 0 };
748
+ // The role lookup a `:::figure` fence's `map` counter reaches through —
749
+ // see `engine/content-figures.mjs`'s `resolveRole`. Addressed form, for a
750
+ // page's own scan below, which runs before its embeds are rewritten into
751
+ // ordinary images; `pathRoles` is the same lookup keyed by the pathname
752
+ // that rewrite leaves behind, for the renderer's own rescan of the
753
+ // resolved body.
754
+ const resolveRole = (address) => embedRole(assets, address);
755
+ const pathRoles = pathnameRoles(assets);
756
+ const frontFigures = new Map();
528
757
  for (const file of resolved.pdf.front) {
529
758
  try {
530
- const scan = scanCaptions(fs.readFileSync(file, "utf8"));
531
- frontCaptions.set(file, numberCaptions(scan.captions, captionCounts));
532
- for (const error of scan.errors)
533
- findings.push({
534
- file,
535
- line: error.line,
536
- column: error.column,
537
- severity: "error",
538
- message: error.message,
539
- });
759
+ const scan = scanFigures(fs.readFileSync(file, "utf8"), { resolveRole });
760
+ // Numbered here, because a number runs across the whole book and only
761
+ // this loop knows the order; what is *wrong* with a figure is
762
+ // reported by the renderer, which reads every one of the three kinds
763
+ // of markdown a book is made of.
764
+ frontFigures.set(file, numberFigures(scan.figures, figureCounts));
540
765
  } catch {
541
766
  // The rendering pass reports an unreadable front-matter file.
542
767
  }
@@ -547,21 +772,95 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
547
772
  config: resolved,
548
773
  book: true,
549
774
  });
775
+
776
+ /**
777
+ * Every entry's own figures, numbered across the whole book in reading
778
+ * order, before any entry's expressions render — so a cross-note `ref`
779
+ * finds the number its target carries in the finished book rather than a
780
+ * page-local recount. Walked in the same order {@link bodies} fills
781
+ * below, so `figureCounts` lands on the same final tallies either way;
782
+ * the note's table expansion is cached here too, so the render loop does
783
+ * not redo it.
784
+ *
785
+ * @type {Map<string, {markdown?: string, errors?: object[],
786
+ * lineMap?: object[], numberedFigures: object[]}>}
787
+ */
788
+ const prepared = new Map();
789
+ // Every note's figures, by the address a `ref` crossing into it would
790
+ // write — see `engine/site-index.mjs`'s `figureIndexKeys`.
791
+ const figuresByAddress = new Map();
792
+ for (const entry of plan.entries) {
793
+ if (entry.kind === "note") {
794
+ const file = noteFile(contentBase, entry.record);
795
+ const page = byFile.get(file);
796
+ if (!page) continue; // reported as `missing` by the render loop below
797
+ const src = page.relPath ?? page.base;
798
+ const { markdown, errors, lineMap } = expandContentTables(page.body, {
799
+ docs: universe.get(page.pkg) ?? [],
800
+ linkable: (d) => Boolean(d.fm.shortcode),
801
+ source: src,
802
+ sqlTables: sqlTables?.get(page.file),
803
+ pageLists: sqlTables?.pageLists?.get(page.file),
804
+ self: { fm: page.fm, path: page.relPath },
805
+ });
806
+ const numberedFigures = numberFigures(
807
+ scanFigures(markdown, { resolveRole }).figures,
808
+ figureCounts,
809
+ );
810
+ prepared.set(entry.anchor, { markdown, errors, lineMap, numberedFigures });
811
+ const shortcode = page.fm.shortcode;
812
+ if (typeof shortcode === "string" && shortcode) {
813
+ const byId = new Map(
814
+ numberedFigures
815
+ .filter((figure) => figure.id)
816
+ .map((figure) => [
817
+ figure.id,
818
+ {
819
+ label: figure.label,
820
+ caption: figure.caption,
821
+ hasCaption: figure.hasCaption,
822
+ },
823
+ ]),
824
+ );
825
+ const type = String(page.fm.type ?? "").toLowerCase();
826
+ for (const key of figureIndexKeys(page.pkg, type, shortcode))
827
+ figuresByAddress.set(key, byId);
828
+ }
829
+ continue;
830
+ }
831
+ if (entry.kind === "prose") {
832
+ const file = path.resolve(resolved.rootDir, entry.file);
833
+ let text;
834
+ try {
835
+ text = fs.readFileSync(file, "utf8");
836
+ } catch {
837
+ continue; // reported by the render loop below
838
+ }
839
+ prepared.set(entry.anchor, {
840
+ numberedFigures: numberFigures(
841
+ scanFigures(text, { resolveRole }).figures,
842
+ figureCounts,
843
+ ),
844
+ });
845
+ }
846
+ }
847
+
550
848
  const renderPage = (page, headingOffset, anchorPrefix, columns) => {
551
849
  const src = page.relPath ?? page.base;
552
850
  const wikiErrors = [];
553
- const { markdown, errors, lineMap } = expandContentTables(page.body, {
554
- docs: universe.get(page.pkg) ?? [],
555
- linkable: (d) => Boolean(d.fm.shortcode),
556
- source: src,
557
- sqlTables: sqlTables?.get(page.file),
558
- self: { fm: page.fm, path: page.relPath },
559
- });
851
+ const { markdown, errors, lineMap, numberedFigures } = prepared.get(anchorPrefix);
852
+ // A content-table finding states its `reason`, its 0-based line within
853
+ // the body and its column, exactly as the site build reports them.
854
+ // Reading it as `message` yields `[object Object]` and discards the
855
+ // position, which is the one thing a reader needs to find the fence.
560
856
  for (const err of errors) {
561
857
  findings.push({
562
858
  file: page.file,
859
+ ...(typeof err.line === "number" ?
860
+ { line: (page.bodyLine ?? 1) + err.line, column: err.column }
861
+ : {}),
563
862
  severity: "error",
564
- message: String(err.message ?? err),
863
+ message: err.reason ?? String(err.message ?? err),
565
864
  });
566
865
  }
567
866
  const linkCtx = wikiContext(gates.index, {
@@ -572,20 +871,31 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
572
871
  foreignIndex: gates.foreign.index,
573
872
  assets,
574
873
  });
575
- const captionScan = scanCaptions(markdown);
576
- const numberedCaptions = numberCaptions(captionScan.captions, captionCounts);
577
874
  linkCtx.captionLabels = new Map(
578
- numberedCaptions.map((caption) => [caption.id, caption.label]),
875
+ numberedFigures
876
+ .filter((figure) => figure.id)
877
+ .map((figure) => [figure.id, figure.label]),
579
878
  );
580
- for (const error of captionScan.errors)
581
- findings.push({
582
- file: page.file,
583
- line: (page.bodyLine ?? 1) + (lineMap[error.line - 1]?.line ?? error.line - 1),
584
- column: error.column,
585
- severity: "error",
586
- message: error.message,
587
- });
879
+ // This page's own anchors, so a `[[#slug]]` self-link is checked
880
+ // against what the page actually declares, exactly as the site build
881
+ // checks it.
882
+ linkCtx.anchors = new Set(collectAnchors(markdown).map((anchor) => anchor.slug));
588
883
  linkCtx.output = "book";
884
+ // What the `ref` expression helper needs beyond the label — the
885
+ // caption text and whether one was authored — keyed as
886
+ // `engine/site-build.mjs` keys its own, but built from this note's
887
+ // *book-wide* numbering rather than its page-local count: the book
888
+ // numbers each kind across the whole book in reading order, so a
889
+ // reference has to read the number the book gives the figure, not the
890
+ // number the note would give it alone.
891
+ const figuresById = new Map(
892
+ numberedFigures
893
+ .filter((figure) => figure.id)
894
+ .map((figure) => [
895
+ figure.id,
896
+ { label: figure.label, caption: figure.caption, hasCaption: figure.hasCaption },
897
+ ]),
898
+ );
589
899
  // Code fences are protected for the same reason every other pass
590
900
  // protects them: a wikilink shown as an example is prose about a
591
901
  // wikilink, and resolving it would make the example impossible to write.
@@ -595,58 +905,91 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
595
905
  sqlResults: sqlTables?.inline?.get(page.file),
596
906
  file: page.file,
597
907
  bodyLine: page.bodyLine,
908
+ figures: {
909
+ get: (id) => figuresById.get(id),
910
+ // A cross-note `ref` resolves the written address exactly as
911
+ // a wikilink does, against every note's figures numbered
912
+ // before any entry's expressions render — see `prepared`
913
+ // above. The default link renders the target's own web
914
+ // address, which `renderLink` in `engine/pdf-render.mjs`
915
+ // resolves to the entry the book gave it, exactly as an
916
+ // ordinary cross-note wikilink does.
917
+ note: (target) =>
918
+ resolveCrossNoteFigures(target, {
919
+ contentTypes: gates.index.contentTypes,
920
+ packages: gates.index.packages,
921
+ noIndexPackages: gates.index.noIndexPackages,
922
+ contentPackage: gates.index.contentPackage,
923
+ siteIndexMap: gates.index.index,
924
+ figuresByAddress,
925
+ }),
926
+ },
598
927
  });
599
928
  findings.push(...expressions.findings);
600
929
  const resolvedBody = protectCode(expressions.markdown, (text) =>
601
930
  resolveWebWikilinks(pass.beforeLinks ? pass.beforeLinks(text, page) : text, linkCtx),
602
931
  );
932
+ // A link finding carries the authored link and which occurrence of it
933
+ // this was, which is exactly what locates it: the position is implicit
934
+ // in the literal the pass matched, so it is recovered by searching the
935
+ // note for it rather than dropped. Read once per page, and only when
936
+ // there is a finding to locate.
937
+ let raw;
938
+ const noteSource = () => {
939
+ if (raw === undefined) {
940
+ try {
941
+ raw = fs.readFileSync(page.file, "utf8");
942
+ } catch {
943
+ raw = "";
944
+ }
945
+ }
946
+ return raw;
947
+ };
948
+ // An **error**, as it is from the pack build and from the site build. One
949
+ // authored address that resolves nowhere is one defect, and a reader of
950
+ // the book is the one reader who cannot act on it: a page printing
951
+ // `(unresolved link)` has shipped the mistake to somebody holding paper.
952
+ // The source is written and the compiler still runs, so the finding is a
953
+ // report on a book that was built, exactly as the site writes its pages
954
+ // and refuses the run.
603
955
  for (const err of wikiErrors) {
604
956
  findings.push({
605
957
  file: page.file,
606
- severity: "warning",
958
+ ...positionOfLiteral(noteSource(), err.link, err.occurrence),
959
+ severity: "error",
607
960
  // A link finding names a `reason` from the shared table and no
608
961
  // sentence of its own; an embed's directive complaint carries
609
962
  // the sentence instead.
610
963
  message: err.message ?? (err.reason ? linkFindingMessage(err) : String(err)),
611
964
  });
612
965
  }
613
- const secrets = renderSecretBlocks(resolvedBody, "book");
614
- for (const error of renderSecretBlocks(page.body, "book").errors) {
615
- findings.push({
616
- file: page.file,
617
- line: (page.bodyLine ?? 1) + error.line - 1,
618
- column: error.column,
619
- severity: "error",
620
- message: error.message,
621
- });
622
- }
623
- for (const error of scanAdmonitions(page.body).errors) {
624
- findings.push({
625
- file: page.file,
626
- line: (page.bodyLine ?? 1) + error.line - 1,
627
- column: error.column,
628
- severity: "error",
629
- message: error.message,
630
- });
631
- }
632
- stageImages(secrets.markdown, page.file, columns);
633
- const prose = markdownToTypst(secrets.markdown, {
966
+ stageImages(resolvedBody, page.file, columns);
967
+ const prose = markdownToTypst(resolvedBody, {
634
968
  md,
635
969
  links: plan.links,
636
970
  glyphs,
637
971
  images,
972
+ assets: imageAssets,
638
973
  headingOffset,
639
974
  anchorPrefix,
640
- captions: numberedCaptions,
975
+ captions: numberedFigures,
976
+ url: site,
977
+ findings,
978
+ file: page.file,
979
+ bodyLine: page.bodyLine,
980
+ lineMap,
981
+ prepared: true,
982
+ resolveRole: (pathname) => pathRoles.get(pathname),
641
983
  });
642
984
  // Infoboxes follow the authored body within this note's entry.
643
985
  const boxes = noteInfoboxes(page.fm, {
644
986
  resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
645
987
  router: routerFor(resolved),
646
988
  dates: gates.index.dateContext,
989
+ contentPackage: gates.index.contentPackage,
647
990
  });
648
991
  const panel = infoboxesToTypst(boxes, {
649
- link: (value) => linkToTypst(value, plan.links, labelFor),
992
+ link: (value) => linkToTypst(value, plan.links, labelFor, site),
650
993
  });
651
994
  // Whether the entry is settled comes before anything it says: the
652
995
  // notice leads the leaf, before the authored body.
@@ -696,9 +1039,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
696
1039
  links: plan.links,
697
1040
  glyphs,
698
1041
  images,
1042
+ assets: imageAssets,
699
1043
  headingOffset: entry.depth,
700
1044
  anchorPrefix: entry.anchor,
701
- captions: numberCaptions(scanCaptions(text).captions, captionCounts),
1045
+ captions: prepared.get(entry.anchor).numberedFigures,
1046
+ url: site,
1047
+ findings,
1048
+ file,
702
1049
  }),
703
1050
  );
704
1051
  }
@@ -722,8 +1069,12 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
722
1069
  links: plan.links,
723
1070
  glyphs,
724
1071
  images,
1072
+ assets: imageAssets,
725
1073
  footnotePrefix: `footnote-front-${index + 1}`,
726
- captions: frontCaptions.get(file),
1074
+ captions: frontFigures.get(file),
1075
+ url: site,
1076
+ findings,
1077
+ file,
727
1078
  });
728
1079
  } catch {
729
1080
  findings.push({
@@ -756,7 +1107,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
756
1107
  const stageMapBackground = (entry, value, title) => {
757
1108
  const file = noteFile(contentBase, entry.record);
758
1109
  const found = artPathname(assets, value, "image", ["image", "icon"]);
759
- const staged = stagedImagePath(found.pathname ?? value, resolved);
1110
+ let staged;
1111
+ try {
1112
+ staged = stagedImagePath(found.pathname ?? value, resolved, stagingOpts);
1113
+ } catch (err) {
1114
+ findings.push({ file, severity: "error", message: err.message });
1115
+ return;
1116
+ }
760
1117
  if (!staged || !fs.existsSync(staged.from)) {
761
1118
  findings.push({
762
1119
  file,