@heroiclands/package-build 22.22.0 → 22.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +51 -14
  4. package/ci/content-format-reference.mjs +1 -1
  5. package/content-config.mjs +80 -2
  6. package/docs/api.md +85 -72
  7. package/docs/authoring/assets.md +29 -0
  8. package/docs/authoring/dates-and-calendars.md +37 -1
  9. package/docs/authoring/links-and-markup.md +112 -31
  10. package/docs/commands.md +30 -29
  11. package/docs/configuration.md +92 -19
  12. package/docs/content-format.md +1 -1
  13. package/docs/guides/book.md +5 -4
  14. package/docs/guides/module-project.md +2 -2
  15. package/docs/guides/site.md +41 -0
  16. package/docs/project-setup.md +4 -4
  17. package/docs/reference/format-details.md +323 -60
  18. package/docs/reference/note-types.md +9 -9
  19. package/docs/reference/theme-pages.md +496 -0
  20. package/engine/anchors.mjs +16 -10
  21. package/engine/art-fields.mjs +118 -0
  22. package/engine/art-slots.mjs +9 -2
  23. package/engine/asset-index.mjs +160 -10
  24. package/engine/asset-replacement.mjs +93 -0
  25. package/engine/asset-types.mjs +240 -0
  26. package/engine/base-compiler.mjs +54 -2
  27. package/engine/bundles.mjs +13 -1
  28. package/engine/content-blocks.mjs +4 -4
  29. package/engine/content-embeds.mjs +19 -0
  30. package/engine/content-figures.mjs +468 -0
  31. package/engine/content-footnotes.mjs +10 -2
  32. package/engine/content-format.yaml +1 -1
  33. package/engine/content-images.mjs +126 -25
  34. package/engine/content-index.mjs +36 -10
  35. package/engine/field-reference.mjs +31 -10
  36. package/engine/field-spec.mjs +10 -8
  37. package/engine/foreign-catalog.mjs +145 -19
  38. package/engine/frontmatter-lint.mjs +73 -1
  39. package/engine/heading-attributes.mjs +31 -18
  40. package/engine/helpers.mjs +102 -33
  41. package/engine/infobox-registry.mjs +4 -1
  42. package/engine/infobox.mjs +47 -8
  43. package/engine/itinerary-scenes.mjs +4 -10
  44. package/engine/journals.mjs +264 -39
  45. package/engine/lore-events.mjs +197 -0
  46. package/engine/markdown-expressions.mjs +257 -7
  47. package/engine/note-dates.mjs +305 -12
  48. package/engine/note-vocabulary.mjs +11 -5
  49. package/engine/pathnames.mjs +36 -0
  50. package/engine/pdf-build.mjs +357 -34
  51. package/engine/pdf-images.mjs +64 -0
  52. package/engine/pdf-render.mjs +160 -36
  53. package/engine/scenes.mjs +16 -4
  54. package/engine/site-build.mjs +127 -13
  55. package/engine/site-config.mjs +19 -24
  56. package/engine/site-index.mjs +72 -1
  57. package/engine/site-preview.mjs +2 -0
  58. package/engine/system-block.mjs +44 -16
  59. package/hm3/actors.mjs +2 -2
  60. package/hugo-theme/data/banners.yaml +69 -0
  61. package/hugo-theme/layouts/404.html +85 -0
  62. package/hugo-theme/layouts/_default/baseof.html +64 -0
  63. package/hugo-theme/layouts/_default/list.html +139 -0
  64. package/hugo-theme/layouts/_default/single.html +4 -0
  65. package/hugo-theme/layouts/blog/list.html +54 -0
  66. package/hugo-theme/layouts/blog-post/single.html +36 -0
  67. package/hugo-theme/layouts/index.html +75 -0
  68. package/hugo-theme/layouts/partials/banner-url.html +117 -0
  69. package/hugo-theme/layouts/partials/cdn-url.html +36 -0
  70. package/hugo-theme/layouts/partials/draft-notice.html +41 -0
  71. package/hugo-theme/layouts/partials/draft-state.html +26 -0
  72. package/hugo-theme/layouts/partials/footer.html +29 -0
  73. package/hugo-theme/layouts/partials/header.html +35 -0
  74. package/hugo-theme/layouts/partials/hero-banner.html +86 -0
  75. package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
  76. package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
  77. package/hugo-theme/layouts/partials/holdings.html +61 -0
  78. package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
  79. package/hugo-theme/layouts/partials/infobox/items.html +26 -0
  80. package/hugo-theme/layouts/partials/infobox/section.html +56 -0
  81. package/hugo-theme/layouts/partials/infobox/value.html +43 -0
  82. package/hugo-theme/layouts/partials/infobox.html +57 -0
  83. package/hugo-theme/layouts/partials/page-body.html +154 -0
  84. package/hugo-theme/layouts/partials/place-map.html +9 -0
  85. package/hugo-theme/layouts/partials/related.html +121 -0
  86. package/hugo-theme/layouts/partials/search.html +84 -0
  87. package/hugo-theme/layouts/partials/site-url.html +32 -0
  88. package/hugo-theme/layouts/partials/tags.html +30 -0
  89. package/hugo-theme/layouts/partials/toc.html +28 -0
  90. package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
  91. package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
  92. package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
  93. package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
  94. package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
  95. package/hugo-theme/static/css/sohl-icons.css +57 -0
  96. package/hugo-theme/static/css/style.css +2953 -0
  97. package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
  98. package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
  99. package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
  100. package/hugo-theme/theme.toml +11 -0
  101. package/manifest.mjs +30 -24
  102. package/package.json +2 -1
  103. package/packagebuild-metadata.jsonl +13 -13
  104. package/sohl/item-fields.mjs +4 -3
  105. package/types/content-config.d.mts +48 -1
  106. package/types/engine/anchors.d.mts +9 -4
  107. package/types/engine/art-fields.d.mts +64 -0
  108. package/types/engine/art-slots.d.mts +2 -1
  109. package/types/engine/asset-index.d.mts +43 -0
  110. package/types/engine/asset-replacement.d.mts +22 -0
  111. package/types/engine/asset-types.d.mts +18 -0
  112. package/types/engine/base-compiler.d.mts +0 -17
  113. package/types/engine/content-embeds.d.mts +16 -0
  114. package/types/engine/content-figures.d.mts +101 -0
  115. package/types/engine/content-footnotes.d.mts +7 -0
  116. package/types/engine/content-images.d.mts +78 -12
  117. package/types/engine/content-index.d.mts +8 -2
  118. package/types/engine/field-reference.d.mts +21 -0
  119. package/types/engine/field-spec.d.mts +20 -16
  120. package/types/engine/foreign-catalog.d.mts +39 -0
  121. package/types/engine/heading-attributes.d.mts +13 -8
  122. package/types/engine/helpers.d.mts +28 -10
  123. package/types/engine/infobox-registry.d.mts +4 -1
  124. package/types/engine/infobox.d.mts +6 -0
  125. package/types/engine/journals.d.mts +31 -9
  126. package/types/engine/lore-events.d.mts +14 -0
  127. package/types/engine/markdown-expressions.d.mts +23 -2
  128. package/types/engine/note-dates.d.mts +64 -1
  129. package/types/engine/pathnames.d.mts +26 -0
  130. package/types/engine/pdf-build.d.mts +28 -6
  131. package/types/engine/pdf-images.d.mts +52 -0
  132. package/types/engine/pdf-render.d.mts +12 -0
  133. package/types/engine/site-build.d.mts +14 -3
  134. package/types/engine/site-config.d.mts +9 -9
  135. package/types/engine/site-index.d.mts +54 -0
  136. package/types/engine/system-block.d.mts +26 -2
  137. package/types/hm3/actors.d.mts +18 -0
  138. package/types/manifest.d.mts +19 -23
  139. package/engine/content-captions.mjs +0 -230
  140. package/types/engine/content-captions.d.mts +0 -58
