@heroiclands/package-build 23.1.0 → 23.2.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 (248) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/bin/content-commands.mjs +68 -149
  3. package/bin/package-build.mjs +56 -10
  4. package/ci/check-banners.mjs +42 -6
  5. package/ci/confirm-npm-version.mjs +135 -0
  6. package/ci/content-format-reference.mjs +14 -5
  7. package/config.mjs +1 -0
  8. package/container.mjs +185 -4
  9. package/content-config.mjs +333 -210
  10. package/deploy.mjs +116 -10
  11. package/docs/api.md +238 -246
  12. package/docs/authoring/assets.md +11 -5
  13. package/docs/authoring/frontmatter.md +1 -1
  14. package/docs/authoring/links-and-markup.md +87 -0
  15. package/docs/commands.md +138 -99
  16. package/docs/configuration.md +277 -125
  17. package/docs/content-format.md +2 -0
  18. package/docs/diagnostics.md +48 -13
  19. package/docs/guides/book.md +341 -21
  20. package/docs/guides/e2e.md +13 -2
  21. package/docs/guides/icons.md +14 -4
  22. package/docs/guides/module-project.md +18 -8
  23. package/docs/guides/release-and-deploy.md +54 -6
  24. package/docs/guides/site.md +5 -2
  25. package/docs/project-setup.md +30 -10
  26. package/docs/reference/format-details.md +373 -170
  27. package/docs/reference/note-types.md +128 -109
  28. package/docs/reference/theme-pages.md +31 -6
  29. package/e2e.mjs +140 -28
  30. package/engine/actor-compiler.mjs +22 -31
  31. package/engine/address-charset.mjs +4 -17
  32. package/engine/address-fields.mjs +1 -1
  33. package/engine/address-render.mjs +12 -1
  34. package/engine/address-values.mjs +6 -0
  35. package/engine/address.mjs +46 -33
  36. package/engine/art-fields.mjs +30 -5
  37. package/engine/art-slots.mjs +10 -1
  38. package/engine/asset-bindings.mjs +12 -1
  39. package/engine/base-compiler.mjs +34 -65
  40. package/engine/being-age.mjs +2 -7
  41. package/engine/being-measurements.mjs +12 -1
  42. package/engine/calendar-artifacts.mjs +6 -0
  43. package/engine/calendar-choice.mjs +12 -1
  44. package/engine/calendar-format.mjs +12 -1
  45. package/engine/calendar-human.mjs +12 -1
  46. package/engine/compile-corpus.mjs +3 -1
  47. package/engine/content-address.mjs +9 -12
  48. package/engine/content-alerts.mjs +13 -1
  49. package/engine/content-blocks.mjs +14 -3
  50. package/engine/content-embeds.mjs +11 -3
  51. package/engine/content-figures.mjs +9 -0
  52. package/engine/content-footnotes.mjs +12 -1
  53. package/engine/content-format-check.mjs +11 -262
  54. package/engine/content-format.yaml +5 -2
  55. package/engine/content-images.mjs +3 -0
  56. package/engine/content-index.mjs +443 -20
  57. package/engine/content-links.mjs +29 -35
  58. package/engine/content-lint.mjs +2 -3
  59. package/engine/content-poetry.mjs +12 -1
  60. package/engine/content-slug.mjs +2 -14
  61. package/engine/content-spans.mjs +13 -1
  62. package/engine/content-tables.mjs +12 -1
  63. package/engine/culture-choice.mjs +210 -13
  64. package/engine/data-keys.mjs +19 -16
  65. package/engine/date-conversion.mjs +12 -1
  66. package/engine/declaration-check.mjs +9 -0
  67. package/engine/derived-fields.mjs +6 -7
  68. package/engine/diagnostics.mjs +34 -4
  69. package/engine/docs-checks.mjs +9 -0
  70. package/engine/document-subtypes.mjs +47 -34
  71. package/engine/event-views.mjs +139 -14
  72. package/engine/exported-scene.mjs +92 -28
  73. package/engine/extension-attributes.mjs +12 -1
  74. package/engine/field-spec.mjs +11 -102
  75. package/engine/folder-notes.mjs +0 -64
  76. package/engine/foreign-catalog.mjs +2 -1
  77. package/engine/format-generated.mjs +9 -0
  78. package/engine/foundry-entries.mjs +10 -28
  79. package/engine/frontmatter-lint.mjs +83 -392
  80. package/engine/frontmatter.mjs +20 -70
  81. package/engine/generate.mjs +10 -15
  82. package/engine/heading-attributes.mjs +12 -1
  83. package/engine/helpers.mjs +41 -102
  84. package/engine/holdings.mjs +1 -1
  85. package/engine/homepage.mjs +3 -10
  86. package/engine/ids.mjs +1 -167
  87. package/engine/index-records.mjs +6 -4
  88. package/engine/index.mjs +0 -3
  89. package/engine/infobox-render.mjs +15 -3
  90. package/engine/infobox.mjs +13 -14
  91. package/engine/item-compiler.mjs +4 -10
  92. package/engine/item-docs.mjs +3 -5
  93. package/engine/item-registry.mjs +2 -10
  94. package/engine/journals.mjs +1 -5
  95. package/engine/literature-notes.mjs +4 -12
  96. package/engine/macros.mjs +1 -2
  97. package/engine/map-graphviz.mjs +12 -1
  98. package/engine/markdown-expressions.mjs +12 -1
  99. package/engine/markdownlint-run.mjs +567 -0
  100. package/engine/metadata-index.mjs +42 -12
  101. package/engine/note-addresses.mjs +143 -35
  102. package/engine/note-claims.mjs +14 -28
  103. package/engine/note-dates.mjs +3 -3
  104. package/engine/note-frontmatter.mjs +6 -0
  105. package/engine/note-ids.mjs +8 -14
  106. package/engine/note-package.mjs +8 -25
  107. package/engine/note-state.mjs +1 -3
  108. package/engine/note-vocabulary.mjs +148 -84
  109. package/engine/number-words.mjs +12 -1
  110. package/engine/pack-config.mjs +15 -25
  111. package/engine/pack-router.mjs +10 -16
  112. package/engine/pathnames.mjs +8 -22
  113. package/engine/pdf-build.mjs +773 -121
  114. package/engine/pdf-coverage.mjs +188 -0
  115. package/engine/pdf-index.mjs +54 -7
  116. package/engine/pdf-pages.mjs +247 -0
  117. package/engine/pdf-render.mjs +114 -4
  118. package/engine/pdf-toc.mjs +29 -32
  119. package/engine/project-init.mjs +46 -6
  120. package/engine/prose-config.mjs +1 -1
  121. package/engine/prose-lint.mjs +52 -88
  122. package/engine/readability-lint.mjs +9 -0
  123. package/engine/reckoning-markers.mjs +12 -1
  124. package/engine/reference-targets.mjs +7 -0
  125. package/engine/runtime-only-fields.mjs +5 -8
  126. package/engine/scene-levels.mjs +2 -2
  127. package/engine/scenes.mjs +1 -1
  128. package/engine/shortcode-references.mjs +7 -0
  129. package/engine/site-build.mjs +30 -16
  130. package/engine/site-index.mjs +10 -16
  131. package/engine/site-preview.mjs +6 -0
  132. package/engine/site-root.mjs +8 -1
  133. package/engine/social-tie-terms.mjs +12 -1
  134. package/engine/social-ties.mjs +3 -0
  135. package/engine/sql-tables.mjs +56 -20
  136. package/engine/standing-terms.mjs +11 -1
  137. package/engine/standings.mjs +11 -27
  138. package/engine/system-block.mjs +39 -141
  139. package/engine/system-vocabulary.mjs +6 -0
  140. package/engine/walk-files.mjs +69 -0
  141. package/engine/web-wikilinks.mjs +23 -3
  142. package/engine/wikilink-syntax.mjs +32 -21
  143. package/engine/wikilinks.mjs +27 -16
  144. package/engine/yaml-lint.mjs +7 -4
  145. package/githooks/attribution.mjs +8 -0
  146. package/githooks/commit-message.mjs +9 -0
  147. package/hm3/actors.mjs +9 -48
  148. package/hm3/default-item-art.mjs +9 -9
  149. package/hm3/item-fields.mjs +2 -9
  150. package/hm3/template-priority.mjs +6 -7
  151. package/hugo-theme/data/banner-kinds.yaml +78 -0
  152. package/hugo-theme/data/banners.yaml +2 -3
  153. package/hugo-theme/layouts/404.html +3 -3
  154. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  155. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  156. package/hugo-theme/layouts/partials/related.html +1 -1
  157. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  158. package/hugo-theme/static/css/style.css +1 -1
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +83 -18
  163. package/sohl/actors.mjs +16 -27
  164. package/sohl/default-item-art.mjs +3 -3
  165. package/sohl/document-subtypes.mjs +1 -16
  166. package/sohl/infobox.mjs +2 -3
  167. package/sohl/item-builders.mjs +5 -8
  168. package/sohl/item-fields.mjs +5 -35
  169. package/sohl/items.mjs +0 -4
  170. package/sohl/kb-passes.mjs +3 -3
  171. package/types/container.d.mts +116 -5
  172. package/types/content-config.d.mts +83 -35
  173. package/types/deploy.d.mts +42 -6
  174. package/types/e2e.d.mts +35 -3
  175. package/types/engine/address-charset.d.mts +4 -17
  176. package/types/engine/address.d.mts +17 -8
  177. package/types/engine/art-fields.d.mts +7 -4
  178. package/types/engine/art-slots.d.mts +6 -0
  179. package/types/engine/base-compiler.d.mts +15 -17
  180. package/types/engine/content-address.d.mts +9 -12
  181. package/types/engine/content-embeds.d.mts +4 -2
  182. package/types/engine/content-format-check.d.mts +0 -64
  183. package/types/engine/content-index.d.mts +82 -3
  184. package/types/engine/content-links.d.mts +2 -4
  185. package/types/engine/culture-choice.d.mts +51 -8
  186. package/types/engine/data-keys.d.mts +2 -2
  187. package/types/engine/derived-fields.d.mts +1 -2
  188. package/types/engine/diagnostics.d.mts +27 -4
  189. package/types/engine/document-subtypes.d.mts +12 -31
  190. package/types/engine/event-views.d.mts +14 -2
  191. package/types/engine/exported-scene.d.mts +3 -2
  192. package/types/engine/field-spec.d.mts +13 -122
  193. package/types/engine/folder-notes.d.mts +0 -39
  194. package/types/engine/foreign-catalog.d.mts +2 -1
  195. package/types/engine/foundry-entries.d.mts +4 -5
  196. package/types/engine/frontmatter-lint.d.mts +2 -2
  197. package/types/engine/frontmatter.d.mts +19 -45
  198. package/types/engine/generate.d.mts +1 -1
  199. package/types/engine/helpers.d.mts +25 -42
  200. package/types/engine/homepage.d.mts +3 -10
  201. package/types/engine/ids.d.mts +0 -114
  202. package/types/engine/index-records.d.mts +6 -4
  203. package/types/engine/index.d.mts +0 -1
  204. package/types/engine/infobox-render.d.mts +7 -1
  205. package/types/engine/infobox.d.mts +3 -2
  206. package/types/engine/item-docs.d.mts +1 -1
  207. package/types/engine/item-registry.d.mts +2 -4
  208. package/types/engine/literature-notes.d.mts +1 -1
  209. package/types/engine/markdownlint-run.d.mts +40 -0
  210. package/types/engine/metadata-index.d.mts +9 -1
  211. package/types/engine/note-addresses.d.mts +27 -2
  212. package/types/engine/note-claims.d.mts +3 -5
  213. package/types/engine/note-ids.d.mts +2 -4
  214. package/types/engine/note-package.d.mts +1 -1
  215. package/types/engine/note-state.d.mts +1 -3
  216. package/types/engine/note-vocabulary.d.mts +28 -21
  217. package/types/engine/pack-config.d.mts +2 -6
  218. package/types/engine/pack-router.d.mts +4 -4
  219. package/types/engine/pathnames.d.mts +3 -3
  220. package/types/engine/pdf-build.d.mts +98 -12
  221. package/types/engine/pdf-coverage.d.mts +60 -0
  222. package/types/engine/pdf-index.d.mts +34 -5
  223. package/types/engine/pdf-pages.d.mts +143 -0
  224. package/types/engine/pdf-render.d.mts +45 -1
  225. package/types/engine/pdf-toc.d.mts +12 -14
  226. package/types/engine/prose-config.d.mts +1 -1
  227. package/types/engine/prose-lint.d.mts +11 -10
  228. package/types/engine/runtime-only-fields.d.mts +2 -2
  229. package/types/engine/site-build.d.mts +9 -5
  230. package/types/engine/site-index.d.mts +4 -5
  231. package/types/engine/site-root.d.mts +1 -1
  232. package/types/engine/sql-tables.d.mts +31 -2
  233. package/types/engine/standing-terms.d.mts +2 -1
  234. package/types/engine/standings.d.mts +7 -9
  235. package/types/engine/system-block.d.mts +10 -44
  236. package/types/engine/walk-files.d.mts +15 -0
  237. package/types/engine/wikilink-syntax.d.mts +18 -19
  238. package/types/engine/yaml-lint.d.mts +5 -4
  239. package/types/hm3/actors.d.mts +1 -2
  240. package/types/hm3/item-fields.d.mts +2 -4
  241. package/types/hm3/template-priority.d.mts +6 -7
  242. package/types/manifest.d.mts +3 -2
  243. package/types/release.d.mts +12 -7
  244. package/types/sohl/actors.d.mts +2 -1
  245. package/types/sohl/document-subtypes.d.mts +1 -2
  246. package/types/sohl/item-fields.d.mts +3 -7
  247. package/engine/retired-fields.mjs +0 -631
  248. package/types/engine/retired-fields.d.mts +0 -354
