@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
@@ -104,6 +104,9 @@ import { authoredFields } from "./field-spec.mjs";
104
104
  import { positionInFrontmatter } from "./diagnostics.mjs";
105
105
  import { RETIRED_FIELD_ALIASES } from "./retired-fields.mjs";
106
106
  import { authoredKey } from "./system-block.mjs";
107
+ // The top-level vocabulary, read rather than restated — a second list of the
108
+ // keys a note's own identity uses is one more thing to drift.
109
+ import { NOTE_TOP_LEVEL_KEY_SET } from "./note-vocabulary.mjs";
107
110
 
108
111
  /**
109
112
  * Every field path any subtype of a published schema declares.
@@ -273,18 +276,18 @@ function nearest(key, candidates) {
273
276
  /**
274
277
  * Top-level keys the format names as the note's own, whatever its type.
275
278
  *
276
- * Top level is otherwise **open** — an unrecognised key there is a Hugo or
277
- * theme parameter this build has no standing to refuse — so this set exists
278
- * only to keep a note's own identity fields from being mistaken for a
279
- * misplaced `data` property where the two share a name. `type` and `subType`
280
- * are the pair that matters: several mapping tables name `subType` as a shared
281
- * source, and it is authored at top level by design.
279
+ * The top-level vocabulary itself, read rather than restated: this set exists
280
+ * to keep a note's own identity fields from being mistaken for a misplaced
281
+ * `data` property where the two share a name, and the keys that are a note's
282
+ * own are exactly the ones the region declares. `type` and `subType` are the
283
+ * pair that matters — several mapping tables name `subType` as a shared source,
284
+ * and it is authored at top level by design. A key the region does *not*
285
+ * declare, such as `id` beside `data.id`, is reported as the misplaced `data`
286
+ * property it is.
282
287
  *
283
288
  * @type {ReadonlySet<string>}
284
289
  */
285
- export const NOTE_LEVEL_KEYS = Object.freeze(
286
- new Set(["id", "type", "subType", "shortcode", "description", "tags", "name"]),
287
- );
290
+ export const NOTE_LEVEL_KEYS = NOTE_TOP_LEVEL_KEY_SET;
288
291
 
289
292
  /** Whether a value is a plain object a block could be written as. */