@@ -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
@@ -56,7 +62,6 @@ import os from "node:os";
56
62
  import path from "node:path";
57
63
 
58
64
  import { unzipSync } from "fflate";
59
- import { extractPack } from "@foundryvtt/foundryvtt-cli";
60
65
 
61
66
  import log from "loglevel";
62
67
 
@@ -73,17 +78,22 @@ import {
73
78
  const STAMP = ".complete";
74
79
 
75
80
  /**
76
- * Every declared relationship that opted into supplying an item catalogue.
81
+ * Every declared relationship carrying a given flag, in declaration order.
82
+ *
83
+ * Shared by {@link itemCatalogRelationships} and
84
+ * {@link assetArchiveRelationships}: both read the same shape out of the same
85
+ * walk, filtered on a different key.
77
86
  *
78
87
  * @param {object} config - The resolved build configuration.
88
+ * @param {"itemCatalog"|"assetArchive"} flag - Which boolean to filter on.
79
89
  * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
80
- * The opted-in relationships, in declaration order.
90
+ * The opted-in relationships.
81
91
  */
82
- export function itemCatalogRelationships(config) {
92
+ function flaggedRelationships(config, flag) {
83
93
  const out = [];
84
94
  for (const [kind, entries] of Object.entries(config.relationships ?? {})) {
85
95
  for (const rel of entries ?? []) {
86
- if (rel.itemCatalog) {
96
+ if (rel[flag]) {
87
97
  out.push({
88
98
  id: rel.id,
89
99
  manifest: rel.manifest,
@@ -96,6 +106,29 @@ export function itemCatalogRelationships(config) {
96
106
  return out;
97
107
  }
98
108
 
109
+ /**
110
+ * Every declared relationship that opted into supplying an item catalogue.
111
+ *
112
+ * @param {object} config - The resolved build configuration.
113
+ * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
114
+ * The opted-in relationships, in declaration order.
115
+ */
116
+ export function itemCatalogRelationships(config) {
117
+ return flaggedRelationships(config, "itemCatalog");
118
+ }
119
+
120
+ /**
121
+ * Every declared relationship that opted into supplying its release archive
122
+ * for asset bytes alone, with no item catalogue built from it.
123
+ *
124
+ * @param {object} config - The resolved build configuration.
125
+ * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
126
+ * The opted-in relationships, in declaration order.
127
+ */
128
+ export function assetArchiveRelationships(config) {
129
+ return flaggedRelationships(config, "assetArchive");
130
+ }
131
+
99
132
  /**
100
133
  * The cache directory for one dependency at one version.
101
134
  *
@@ -292,6 +325,8 @@ async function extractItemPacks(id, version, manifest, root, dir) {
292
325
  `cannot supply an item catalogue`,
293
326
  );
294
327
  }
328
+ // Loaded here so importing the content index does not load the extractor.
329
+ const { extractPack } = await import("@foundryvtt/foundryvtt-cli");
295
330
  for (const pack of itemPacks) {
296
331
  const src = resolvePackPath(root, pack.path);
297
332
  if (!src) {
@@ -366,15 +401,21 @@ export function pinnedManifestUrl(url, verified) {
366
401
  }
367
402
 
368
403
  /**
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.
404
+ * Resolve a dependency's manifest, pinned to `compatibility.verified` the way
405
+ * both fetch paths require.
406
+ *
407
+ * Shared by {@link fetchCatalog} and {@link fetchAssetArchive}: both read the
408
+ * same manifest before deciding what to unpack from it.
409
+ *
410
+ * @param {{id: string, manifest: string, verified?: string}} rel - The
411
+ * declared relationship.
412
+ * @param {string} subject - What floats when no version is verified —
413
+ * "catalogue" or "archive" — named in the warning a reader would otherwise
414
+ * get no word for.
415
+ * @returns {Promise<{manifest: object, version: string}>} The manifest and
416
+ * its resolved version.
376
417
  */
377
- export async function fetchCatalog(config, rel) {
418
+ async function resolveManifest(rel, subject) {
378
419
  const { url, pinned } = pinnedManifestUrl(rel.manifest, rel.verified);
379
420
  const manifest = await fetchManifest(url);
380
421
  const version = manifest.version;
@@ -391,10 +432,24 @@ export async function fetchCatalog(config, rel) {
391
432
  }
392
433
  if (!rel.verified) {
393
434
  log.warn(
394
- `${rel.id}: no \`compatibility.verified\`, so its catalogue floats ` +
435
+ `${rel.id}: no \`compatibility.verified\`, so its ${subject} floats ` +
395
436
  `with whatever ${url} currently serves`,
396
437
  );
397
438
  }
439
+ return { manifest, version };
440
+ }
441
+
442
+ /**
443
+ * Fetch one dependency and extract its Item packs.
444
+ *
445
+ * Idempotent: a complete cache for the resolved version is left alone.
446
+ *
447
+ * @param {object} config - The resolved build configuration.
448
+ * @param {{id: string, manifest: string}} rel - The declared relationship.
449
+ * @returns {Promise<string>} The dependency's cache directory.
450
+ */
451
+ export async function fetchCatalog(config, rel) {
452
+ const { manifest, version } = await resolveManifest(rel, "catalogue");
398
453
  const dir = catalogDir(config, rel.id, version);
399
454
  if (isComplete(dir)) {
400
455
  log.info(`${rel.id}@${version}: already cached`);
@@ -420,6 +475,61 @@ export async function fetchCatalog(config, rel) {
420
475
  return dir;
421
476
  }
422
477
 
478
+ /**
479
+ * Whether an asset-archive-only cache is present and complete.
480
+ *
481
+ * No item catalogue is built on this path, so completeness never asks for
482
+ * `item-packs.json` — an asset-only dependency, such as `thalornaaltart`,
483
+ * ships none.
484
+ *
485
+ * @param {string} dir - The dependency's cache directory.
486
+ * @returns {boolean} True when the archive was fetched to completion.
487
+ */
488
+ const isArchiveComplete = (dir) => fs.existsSync(path.join(dir, STAMP));
489
+
490
+ /**
491
+ * Fetch one dependency's release archive for its asset bytes alone, building
492
+ * no item catalogue from it.
493
+ *
494
+ * Idempotent: a complete cache for the resolved version is left alone. Where a
495
+ * relationship also declares `itemCatalog: true`, {@link fetchCatalog} already
496
+ * unpacked this same archive into this same cache directory, and
497
+ * {@link fetchAllCatalogs} does not call this function for it — the two never
498
+ * download the same bytes twice.
499
+ *
500
+ * @param {object} config - The resolved build configuration.
501
+ * @param {{id: string, manifest: string, verified?: string}} rel - The
502
+ * declared relationship.
503
+ * @returns {Promise<string>} The dependency's cache directory.
504
+ */
505
+ export async function fetchAssetArchive(config, rel) {
506
+ const { manifest, version } = await resolveManifest(rel, "archive");
507
+ const dir = catalogDir(config, rel.id, version);
508
+ if (isArchiveComplete(dir)) {
509
+ log.info(`${rel.id}@${version}: already cached`);
510
+ return dir;
511
+ }
512
+
513
+ const download = manifest.download;
514
+ if (!download) {
515
+ throw new Error(`${rel.id}@${version}: its manifest declares no \`download\``);
516
+ }
517
+
518
+ // Rebuild from empty: a previous run may have died partway, and a stale
519
+ // half-tree is worse than no tree.
520
+ fs.rmSync(dir, { recursive: true, force: true });
521
+ const raw = path.join(dir, "package");
522
+ fs.mkdirSync(raw, { recursive: true });
523
+
524
+ log.info(`${rel.id}@${version}: downloading ${download}`);
525
+ await downloadAndUnzip(download, raw);
526
+
527
+ cacheSchemaArtifact(raw, dir);
528
+ // Last, so a fetch that died partway is never mistaken for a complete one.
529
+ fs.writeFileSync(path.join(dir, STAMP), `${version}\n`);
530
+ return dir;
531
+ }
532
+
423
533
  /**
424
534
  * Locate a declared pack inside an unpacked archive.
425
535
  *
@@ -700,17 +810,33 @@ export async function fetchAllMetadata(config) {
700
810
  /**
701
811
  * Fetch every opted-in dependency. The `deps fetch` command.
702
812
  *
813
+ * A relationship declaring both `itemCatalog: true` and `assetArchive: true`
814
+ * is fetched once: {@link fetchCatalog} already unpacks the archive on its
815
+ * way to extracting the item catalogue, so this skips the archive-only fetch
816
+ * for any id {@link fetchCatalog} already handled.
817
+ *
703
818
  * @param {object} config - The resolved build configuration.
704
819
  * @returns {Promise<number>} How many dependencies were fetched.
705
820
  */
706
821
  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.");
822
+ const itemRels = itemCatalogRelationships(config);
823
+ const archiveRels = assetArchiveRelationships(config);
824
+ if (!itemRels.length && !archiveRels.length) {
825
+ log.info(
826
+ "No relationship declares `itemCatalog: true` or `assetArchive: true`; " +
827
+ "nothing to fetch.",
828
+ );
710
829
  return 0;
711
830
  }
712
- for (const rel of rels) await fetchCatalog(config, rel);
713
- return rels.length;
831
+ for (const rel of itemRels) await fetchCatalog(config, rel);
832
+ const alreadyFetched = new Set(itemRels.map((rel) => rel.id));
833
+ let archiveFetched = 0;
834
+ for (const rel of archiveRels) {
835
+ if (alreadyFetched.has(rel.id)) continue;
836
+ await fetchAssetArchive(config, rel);
837
+ archiveFetched++;
838
+ }
839
+ return itemRels.length + archiveFetched;
714
840
  }
715
841
 
716
842
  /**
@@ -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,16 +610,18 @@ 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
- * inside a Foundry install: it reaches no compiled document and no book, its
556
- * only consumer is the Hugo theme, and the theme resolves it against the site's
557
- * own asset root. See `docs/content-format.md`.
620
+ * inside a Foundry install: it reaches no compiled document, and the site build
621
+ * resolves it through the website's own resolver before a page is written, so
622
+ * the theme receives a URL rather than an address. The book's section plates
623
+ * read a banner of their own, from the book plan's `presentation.page.banner`
624
+ * rather than from a note. See `docs/guides/site.md`.
558
625
  *
559
626
  * For items, the default is the art paired with the type's builder, reached
560
627
  * through `itemArt()`, which runs the pathname back through this function so a
@@ -941,7 +1008,9 @@ export function convertNoteWikilinks(
941
1008
  docPack,
942
1009
  index,
943
1010
  captionLabels: new Map(
944
- scanCaptions(source).captions.map((caption) => [caption.id, caption.label]),
1011
+ scanFigures(source, { resolveRole: (address) => embedRole(index, address) })
1012
+ .figures.filter((figure) => figure.id)
1013
+ .map((figure) => [figure.id, figure.label]),
945
1014
  ),
946
1015
  });
947
1016
  /**