@heroiclands/package-build 22.15.0 → 22.16.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 (223) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +39 -678
  3. package/bin/{content-build.mjs → content-commands.mjs} +197 -118
  4. package/bin/package-build.mjs +303 -42
  5. package/bin/report.mjs +1 -1
  6. package/ci/ci-docker.mjs +6 -3
  7. package/ci/ci-steps.mjs +6 -4
  8. package/ci/content-format-reference.mjs +106 -0
  9. package/config.mjs +22 -2
  10. package/content-config.mjs +74 -134
  11. package/docs/api.md +294 -234
  12. package/docs/authoring/assets.md +31 -0
  13. package/docs/authoring/dates-and-calendars.md +33 -0
  14. package/docs/authoring/first-note.md +48 -0
  15. package/docs/authoring/frontmatter.md +40 -0
  16. package/docs/authoring/links-and-markup.md +137 -0
  17. package/docs/commands.md +631 -162
  18. package/docs/configuration.md +176 -140
  19. package/docs/content-format.md +50 -4229
  20. package/docs/design.md +32 -0
  21. package/docs/diagnostics.md +29 -22
  22. package/docs/getting-started.md +21 -877
  23. package/docs/guides/book.md +153 -0
  24. package/docs/guides/e2e.md +143 -0
  25. package/docs/guides/icons.md +126 -0
  26. package/docs/guides/localization.md +120 -0
  27. package/docs/guides/module-project.md +915 -0
  28. package/docs/guides/packs.md +167 -0
  29. package/docs/guides/reference-pages.md +148 -0
  30. package/docs/guides/release-and-deploy.md +114 -0
  31. package/docs/guides/site.md +152 -0
  32. package/docs/guides/system-project.md +45 -0
  33. package/docs/index.md +16 -0
  34. package/docs/project-setup.md +54 -35
  35. package/docs/reference/format-details.md +4420 -0
  36. package/docs/reference/note-types.md +531 -0
  37. package/engine/address-render.mjs +26 -0
  38. package/engine/address.mjs +3 -20
  39. package/engine/anchors.mjs +15 -6
  40. package/engine/asset-bindings.mjs +6 -2
  41. package/engine/base-compiler.mjs +47 -11
  42. package/engine/being-measurements.mjs +70 -0
  43. package/engine/calendar-artifacts.mjs +1 -1
  44. package/engine/calendar-choice.mjs +32 -0
  45. package/engine/calendar-human.mjs +71 -0
  46. package/engine/calendar-notes.mjs +41 -3
  47. package/engine/calendars.mjs +8 -1
  48. package/engine/changelog-lint.mjs +2 -2
  49. package/engine/code-fences.mjs +23 -97
  50. package/engine/compile-corpus.mjs +6 -7
  51. package/engine/content-admonitions.mjs +114 -0
  52. package/engine/content-captions.mjs +181 -0
  53. package/engine/content-embeds.mjs +2 -2
  54. package/engine/content-footnotes.mjs +34 -0
  55. package/engine/content-format-check.mjs +2 -2
  56. package/engine/content-format.mjs +127 -73
  57. package/engine/content-format.yaml +745 -0
  58. package/engine/content-html.mjs +1 -1
  59. package/engine/content-icons.mjs +45 -92
  60. package/engine/content-images.mjs +22 -74
  61. package/engine/content-index.mjs +12 -3
  62. package/engine/content-links.mjs +21 -26
  63. package/engine/content-lint.mjs +1 -5
  64. package/engine/content-secrets.mjs +3 -1
  65. package/engine/content-tables.mjs +47 -1257
  66. package/engine/culture-choice.mjs +32 -0
  67. package/engine/date-conversion.mjs +47 -0
  68. package/engine/declaration-check.mjs +87 -0
  69. package/engine/docs-checks.mjs +140 -0
  70. package/engine/documentation-links.mjs +31 -0
  71. package/engine/exported-scene.mjs +1 -1
  72. package/engine/extension-attributes.mjs +74 -0
  73. package/engine/field-reference.mjs +1 -1
  74. package/engine/field-spec.mjs +4 -0
  75. package/engine/foreign-catalog.mjs +5 -5
  76. package/engine/format-generated.mjs +15 -0
  77. package/engine/foundry-entries.mjs +3 -1
  78. package/engine/frontmatter-lint.mjs +5 -1
  79. package/engine/frontmatter.mjs +10 -0
  80. package/engine/generate.mjs +2 -2
  81. package/engine/helpers.mjs +71 -117
  82. package/engine/homepage.mjs +2 -3
  83. package/engine/index-records.mjs +1 -0
  84. package/engine/index.mjs +18 -3
  85. package/engine/infobox-registry.mjs +3 -1
  86. package/engine/infobox.mjs +38 -20
  87. package/engine/item-compiler.mjs +10 -8
  88. package/engine/item-docs.mjs +7 -6
  89. package/engine/itinerary-scenes.mjs +4 -2
  90. package/engine/journals.mjs +51 -10
  91. package/engine/macros.mjs +1 -1
  92. package/engine/map-build.mjs +1 -1
  93. package/engine/map-dot.mjs +1 -1
  94. package/engine/map-raster.mjs +4 -2
  95. package/engine/markdown-expressions.mjs +117 -0
  96. package/engine/metadata-index.mjs +7 -3
  97. package/engine/note-addresses.mjs +1 -0
  98. package/engine/note-dates.mjs +283 -61
  99. package/engine/note-package.mjs +5 -25
  100. package/engine/note-state.mjs +6 -2
  101. package/engine/note-vocabulary.mjs +74 -19
  102. package/engine/number-words.mjs +75 -0
  103. package/engine/office-holders.mjs +188 -0
  104. package/engine/pack-config.mjs +2 -15
  105. package/engine/pack-router.mjs +24 -5
  106. package/engine/packages.mjs +3 -2
  107. package/engine/pathnames.mjs +21 -24
  108. package/engine/pdf-build.mjs +217 -55
  109. package/engine/pdf-fonts.mjs +2 -2
  110. package/engine/pdf-images.mjs +82 -0
  111. package/engine/pdf-render.mjs +174 -22
  112. package/engine/populations.mjs +3 -2
  113. package/engine/project-init.mjs +499 -0
  114. package/engine/prose-config.mjs +1 -1
  115. package/engine/prose-lint.mjs +7 -6
  116. package/engine/readability-lint.mjs +86 -0
  117. package/engine/reckoning-markers.mjs +205 -93
  118. package/engine/scenes.mjs +5 -2
  119. package/engine/schema-check.mjs +3 -3
  120. package/engine/site-build.mjs +87 -37
  121. package/engine/site-config.mjs +8 -8
  122. package/engine/site-index.mjs +4 -0
  123. package/engine/site-maps.mjs +1 -1
  124. package/engine/site-preview.mjs +19 -8
  125. package/engine/social-ties.mjs +5 -1
  126. package/engine/sql-tables.mjs +92 -22
  127. package/engine/web-wikilinks.mjs +15 -31
  128. package/engine/wikilink-syntax.mjs +4 -1
  129. package/engine/wikilinks.mjs +12 -14
  130. package/hm3/item-fields.mjs +1 -1
  131. package/hm3/items.mjs +12 -45
  132. package/index.mjs +2 -14
  133. package/manifest.mjs +31 -4
  134. package/markdownlint-config.mjs +1 -1
  135. package/package.json +32 -11
  136. package/prettier-config.mjs +1 -1
  137. package/sohl/being-info.mjs +3 -145
  138. package/sohl/index.mjs +1 -1
  139. package/sohl/infobox.mjs +2 -1
  140. package/sohl/item-builders.mjs +3 -27
  141. package/sohl/item-fields.mjs +61 -22
  142. package/sohl/kb-passes.mjs +2 -2
  143. package/sohl/note-schemas.mjs +0 -8
  144. package/stage.mjs +19 -0
  145. package/types/config.d.mts +12 -3
  146. package/types/content-config.d.mts +15 -81
  147. package/types/engine/address-render.d.mts +13 -0
  148. package/types/engine/address.d.mts +2 -11
  149. package/types/engine/anchors.d.mts +3 -4
  150. package/types/engine/asset-bindings.d.mts +9 -2
  151. package/types/engine/base-compiler.d.mts +1 -2
  152. package/types/engine/being-measurements.d.mts +16 -0
  153. package/types/engine/calendar-choice.d.mts +14 -0
  154. package/types/engine/calendar-human.d.mts +4 -0
  155. package/types/engine/calendar-notes.d.mts +2 -0
  156. package/types/engine/calendars.d.mts +5 -3
  157. package/types/engine/code-fences.d.mts +6 -72
  158. package/types/engine/compile-corpus.d.mts +1 -3
  159. package/types/engine/content-admonitions.d.mts +24 -0
  160. package/types/engine/content-captions.d.mts +45 -0
  161. package/types/engine/content-footnotes.d.mts +10 -0
  162. package/types/engine/content-format-check.d.mts +17 -4
  163. package/types/engine/content-format.d.mts +24 -11
  164. package/types/engine/content-icons.d.mts +14 -23
  165. package/types/engine/content-images.d.mts +2 -2
  166. package/types/engine/content-index.d.mts +9 -2
  167. package/types/engine/content-links.d.mts +3 -5
  168. package/types/engine/content-tables.d.mts +11 -140
  169. package/types/engine/culture-choice.d.mts +14 -0
  170. package/types/engine/date-conversion.d.mts +4 -0
  171. package/types/engine/declaration-check.d.mts +24 -0
  172. package/types/engine/docs-checks.d.mts +28 -0
  173. package/types/engine/documentation-links.d.mts +7 -0
  174. package/types/engine/exported-scene.d.mts +6 -2
  175. package/types/engine/extension-attributes.d.mts +14 -0
  176. package/types/engine/field-spec.d.mts +9 -0
  177. package/types/engine/format-generated.d.mts +7 -0
  178. package/types/engine/frontmatter.d.mts +14 -4
  179. package/types/engine/generate.d.mts +1 -1
  180. package/types/engine/helpers.d.mts +37 -92
  181. package/types/engine/homepage.d.mts +2 -3
  182. package/types/engine/index.d.mts +5 -0
  183. package/types/engine/infobox-registry.d.mts +3 -1
  184. package/types/engine/infobox.d.mts +17 -6
  185. package/types/engine/item-compiler.d.mts +4 -4
  186. package/types/engine/item-docs.d.mts +4 -3
  187. package/types/engine/journals.d.mts +10 -7
  188. package/types/engine/map-graphviz.d.mts +1 -1
  189. package/types/engine/markdown-expressions.d.mts +17 -0
  190. package/types/engine/note-dates.d.mts +47 -1
  191. package/types/engine/note-package.d.mts +3 -19
  192. package/types/engine/note-state.d.mts +2 -1
  193. package/types/engine/note-vocabulary.d.mts +9 -0
  194. package/types/engine/number-words.d.mts +4 -0
  195. package/types/engine/office-holders.d.mts +14 -0
  196. package/types/engine/pack-config.d.mts +1 -1
  197. package/types/engine/packages.d.mts +3 -2
  198. package/types/engine/pdf-images.d.mts +38 -0
  199. package/types/engine/populations.d.mts +2 -2
  200. package/types/engine/project-init.d.mts +17 -0
  201. package/types/engine/prose-config.d.mts +1 -1
  202. package/types/engine/prose-lint.d.mts +3 -2
  203. package/types/engine/readability-lint.d.mts +22 -0
  204. package/types/engine/reckoning-markers.d.mts +4 -2
  205. package/types/engine/schema-check.d.mts +3 -3
  206. package/types/engine/site-build.d.mts +38 -28
  207. package/types/engine/site-config.d.mts +2 -2
  208. package/types/engine/site-index.d.mts +4 -0
  209. package/types/engine/site-preview.d.mts +1 -1
  210. package/types/engine/social-ties.d.mts +8 -2
  211. package/types/engine/sql-tables.d.mts +13 -2
  212. package/types/engine/web-wikilinks.d.mts +2 -1
  213. package/types/engine/wikilinks.d.mts +7 -1
  214. package/types/hm3/items.d.mts +5 -6
  215. package/types/labels.d.mts +40 -0
  216. package/types/manifest.d.mts +3 -3
  217. package/types/sohl/actors.d.mts +2 -2
  218. package/types/sohl/being-info.d.mts +3 -43
  219. package/types/sohl/index.d.mts +1 -1
  220. package/types/sohl/infobox.d.mts +5 -2
  221. package/types/sohl/item-fields.d.mts +4 -13
  222. package/types/stage.d.mts +9 -0
  223. package/CONTENT.md +0 -3239