290
293
  function isBlock(value) {
@@ -379,9 +382,9 @@ export function measureNote(note, format, { severity = "warning" } = {}) {
379
382
  }
380
383
 
381
384
  // The two regions a shared source is written in today, and neither is where
382
- // the format puts it. Top level is open in general — but a key the type's
383
- // own table declares is not an unrecognised one, it is one whose home the
384
- // format states.
385
+ // the format puts it. A key the type's own table declares is not merely an
386
+ // unrecognised one — it is one whose home the format states, so the finding
387
+ // names that home rather than the vocabulary.
385
388
  for (const key of spec.dataKeys) {
386
389
  if (NOTE_LEVEL_KEYS.has(key)) continue;
387
390
  if (Object.hasOwn(fm, key)) {
@@ -242,7 +242,7 @@ types:
242
242
  - namedDays
243
243
  - eras
244
244
  - formats
245
- - event
245
+ - events
246
246
  subTypes:
247
247
  - cosmology
248
248
  - deity
@@ -58,10 +58,12 @@
58
58
  * agree: a figure is a block on all three, and nothing has to decide what a
59
59
  * floated run of text inside a paragraph would mean.
60
60
  *
61
- * **The alt text is the caption.** Print has no `alt` attribute and has to put
62
- * those words somewhere visible; rather than one surface showing them and two
63
- * hiding them, every surface draws them under the picture, and the HTML
64
- * surfaces carry them as `alt` as well.
61
+ * **Alt text renders as a caption on no surface.** A caption is drawn only for
62
+ * an image inside a `:::figure` fence that carries an authored caption, and the
63
+ * text drawn is always that caption. An image with no fence, or a fence with no
64
+ * caption, draws no line under the picture anywhere. The HTML surfaces still
65
+ * carry the alt text as the `img` element's `alt` attribute, for the reader a
66
+ * screen reader serves.
65
67
  *
66
68
  * ## Where the address resolves
67
69
  *
@@ -79,7 +81,9 @@ import path from "node:path";
79
81
  import { matchAllOutsideCode } from "./code-fences.mjs";
80
82
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
81
83
  import { positionInBody } from "./diagnostics.mjs";
84
+ import { FOOTNOTE_REFERENCE } from "./content-footnotes.mjs";
82
85
  import { foundryAddressProblem, pathnameProblem, servesFoundry } from "./pathnames.mjs";
86
+ import { ASSET_ROLES } from "./asset-index.mjs";
83
87
 
84
88
  /**
85
89
  * The width classes an image may carry, and what each means to a renderer.
@@ -273,13 +277,27 @@ export function parseImageDirective(raw) {
273
277
  }
274
278
 
275
279
  /**
276
- * The classes a figure carries, from a parsed directive.
280
+ * The class a picture's role names, or `""` for no role or one outside the
281
+ * closed set — the same silent-degrade rule every other unrecognised value
282
+ * here gets, since a role this far downstream has already been validated or
283
+ * dropped by the asset record that carries it.
277
284
  *
278
- * @param {{classes?: string[], size?: string, float?: string}} [directive] - As parsed.
285
+ * @param {string} [role] - The role, from the resolved asset record.
286
+ * @returns {string} The class, or `""`.
287
+ */
288
+ export function roleClass(role) {
289
+ return role && ASSET_ROLES.includes(role) ? `note-image-role-${role}` : "";
290
+ }
291
+
292
+ /**
293
+ * The classes a figure carries, from a parsed directive and its asset's role.
294
+ *
295
+ * @param {{classes?: string[], size?: string, float?: string, role?: string}} [directive] -
296
+ * As parsed, plus the role the resolved asset record carries, if any.
279
297
  * @returns {string} A space-separated class list, always naming
280
298
  * {@link IMAGE_FIGURE_CLASS} first.
281
299
  */
282
- export function figureClasses({ classes = [], size = "auto", float = "" } = {}) {
300
+ export function figureClasses({ classes = [], size = "auto", float = "", role = "" } = {}) {
283
301
  const names = [IMAGE_FIGURE_CLASS];
284
302
  for (const name of classes) {
285
303
  const spec = IMAGE_CLASSES[/** @type {keyof typeof IMAGE_CLASSES} */ (name)];
@@ -288,6 +306,8 @@ export function figureClasses({ classes = [], size = "auto", float = "" } = {})
288
306
  if (size !== "auto" && IMAGE_SIZES.includes(size)) names.push(`note-image-size-${size}`);
289
307
  const position = IMAGE_FLOATS[/** @type {keyof typeof IMAGE_FLOATS} */ (float)];
290
308
  if (position) names.push(position.class);
309
+ const roleName = roleClass(role);
310
+ if (roleName) names.push(roleName);
291
311
  return names.join(" ");
292
312
  }
293
313
 
@@ -313,30 +333,85 @@ export function escapeHtml(text) {
313
333
  * function, so the two cannot drift into styling the same directive through
314
334
  * different class names.
315
335
  *
336
+ * **No `<figcaption>` is emitted here.** A `:::figure` fence draws its own
337
+ * label and authored caption around whatever this returns; the alt text stays
338
+ * on the `img` element alone.
339
+ *
340
+ * **The role class and the `width`/`height` attributes are what a stylesheet
341
+ * elsewhere keys a role's size on.** This module states none — the measure a
342
+ * `portrait` fills on a page is a Hugo theme's or a Foundry system's own
343
+ * decision — and emits only what the resolved asset record states: a role
344
+ * class when the picture declares one, and the attributes whenever the
345
+ * picture resolved to an asset record at all, blank for a vector. The
346
+ * attributes reserve the picture's own layout space before it loads, which is
347
+ * what lets a page avoid reflowing as pictures load in.
348
+ *
316
349
  * @param {object} image - The image.
317
350
  * @param {string} image.src - The address, resolved for the surface.
318
- * @param {string} [image.alt] - The alt text, which is also the caption.
351
+ * @param {string} [image.alt] - The alt text, for a reader who cannot see the
352
+ * picture.
319
353
  * @param {string[]} [image.classes] - Width classes, from the directive.
320
354
  * @param {string} [image.size] - Named display size, from the directive.
321
355
  * @param {string} [image.float] - The float position, from the directive.
356
+ * @param {string} [image.role] - The picture's declared role, from the
357
+ * resolved asset record.
358
+ * @param {number|""} [image.width] - The file's own pixel width, from the
359
+ * resolved asset record; `""` for a vector. Omitted entirely when the
360
+ * address resolved to no asset record at all.
361
+ * @param {number|""} [image.height] - As `width`.
322
362
  * @returns {string} The figure, as one HTML block.
323
363
  */
324
- export function imageFigureHtml({ src, alt = "", classes = [], size = "auto", float = "" }) {
325
- const caption = alt ? `\n<figcaption>${escapeHtml(alt)}</figcaption>` : "";
364
+ export function imageFigureHtml({
365
+ src,
366
+ alt = "",
367
+ classes = [],
368
+ size = "auto",
369
+ float = "",
370
+ role = "",
371
+ width,
372
+ height,
373
+ }) {
374
+ const dims =
375
+ width !== undefined && height !== undefined ?
376
+ ` width="${escapeHtml(String(width))}" height="${escapeHtml(String(height))}"`
377
+ : "";
326
378
  return (
327
- `<figure class="${figureClasses({ classes, size, float })}">\n` +
328
- `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}">${caption}\n` +
379
+ `<figure class="${figureClasses({ classes, size, float, role })}">\n` +
380
+ `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}"${dims}>\n` +
329
381
  `</figure>`
330
382
  );
331
383
  }
332
384
 
385
+ /**
386
+ * A body extension's own delimiter — a `:::` opener or closer, or a figure's
387
+ * `///`.
388
+ *
389
+ * It bounds a paragraph exactly as a blank line does: the line belongs to the
390
+ * construct rather than to the prose, and the pass that owns the construct
391
+ * hands its body on by itself. So a picture written tight against one is a
392
+ * block of its own, which is how `:::figure` is written.
393
+ */
394
+ const DELIMITER = /^(?::{3,}[^\n]*|\/{3,}[ \t]*)$/;
395
+
396
+ /**
397
+ * A footnote reference immediately trailing a match, anchored so it only
398
+ * consumes one sitting right against the end of it.
399
+ */
400
+ const TRAILING_FOOTNOTE_REFERENCE = new RegExp(`^${FOOTNOTE_REFERENCE.source}`);
401
+
333
402
  /**
334
403
  * Whether a match sits alone in its own paragraph.
335
404
  *
336
- * "Alone" is the whole of a block: nothing else on its line, and a blank line
337
- * or the end of the body either side of it. A leading `>` or list marker
338
- * disqualifies it for the same reason a word does — the paragraph it belongs to
339
- * holds something the figure would have to be lifted out of.
405
+ * "Alone" is the whole of a block: nothing else on its line, and a blank line,
406
+ * a body-extension delimiter or the end of the body either side of it. A
407
+ * leading `>` or list marker disqualifies it for the same reason a word does —
408
+ * the paragraph it belongs to holds something the figure would have to be
409
+ * lifted out of.
410
+ *
411
+ * **A footnote reference trailing the match is not "other text."** `[^note]`
412
+ * annotates the picture rather than sitting beside it in prose, the same
413
+ * standing its own directive already has, so `![A ranger](r.webp)[^note]` is
414
+ * still alone.
340
415
  *
341
416
  * @param {string} text - The body the match indexes into.
342
417
  * @param {number} start - Where the match begins.
@@ -350,12 +425,18 @@ export function standsAlone(text, start, end) {
350
425
  // makes it an indented code block, which `matchAllOutsideCode` skips.
351
426
  if (!/^[ \t]*$/.test(src.slice(lineStart, start))) return false;
352
427
  const lineEnd = src.indexOf("\n", end);
353
- if (!/^[ \t]*$/.test(src.slice(end, lineEnd === -1 ? src.length : lineEnd))) return false;
428
+ const afterMatch = src.slice(end, lineEnd === -1 ? src.length : lineEnd);
429
+ const reference = TRAILING_FOOTNOTE_REFERENCE.exec(afterMatch);
430
+ if (!/^[ \t]*$/.test(reference ? afterMatch.slice(reference[0].length) : afterMatch))
431
+ return false;
354
432
 
355
433
  const before = src.slice(0, lineStart);
356
- if (before.trim() && !/\n[ \t]*\n[ \t]*$/.test(before)) return false;
434
+ const previous = before.replace(/\n$/, "").split("\n").pop() ?? "";
435
+ if (before.trim() && !/\n[ \t]*\n[ \t]*$/.test(before) && !DELIMITER.test(previous))
436
+ return false;
357
437
  const after = lineEnd === -1 ? "" : src.slice(lineEnd);
358
- if (after.trim() && !/^\n[ \t]*\n/.test(after)) return false;
438
+ const next = after.replace(/^\n/, "").split("\n")[0] ?? "";
439
+ if (after.trim() && !/^\n[ \t]*\n/.test(after) && !DELIMITER.test(next)) return false;
359
440
  return true;
360
441
  }
361
442
 
@@ -414,6 +495,9 @@ export function imageSourcesIn(body) {
414
495
  * @param {object} [opts]
415
496
  * @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
416
497
  * @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
498
+ * @param {Array<{line: number, generated: boolean}>} [opts.lineMap] - From
499
+ * table expansion, mapping each line of `body` back to the line an author
500
+ * wrote, for a caller handing in a body whose tables already expanded.
417
501
  * @param {object} [opts.config] - The resolved build configuration. Supplied,
418
502
  * an address is also held to the one surface a pathname can be dead on
419
503
  * without any other pass noticing — see the Foundry address below. Omitted,
@@ -423,7 +507,7 @@ export function imageSourcesIn(body) {
423
507
  * severity: "error", message: string}>} One finding per defect, in source
424
508
  * order.
425
509
  */
426
- export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, config } = {}) {
510
+ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, lineMap, config } = {}) {
427
511
  const text = String(body ?? "");
428
512
  if (!text) return [];
429
513
 
@@ -434,7 +518,7 @@ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, config }
434
518
  * @param {string} message - What is wrong.
435
519
  */
436
520
  const report = (offset, message) => {
437
- const { line, column } = positionInBody(text, offset, { bodyLine, bodyColumn });
521
+ const { line, column } = positionInBody(text, offset, { bodyLine, bodyColumn, lineMap });
438
522
  findings.push({ file, line, column, severity: /** @type {"error"} */ ("error"), message });
439
523
  };
440
524
 
@@ -454,7 +538,7 @@ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, config }
454
538
  report(
455
539
  image.index,
456
540
  `\`"${image.title}"\` is a title on an image, and no surface here draws ` +
457
- "one — an image's alt text is its caption",
541
+ "one — a caption is written in a `:::figure` fence's /// section",
458
542
  );
459
543
  }
460
544
  if (!image.block) {
@@ -587,9 +671,13 @@ export function lintContentImages(contentBase, { skipDirectories = [], config }
587
671
  * @param {(src: string) => string} [resolveSrc] - Translates an authored
588
672
  * pathname into the address this surface serves. The default is the identity,
589
673
  * for a caller rendering the format rather than publishing it.
674
+ * @param {(src: string) => {type?: string, role?: string, width?: number|"", height?: number|""}|undefined}
675
+ * [lookupAsset] - What the resolved asset record says about the address, as
676
+ * authored — before `resolveSrc` translates it. The default answers nothing,
677
+ * which is what an address resolving to no asset record gets anyway.
590
678
  * @returns {string} The same body, with each block image as a `<figure>`.
591
679
  */
592
- export function renderImageFigures(body, resolveSrc = (src) => src) {
680
+ export function renderImageFigures(body, resolveSrc = (src) => src, lookupAsset = () => undefined) {
593
681
  const text = String(body ?? "");
594
682
  let out = "";
595
683
  let last = 0;
@@ -598,12 +686,16 @@ export function renderImageFigures(body, resolveSrc = (src) => src) {
598
686
  const { classes, size, float, problems } = parseImageDirective(image.directive);
599
687
  if (problems.length) continue;
600
688
  out += text.slice(last, image.index);
689
+ const asset = lookupAsset(image.src);
601
690
  out += imageFigureHtml({
602
691
  src: resolveSrc(image.src),
603
692
  alt: image.alt,
604
693
  classes,
605
694
  size,
606
695
  float,
696
+ role: asset?.role,
697
+ width: asset?.width,
698
+ height: asset?.height,
607
699
  });
608
700
  last = image.index + image.length;
609
701
  }
@@ -632,9 +724,13 @@ export function renderImageFigures(body, resolveSrc = (src) => src) {
632
724
  * address into the one this surface serves. Foundry is handed the path inside
633
725
  * the install; a renderer that resolves the address itself — the book stages
634
726
  * its own copy — passes nothing and gets the address as authored.
727
+ * @param {(src: string) => {type?: string, role?: string, width?: number|"", height?: number|""}|undefined}
728
+ * [lookupAsset] - What the resolved asset record says about the address, as
729
+ * authored — before `resolveSrc` runs. The default answers nothing, which is
730
+ * what an address resolving to no asset record gets anyway.
635
731
  * @returns {(md: object) => void} A markdown-it plugin.
636
732
  */
637
- export function imagePlugin(resolveSrc = (src) => src) {
733
+ export function imagePlugin(resolveSrc = (src) => src, lookupAsset = () => undefined) {
638
734
  return (md) => {
639
735
  /** @type {any} */ (md).core.ruler.push("heroiclands_image", (state) => {
640
736
  attachImageDirectives(state.tokens);
@@ -649,12 +745,17 @@ export function imagePlugin(resolveSrc = (src) => src) {
649
745
  ) => {
650
746
  const token = tokens[idx];
651
747
  if (!token.meta?.block) return base(tokens, idx, options, env, self);
748
+ const src = token.attrGet("src") ?? "";
749
+ const asset = lookupAsset(src);
652
750
  return `${imageFigureHtml({
653
- src: resolveSrc(token.attrGet("src") ?? ""),
751
+ src: resolveSrc(src),
654
752
  alt: token.content ?? "",
655
753
  classes: token.meta.classes,
656
754
  size: token.meta.size,
657
755
  float: token.meta.float,
756
+ role: asset?.role,
757
+ width: asset?.width,
758
+ height: asset?.height,
658
759
  })}\n`;
659
760
  };
660
761
  };
@@ -32,8 +32,8 @@
32
32
  * over 15 types, from 9 on a `macro` to 72 on a `being`, and adding a field to
33
33
  * one type is ordinary authoring. Any format that fixes a column set would turn
34
34
  * that authoring into a schema migration, so nothing here selects, flattens, or
35
- * renames — a reader addresses `sohl.body.weight.base` because that is what the
36
- * note says, which a SQL query can address directly.
35
+ * renames — a reader addresses `sohl.system.body.weight.base` because that is
36
+ * what the note says, which a SQL query can address directly.
37
37
  *
38
38
  * **JSON Lines rather than a database.** The artifact has to survive its build
39
39
  * and be usable by anything — a person with `jq`, an editor, a CI check,
@@ -104,6 +104,8 @@ import { checkForeignAssetBindings } from "./asset-bindings.mjs";
104
104
  import { addressSlug, canonicalKey } from "./content-address.mjs";
105
105
  import { ownDocumentSystem } from "./address.mjs";
106
106
  import { NOTE_SYSTEM } from "./systems.mjs";
107
+ import { assetAddressIndex } from "./art-fields.mjs";
108
+ import { embedRole } from "./content-embeds.mjs";
107
109
  // One reader for a note's anchors, shared with the link checker and with the
108
110
  // builds that emit a link. Re-exported because this is where callers
109
111
  // have always addressed it.
@@ -443,6 +445,10 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
443
445
  * entries are derived against.
444
446
  * @param {object} [options.addressContext] - Address resolution context.
445
447
  * @param {object} [options.dateContext] - Calendar conversion context.
448
+ * @param {(address: string) => string|undefined} [options.resolveRole] - From
449
+ * a picture's address to the role its asset declares, so a figure anchor's
450
+ * `name` reads `Map 1` rather than `Figure 1` where it is due — see
451
+ * {@link module:engine/content-figures.scanFigures}.
446
452
  * @returns {Record<string, any>} The record, keys sorted at every depth. A
447
453
  * **stub** — a note with an empty body, on a type an empty body suppresses —
448
454
  * carries `address` and `anchors` as `null`: it publishes no page, so it
@@ -464,6 +470,7 @@ export function buildIndexRecord({
464
470
  manifest,
465
471
  addressContext,
466
472
  dateContext,
473
+ resolveRole,
467
474
  }) {
468
475
  assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
469
476
 
@@ -514,7 +521,7 @@ export function buildIndexRecord({
514
521
  // there rather than search for the heading.
515
522
  anchors:
516
523
  stub ? null : (
517
- collectAnchors(body, bodyLine).map((a) => ({
524
+ collectAnchors(body, bodyLine, resolveRole).map((a) => ({
518
525
  ...a,
519
526
  link: address ? `${address.slug}#${a.slug}` : null,
520
527
  }))
@@ -615,6 +622,7 @@ export function indexRecordsForNote({
615
622
  manifest,
616
623
  addressContext,
617
624
  dateContext,
625
+ resolveRole,
618
626
  }) {
619
627
  resolveNoteId(frontmatter, { pkg: contentPackage });
620
628
  const record = buildIndexRecord({
@@ -627,6 +635,7 @@ export function indexRecordsForNote({
627
635
  manifest,
628
636
  addressContext,
629
637
  dateContext,
638
+ resolveRole,
630
639
  });
631
640
  const records = [decodeIndexAddresses(record, addressContext ?? { package: contentPackage })];
632
641
  const address = noteAddress(frontmatter, contentPackage);
@@ -726,7 +735,24 @@ export function collectContentIndex(
726
735
  // this same walk states one — the whole tree is already in memory, so no
727
736
  // second read is needed to answer a question about all of it.
728
737
  const present = presentAmongFrontmatters(parsedNotes.map((n) => n.frontmatter));
729
- const dates = reckoningContext({ notes: parsedNotes.map((n) => n.frontmatter) });
738
+ // Carried on the same context `resolvedDateFields` reads: a recurring
739
+ // event's `next` is computed against this present exactly as a being's
740
+ // `age` already is, above.
741
+ const dates = {
742
+ ...reckoningContext({ notes: parsedNotes.map((n) => n.frontmatter) }),
743
+ present,
744
+ };
745
+
746
+ // Walked ahead of the notes, rather than after them as the records
747
+ // themselves are emitted: a note's anchors are collected below, and a
748
+ // figure fence's `map` counter needs this package's own asset roles
749
+ // before the first note is read, the same precedent the book's own
750
+ // cross-file numbering sets. A package with no `assetsBase` resolves no
751
+ // role, which is the one it would have resolved anyway.
752
+ const assetRecords =
753
+ assetsBase ? collectAssetRecords(assetsBase, { contentPackage, problems }) : [];
754
+ const assetIndex = assetAddressIndex(assetRecords, { config: { contentPackage } });
755
+ const resolveRole = (address) => embedRole(assetIndex, address);
730
756
 
731
757
  for (const { frontmatter, body, bodyLine, absPath } of parsedNotes) {
732
758
  const fm = frontmatter ?? {};
@@ -744,6 +770,7 @@ export function collectContentIndex(
744
770
  manifest,
745
771
  addressContext,
746
772
  dateContext: dates,
773
+ resolveRole,
747
774
  }),
748
775
  );
749
776
  } catch (err) {
@@ -776,14 +803,13 @@ export function collectContentIndex(
776
803
  }
777
804
  }
778
805
 
779
- if (assetsBase) {
780
- // Sorted at every depth like a note's record, and for the same reason:
781
- // the declaration order of the `asset` fields is a fact about the
782
- // emitter, not about the content, and the artifact is meant to be
783
- // byte-identical across two runs over an unchanged tree.
784
- for (const record of collectAssetRecords(assetsBase, { contentPackage, problems })) {
785
- records.push(/** @type {Record<string, any>} */ (sortKeysDeep(record)));
786
- }
806
+ // Sorted at every depth like a note's record, and for the same reason: the
807
+ // declaration order of the `asset` fields is a fact about the emitter, not
808
+ // about the content, and the artifact is meant to be byte-identical across
809
+ // two runs over an unchanged tree. Collected above, ahead of the notes —
810
+ // see `resolveRole` — rather than walked a second time here.
811
+ for (const record of assetRecords) {
812
+ records.push(/** @type {Record<string, any>} */ (sortKeysDeep(record)));
787
813
  }
788
814
 
789
815
  // Source path, then the canonical address, then the note id. The walk
@@ -302,24 +302,25 @@ export function buildLinkIndex(
302
302
  };
303
303
 
304
304
  /**
305
- * One note's body with its SQL tables expanded.
305
+ * One note's body with its SQL tables and page lists expanded.
306
306
  *
307
307
  * The body every body-level check reads, so a link and an embed in one note
308
- * are found in the same text — a generated table is as free to carry either
309
- * as prose is.
308
+ * are found in the same text — a generated table or list is as free to
309
+ * carry either as prose is.
310
310
  *
311
311
  * @param {object} note - A note from this index.
312
312
  * @returns {string} The markdown.
313
313
  */
314
314
  function expandedBody(note) {
315
315
  const body = note.body;
316
- if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*sql\b/im.test(body)) return body;
316
+ if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:sql|pagelist)\b/im.test(body)) return body;
317
317
  return expandContentTables(body, {
318
318
  source: note.file,
319
- // A `sql` table's links are checked like an authored one's, so
320
- // its rows are prepared ahead of this walk — see
319
+ // A generated link is checked like an authored one, so both
320
+ // directives are prepared ahead of this walk — see
321
321
  // {@link module:engine/sql-tables.prepareTreeSqlTables}.
322
322
  sqlTables: sqlTables?.get(note.file),
323
+ pageLists: sqlTables?.pageLists?.get(note.file),
323
324
  }).markdown;
324
325
  }
325
326
 
@@ -954,6 +955,13 @@ export function auditLinks(index) {
954
955
  deadAnchors.push({
955
956
  note,
956
957
  link: `${rawTarget}#${anchor}`,
958
+ // The two the shared `unknown-anchor` wording reads, in the
959
+ // same spelling the build's own pass gives them: the
960
+ // address alone, and the slug the heading would have had to
961
+ // declare. Carrying them is what lets one sentence serve
962
+ // the checker and the build.
963
+ target: rawTarget,
964
+ anchor: slugify(anchor),
957
965
  dest,
958
966
  text,
959
967
  occurrence,
@@ -1,21 +1,30 @@
1
1
  /* SPDX-License-Identifier: GPL-3.0-or-later */
2
2
 
3
3
  import { FENCE_LINE } from "./code-fences.mjs";
4
+ import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
4
5
 
5
6
  /**
6
- * Replace prepared SQL fences with Markdown tables while retaining source lines.
7
- * Query execution happens in the asynchronous SQL preparation pass.
7
+ * Replace prepared SQL fences and page lists with Markdown while retaining
8
+ * source lines.
9
+ *
10
+ * Both directives read the content index, so both are answered before this
11
+ * pass runs — query execution in the asynchronous SQL preparation pass, page
12
+ * lists in {@link module:engine/page-lists.preparePageLists} — and each is
13
+ * looked up by the ordinal of its own kind of fence within the body.
14
+ *
8
15
  * @param {string} markdown - The authored body.
9
- * @param {{source?: string, sqlTables?: object[]}} [context] - Prepared results.
16
+ * @param {{source?: string, sqlTables?: object[], pageLists?: object[]}} [context] -
17
+ * Prepared results.
10
18
  * @returns {{markdown: string, errors: object[], warnings: object[], lineMap: object[]}}
11
19
  */
12
- export function expandContentTables(markdown, { source = "", sqlTables } = {}) {
20
+ export function expandContentTables(markdown, { source = "", sqlTables, pageLists } = {}) {
13
21
  const lines = String(markdown ?? "").split("\n");
14
22
  const out = [];
15
23
  const lineMap = [];
16
24
  const errors = [];
17
25
  const warnings = [];
18
26
  let sqlOrdinal = 0;
27
+ let pageListOrdinal = 0;
19
28
  const emit = (value, line, generated = false) => {
20
29
  out.push(value);
21
30
  lineMap.push({ line, generated });
@@ -41,6 +50,38 @@ export function expandContentTables(markdown, { source = "", sqlTables } = {}) {
41
50
  column: indent.length + 1,
42
51
  });
43
52
  }
53
+ if (language === PAGE_LIST_LANGUAGE) {
54
+ // The ordinal is consumed whether or not the fence was closed, so
55
+ // the two readings agree: `findPageListBlocks` records an unclosed
56
+ // directive as a directive with a problem rather than skipping it.
57
+ const prepared = pageLists?.[pageListOrdinal++];
58
+ const failure =
59
+ !prepared ? "page list was not prepared"
60
+ : prepared.reason ? prepared.reason
61
+ : prepared.pages === 0 && !prepared.allowEmpty ? prepared.empty
62
+ : undefined;
63
+ if (failure) {
64
+ errors.push({
65
+ source,
66
+ directive: block.join("\n"),
67
+ reason: failure,
68
+ line: i,
69
+ column: indent.length + 1,
70
+ });
71
+ block.forEach((value, offset) => emit(value, i + offset));
72
+ i = close;
73
+ continue;
74
+ }
75
+ // A list the directive permitted to be empty leaves nothing
76
+ // behind, rather than a blank line where a list would have been.
77
+ if (prepared.markdown) {
78
+ if (out.length && out.at(-1).trim()) emit("", i, true);
79
+ for (const row of prepared.markdown.split("\n")) emit(`${indent}${row}`, i, true);
80
+ if (close + 1 < lines.length && lines[close + 1].trim()) emit("", i, true);
81
+ }
82
+ i = close;
83
+ continue;
84
+ }
44
85
  if (language !== "sql" || close >= lines.length) {
45
86
  block.forEach((value, offset) => emit(value, i + offset));
46
87
  i = close;