@heroiclands/package-build 22.22.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 (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/content-commands.mjs +51 -14
  3. package/ci/content-format-reference.mjs +1 -1
  4. package/content-config.mjs +80 -2
  5. package/docs/api.md +85 -72
  6. package/docs/authoring/assets.md +14 -0
  7. package/docs/authoring/dates-and-calendars.md +37 -1
  8. package/docs/authoring/links-and-markup.md +112 -31
  9. package/docs/commands.md +12 -10
  10. package/docs/configuration.md +90 -13
  11. package/docs/content-format.md +1 -1
  12. package/docs/guides/book.md +5 -4
  13. package/docs/reference/format-details.md +323 -60
  14. package/docs/reference/note-types.md +9 -9
  15. package/engine/anchors.mjs +16 -10
  16. package/engine/art-fields.mjs +118 -0
  17. package/engine/art-slots.mjs +7 -1
  18. package/engine/asset-index.mjs +160 -10
  19. package/engine/asset-replacement.mjs +93 -0
  20. package/engine/asset-types.mjs +240 -0
  21. package/engine/base-compiler.mjs +54 -2
  22. package/engine/bundles.mjs +13 -1
  23. package/engine/content-blocks.mjs +4 -4
  24. package/engine/content-embeds.mjs +19 -0
  25. package/engine/content-figures.mjs +468 -0
  26. package/engine/content-footnotes.mjs +10 -2
  27. package/engine/content-format.yaml +1 -1
  28. package/engine/content-images.mjs +126 -25
  29. package/engine/content-index.mjs +36 -10
  30. package/engine/field-reference.mjs +31 -10
  31. package/engine/field-spec.mjs +10 -8
  32. package/engine/foreign-catalog.mjs +143 -18
  33. package/engine/frontmatter-lint.mjs +73 -1
  34. package/engine/heading-attributes.mjs +31 -18
  35. package/engine/helpers.mjs +97 -30
  36. package/engine/infobox-registry.mjs +4 -1
  37. package/engine/infobox.mjs +47 -8
  38. package/engine/itinerary-scenes.mjs +4 -10
  39. package/engine/journals.mjs +264 -39
  40. package/engine/lore-events.mjs +197 -0
  41. package/engine/markdown-expressions.mjs +257 -7
  42. package/engine/note-dates.mjs +305 -12
  43. package/engine/note-vocabulary.mjs +11 -5
  44. package/engine/pathnames.mjs +36 -0
  45. package/engine/pdf-build.mjs +357 -34
  46. package/engine/pdf-images.mjs +64 -0
  47. package/engine/pdf-render.mjs +160 -36
  48. package/engine/scenes.mjs +16 -4
  49. package/engine/site-build.mjs +127 -13
  50. package/engine/site-index.mjs +72 -1
  51. package/engine/site-preview.mjs +2 -0
  52. package/engine/system-block.mjs +44 -16
  53. package/hm3/actors.mjs +2 -2
  54. package/manifest.mjs +30 -24
  55. package/package.json +1 -1
  56. package/packagebuild-metadata.jsonl +13 -13
  57. package/sohl/item-fields.mjs +4 -3
  58. package/types/content-config.d.mts +48 -1
  59. package/types/engine/anchors.d.mts +9 -4
  60. package/types/engine/art-fields.d.mts +64 -0
  61. package/types/engine/asset-index.d.mts +43 -0
  62. package/types/engine/asset-replacement.d.mts +22 -0
  63. package/types/engine/asset-types.d.mts +18 -0
  64. package/types/engine/base-compiler.d.mts +0 -17
  65. package/types/engine/content-embeds.d.mts +16 -0
  66. package/types/engine/content-figures.d.mts +101 -0
  67. package/types/engine/content-footnotes.d.mts +7 -0
  68. package/types/engine/content-images.d.mts +78 -12
  69. package/types/engine/content-index.d.mts +8 -2
  70. package/types/engine/field-reference.d.mts +21 -0
  71. package/types/engine/field-spec.d.mts +20 -16
  72. package/types/engine/foreign-catalog.d.mts +39 -0
  73. package/types/engine/heading-attributes.d.mts +13 -8
  74. package/types/engine/helpers.d.mts +23 -7
  75. package/types/engine/infobox-registry.d.mts +4 -1
  76. package/types/engine/infobox.d.mts +6 -0
  77. package/types/engine/journals.d.mts +31 -9
  78. package/types/engine/lore-events.d.mts +14 -0
  79. package/types/engine/markdown-expressions.d.mts +23 -2
  80. package/types/engine/note-dates.d.mts +64 -1
  81. package/types/engine/pathnames.d.mts +26 -0
  82. package/types/engine/pdf-build.d.mts +28 -6
  83. package/types/engine/pdf-images.d.mts +52 -0
  84. package/types/engine/pdf-render.d.mts +12 -0
  85. package/types/engine/site-build.d.mts +14 -3
  86. package/types/engine/site-index.d.mts +54 -0
  87. package/types/engine/system-block.d.mts +26 -2
  88. package/types/hm3/actors.d.mts +18 -0
  89. package/types/manifest.d.mts +19 -23
  90. package/engine/content-captions.mjs +0 -230
  91. package/types/engine/content-captions.d.mts +0 -58
@@ -38,6 +38,12 @@
38
38
  * local pack's output — the resolution logic needs no knowledge of where an
39
39
  * item came from.
40
40
  *
41
+ * `assetArchive: true` is the sibling flag, for a dependency whose bytes are
42
+ * wanted and whose items are not: an asset-replacement source such as
43
+ * `thalornaaltart` ships no Item packs at all. It unpacks the same release
44
+ * archive without extracting anything from it, so a relationship declaring
45
+ * both flags on one entry is fetched once rather than twice.
46
+ *
41
47
  * **The network is never touched by a compile.** Fetching is its own command
42
48
  * (`package-build deps fetch`), and a compile whose cache is cold fails saying
43
49
  * so. A build that silently downloads is not reproducible, fails strangely
@@ -73,17 +79,22 @@ import {
73
79
  const STAMP = ".complete";
74
80
 
75
81
  /**
76
- * Every declared relationship that opted into supplying an item catalogue.
82
+ * Every declared relationship carrying a given flag, in declaration order.
83
+ *
84
+ * Shared by {@link itemCatalogRelationships} and
85
+ * {@link assetArchiveRelationships}: both read the same shape out of the same
86
+ * walk, filtered on a different key.
77
87
  *
78
88
  * @param {object} config - The resolved build configuration.
89
+ * @param {"itemCatalog"|"assetArchive"} flag - Which boolean to filter on.
79
90
  * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
80
- * The opted-in relationships, in declaration order.
91
+ * The opted-in relationships.
81
92
  */
82
- export function itemCatalogRelationships(config) {
93
+ function flaggedRelationships(config, flag) {
83
94
  const out = [];
84
95
  for (const [kind, entries] of Object.entries(config.relationships ?? {})) {
85
96
  for (const rel of entries ?? []) {
86
- if (rel.itemCatalog) {
97
+ if (rel[flag]) {
87
98
  out.push({
88
99
  id: rel.id,
89
100
  manifest: rel.manifest,
@@ -96,6 +107,29 @@ export function itemCatalogRelationships(config) {
96
107
  return out;
97
108
  }
98
109
 
110
+ /**
111
+ * Every declared relationship that opted into supplying an item catalogue.
112
+ *
113
+ * @param {object} config - The resolved build configuration.
114
+ * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
115
+ * The opted-in relationships, in declaration order.
116
+ */
117
+ export function itemCatalogRelationships(config) {
118
+ return flaggedRelationships(config, "itemCatalog");
119
+ }
120
+
121
+ /**
122
+ * Every declared relationship that opted into supplying its release archive
123
+ * for asset bytes alone, with no item catalogue built from it.
124
+ *
125
+ * @param {object} config - The resolved build configuration.
126
+ * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
127
+ * The opted-in relationships, in declaration order.
128
+ */
129
+ export function assetArchiveRelationships(config) {
130
+ return flaggedRelationships(config, "assetArchive");
131
+ }
132
+
99
133
  /**
100
134
  * The cache directory for one dependency at one version.
101
135
  *
@@ -366,15 +400,21 @@ export function pinnedManifestUrl(url, verified) {
366
400
  }
367
401
 
368
402
  /**
369
- * Fetch one dependency and extract its Item packs.
370
- *
371
- * Idempotent: a complete cache for the resolved version is left alone.
372
- *
373
- * @param {object} config - The resolved build configuration.
374
- * @param {{id: string, manifest: string}} rel - The declared relationship.
375
- * @returns {Promise<string>} The dependency's cache directory.
403
+ * Resolve a dependency's manifest, pinned to `compatibility.verified` the way
404
+ * both fetch paths require.
405
+ *
406
+ * Shared by {@link fetchCatalog} and {@link fetchAssetArchive}: both read the
407
+ * same manifest before deciding what to unpack from it.
408
+ *
409
+ * @param {{id: string, manifest: string, verified?: string}} rel - The
410
+ * declared relationship.
411
+ * @param {string} subject - What floats when no version is verified —
412
+ * "catalogue" or "archive" — named in the warning a reader would otherwise
413
+ * get no word for.
414
+ * @returns {Promise<{manifest: object, version: string}>} The manifest and
415
+ * its resolved version.
376
416
  */
377
- export async function fetchCatalog(config, rel) {
417
+ async function resolveManifest(rel, subject) {
378
418
  const { url, pinned } = pinnedManifestUrl(rel.manifest, rel.verified);
379
419
  const manifest = await fetchManifest(url);
380
420
  const version = manifest.version;
@@ -391,10 +431,24 @@ export async function fetchCatalog(config, rel) {
391
431
  }
392
432
  if (!rel.verified) {
393
433
  log.warn(
394
- `${rel.id}: no \`compatibility.verified\`, so its catalogue floats ` +
434
+ `${rel.id}: no \`compatibility.verified\`, so its ${subject} floats ` +
395
435
  `with whatever ${url} currently serves`,
396
436
  );
397
437
  }
438
+ return { manifest, version };
439
+ }
440
+
441
+ /**
442
+ * Fetch one dependency and extract its Item packs.
443
+ *
444
+ * Idempotent: a complete cache for the resolved version is left alone.
445
+ *
446
+ * @param {object} config - The resolved build configuration.
447
+ * @param {{id: string, manifest: string}} rel - The declared relationship.
448
+ * @returns {Promise<string>} The dependency's cache directory.
449
+ */
450
+ export async function fetchCatalog(config, rel) {
451
+ const { manifest, version } = await resolveManifest(rel, "catalogue");
398
452
  const dir = catalogDir(config, rel.id, version);
399
453
  if (isComplete(dir)) {
400
454
  log.info(`${rel.id}@${version}: already cached`);
@@ -420,6 +474,61 @@ export async function fetchCatalog(config, rel) {
420
474
  return dir;
421
475
  }
422
476
 
477
+ /**
478
+ * Whether an asset-archive-only cache is present and complete.
479
+ *
480
+ * No item catalogue is built on this path, so completeness never asks for
481
+ * `item-packs.json` — an asset-only dependency, such as `thalornaaltart`,
482
+ * ships none.
483
+ *
484
+ * @param {string} dir - The dependency's cache directory.
485
+ * @returns {boolean} True when the archive was fetched to completion.
486
+ */
487
+ const isArchiveComplete = (dir) => fs.existsSync(path.join(dir, STAMP));
488
+
489
+ /**
490
+ * Fetch one dependency's release archive for its asset bytes alone, building
491
+ * no item catalogue from it.
492
+ *
493
+ * Idempotent: a complete cache for the resolved version is left alone. Where a
494
+ * relationship also declares `itemCatalog: true`, {@link fetchCatalog} already
495
+ * unpacked this same archive into this same cache directory, and
496
+ * {@link fetchAllCatalogs} does not call this function for it — the two never
497
+ * download the same bytes twice.
498
+ *
499
+ * @param {object} config - The resolved build configuration.
500
+ * @param {{id: string, manifest: string, verified?: string}} rel - The
501
+ * declared relationship.
502
+ * @returns {Promise<string>} The dependency's cache directory.
503
+ */
504
+ export async function fetchAssetArchive(config, rel) {
505
+ const { manifest, version } = await resolveManifest(rel, "archive");
506
+ const dir = catalogDir(config, rel.id, version);
507
+ if (isArchiveComplete(dir)) {
508
+ log.info(`${rel.id}@${version}: already cached`);
509
+ return dir;
510
+ }
511
+
512
+ const download = manifest.download;
513
+ if (!download) {
514
+ throw new Error(`${rel.id}@${version}: its manifest declares no \`download\``);
515
+ }
516
+
517
+ // Rebuild from empty: a previous run may have died partway, and a stale
518
+ // half-tree is worse than no tree.
519
+ fs.rmSync(dir, { recursive: true, force: true });
520
+ const raw = path.join(dir, "package");
521
+ fs.mkdirSync(raw, { recursive: true });
522
+
523
+ log.info(`${rel.id}@${version}: downloading ${download}`);
524
+ await downloadAndUnzip(download, raw);
525
+
526
+ cacheSchemaArtifact(raw, dir);
527
+ // Last, so a fetch that died partway is never mistaken for a complete one.
528
+ fs.writeFileSync(path.join(dir, STAMP), `${version}\n`);
529
+ return dir;
530
+ }
531
+
423
532
  /**
424
533
  * Locate a declared pack inside an unpacked archive.
425
534
  *
@@ -700,17 +809,33 @@ export async function fetchAllMetadata(config) {
700
809
  /**
701
810
  * Fetch every opted-in dependency. The `deps fetch` command.
702
811
  *
812
+ * A relationship declaring both `itemCatalog: true` and `assetArchive: true`
813
+ * is fetched once: {@link fetchCatalog} already unpacks the archive on its
814
+ * way to extracting the item catalogue, so this skips the archive-only fetch
815
+ * for any id {@link fetchCatalog} already handled.
816
+ *
703
817
  * @param {object} config - The resolved build configuration.
704
818
  * @returns {Promise<number>} How many dependencies were fetched.
705
819
  */
706
820
  export async function fetchAllCatalogs(config) {
707
- const rels = itemCatalogRelationships(config);
708
- if (!rels.length) {
709
- log.info("No relationship declares `itemCatalog: true`; nothing to fetch.");
821
+ const itemRels = itemCatalogRelationships(config);
822
+ const archiveRels = assetArchiveRelationships(config);
823
+ if (!itemRels.length && !archiveRels.length) {
824
+ log.info(
825
+ "No relationship declares `itemCatalog: true` or `assetArchive: true`; " +
826
+ "nothing to fetch.",
827
+ );
710
828
  return 0;
711
829
  }
712
- for (const rel of rels) await fetchCatalog(config, rel);
713
- return rels.length;
830
+ for (const rel of itemRels) await fetchCatalog(config, rel);
831
+ const alreadyFetched = new Set(itemRels.map((rel) => rel.id));
832
+ let archiveFetched = 0;
833
+ for (const rel of archiveRels) {
834
+ if (alreadyFetched.has(rel.id)) continue;
835
+ await fetchAssetArchive(config, rel);
836
+ archiveFetched++;
837
+ }
838
+ return itemRels.length + archiveFetched;
714
839
  }
715
840
 
716
841
  /**
@@ -64,10 +64,12 @@ import {
64
64
  } from "./note-frontmatter.mjs";
65
65
  import { AddressEntries } from "./address-values.mjs";
66
66
  import { addressPositions } from "./note-addresses.mjs";
67
- import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
67
+ import { authoredFields, readsLegacyKey, readsRetiredTopLevel } from "./field-spec.mjs";
68
68
  import {
69
69
  legacyKeyOf,
70
+ resolveDataProperty,
70
71
  resolveFieldValue,
72
+ retiredTopLevelKey,
71
73
  systemBlock,
72
74
  SYSTEM_BLOCK_KEYS,
73
75
  unknownBlockKeys,
@@ -101,6 +103,7 @@ import {
101
103
  legacyKeyMessage,
102
104
  readAliasedField,
103
105
  retiredAliasMessage,
106
+ retiredTopLevelMessage,
104
107
  sectionRetiredMessage,
105
108
  traitsRetiredMessage,
106
109
  } from "./retired-fields.mjs";
@@ -137,6 +140,19 @@ export const UNIVERSAL_KEYS = Object.freeze(
137
140
  new Set(["packFolder", "pack", "archetype", "templatePriority", "kbcat"]),
138
141
  );
139
142
 
143
+ /**
144
+ * The shared `data:` keys declared for every note type that still have a
145
+ * retiring top-level spelling — `pack` and `packFolder`, the two
146
+ * {@link module:engine/note-vocabulary.SHARED_DATA_FIELDS} entries
147
+ * {@link module:engine/system-block.resolveDataProperty} reads off a tree's
148
+ * bare top level. Unlike a type's own declarations, which the retiring set
149
+ * below derives from `schemas`, these apply to every type at once, so they
150
+ * are named once here rather than attached to one type's vocabulary.
151
+ *
152
+ * @type {readonly string[]}
153
+ */
154
+ const UNIVERSAL_RETIRING_DATA_KEYS = Object.freeze(["pack", "packFolder"]);
155
+
140
156
  /**
141
157
  * The system blocks a build checks, and what each accepts beyond the shared
142
158
  * vocabulary.
@@ -511,6 +527,9 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
511
527
  message:
512
528
  type === "affiliation" && key === "commonSkills" ?
513
529
  "`data.commonSkills` is not an affiliation field; write skill Addresses at `sohl.system.commonSkills`"
530
+ : type === "lore" && key === "event" ?
531
+ "`data.event` is retired — write `data.events`, a list of occurrences, " +
532
+ "instead, each `{ when, until?, recurs? }`; a note may state several"
514
533
  : `"${key}" is not a \`data:\` property declared by ${type}; ` +
515
534
  `the container is closed, so the key reaches no document ` +
516
535
  `and no page` +
@@ -1209,6 +1228,22 @@ export function lintNote(
1209
1228
  const type = String(fm.type ?? "");
1210
1229
  const raw = () => note.raw ?? "";
1211
1230
  const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
1231
+
1232
+ // The keys the closed top-level region refuses in general, but a
1233
+ // declaration still reads at the retiring position — this type's own
1234
+ // fields, which `resolveFieldValue`'s step 3b resolves, plus `pack` and
1235
+ // `packFolder`, declared for every type and resolved the same way by
1236
+ // {@link resolveDataProperty}. Derived ahead of the schema lookup below,
1237
+ // which runs after the closed-region check, so that check can skip
1238
+ // exactly these and nothing else: a key no declaration names stays
1239
+ // refused.
1240
+ const retiringTopLevelKeys = new Set([
1241
+ ...UNIVERSAL_RETIRING_DATA_KEYS,
1242
+ ...authoredFields(schemas?.[currentType(type)] ?? [])
1243
+ .filter((field) => field.topLevelMeans === undefined)
1244
+ .map((field) => retiredTopLevelKey(field))
1245
+ .filter((key) => key !== undefined),
1246
+ ]);
1212
1247
  if (type === "being") {
1213
1248
  const archetypes = fm.data?.archetypes;
1214
1249
  if (
@@ -1589,6 +1624,9 @@ export function lintNote(
1589
1624
 
1590
1625
  for (const key of authoredNoteKeys(raw())) {
1591
1626
  if (NOTE_TOP_LEVEL_KEY_SET.has(key)) continue;
1627
+ // Reported below, as the retiring position it is rather than a key
1628
+ // nobody recognises.
1629
+ if (retiringTopLevelKeys.has(key)) continue;
1592
1630
  const position = positionInFrontmatter(raw(), key, undefined, { topLevel: true });
1593
1631
  if (
1594
1632
  findings.some(
@@ -1610,6 +1648,28 @@ export function lintNote(
1610
1648
  });
1611
1649
  }
1612
1650
 
1651
+ // `pack` and `packFolder` are declared for every type, so each is
1652
+ // checked once here rather than in the per-type `fields` loop below,
1653
+ // which only iterates a type's own declarations. A **warning**, for the
1654
+ // reason the per-type retiring position above is one: the note compiles
1655
+ // to the correct document either way, so failing a build over it would
1656
+ // red a tree that has done nothing wrong yet. Checked against every
1657
+ // configured system block, because `resolveDataProperty` reads a
1658
+ // system's own override before the top level, and a note may configure
1659
+ // more than one.
1660
+ for (const key of UNIVERSAL_RETIRING_DATA_KEYS) {
1661
+ const retiring = Object.keys(systems ?? {}).some(
1662
+ (blockName) => resolveDataProperty(fm, blockName, key).from === "topLevel",
1663
+ );
1664
+ if (!retiring) continue;
1665
+ findings.push({
1666
+ file: note.file,
1667
+ ...at(key),
1668
+ severity: "warning",
1669
+ message: retiredTopLevelMessage({ name: `data.${key}` }),
1670
+ });
1671
+ }
1672
+
1613
1673
  // The type's charset, before anything that looks the type up. A
1614
1674
  // hyphenated type is unaddressable, and every lookup below would report it
1615
1675
  // as a type nobody declared — true, but not the reason, and it would send
@@ -1827,6 +1887,18 @@ export function lintNote(
1827
1887
  message: legacyKeyMessage("sohl", field),
1828
1888
  });
1829
1889
  }
1890
+ // `readsLegacyKey`'s sibling for the other retiring position — the
1891
+ // note's own top level, which `data:` gathered the fact off. Also a
1892
+ // **warning**: the closed-region check above already let this exact
1893
+ // key through for this exact reason, and the two must agree.
1894
+ if (readsRetiredTopLevel(field, from)) {
1895
+ findings.push({
1896
+ file: note.file,
1897
+ ...at(retiredTopLevelKey(field)),
1898
+ severity: "warning",
1899
+ message: retiredTopLevelMessage(field),
1900
+ });
1901
+ }
1830
1902
  const absent = from === "default" || value === undefined || value === null;
1831
1903
  // Where the field belongs, as a message names it: a shared field is not
1832
1904
  // under `sohl:`, so telling an author to write `sohl.img` would send
@@ -7,15 +7,15 @@
7
7
  * # The Harbor {#harbor .wide}
8
8
  * ```
9
9
  *
10
- * The index, the link checker, the journal compiler, the caption pass and the
10
+ * The index, the link checker, the journal compiler, the figure pass and the
11
11
  * book all ask what a heading declares, and they have to give the same answer:
12
12
  * an id a surface publishes as an anchor, classes a surface may honour, and
13
13
  * attributes an HTML surface writes onto the element. This is where that
14
14
  * reading lives, so there is one grammar — the same one a `:::` block and a
15
- * `:::caption` are written in.
15
+ * `:::figure` are written in.
16
16
  *
17
17
  * **A leaf, deliberately.** `anchors.mjs` is shared by the compilers, the index
18
- * and the link checker and cannot import any of them; the caption pass is
18
+ * and the link checker and cannot import any of them; the figure pass is
19
19
  * imported by `anchors.mjs` and so cannot import it back. A module that imports
20
20
  * only the attribute grammar can be asked by all of them.
21
21
  *
@@ -38,7 +38,7 @@ const SUFFIX = /^(.*?)\s*\{([^}\n]*)\}\s*$/;
38
38
  /**
39
39
  * A heading's id.
40
40
  *
41
- * Looser than a caption's or a block's, which must begin with a letter: a
41
+ * Looser than a figure's or a block's, which must begin with a letter: a
42
42
  * heading's id is published as an HTML `id` and as a Foundry page anchor, both
43
43
  * of which admit a digit first, and the corpus carries ids that are Foundry
44
44
  * document ids and ids derived from a numbered heading.
@@ -108,9 +108,9 @@ export const WITHHELD_CLASS = "secret";
108
108
  * A Foundry UUID addresses a page and nothing smaller, so a section a link can
109
109
  * reach has to be one: a top-level heading starts a page, and so does any
110
110
  * heading that names an anchor. {@link module:engine/journals.splitPages}
111
- * decides *where* a page begins — a captioned heading and a heading inside a
112
- * fence or a named block are its business — and asks this which headings are
113
- * eligible, so no caller re-decides it.
111
+ * decides *where* a page begins — a heading inside a fence or a named block is
112
+ * its business — and asks this which headings are eligible, so no caller
113
+ * re-decides it.
114
114
  *
115
115
  * @param {{level: number, id: string}} heading - From
116
116
  * {@link scanHeadingAttributes}, or any `{level, id}` pair.
@@ -204,7 +204,7 @@ export function headingAttributesPlugin(md) {
204
204
  * Parse one line as a heading, and say whether it starts a journal page.
205
205
  *
206
206
  * The single reading of that rule. {@link module:engine/journals.splitPages}
207
- * builds its pages from it; `scanBlocks` and `scanCaptions` refuse a heading
207
+ * builds its pages from it; `scanBlocks` and `scanFigures` refuse a heading
208
208
  * that starts a page written where it cannot become one. All three ask this, so
209
209
  * none of them can disagree about what a heading line means or about what starts
210
210
  * a page — and the attribute block comes off here, so a class beside an anchor
@@ -240,24 +240,37 @@ export function parseHeadingLine(line) {
240
240
  * builds the pages from this, and the passes that honour a class on a
241
241
  * page-opening heading ask the same question of the same answer.
242
242
  *
243
- * A caption also opens a page, which is `splitPages`' business: a caption is
244
- * found by the caption pass, and a heading it captions is listed here and
245
- * dropped there.
243
+ * A figure fence also opens a page, which is `splitPages`' business: a fence is
244
+ * found by the figure pass, and the page it opens is named for its label.
246
245
  *
247
246
  * @param {string} source - A note's markdown body.
247
+ * @param {ReadonlySet<number>} [blockLines] - The 0-based lines sitting inside
248
+ * a named block, supplied by the caller. A named block is read by
249
+ * {@link module:engine/content-blocks.scanBlocks}, which knows every name in
250
+ * the registry, reads an opener's attribute block, and tracks nesting by
251
+ * depth — so the caller that already holds that answer passes it rather than
252
+ * a second reading of the same lines being kept here.
248
253
  * @returns {Map<number, {level: number, line: number, text: string, id: string,
249
254
  * classes: string[], values: Record<string, string>}>} Keyed by 0-based line.
250
255
  */
251
- export function pageOpenings(source) {
256
+ export function pageOpenings(source, blockLines = new Set()) {
252
257
  const lines = String(source ?? "").split("\n");
253
258
  const openings = new Map();
254
- let inCodeBlock = false;
255
- let inBlock = false;
259
+ let codeFence = null;
256
260
  for (const [index, line] of lines.entries()) {
257
- if (line.trim().startsWith("```")) inCodeBlock = !inCodeBlock;
258
- if (!inCodeBlock && /^:::secret[ \t]*$/.test(line)) inBlock = true;
259
- else if (!inCodeBlock && /^:::[ \t]*$/.test(line)) inBlock = false;
260
- if (inCodeBlock || inBlock) continue;
261
+ // A fence's closer must carry the same character as its opener and
262
+ // be at least as long, same as {@link scanHeadingAttributes} tracks
263
+ // it below — a longer fence is what lets a fenced example carry a
264
+ // shorter fence of its own as literal content.
265
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
266
+ if (codeFence) {
267
+ if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
268
+ codeFence = null;
269
+ }
270
+ } else if (fence) {
271
+ codeFence = fence[1];
272
+ }
273
+ if (codeFence !== null || blockLines.has(index)) continue;
261
274
  const heading = parseHeadingLine(line);
262
275
  if (!heading?.startsPage) continue;
263
276
  openings.set(index, { ...heading, line: index + 1 });
@@ -42,8 +42,15 @@ import footnotePlugin from "markdown-it-footnote";
42
42
  import deflistPlugin from "markdown-it-deflist";
43
43
  import { iconPlugin } from "./content-icons.mjs";
44
44
  import { imagePlugin, imagesIn } from "./content-images.mjs";
45
- import { resolveEmbeds } from "./content-embeds.mjs";
46
- import { foundryAddressProblem, pathnameProblem, resolvePathname } from "./pathnames.mjs";
45
+ import { embedRole, resolveEmbeds } from "./content-embeds.mjs";
46
+ import { collectAssetRecords } from "./asset-index.mjs";
47
+ import { assetImageInfoByPathname } from "./art-fields.mjs";
48
+ import {
49
+ assetPathnameKey,
50
+ foundryAddressProblem,
51
+ pathnameProblem,
52
+ resolvePathname,
53
+ } from "./pathnames.mjs";
47
54
  import log from "loglevel";
48
55
 
49
56
  import { loadPackConfig } from "./pack-config.mjs";
@@ -67,7 +74,7 @@ import { DERIVED_PACKED_TYPES, NEVER_PACKED_TYPES } from "./note-claims.mjs";
67
74
  import { expandContentTables } from "./content-tables.mjs";
68
75
  import { renderBlocks } from "./content-blocks.mjs";
69
76
  import { headingAttributesPlugin } from "./heading-attributes.mjs";
70
- import { renderCaptionBlocks, scanCaptions } from "./content-captions.mjs";
77
+ import { renderFigureBlocks, scanFigures } from "./content-figures.mjs";
71
78
  import { positionInBody } from "./diagnostics.mjs";
72
79
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
73
80
  // registry can import them without reaching back through this one.
@@ -84,6 +91,9 @@ export {
84
91
  parseValueDesc,
85
92
  } from "./frontmatter.mjs";
86
93
 
94
+ /** @type {Map<string, object>|undefined} */
95
+ let localAssetImageInfoCache;
96
+
87
97
  /**
88
98
  * The markdown renderer every surface shares.
89
99
  *
@@ -117,25 +127,65 @@ export const md = markdownit({ html: true })
117
127
  // serves a file from inside the install, so a body image's address is
118
128
  // translated by the same rule `img:` follows.
119
129
  .use(
120
- imagePlugin((src) => {
121
- // **Reported upstream, never here.** A renderer has no channel to
122
- // report through, and this one runs inside the very passes whose
123
- // job is to collect findings — a throw would take the whole lint
124
- // down and lose every other finding in the tree. So nothing reaches
125
- // this point unreported: {@link convertNoteWikilinks} refuses a body
126
- // image with no address inside the install before a compiler renders
127
- // one, and the address passes report the same pathname with a line
128
- // and a column. What is left here is a fallback for a caller with no
129
- // configuration to resolve against, where the authored pathname is
130
- // the most honest thing to emit.
131
- try {
132
- return resolveImg(src, loadPackConfig()) ?? src;
133
- } catch {
134
- return src;
135
- }
136
- }),
130
+ imagePlugin(
131
+ (src) => {
132
+ // **Reported upstream, never here.** A renderer has no channel to
133
+ // report through, and this one runs inside the very passes whose
134
+ // job is to collect findings — a throw would take the whole lint
135
+ // down and lose every other finding in the tree. So nothing reaches
136
+ // this point unreported: {@link convertNoteWikilinks} refuses a body
137
+ // image with no address inside the install before a compiler renders
138
+ // one, and the address passes report the same pathname with a line
139
+ // and a column. What is left here is a fallback for a caller with no
140
+ // configuration to resolve against, where the authored pathname is
141
+ // the most honest thing to emit.
142
+ try {
143
+ return resolveImg(src, loadPackConfig()) ?? src;
144
+ } catch {
145
+ return src;
146
+ }
147
+ },
148
+ (src) => {
149
+ // A body image is free to write the bare, own-package form,
150
+ // so the address is normalized to the pathname the cache
151
+ // below keys by before it is looked up.
152
+ try {
153
+ const key = assetPathnameKey(src, loadPackConfig());
154
+ return key ? localAssetImageInfo().get(key) : undefined;
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ },
159
+ ),
137
160
  );
138
161
 
162
+ /**
163
+ * The pictures this package's own tree ships, by the pathname their address
164
+ * resolves to — read lazily and cached for the process, exactly as
165
+ * {@link loadPackConfig} is, since one compile reads one tree once.
166
+ *
167
+ * **Local only.** A picture embedded from a dependency carries no role or
168
+ * pixel size here: fetching a foreign package's own asset index to size one
169
+ * inline Foundry image is a cost this lazy cache does not pay, and the
170
+ * picture simply draws at the medium's ordinary size, as it always has.
171
+ *
172
+ * @returns {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
173
+ */
174
+ function localAssetImageInfo() {
175
+ if (localAssetImageInfoCache) return localAssetImageInfoCache;
176
+ try {
177
+ const config = loadPackConfig();
178
+ const records = collectAssetRecords(config.paths.assets, {
179
+ contentPackage: config.contentPackage,
180
+ problems: [],
181
+ });
182
+ localAssetImageInfoCache = assetImageInfoByPathname(records);
183
+ } catch {
184
+ localAssetImageInfoCache = new Map();
185
+ }
186
+ return localAssetImageInfoCache;
187
+ }
188
+
139
189
  md.renderer.rules.footnote_block_open = () =>
140
190
  '<section class="footnotes"><h2>Footnotes</h2><ol class="footnotes-list">\n';
141
191
  md.renderer.rules.footnote_caption = (tokens, idx, _options, env) => {
@@ -155,11 +205,26 @@ md.renderer.rules.footnote_open = (tokens, idx, options, env, renderer) => {
155
205
  return `<li id="fn${id}" class="footnote-item" value="${number}">`;
156
206
  };
157
207
 
158
- /** Render a note body with Foundry's named-block markup. */
159
- export function renderFoundryMarkdown(body, captions, footnoteNumbers, docId) {
208
+ /**
209
+ * Render a note body with Foundry's named-block markup.
210
+ *
211
+ * @param {string} body - The body, tables expanded and wikilinks resolved.
212
+ * @param {Array<{id: string, label: string}>} [figures] - Labels assigned by
213
+ * a note-wide scan, matched by id — see
214
+ * {@link module:engine/content-figures.renderFigureBlocks}.
215
+ * @param {Map<string, number>} [footnoteNumbers] - Shared across the note.
216
+ * @param {string} [docId] - Threaded into footnote anchors.
217
+ * @param {(address: string) => string|undefined} [resolveRole] - From a
218
+ * picture's address to the role its asset declares — see
219
+ * {@link module:engine/content-figures.scanFigures}.
220
+ * @returns {string} The rendered HTML.
221
+ */
222
+ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, resolveRole) {
160
223
  const blocks = renderBlocks(body, "foundry");
161
- const captioned = renderCaptionBlocks(blocks.markdown, (block) => md.render(block), captions);
162
- return md.render(captioned.markdown, { footnoteNumbers, docId });
224
+ const figured = renderFigureBlocks(blocks.markdown, (block) => md.render(block), figures, {
225
+ resolveRole,
226
+ });
227
+ return md.render(figured.markdown, { footnoteNumbers, docId });
163
228
  }
164
229
 
165
230
  /**
@@ -545,11 +610,11 @@ export function makeFilename(name, id) {
545
610
  * the other.
546
611
  *
547
612
  * **`title` does not follow this rule**, and must not be made to. On a
548
- * `type: affiliation` note `title` is *also* a declared item field whose default
549
- * is `""` (`sohl/item-fields.mjs`), resolved from the very same shared top-level
550
- * key the site emitter reads as the page title — so `title: null` stringifies
551
- * into the compiled document as the literal `"null"`. One key, two destinations
552
- * that disagree about what empty means.
613
+ * `type: affiliation` note `title` is a declared item field with its own
614
+ * default (`""`, in `sohl/item-fields.mjs`), authored only inside the item's
615
+ * own `sohl` block — the closed top-level vocabulary has no `title` entry, so
616
+ * there is no shared key for this function's null-versus-blank distinction to
617
+ * apply to.
553
618
  *
554
619
  * **`banner:` does not follow it either, deliberately.** It is not a file
555
620
  * inside a Foundry install: it reaches no compiled document and no book, its
@@ -941,7 +1006,9 @@ export function convertNoteWikilinks(
941
1006
  docPack,
942
1007
  index,
943
1008
  captionLabels: new Map(
944
- scanCaptions(source).captions.map((caption) => [caption.id, caption.label]),
1009
+ scanFigures(source, { resolveRole: (address) => embedRole(index, address) })
1010
+ .figures.filter((figure) => figure.id)
1011
+ .map((figure) => [figure.id, figure.label]),
945
1012
  ),
946
1013
  });
947
1014
  /**
@@ -112,11 +112,13 @@ export function compilesSystemDocument(fm, map, router) {
112
112
  * @param {object} [options.router] - The pack router deciding which system
113
113
  * compiles a document for this note. Defaults to the consuming repository's.
114
114
  * @param {object} [options.dates] - Calendar conversion context.
115
+ * @param {string} [options.contentPackage] - This build's content package,
116
+ * the default a note box's short-form Address resolves against.
115
117
  * @returns {object[]} The boxes, in the order every medium renders them.
116
118
  * @throws {Error} When the built set disagrees with what the note's type maps
117
119
  * to — see {@link module:engine/infobox.assertInfoboxSet}.
118
120
  */
119
- export function noteInfoboxes(fm, { resolve, router = packRouter(), dates } = {}) {
121
+ export function noteInfoboxes(fm, { resolve, router = packRouter(), dates, contentPackage } = {}) {
120
122
  const boxes = buildInfoboxes(fm, {
121
123
  maps: KNOWN_DOCUMENT_SUBTYPE_MAPS,
122
124
  providers: KNOWN_INFOBOXES,
@@ -124,6 +126,7 @@ export function noteInfoboxes(fm, { resolve, router = packRouter(), dates } = {}
124
126
  resolveField: resolveFieldValue,
125
127
  resolve,
126
128
  dates,
129
+ contentPackage,
127
130
  });
128
131
  return /** @type {object[]} */ (
129
132
  assertInfoboxSet(boxes, fm, { maps: KNOWN_DOCUMENT_SUBTYPE_MAPS })