@@ -97,10 +97,18 @@ import {
97
97
  // question, asked the way `checkPlace` and `checkCalendarNote` are.
98
98
  import { checkBeingAge } from "./being-age.mjs";
99
99
  import { checkSocialTies } from "./social-ties.mjs";
100
+ import { checkDatedOffices } from "./office-holders.mjs";
101
+ import { checkCalendarChoice } from "./calendar-choice.mjs";
102
+ import { checkCultureChoice } from "./culture-choice.mjs";
100
103
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
101
104
  import { parseNoteDate } from "./note-dates.mjs";
102
105
  import { reckoningContext } from "./reckoning-markers.mjs";
103
106
  import { checkHeld } from "./holdings.mjs";
107
+ import {
108
+ checkBeingMeasurement,
109
+ parseBeingHeight,
110
+ parseBeingWeight,
111
+ } from "./being-measurements.mjs";
104
112
  import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
105
113
  // What trade a settlement supports — the scale and the check that holds a
106
114
  // value to it, read rather than restated, so the reference below and the
@@ -435,6 +443,9 @@ function checkPlacePurpose(note) {
435
443
  */
436
444
  export const DRAFT_TAG = "draft";
437
445
 
446
+ /** A note intended for the GM rather than the player-facing outputs. */
447
+ export const GM_TAG = "gm";
448
+
438
449
  /**
439
450
  * The tags that **classify** a note, grouped by what they classify.
440
451
  *
@@ -571,6 +582,8 @@ export const DECLARED_TAGS = Object.freeze({
571
582
  }),
572
583
  /** A note's working state, which any note may carry. */
573
584
  state: Object.freeze({ types: null, tags: Object.freeze([DRAFT_TAG]) }),
585
+ /** Who may read a completed note. */
586
+ audience: Object.freeze({ types: null, tags: Object.freeze([GM_TAG]) }),
574
587
  });