@@ -86,7 +86,12 @@
86
86
  */
87
87
 
88
88
  import { isAddressSegment } from "./address-charset.mjs";
89
- import { formatDiagnostic, positionOfYamlPath } from "./diagnostics.mjs";
89
+ import {
90
+ formatDiagnostic,
91
+ locateFrontmatterKey,
92
+ positionOfFrontmatterPath,
93
+ positionOfYamlPath,
94
+ } from "./diagnostics.mjs";
90
95
  import {
91
96
  decodeNoteAddresses,
92
97
  decodeIndexAddresses,
@@ -100,11 +105,11 @@ import path from "node:path";
100
105
 
101
106
  import unidecode from "unidecode";
102
107
 
103
- import { metadataFileName } from "./metadata-index.mjs";
108
+ import { loadForeignIndexes, metadataFileName } from "./metadata-index.mjs";
104
109
  import { collectAssetRecords } from "./asset-index.mjs";
105
110
  import { checkForeignAssetBindings } from "./asset-bindings.mjs";
106
111
  import { addressSlug, canonicalKey } from "./content-address.mjs";
107
- import { ownDocumentSystem, readCanonicalKey } from "./address.mjs";
112
+ import { isAddressTuple, ownDocumentSystem, readCanonicalKey, renderAddress } from "./address.mjs";
108
113
  import { NOTE_SYSTEM } from "./systems.mjs";
109
114
  import { assetAddressIndex } from "./art-fields.mjs";
110
115
  import { embedRole } from "./content-embeds.mjs";
@@ -131,10 +136,9 @@ import { walkMarkdownTree } from "./helpers.mjs";
131
136
  // SQL view that derives the ladder from the absence this writes.
132
137
  import { isStubNote } from "./note-state.mjs";
133
138
  import { resolveNoteId } from "./note-ids.mjs";
134
- // The retired-field refusal and the key locator, so a note authoring a derived
135
- // key is reported where it is rather than as a bare abort.
139
+ // The `package:` refusal, so a note declaring one is reported where it is
140
+ // rather than as a bare abort.
136
141
  import { assertNoDeclaredPackage } from "./note-package.mjs";
137
- import { locateFrontmatterKey } from "./retired-fields.mjs";
138
142
  import { loadPackConfig } from "./pack-config.mjs";
139
143
  // The record accessors, which live apart so that a module the compilers load
140
144
  // can read a record without importing this one and closing a cycle.
@@ -392,11 +396,11 @@ function foundryBlock(entry, system) {
392
396
  * carries `file` and a `position`, and a pass that collects rather than throws
393
397
  * can emit `file:line:column: error: …` like any other finding.
394
398
  *
395
- * **`package:` keeps its own words.** It is not a name collision but a *retired
396
- * field*, and the correction is to delete it, not to rename it — which is
397
- * what {@link module:engine/note-package.assertNoDeclaredPackage} has always
398
- * said, and had no caller to say it to. Deferring to it means one message for
399
- * one mistake rather than two that contradict each other about the fix.
399
+ * **`package:` keeps its own words.** It is not a name collision but a field
400
+ * the configuration owns, and the correction is to delete it, not to rename
401
+ * it — which is what {@link module:engine/note-package.assertNoDeclaredPackage}
402
+ * says. Deferring to it means one message for one mistake rather than two that
403
+ * contradict each other about the fix.
400
404
  *
401
405
  * @param {object} frontmatter - The note's parsed frontmatter.
402
406
  * @param {string} relPath - The note's path within the tree, for the message.
@@ -486,9 +490,15 @@ export function buildIndexRecord({
486
490
  const yamlText = fs
487
491
  .readFileSync(absPath, "utf8")
488
492
  .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
489
- const position = positionOfYamlPath(yamlText, error.keyPath, { key: error.addressKey });
490
- if (position.line) position.line++;
491
- error.position = position;
493
+ const locate = (/** @type {any} */ found) => {
494
+ const position = positionOfYamlPath(yamlText, found.keyPath, {
495
+ key: found.addressKey,
496
+ });
497
+ if (position.line) position.line++;
498
+ found.position = position;
499
+ };
500
+ locate(error);
501
+ for (const found of error.findings ?? []) if (found !== error) locate(found);
492
502
  }
493
503
  throw error;
494
504
  }
@@ -683,6 +693,302 @@ export function indexRecordsForNote({
683
693
  return records;
684
694
  }
685
695
 
696
+ /** The `lore` subType that is a culture, and so is its own. */
697
+ const CULTURE_SUBTYPE = "culture";
698
+
699
+ /** The `doc` subType whose culture is its own value. */
700
+ const SETTING_GUIDE_SUBTYPE = "settingguide";
701
+
702
+ /** The `affiliation` subType that also takes the culture of the places it holds. */
703
+ const POLITY_SUBTYPE = "polity";
704
+
705
+ /** The types whose culture is their own value and nothing else. */
706
+ const OWN_CULTURE_TYPES = new Set(["lore", "being", "doc"]);
707
+
708
+ /** The types that inherit a culture through `data.parents`. */
709
+ const INHERITING_TYPES = new Set(["place", "affiliation"]);
710
+
711
+ /** A resolution that yields no culture. */
712
+ const NO_CULTURE = Object.freeze({ culture: undefined, errored: false });
713
+
714
+ /**
715
+ * The canonical key an Address names, without any anchor.
716
+ *
717
+ * @param {unknown} value - A tuple, an {@link AddressLink}, or anything else.
718
+ * @returns {string|undefined} The key, or `undefined` for a value that is not a
719
+ * decoded Address.
720
+ */
721
+ function addressKey(value) {
722
+ const tuple = value instanceof AddressLink ? value.target : value;
723
+ return isAddressTuple(tuple) ? renderAddress(tuple) : undefined;
724
+ }
725
+
726
+ /**
727
+ * The culture Address a value states, as a tuple.
728
+ *
729
+ * @param {unknown} value - A `data.culture` value.
730
+ * @returns {object|undefined} The tuple, or `undefined` when the value is not a
731
+ * decoded Address.
732
+ */
733
+ function cultureTuple(value) {
734
+ const tuple = value instanceof AddressLink ? value.target : value;
735
+ return isAddressTuple(tuple) ? tuple : undefined;
736
+ }
737
+
738
+ /**
739
+ * Write each note's resolved culture into its record, as `data.culture`.
740
+ *
741
+ * A note's culture is:
742
+ *
743
+ * - its own `data.culture`, when it states one;
744
+ * - for a `lore` note of `subType: culture`, its own canonical Address;
745
+ * - for a `place` or an `affiliation`, else the resolved cultures of its
746
+ * `data.parents`;
747
+ * - for a `polity` affiliation, else the resolved cultures of its places: its
748
+ * `data.seat` pooled with every place whose `data.government` names it, as
749
+ * one set;
750
+ * - else nothing. A `being`, a `doc` and any other `lore` note have their own
751
+ * value only; every other type has none.
752
+ *
753
+ * A parent's culture is its own resolved culture, found recursively; a parent
754
+ * resolving to nothing is ignored, as is one in another package that its index
755
+ * gives none. Sources that agree give that culture. Sources that differ, on a
756
+ * note with no value of its own, give it none and one error finding, at
757
+ * `data.parents`, or for a polity's places at `data.seat`, else at its `data:`
758
+ * line. A note whose parent raised that error treats the parent as resolving to
759
+ * nothing, so a disagreement is reported once, where it is. A cycle resolves to
760
+ * nothing.
761
+ *
762
+ * A parent in another package is read through `foreignRecord`, whose published
763
+ * entry already carries that parent's resolved culture, so a foreign chain is
764
+ * never walked.
765
+ *
766
+ * The record's `data` is replaced by a copy carrying the culture, never
767
+ * assigned into, because the note's parsed frontmatter shares that object. The
768
+ * record then carries the resolved culture to every pass that reads it — lint,
769
+ * compile and site alike. A note resolving to nothing gains no `culture` key.
770
+ *
771
+ * @param {Array<Record<string, any>>} records - Index records, every Address a
772
+ * tuple. Records that are not notes are passed over.
773
+ * @param {object} options - Options.
774
+ * @param {string} options.contentPackage - The package the records belong to.
775
+ * @param {string} [options.contentBase] - The content tree, for opening a
776
+ * conflicting note to locate the finding. Without it a finding names the
777
+ * note's path within the tree and no position.
778
+ * @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
779
+ * Another package's entry for a canonical address. Asked only for an
780
+ * immediate parent whose package is not this one.
781
+ * @param {object[]} [options.problems] - Receives each conflict as a finding.
782
+ * Omitted, every conflict is collected and one Error lists them all.
783
+ * @returns {Array<Record<string, any>>} The same records.
784
+ * @throws {Error} When `problems` is omitted and any note's sources disagree.
785
+ */
786
+ export function resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems }) {
787
+ // Each note under every key a link may name it by: its record's canonical
788
+ // address, and its page address in the `note` system, which is what a
789
+ // `data.parents`, `data.seat` or `data.government` link names.
790
+ const notes = new Map();
791
+ const pageKey = new Map();
792
+ for (const record of records) {
793
+ if (!isNoteRecord(record) || !isAddressSegment(String(record.shortcode ?? ""))) continue;
794
+ const page = canonicalKey(contentPackage, NOTE_SYSTEM, record.type, record.shortcode);
795
+ const own =
796
+ record.address ?
797
+ addressKey(record.address.canonical)
798
+ : noteAddress(record, contentPackage)?.canonical;
799
+ pageKey.set(record, page);
800
+ for (const key of [page, own]) if (key && !notes.has(key)) notes.set(key, record);
801
+ }
802
+ const recordOf = (value) => notes.get(addressKey(value));
803
+
804
+ // The places each polity governs, read once from the places that name it.
805
+ const governed = new Map();
806
+ for (const record of pageKey.keys()) {
807
+ if (record.type !== "place") continue;
808
+ const polity = recordOf(record.data?.government);
809
+ if (!polity) continue;
810
+ if (!governed.has(polity)) governed.set(polity, []);
811
+ governed.get(polity).push(record);
812
+ }
813
+
814
+ const memo = new Map();
815
+ const inProgress = new Set();
816
+ const findings = [];
817
+
818
+ /**
819
+ * A source Address's culture: a local note's resolved one, or the one a
820
+ * foreign entry publishes. A foreign item type is published under its
821
+ * system's address as well as its page's, so both are asked for.
822
+ *
823
+ * @param {unknown} value - The Address.
824
+ * @returns {object|undefined} The culture tuple.
825
+ */
826
+ const cultureThrough = (value) => {
827
+ const key = addressKey(value);
828
+ if (!key) return undefined;
829
+ const tuple = value instanceof AddressLink ? value.target : value;
830
+ if (tuple.package === contentPackage) {
831
+ const record = recordOf(tuple);
832
+ return record ? resolve(record).culture : undefined;
833
+ }
834
+ if (!foreignRecord) return undefined;
835
+ const entry =
836
+ foreignRecord(key) ??
837
+ foreignRecord(
838
+ canonicalKey(
839
+ tuple.package,
840
+ ownDocumentSystem(tuple.type),
841
+ tuple.type,
842
+ tuple.shortcode,
843
+ ),
844
+ );
845
+ return cultureTuple(entry?.culture);
846
+ };
847
+
848
+ /**
849
+ * Agree the cultures of a set of sources.
850
+ *
851
+ * @param {Array<unknown>} sources - Addresses, in the order they are named.
852
+ * @returns {{culture?: object, distinct: Array<{key: string, via: unknown}>}}
853
+ * The one culture they agree on, or each distinct culture with the first
854
+ * source it came through.
855
+ */
856
+ const agree = (sources) => {
857
+ const distinct = new Map();
858
+ for (const source of sources) {
859
+ const culture = cultureThrough(source);
860
+ const key = addressKey(culture);
861
+ if (key && !distinct.has(key)) distinct.set(key, { culture, via: source });
862
+ }
863
+ const list = [...distinct].map(([key, { culture, via }]) => ({ key, culture, via }));
864
+ return { culture: list.length === 1 ? list[0].culture : undefined, distinct: list };
865
+ };
866
+
867
+ /**
868
+ * Report a disagreement on one note.
869
+ *
870
+ * @param {Record<string, any>} record - The note.
871
+ * @param {string[]} keyPath - Where the disagreement is written.
872
+ * @param {string} subject - What disagrees, as the message opens.
873
+ * @param {Array<{key: string, via: unknown}>} distinct - The cultures and
874
+ * their sources.
875
+ * @returns {{culture: undefined, errored: true}} The resolution.
876
+ */
877
+ const conflict = (record, keyPath, subject, distinct) => {
878
+ const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
879
+ const raw = contentBase && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined;
880
+ let position = positionOfFrontmatterPath(raw, keyPath, { key: true });
881
+ if (position.line === undefined && keyPath.length > 1)
882
+ position = positionOfFrontmatterPath(raw, ["data"], { key: true });
883
+ const via = (value) => {
884
+ const tuple = value instanceof AddressLink ? value.target : value;
885
+ return tuple.package === contentPackage ? tuple.shortcode : addressKey(tuple);
886
+ };
887
+ const named = distinct.map(({ key, via: source }) => `${key} via ${via(source)}`);
888
+ findings.push({
889
+ file,
890
+ ...position,
891
+ severity: "error",
892
+ message:
893
+ `${subject} resolve to different cultures (${named.join(", ")}); ` +
894
+ `state data.culture`,
895
+ });
896
+ return { culture: undefined, errored: true };
897
+ };
898
+
899
+ /**
900
+ * One note's resolution, by the rules above.
901
+ *
902
+ * @param {Record<string, any>} record - The note's record.
903
+ * @returns {{culture?: object, errored: boolean}} Its culture, and whether
904
+ * it raised a conflict.
905
+ */
906
+ const resolveRecord = (record) => {
907
+ const type = String(record.type ?? "");
908
+ const subType = String(record.subType ?? "").toLowerCase();
909
+ if (type === "lore" && subType === CULTURE_SUBTYPE)
910
+ return { culture: readCanonicalKey(pageKey.get(record)), errored: false };
911
+ if (!OWN_CULTURE_TYPES.has(type) && !INHERITING_TYPES.has(type)) return NO_CULTURE;
912
+ if (type === "doc" && subType !== SETTING_GUIDE_SUBTYPE) return NO_CULTURE;
913
+ const own = cultureTuple(record.data?.culture);
914
+ if (own) return { culture: own, errored: false };
915
+ if (!INHERITING_TYPES.has(type)) return NO_CULTURE;
916
+
917
+ const parents = Array.isArray(record.data?.parents) ? record.data.parents : [];
918
+ const fromParents = agree(parents);
919
+ if (fromParents.distinct.length > 1)
920
+ return conflict(record, ["data", "parents"], "data.parents", fromParents.distinct);
921
+ if (fromParents.culture) return { culture: fromParents.culture, errored: false };
922
+
923
+ if (type !== "affiliation" || subType !== POLITY_SUBTYPE) return NO_CULTURE;
924
+ const seat = addressKey(record.data?.seat) ? record.data.seat : undefined;
925
+ const seatRecord = seat ? recordOf(seat) : undefined;
926
+ const places = [
927
+ ...(seat ? [seat] : []),
928
+ ...(governed.get(record) ?? [])
929
+ .filter((place) => place !== seatRecord)
930
+ .map((place) => pageKey.get(place))
931
+ .sort()
932
+ .map((key) => readCanonicalKey(key)),
933
+ ];
934
+ const fromPlaces = agree(places);
935
+ if (fromPlaces.distinct.length > 1)
936
+ return conflict(
937
+ record,
938
+ seat ? ["data", "seat"] : ["data"],
939
+ seat ?
940
+ "data.seat and the places this polity governs"
941
+ : "the places this polity governs",
942
+ fromPlaces.distinct,
943
+ );
944
+ return fromPlaces.culture ? { culture: fromPlaces.culture, errored: false } : NO_CULTURE;
945
+ };
946
+
947
+ /**
948
+ * A local note's resolution, memoised; a note already being resolved is a
949
+ * cycle and resolves to nothing.
950
+ *
951
+ * @param {Record<string, any>} record - The note's record.
952
+ * @returns {{culture?: object, errored: boolean}} Its resolution.
953
+ */
954
+ const resolve = (record) => {
955
+ if (memo.has(record)) return memo.get(record);
956
+ if (inProgress.has(record)) return NO_CULTURE;
957
+ inProgress.add(record);
958
+ const result = resolveRecord(record);
959
+ inProgress.delete(record);
960
+ memo.set(record, result);
961
+ return result;
962
+ };
963
+
964
+ // Walked in address order, so which note of a cycle is met first is a fact
965
+ // about the content rather than about directory-read order.
966
+ const ordered = [...pageKey].sort(([, a], [, b]) =>
967
+ a < b ? -1
968
+ : a > b ? 1
969
+ : 0,
970
+ );
971
+ for (const [record] of ordered) {
972
+ const { culture } = resolve(record);
973
+ if (culture && addressKey(culture) !== addressKey(record.data?.culture))
974
+ record.data = { ...record.data, culture };
975
+ }
976
+
977
+ findings.sort(
978
+ (a, b) =>
979
+ String(a.file).localeCompare(String(b.file), "en") ||
980
+ (a.line ?? 0) - (b.line ?? 0) ||
981
+ (a.column ?? 0) - (b.column ?? 0),
982
+ );
983
+ if (problems) problems.push(...findings);
984
+ else if (findings.length)
985
+ // Each line already starts with its path, so it is printed unprefixed.
986
+ throw Object.assign(new Error(findings.map(formatDiagnostic).join("\n")), {
987
+ located: true,
988
+ });
989
+ return records;
990
+ }
991
+
686
992
  /**
687
993
  * Read a content tree into index records, in the order they will be written.
688
994
  *
@@ -711,13 +1017,30 @@ export function indexRecordsForNote({
711
1017
  * @param {object[]} [options.problems] - Supplied by a **reader**: a note that
712
1018
  * cannot be recorded is pushed here as a diagnostic and skipped. Omitted, the
713
1019
  * note throws — the contract the emitter needs, since an index missing a note
714
- * asserts that it does not exist.
1020
+ * asserts that it does not exist. The same holds for a culture conflict: a
1021
+ * reader receives each as a finding, and the emitter throws once, listing
1022
+ * every one.
1023
+ * @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
1024
+ * Another package's entry for a canonical address, read for a parent that
1025
+ * package publishes; see {@link resolveCultures}.
1026
+ * @param {Map<string, {body: string, frontmatter: object|null}>} [options.parsed] -
1027
+ * Filled with each note's body and its frontmatter as authored, keyed by the
1028
+ * note's file, so a later pass reads the notes this walk already parsed.
715
1029
  * @returns {Array<Record<string, any>>} The records, in a total order that does
716
1030
  * not depend on directory-read order.
717
1031
  */
718
1032
  export function collectContentIndex(
719
1033
  contentBase,
720
- { contentPackage, skipDirectories, assetsBase, manifest, problems, addressContext },
1034
+ {
1035
+ contentPackage,
1036
+ skipDirectories,
1037
+ assetsBase,
1038
+ manifest,
1039
+ problems,
1040
+ addressContext,
1041
+ foreignRecord,
1042
+ parsed,
1043
+ },
721
1044
  ) {
722
1045
  const records = [];
723
1046
  // Passed through rather than defaulted away: an absent scope is the
@@ -781,8 +1104,16 @@ export function collectContentIndex(
781
1104
  assetsBase ? collectAssetRecords(assetsBase, { contentPackage, problems }) : [];
782
1105
  const assetIndex = assetAddressIndex(assetRecords, { config: { contentPackage } });
783
1106
  const resolveRole = (address) => embedRole(assetIndex, address);
1107
+ const decodeContext = withDeclaredAddresses(
1108
+ addressContext ?? { package: contentPackage },
1109
+ parsedNotes,
1110
+ contentPackage,
1111
+ );
784
1112
 
785
1113
  for (const { frontmatter, body, bodyLine, absPath } of parsedNotes) {
1114
+ // Before the index derives anything onto the frontmatter, so a later
1115
+ // pass reads the note as authored.
1116
+ parsed?.set(absPath, { body, frontmatter: structuredClone(frontmatter) });
786
1117
  const fm = frontmatter ?? {};
787
1118
  applyComputedBeingAge(fm, present, dates);
788
1119
  const relPath = path.relative(contentBase, absPath);
@@ -796,7 +1127,7 @@ export function collectContentIndex(
796
1127
  body,
797
1128
  bodyLine,
798
1129
  manifest,
799
- addressContext,
1130
+ addressContext: decodeContext,
800
1131
  dateContext: dates,
801
1132
  resolveRole,
802
1133
  }),
@@ -819,7 +1150,19 @@ export function collectContentIndex(
819
1150
  // would state that the note does not exist.
820
1151
  if (!problems) throw err;
821
1152
  // A reader, by contrast, reports it and carries on: one malformed
822
- // note must not take every other finding in the tree with it.
1153
+ // note must not take every other finding in the tree with it. A
1154
+ // decode that refused several values reports each at its own
1155
+ // position.
1156
+ if (err.findings?.length) {
1157
+ for (const found of err.findings)
1158
+ problems.push({
1159
+ file: absPath,
1160
+ ...(found.position ?? {}),
1161
+ severity: "error",
1162
+ message: String(found.message),
1163
+ });
1164
+ continue;
1165
+ }
823
1166
  problems.push({
824
1167
  file: absPath,
825
1168
  ...(err.position ?? {}),
@@ -831,6 +1174,11 @@ export function collectContentIndex(
831
1174
  }
832
1175
  }
833
1176
 
1177
+ // Every note record exists and every Address in it is a tuple, so a parent
1178
+ // is found by its canonical address. Before the sort, which reads nothing
1179
+ // the pass writes.
1180
+ resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems });
1181
+
834
1182
  // Sorted at every depth like a note's record, and for the same reason: the
835
1183
  // declaration order of the `asset` fields is a fact about the emitter, not
836
1184
  // about the content, and the artifact is meant to be byte-identical across
@@ -893,6 +1241,61 @@ function needsContentTree(resolved) {
893
1241
  return (resolved.packs ?? []).length > 0;
894
1242
  }
895
1243
 
1244
+ /**
1245
+ * An Address context that answers whether a complete Address is declared.
1246
+ *
1247
+ * A refused shortcode is reported with the lowercase spelling it means when
1248
+ * that spelling names a note in this tree, or something the context's own
1249
+ * `declaresAddress` answers for — a dependency's index. The package, type and
1250
+ * shortcode decide it; the system does not, since one note answers under
1251
+ * several.
1252
+ *
1253
+ * @param {object} context - The Address context the tree is decoded with.
1254
+ * @param {Array<{frontmatter?: object}>} notes - The tree's parsed notes.
1255
+ * @param {string} contentPackage - The package the tree compiles as.
1256
+ * @returns {object} The context, with `declaresAddress`.
1257
+ */
1258
+ function withDeclaredAddresses(context, notes, contentPackage) {
1259
+ const local = new Set(
1260
+ notes
1261
+ .map(({ frontmatter }) => frontmatter)
1262
+ .filter((fm) => typeof fm?.type === "string" && typeof fm?.shortcode === "string")
1263
+ .map((fm) => `${fm.type}\0${fm.shortcode}`),
1264
+ );
1265
+ const beyond = context.declaresAddress;
1266
+ return {
1267
+ ...context,
1268
+ declaresAddress: (tuple) =>
1269
+ (tuple.package === contentPackage && local.has(`${tuple.type}\0${tuple.shortcode}`)) ||
1270
+ Boolean(beyond?.(tuple)),
1271
+ };
1272
+ }
1273
+
1274
+ /**
1275
+ * Whether a complete Address names something a fetched dependency index
1276
+ * declares.
1277
+ *
1278
+ * Read on first use only: it answers for the message of a refused shortcode,
1279
+ * and a tree with none never reads a dependency's index for it.
1280
+ *
1281
+ * @param {object} config - The resolved configuration.
1282
+ * @returns {(tuple: {package: string, type: string, shortcode: string}) => boolean}
1283
+ * The predicate.
1284
+ */
1285
+ function dependencyAddresses(config) {
1286
+ /** @type {Set<string>|undefined} */
1287
+ let known;
1288
+ return (tuple) => {
1289
+ known ??= new Set(
1290
+ [...loadForeignIndexes(config, [config.contentPackage]).index.keys()]
1291
+ .map((key) => readCanonicalKey(key))
1292
+ .filter(Boolean)
1293
+ .map((t) => `${t.package}\0${t.type}\0${t.shortcode}`),
1294
+ );
1295
+ return known.has(`${tuple.package}\0${tuple.type}\0${tuple.shortcode}`);
1296
+ };
1297
+ }
1298
+
896
1299
  /**
897
1300
  * The index records for a content tree, without writing anything.
898
1301
  *
@@ -919,6 +1322,10 @@ function needsContentTree(resolved) {
919
1322
  * aborting the derivation. Omitted, the note throws — which is the contract
920
1323
  * the emitter needs, since an index missing a note asserts that it does not
921
1324
  * exist.
1325
+ * @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] -
1326
+ * Filled with each note's body and authored frontmatter, keyed by the note's
1327
+ * file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
1328
+ * spares that pass reading and parsing every note again.
922
1329
  * @returns {object[]} One record per note, plus one per documentation entry.
923
1330
  */
924
1331
  export function indexRecordsFor({
@@ -927,20 +1334,33 @@ export function indexRecordsFor({
927
1334
  config,
928
1335
  skipDirectories,
929
1336
  problems,
1337
+ parsed,
930
1338
  } = {}) {
931
1339
  const resolved = config ?? loadPackConfig();
932
1340
  const tree = contentBase ?? resolved.paths.content;
933
1341
  if (!fs.existsSync(tree) && needsContentTree(resolved)) {
934
1342
  throw new Error(`no content tree at ${tree}`);
935
1343
  }
1344
+ // Read on the first parent another package publishes, and not before, so
1345
+ // a package whose parents are all its own reads no dependency index.
1346
+ let foreign;
1347
+ const foreignRecord = (key) => {
1348
+ foreign ??= loadForeignIndexes(resolved, [resolved.contentPackage]).index;
1349
+ return foreign.get(key);
1350
+ };
936
1351
  return collectContentIndex(tree, {
937
1352
  contentPackage: resolved.contentPackage,
938
- addressContext: noteAddressContext(resolved),
1353
+ addressContext: {
1354
+ ...noteAddressContext(resolved),
1355
+ declaresAddress: dependencyAddresses(resolved),
1356
+ },
939
1357
  skipDirectories: skipDirectories ?? resolved.skipDirectories,
940
1358
  assetsBase: assetsBase ?? resolved.paths.assets,
941
1359
  // Only the identities a UUID is a function of — see emitContentIndex.
942
1360
  manifest: foundryIdentities(resolved),
943
1361
  problems,
1362
+ foreignRecord,
1363
+ parsed,
944
1364
  });
945
1365
  }
946
1366
 
@@ -985,7 +1405,10 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
985
1405
  // state one.
986
1406
  const records = indexRecordsFor({ contentBase: tree, config: resolved });
987
1407
  const assetBindings = checkForeignAssetBindings(resolved);
988
- if (assetBindings.length) throw new Error(assetBindings.map(formatDiagnostic).join("\n"));
1408
+ if (assetBindings.length)
1409
+ throw Object.assign(new Error(assetBindings.map(formatDiagnostic).join("\n")), {
1410
+ located: true,
1411
+ });
989
1412
  if (records.length === 0) {
990
1413
  throw new Error(
991
1414
  `${tree} yielded no notes, so the index would state that this ` +