575
588
 
576
589
  /**
@@ -657,6 +670,11 @@ export function isDraftNote(fm) {
657
670
  return hasTag(fm, DRAFT_TAG);
658
671
  }
659
672
 
673
+ /** Whether a note is restricted to GM-facing output. */
674
+ export function isGmNote(fm) {
675
+ return hasTag(fm, GM_TAG);
676
+ }
677
+
660
678
  /**
661
679
  * Every note type this toolchain compiles, and the closed vocabulary it
662
680
  * declares.
@@ -700,8 +718,15 @@ export const NOTE_VOCABULARY = Object.freeze({
700
718
  ref: "lore",
701
719
  accepts: ["lore"],
702
720
  describe:
703
- "Lore concerning this being — the people it is of, the standing it " +
704
- "holds, the law it lives under.",
721
+ "Lore concerning this being, such as the standing it holds or the law it lives under.",
722
+ },
723
+ {
724
+ name: "culture",
725
+ ...LINK,
726
+ ref: "lore",
727
+ accepts: ["lore"],
728
+ check: checkCultureChoice,
729
+ describe: "The being's primary culture, as a culture lore note.",
705
730
  },
706
731
  {
707
732
  name: "homes",
@@ -743,6 +768,14 @@ export const NOTE_VOCABULARY = Object.freeze({
743
768
  "When the being was born — a date, or `unknown` where the birth is " +
744
769
  "unrecorded. Absent, the being was never born.",
745
770
  },
771
+ {
772
+ name: "calendar",
773
+ ...LINK,
774
+ ref: "lore",
775
+ accepts: ["lore"],
776
+ check: checkCalendarChoice,
777
+ describe: "Calendar used to display this being's dates.",
778
+ },
746
779
  {
747
780
  name: "died",
748
781
  ...TEXT,
@@ -770,8 +803,20 @@ export const NOTE_VOCABULARY = Object.freeze({
770
803
  "Written by the compiler beside an `age` estimate — the `~` stripped, " +
771
804
  "the magnitude alone. A note never authors this.",
772
805
  },
773
- { name: "height", ...NUM, describe: "Height in metres." },
774
- { name: "weight", ...NUM, describe: "Weight in kilograms." },
806
+ {
807
+ name: "height",
808
+ shape: "metres or feet and inches",
809
+ kind: "string",
810
+ check: (note) => checkBeingMeasurement(note, "height", parseBeingHeight),
811
+ describe: "Height in metres or feet and inches.",
812
+ },
813
+ {
814
+ name: "weight",
815
+ shape: "kilograms or pounds",
816
+ kind: "string",
817
+ check: (note) => checkBeingMeasurement(note, "weight", parseBeingWeight),
818
+ describe: "Body weight in kilograms or pounds.",
819
+ },
775
820
  {
776
821
  name: "frame",
777
822
  ...TEXT,
@@ -849,7 +894,8 @@ export const NOTE_VOCABULARY = Object.freeze({
849
894
  {
850
895
  name: "governance.offices",
851
896
  ...ANY,
852
- describe: "The named offices it appoints, each with what the office does.",
897
+ check: checkDatedOffices,
898
+ describe: "Named offices, each with a description and optional dated holders.",
853
899
  },
854
900
  {
855
901
  name: "seat",
@@ -1241,20 +1287,21 @@ export const NOTE_VOCABULARY = Object.freeze({
1241
1287
  lore: Object.freeze({
1242
1288
  stubbable: true,
1243
1289
  subTypes: Object.freeze([
1244
- "cosmology",
1245
- "deity",
1246
- "theology",
1247
- "arcana",
1248
- "spirit",
1249
- "economy",
1250
- "law",
1251
- "calendar",
1252
- "history",
1253
- "material",
1254
- "folk",
1255
- "culture",
1256
- "bestiary",
1257
- "gathering",
1290
+ "cosmology", // The structure and origin of reality.
1291
+ "deity", // An individual god and its attributed nature.
1292
+ "theology", // Beliefs about divinity, worship, and the afterlife.
1293
+ "arcana", // Beliefs and traditions concerning magic.
1294
+ "spirit", // Non-divine supernatural beings and their natures.
1295
+ "economy", // Trade, money, and the movement of wealth.
1296
+ "law", // Obligations, rights, courts, and tenure.
1297
+ "calendar", // The reckoning and marking of time.
1298
+ "history", // Events, eras, and chronicles of the past.
1299
+ "material", // Physical constituents and their qualities, including regional varieties.
1300
+ "folk", // A kindred or ancestry of related sapient beings.
1301
+ "culture", // A people sharing beliefs, mores, and values.
1302
+ "custom", // How a people practices a rite, observance, or usage.
1303
+ "bestiary", // A kind of creature that is not a people.
1304
+ "gathering", // A recurring public assembly, such as a fair or tournament.
1258
1305
  ]),
1259
1306
  // A lore note is prose, and what it *is* about is its subType — with
1260
1307
  // one exception. A calendar is a division of the year, and a division
@@ -1286,6 +1333,14 @@ export const NOTE_VOCABULARY = Object.freeze({
1286
1333
  ]),
1287
1334
  check: checkPlace,
1288
1335
  data: Object.freeze([
1336
+ {
1337
+ name: "calendar",
1338
+ ...LINK,
1339
+ ref: "lore",
1340
+ accepts: ["lore"],
1341
+ check: checkCalendarChoice,
1342
+ describe: "Calendar used to display this place's dates.",
1343
+ },
1289
1344
  {
1290
1345
  name: "demonym",
1291
1346
  ...TEXT,
@@ -0,0 +1,75 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ /** English cardinal words for finite whole numbers. */
4
+ const ONES = [
5
+ "zero",
6
+ "one",
7
+ "two",
8
+ "three",
9
+ "four",
10
+ "five",
11
+ "six",
12
+ "seven",
13
+ "eight",
14
+ "nine",
15
+ "ten",
16
+ "eleven",
17
+ "twelve",
18
+ "thirteen",
19
+ "fourteen",
20
+ "fifteen",
21
+ "sixteen",
22
+ "seventeen",
23
+ "eighteen",
24
+ "nineteen",
25
+ ];
26
+ const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
27
+ const SCALES = ["", "thousand", "million", "billion", "trillion", "quadrillion", "quintillion"];
28
+
29
+ function underThousand(value) {
30
+ const parts = [];
31
+ const hundreds = Math.floor(value / 100);
32
+ if (hundreds) parts.push(`${ONES[hundreds]} hundred`);
33
+ const rest = value % 100;
34
+ if (rest >= 20) {
35
+ const ten = TENS[Math.floor(rest / 10)];
36
+ parts.push(rest % 10 ? `${ten}-${ONES[rest % 10]}` : ten);
37
+ } else if (rest) parts.push(ONES[rest]);
38
+ return parts.join(" ");
39
+ }
40
+
41
+ /** Spell a SQL integer in running prose, using American cardinal style. */
42
+ export function numberWords(value) {
43
+ if (typeof value !== "bigint" && (typeof value !== "number" || !Number.isSafeInteger(value))) {
44
+ throw new TypeError("words needs a whole number; use digits for decimals");
45
+ }
46
+ let remaining = BigInt(value);
47
+ if (remaining === 0n) return "zero";
48
+ const negative = remaining < 0n;
49
+ if (negative) remaining = -remaining;
50
+ const parts = [];
51
+ let scale = 0;
52
+ while (remaining > 0n) {
53
+ if (scale >= SCALES.length) throw new RangeError("words cannot spell a number this large");
54
+ const group = Number(remaining % 1000n);
55
+ if (group)
56
+ parts.unshift(`${underThousand(group)}${SCALES[scale] ? ` ${SCALES[scale]}` : ""}`);
57
+ remaining /= 1000n;
58
+ scale++;
59
+ }
60
+ return `${negative ? "minus " : ""}${parts.join(" ")}`;
61
+ }
62
+
63
+ /** Render a SQL number with grouped thousands and its fractional part. */
64
+ export function numberDigits(value) {
65
+ if (typeof value === "bigint") return value.toLocaleString("en-US");
66
+ if (typeof value !== "number" || !Number.isFinite(value)) {
67
+ throw new TypeError("digits needs a finite number");
68
+ }
69
+ if (/[eE]/.test(String(value))) {
70
+ return new Intl.NumberFormat("en-US", { maximumFractionDigits: 20 }).format(value);
71
+ }
72
+ const [whole, fraction] = String(value).split(".");
73
+ const grouped = BigInt(whole).toLocaleString("en-US");
74
+ return fraction === undefined ? grouped : `${grouped}.${fraction}`;
75
+ }
@@ -0,0 +1,188 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ import { parseAddress, renderAddress } from "./address.mjs";
6
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
7
+ import { parseNoteDate } from "./note-dates.mjs";
8
+ import { reckoningContext } from "./reckoning-markers.mjs";
9
+
10
+ const OFFICE_KEYS = new Set(["description", "holders"]);
11
+ const HOLDER_KEYS = new Set(["being", "start", "end", "contested"]);
12
+ const mapping = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
13
+
14
+ /**
15
+ * Validate one affiliation's office roster against dated beings in the corpus.
16
+ * @param {object} note
17
+ * @param {{index?: object}} [options]
18
+ */
19
+ export function checkDatedOffices(note, { index } = {}) {
20
+ const offices = note.fm?.data?.governance?.offices;
21
+ if (offices === undefined || offices === null) return [];
22
+ const findings = [];
23
+ const at = (path, message) => ({
24
+ file: note.file,
25
+ ...positionOfFrontmatterPath(note.raw ?? "", path),
26
+ severity: "error",
27
+ message,
28
+ });
29
+ if (!mapping(offices))
30
+ return [
31
+ at(
32
+ ["data", "governance", "offices"],
33
+ "data.governance.offices must be a map of named offices",
34
+ ),
35
+ ];
36
+
37
+ const dates = reckoningContext(index);
38
+ for (const [office, value] of Object.entries(offices)) {
39
+ const base = ["data", "governance", "offices", office];
40
+ if (typeof value === "string") continue;
41
+ if (!mapping(value)) {
42
+ findings.push(
43
+ at(base, `office ${office} must be a description or a {description, holders} map`),
44
+ );
45
+ continue;
46
+ }
47
+ for (const key of Object.keys(value))
48
+ if (!OFFICE_KEYS.has(key))
49
+ findings.push(
50
+ at(
51
+ [...base, key],
52
+ `office ${office} has unknown key ${key}; use description or holders`,
53
+ ),
54
+ );
55
+ if (typeof value.description !== "string")
56
+ findings.push(
57
+ at([...base, "description"], `office ${office} needs a string description`),
58
+ );
59
+ if (!Array.isArray(value.holders)) {
60
+ findings.push(at([...base, "holders"], `office ${office} needs a holders list`));
61
+ continue;
62
+ }
63
+ const terms = [];
64
+ value.holders.forEach((row, position) => {
65
+ const rowPath = [...base, "holders", position];
66
+ if (!mapping(row)) {
67
+ findings.push(at(rowPath, `office ${office} holder must be a map`));
68
+ return;
69
+ }
70
+ for (const key of Object.keys(row))
71
+ if (!HOLDER_KEYS.has(key))
72
+ findings.push(
73
+ at([...rowPath, key], `office ${office} holder has unknown key ${key}`),
74
+ );
75
+ if (row.contested !== undefined && typeof row.contested !== "boolean")
76
+ findings.push(
77
+ at(
78
+ [...rowPath, "contested"],
79
+ `office ${office} contested must be true or false`,
80
+ ),
81
+ );
82
+
83
+ if (typeof row.being !== "string" || !row.being.trim()) {
84
+ findings.push(
85
+ at([...rowPath, "being"], `office ${office} holder needs a being Address`),
86
+ );
87
+ } else {
88
+ const tuple = parseAddress(row.being, {
89
+ package: index?.contentPackage,
90
+ system: "note",
91
+ type: "being",
92
+ types: index?.types,
93
+ packages: index?.packages,
94
+ });
95
+ if (tuple.reason || tuple.type !== "being") {
96
+ findings.push(
97
+ at(
98
+ [...rowPath, "being"],
99
+ `office ${office} holder ${row.being} must name a being Address`,
100
+ ),
101
+ );
102
+ } else if (index?.addressHit) {
103
+ const target = index.addressHit(renderAddress(tuple));
104
+ if (!target)
105
+ findings.push(
106
+ at(
107
+ [...rowPath, "being"],
108
+ `office ${office} holder ${row.being} does not resolve`,
109
+ ),
110
+ );
111
+ else if ((target.fm ?? target).data?.died != null && row.end == null)
112
+ findings.push(
113
+ at(
114
+ [...rowPath, "being"],
115
+ `office ${office} claims a current holder ${row.being} whose died value is present`,
116
+ ),
117
+ );
118
+ }
119
+ }
120
+
121
+ const bounds = {};
122
+ let invalidDate = false;
123
+ for (const key of ["start", "end"]) {
124
+ if (row[key] === undefined || row[key] === null) continue;
125
+ const parsed = parseNoteDate(row[key], {
126
+ ...dates,
127
+ allowUnknown: false,
128
+ field: `data.governance.offices.${office}.holders.${key}`,
129
+ file: note.file,
130
+ raw: note.raw,
131
+ keyPath: [...rowPath, key],
132
+ });
133
+ findings.push(...parsed.findings);
134
+ if (
135
+ parsed.date &&
136
+ parsed.date.month !== null &&
137
+ parsed.date.canonicalDay === undefined
138
+ ) {
139
+ findings.push(
140
+ at(
141
+ [...rowPath, key],
142
+ `office ${office} holder ${key} needs a marker or canonical day to order it`,
143
+ ),
144
+ );
145
+ invalidDate = true;
146
+ } else if (parsed.date && Number.isFinite(parsed.date.sort))
147
+ bounds[key] =
148
+ parsed.date.sort +
149
+ (key === "end" ?
150
+ ((parsed.date.spanDays ?? 1) - 1) / (dates.daysPerYear ?? 365)
151
+ : 0);
152
+ else {
153
+ if (parsed.date)
154
+ findings.push(
155
+ at(
156
+ [...rowPath, key],
157
+ `office ${office} holder ${key} needs a resolved date`,
158
+ ),
159
+ );
160
+ invalidDate = true;
161
+ }
162
+ }
163
+ if (bounds.start !== undefined && bounds.end !== undefined && bounds.end < bounds.start)
164
+ findings.push(
165
+ at([...rowPath, "end"], `office ${office} holder ends before its start`),
166
+ );
167
+ if (!invalidDate)
168
+ terms.push({
169
+ start: bounds.start ?? -Infinity,
170
+ end: bounds.end ?? Infinity,
171
+ contested: row.contested === true,
172
+ rowPath,
173
+ });
174
+ });
175
+ for (let i = 0; i < terms.length; i++)
176
+ for (let j = i + 1; j < terms.length; j++)
177
+ if (
178
+ !terms[i].contested &&
179
+ !terms[j].contested &&
180
+ terms[i].start <= terms[j].end &&
181
+ terms[j].start <= terms[i].end
182
+ )
183
+ findings.push(
184
+ at(terms[j].rowPath, `office ${office} has overlapping holder terms`),
185
+ );
186
+ }
187
+ return findings;
188
+ }
@@ -123,7 +123,7 @@ export const CONFIG_BASENAME = "package-build.config";
123
123
  * the order the names are *reported* in, which is the order a reader should
124
124
  * reach for them: YAML first, `.mjs` last.
125
125
  *
126
- * The `content-build.config.*` stem this package read before the two toolchains
126
+ * The `package-build.config.*` stem this package read before the two toolchains
127
127
  * merged is **not** resolved. 3.0.0 renames the file rather than accepting both:
128
128
  * a deprecation window here would mean a repository could sit indefinitely on a
129
129
  * name for a package that no longer exists, and the upgrade already requires
@@ -570,22 +570,9 @@ export function configFromData(data, configPath) {
570
570
  );
571
571
  }
572
572
  }
573
- const { manifestPath, pkg } = readPackageJson(rootDir);
573
+ const { pkg } = readPackageJson(rootDir);
574
574
  if (pkg.homepage !== undefined) input.homepage = pkg.homepage;
575
575
  if (pkg.author !== undefined) input.author = pkg.author;
576
- // `description` is npm metadata nothing displays for a private package —
577
- // read by neither the manifest nor the site, so a declared one is a
578
- // warning rather than a silent no-op that looks like it did something.
579
- if (pkg.description !== undefined) {
580
- emitDiagnostic({
581
- file: manifestPath,
582
- severity: "warning",
583
- message:
584
- "`description` is read by nothing; the Foundry pitch is " +
585
- "`packageBuild.manifest.descriptionHtml` and the site's is " +
586
- "`site.description`",
587
- });
588
- }
589
576
 
590
577
  if (input.itemBuilders !== undefined) {
591
578
  const declared = input.itemBuilders;
@@ -44,9 +44,9 @@
44
44
  * another document type, or a companion pack, which no note may address —
45
45
  * fails the build, naming the note and what it asked for. A silent fall-back
46
46
  * to the default would publish an address into the wrong compendium.
47
- * - A note's **derived** documents are routed by the default of *their* type,
48
- * not by the note's declaration: an item note's prose compiles into a
49
- * JournalEntry, and `pack:` names where the *item* goes.
47
+ * - A note's **derived** documents use the default of their type. GM-only
48
+ * prose uses a private pack of that type. An item's `pack:` names where the
49
+ * item goes, not its prose JournalEntry.
50
50
  * - A note feeding **more than one system** declares `pack:` inside the block
51
51
  * that differs. `pack` needed no new mechanism for that: it is an
52
52
  * ordinary shared property, so `<system>.pack` overrides the top-level one
@@ -83,6 +83,7 @@
83
83
  import { NO_PACK, packForType } from "./ids.mjs";
84
84
  import { loadPackConfig } from "./pack-config.mjs";
85
85
  import { blockDataProperty, systemBlock } from "./system-block.mjs";
86
+ import { isGmNote } from "./note-vocabulary.mjs";
86
87
 
87
88
  /**
88
89
  * A note that cannot be routed to a pack. Thrown rather than returned so no
@@ -236,7 +237,7 @@ export function createPackRouter(packs) {
236
237
  * asking, only a pack of that system answers, or one that declares no
237
238
  * system at all and so belongs to all of them. The type-wide default is
238
239
  * otherwise free to be a neighbour's — `actors-sohl` is what
239
- * `CONTENT.md`'s two-system layout marks `default: true` — and returning it
240
+ * the package's two-system layout marks `default: true` — and returning it
240
241
  * to the HM3 pass would either route an HM3 document into the SoHL pack or,
241
242
  * as it actually did, have the HM3 pass see a pack name that is not its own
242
243
  * and skip every note in the tree. The second is worse: it is silent, and
@@ -364,7 +365,20 @@ export function createPackRouter(packs) {
364
365
  return declared;
365
366
  }
366
367
 
367
- const fallback = defaultFor(docType, system);
368
+ let fallback = defaultFor(docType, system);
369
+ if (isGmNote(fm) && docType !== ownDocType) {
370
+ const privatePacks = (byType.get(docType) ?? []).filter(
371
+ (name) => byName.get(name)?.private === true && !byName.get(name)?.system,
372
+ );
373
+ if (!fallback || !byName.get(fallback)?.private) {
374
+ fallback = privatePacks.length === 1 ? privatePacks[0] : undefined;
375
+ }
376
+ if (!fallback) {
377
+ throw new PackRoutingError(
378
+ `${noteLabel(fm)} needs one private ${docType} pack for its GM-only prose.`,
379
+ );
380
+ }
381
+ }
368
382
  if (!fallback) {
369
383
  const candidates =
370
384
  system === undefined ?
@@ -466,6 +480,11 @@ export function createPackRouter(packs) {
466
480
  return byName.get(name)?.system || undefined;
467
481
  },
468
482
 
483
+ /** Whether Foundry restricts this pack to GMs. */
484
+ privateOf(name) {
485
+ return byName.get(name)?.private === true;
486
+ },
487
+
469
488
  /**
470
489
  * The pack of a type that receives notes declaring none.
471
490
  *
@@ -25,8 +25,9 @@
25
25
  * with, which is the whole point: the alternative is every package depending on
26
26
  * one of the others just to borrow a banner.
27
27
  *
28
- * So nothing may create a real package that collides with the name, and its
29
- * resolution is special-cased, because no installed directory sits behind it.
28
+ * Only this toolchain's own documentation package may share the name. Other
29
+ * repositories cannot claim it, and asset resolution is special-cased because
30
+ * no installed Foundry directory sits behind it.
30
31
  *
31
32
  * This module is a **leaf with no local imports**, so the configuration
32
33
  * validator can name it without closing a cycle around `content-config.mjs`.
@@ -36,22 +36,21 @@
36
36
  * being built, and the whole of it is the suffix. That is the ordinary case and
37
37
  * the one nearly every note writes.
38
38
  *
39
- * The four forms, for a `thalorna` note writing `images/map.webp` (`thalorna`
40
- * ships as the Foundry module `sohl-thalorna`):
39
+ * The four forms, for a `harnensemble` note writing `images/map.webp`
40
+ * (`harnensemble` ships as the Foundry module `harn-ensemble`):
41
41
  *
42
- * | Form | Address |
43
- * | --------- | -------------------------------------------------- |
44
- * | `foundry` | `modules/sohl-thalorna/assets/images/map.webp` |
45
- * | `local` | `assets/images/map.webp` |
46
- * | `web` | `https://cdn.heroiclands.org/thalorna/images/map.webp` |
47
- * | `pdf` | `assets/images/map.webp` |
42
+ * | Form | Address |
43
+ * | --------- | ------------------------------------------------------------- |
44
+ * | `foundry` | `modules/harn-ensemble/assets/images/map.webp` |
45
+ * | `local` | `assets/images/map.webp` |
46
+ * | `web` | `https://cdn.heroiclands.org/harnensemble/images/map.webp` |
47
+ * | `pdf` | `assets/images/map.webp` |
48
48
  *
49
49
  * **`<package>` and `<foundry-id>` are two different names.** The package is
50
- * `thalorna` — what the content is called, what the website serves it under,
51
- * and what a note writes. The Foundry id is `sohl-thalorna` — what Foundry
52
- * installs the module as, and the only place that name appears. They coincide
53
- * for `sohl` and `hm3`, which is exactly why the two are kept apart here rather
54
- * than treated as one value.
50
+ * `harnensemble` — what the content is called, what the website serves it under,
51
+ * and what a note writes. The Foundry id is `harn-ensemble` — what Foundry
52
+ * installs the module as. The values coincide for `thalorna`, `sohl`, and
53
+ * `hm3`, but pathname resolution uses each in its own surface.
55
54
  *
56
55
  * `local` and `pdf` read the same and mean different places: `local` is the file
57
56
  * in the owning repository's working tree, `pdf` is where the book stages a copy
@@ -338,16 +337,14 @@ function declaredSystemIds(config) {
338
337
  export function packageAddresses(config) {
339
338
  /** @type {Map<string, {root: string|null, id: string|null, own: boolean}>} */
340
339
  const out = new Map();
341
- if (config.contentPackage) {
342
- out.set(config.contentPackage, {
343
- // `assetRoot` is `<root>/<id>/assets`, and `null` for a
344
- // `documentation` package — which is the same "Foundry serves no
345
- // files for this" the `root` below says.
346
- root: config.assetRoot ? config.packageKind : null,
347
- id: config.foundryPackage ?? null,
348
- own: true,
349
- });
350
- }
340
+ out.set(config.contentPackage, {
341
+ // `assetRoot` is `<root>/<id>/assets`, and `null` for a
342
+ // `documentation` package — which is the same "Foundry serves no
343
+ // files for this" the `root` below says.
344
+ root: config.assetRoot ? config.packageKind : null,
345
+ id: config.foundryPackage ?? null,
346
+ own: true,
347
+ });
351
348
  // The package being built wins every collision: it is the one whose files
352
349
  // this repository actually holds, and another declaration of the same name
353
350
  // describes that very package from outside.
@@ -420,7 +417,7 @@ export function resolvePathname(raw, config) {
420
417
  // reading it as this package's own would put one package's name inside
421
418
  // another's tree and report nothing.
422
419
  const named = segments.length > 2 && segments[1] === ASSETS_SEGMENT ? segments[0] : "";
423
- const owner = named || (config.contentPackage ?? null);
420
+ const owner = named || config.contentPackage;
424
421
  const suffix = named ? segments.slice(2).join("/") : authored;
425
422
  const entry = owner ? packages.get(owner) : undefined;
426
423
  const host = String(config.site?.assets ?? "").replace(/\/+$/, "");