@heroiclands/package-build 23.0.0 → 23.1.1

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 (157) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +1 -1
  3. package/bin/content-commands.mjs +1 -1
  4. package/ci/content-format-reference.mjs +46 -0
  5. package/docs/api.md +124 -141
  6. package/docs/authoring/assets.md +1 -1
  7. package/docs/authoring/dates-and-calendars.md +12 -6
  8. package/docs/authoring/frontmatter.md +2 -2
  9. package/docs/authoring/links-and-markup.md +145 -29
  10. package/docs/commands.md +8 -10
  11. package/docs/configuration.md +13 -11
  12. package/docs/content-format.md +2 -2
  13. package/docs/guides/book.md +59 -4
  14. package/docs/guides/icons.md +4 -5
  15. package/docs/guides/packs.md +6 -0
  16. package/docs/guides/reference-pages.md +21 -1
  17. package/docs/guides/release-and-deploy.md +39 -7
  18. package/docs/guides/site.md +17 -18
  19. package/docs/reference/format-details.md +877 -276
  20. package/docs/reference/note-types.md +278 -91
  21. package/docs/reference/theme-pages.md +58 -97
  22. package/engine/address-values.mjs +124 -4
  23. package/engine/address.mjs +44 -3
  24. package/engine/anchors.mjs +103 -3
  25. package/engine/art-slots.mjs +2 -9
  26. package/engine/base-compiler.mjs +25 -6
  27. package/engine/calendar-artifacts.mjs +4 -1
  28. package/engine/calendar-notes.mjs +166 -102
  29. package/engine/content-format-check.mjs +2 -3
  30. package/engine/content-format.mjs +44 -1
  31. package/engine/content-format.yaml +79 -16
  32. package/engine/content-images.mjs +10 -1
  33. package/engine/content-index.mjs +37 -9
  34. package/engine/content-links.mjs +24 -5
  35. package/engine/content-tables.mjs +24 -3
  36. package/engine/data-keys.mjs +347 -0
  37. package/engine/derived-sections.mjs +322 -0
  38. package/engine/diagnostics.mjs +28 -0
  39. package/engine/event-fields.mjs +187 -0
  40. package/engine/event-rows.mjs +126 -0
  41. package/engine/event-views.mjs +305 -0
  42. package/engine/exported-scene.mjs +29 -53
  43. package/engine/frontmatter-lint.mjs +103 -77
  44. package/engine/frontmatter.mjs +7 -2
  45. package/engine/generate.mjs +53 -0
  46. package/engine/generated-sections.mjs +223 -0
  47. package/engine/heading-attributes.mjs +4 -2
  48. package/engine/helpers.mjs +12 -1
  49. package/engine/holdings.mjs +12 -33
  50. package/engine/ids.mjs +17 -11
  51. package/engine/index.mjs +4 -4
  52. package/engine/infobox-render.mjs +21 -6
  53. package/engine/infobox.mjs +217 -99
  54. package/engine/journals.mjs +104 -47
  55. package/engine/literature-notes.mjs +4 -1
  56. package/engine/literature-works.mjs +21 -13
  57. package/engine/map-graphviz.mjs +4 -18
  58. package/engine/markdown-expressions.mjs +116 -11
  59. package/engine/metadata-index.mjs +26 -0
  60. package/engine/near-miss.mjs +90 -0
  61. package/engine/note-addresses.mjs +75 -19
  62. package/engine/note-dates.mjs +5 -3
  63. package/engine/note-event-terms.mjs +364 -0
  64. package/engine/note-events.mjs +823 -0
  65. package/engine/note-vocabulary.mjs +184 -88
  66. package/engine/office-holders.mjs +20 -54
  67. package/engine/packagebuild-index.mjs +16 -1
  68. package/engine/pdf-build.mjs +97 -60
  69. package/engine/pdf-index.mjs +263 -0
  70. package/engine/pdf-render.mjs +47 -5
  71. package/engine/place-relation-terms.mjs +174 -0
  72. package/engine/place-relations.mjs +72 -160
  73. package/engine/rank-ladder.mjs +14 -41
  74. package/engine/reckoning-markers.mjs +2 -0
  75. package/engine/region-events.mjs +6 -12
  76. package/engine/scenes.mjs +73 -468
  77. package/engine/site-build.mjs +75 -101
  78. package/engine/site-index.mjs +3 -1
  79. package/engine/site-maps.mjs +43 -29
  80. package/engine/site-preview.mjs +40 -31
  81. package/engine/social-ties.mjs +2 -1
  82. package/engine/sql-tables.mjs +212 -9
  83. package/engine/standing-terms.mjs +145 -6
  84. package/engine/standings.mjs +37 -49
  85. package/engine/web-wikilinks.mjs +21 -1
  86. package/engine/wikilink-syntax.mjs +34 -4
  87. package/engine/wikilinks.mjs +27 -2
  88. package/engine/yaml-lint.mjs +7 -4
  89. package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
  90. package/hugo-theme/layouts/partials/page-body.html +0 -3
  91. package/hugo-theme/static/css/style.css +25 -55
  92. package/package.json +1 -2
  93. package/packagebuild-metadata.jsonl +13 -13
  94. package/release.mjs +62 -4
  95. package/sohl/note-schemas.mjs +8 -73
  96. package/types/engine/address-values.d.mts +50 -3
  97. package/types/engine/address.d.mts +21 -0
  98. package/types/engine/anchors.d.mts +49 -2
  99. package/types/engine/art-slots.d.mts +1 -1
  100. package/types/engine/base-compiler.d.mts +13 -4
  101. package/types/engine/calendar-notes.d.mts +7 -0
  102. package/types/engine/content-format.d.mts +52 -0
  103. package/types/engine/content-images.d.mts +5 -1
  104. package/types/engine/content-tables.d.mts +9 -1
  105. package/types/engine/data-keys.d.mts +145 -0
  106. package/types/engine/derived-sections.d.mts +73 -0
  107. package/types/engine/diagnostics.d.mts +15 -0
  108. package/types/engine/event-fields.d.mts +99 -0
  109. package/types/engine/event-rows.d.mts +12 -0
  110. package/types/engine/event-views.d.mts +67 -0
  111. package/types/engine/exported-scene.d.mts +5 -2
  112. package/types/engine/frontmatter-lint.d.mts +3 -3
  113. package/types/engine/generated-sections.d.mts +143 -0
  114. package/types/engine/holdings.d.mts +6 -20
  115. package/types/engine/ids.d.mts +15 -12
  116. package/types/engine/index.d.mts +1 -1
  117. package/types/engine/infobox.d.mts +13 -3
  118. package/types/engine/journals.d.mts +24 -11
  119. package/types/engine/literature-works.d.mts +1 -2
  120. package/types/engine/map-graphviz.d.mts +2 -10
  121. package/types/engine/markdown-expressions.d.mts +12 -3
  122. package/types/engine/near-miss.d.mts +42 -0
  123. package/types/engine/note-addresses.d.mts +7 -1
  124. package/types/engine/note-event-terms.d.mts +68 -0
  125. package/types/engine/note-events.d.mts +71 -0
  126. package/types/engine/note-vocabulary.d.mts +37 -8
  127. package/types/engine/office-holders.d.mts +3 -0
  128. package/types/engine/pdf-index.d.mts +35 -0
  129. package/types/engine/pdf-render.d.mts +8 -2
  130. package/types/engine/place-relation-terms.d.mts +83 -0
  131. package/types/engine/place-relations.d.mts +11 -66
  132. package/types/engine/rank-ladder.d.mts +2 -0
  133. package/types/engine/region-events.d.mts +6 -12
  134. package/types/engine/scenes.d.mts +6 -32
  135. package/types/engine/site-build.d.mts +8 -22
  136. package/types/engine/site-maps.d.mts +15 -7
  137. package/types/engine/sql-tables.d.mts +77 -2
  138. package/types/engine/standing-terms.d.mts +37 -4
  139. package/types/engine/standings.d.mts +14 -9
  140. package/types/engine/wikilink-syntax.d.mts +20 -0
  141. package/types/engine/wikilinks.d.mts +3 -2
  142. package/types/engine/yaml-lint.d.mts +5 -4
  143. package/types/release.d.mts +4 -0
  144. package/docs/guides/government-migration.md +0 -52
  145. package/engine/itinerary-scenes.mjs +0 -166
  146. package/engine/lore-events.mjs +0 -197
  147. package/engine/map-notes.mjs +0 -1309
  148. package/engine/map-raster.mjs +0 -51
  149. package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
  150. package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
  151. package/hugo-theme/layouts/partials/holdings.html +0 -61
  152. package/hugo-theme/layouts/partials/literature-works.html +0 -29
  153. package/hugo-theme/layouts/partials/place-map.html +0 -9
  154. package/types/engine/itinerary-scenes.d.mts +0 -29
  155. package/types/engine/lore-events.d.mts +0 -14
  156. package/types/engine/map-notes.d.mts +0 -297
  157. package/types/engine/map-raster.d.mts +0 -8
@@ -128,32 +128,45 @@ function applyFixups(scene, fixups, resolveAddress) {
128
128
  }
129
129
  }
130
130
 
131
- /** Add a compendium key to one exported embedded document. */
132
- function keyDocument(doc, collection, parentIds, sceneId) {
133
- if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
134
- throw new Error(`data.scene.${collection} contains a non-document value`);
135
- }
136
- if (!/^[A-Za-z0-9]{16}$/.test(doc._id ?? "")) {
137
- throw new Error(`data.scene.${collection} needs a 16-character _id on every document`);
131
+ /**
132
+ * Add a compendium key to every embedded document that carries an `_id`.
133
+ *
134
+ * The keys are what the compendium CLI splits embedded documents by. A value
135
+ * that is not a document, or one carrying no `_id`, passes through as exported.
136
+ *
137
+ * @param {unknown} docs - One embedded collection, as exported.
138
+ * @param {string} collection - Its key path, such as `regions.behaviors`.
139
+ * @param {string[]} parentIds - The ids of the documents it is embedded in.
140
+ * @param {string} sceneId - The Scene's derived id.
141
+ * @returns {void}
142
+ */
143
+ function keyDocuments(docs, collection, parentIds, sceneId) {
144
+ if (!Array.isArray(docs)) return;
145
+ for (const doc of docs) {
146
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) continue;
147
+ if (typeof doc._id !== "string" || !doc._id) continue;
148
+ doc._key = `!scenes.${collection}!${[sceneId, ...parentIds, doc._id].join(".")}`;
149
+ if (collection === "regions") {
150
+ keyDocuments(doc.behaviors, "regions.behaviors", [doc._id], sceneId);
151
+ }
138
152
  }
139
- doc._key = `!scenes.${collection}!${[sceneId, ...parentIds, doc._id].join(".")}`;
140
153
  }
141
154
 
142
155
  /**
143
156
  * Compile a Foundry Scene export into a pack document.
144
157
  *
145
- * Only the note-derived Scene ID, compendium metadata, and pins marked
146
- * `#anchor` change. Every other authored Scene field passes through.
158
+ * The Scene passes through as exported. Only the note-derived Scene ID,
159
+ * compendium metadata, `data.fixup` and pins marked `#anchor` change; nothing
160
+ * else inside the Scene is read or checked.
147
161
  *
148
162
  * @param {object} fm - The map note's frontmatter.
149
163
  * @param {string} markdown - Its converted Markdown body.
150
164
  * @param {{journalEntryId?: string, stats?: object, resolveAddress?: Function}} [opts] - Pack metadata, derived JournalEntry ID, and asset resolver.
151
165
  * @returns {object} The keyed Scene document.
166
+ * @throws {Error} When `data.scene` is not an object, or a fixup or pin anchor
167
+ * names something the Scene or the note does not have.
152
168
  */
153
169
  export function buildExportedScene(fm, markdown, { journalEntryId, stats, resolveAddress } = {}) {
154
- if (!["battlemap", "localmap"].includes(fm.subType)) {
155
- throw new Error("`data.scene` is for battlemap and localmap notes");
156
- }
157
170
  const source = fm.data?.scene;
158
171
  if (!source || typeof source !== "object" || Array.isArray(source)) {
159
172
  throw new Error("a map note needs a Foundry Scene object at `data.scene`");
@@ -163,48 +176,10 @@ export function buildExportedScene(fm, markdown, { journalEntryId, stats, resolv
163
176
  }
164
177
  const scene = structuredClone(source);
165
178
  applyFixups(scene, fm.data?.fixup, resolveAddress);
166
- if (
167
- !(Number.isInteger(scene.width) && scene.width > 0) ||
168
- !(Number.isInteger(scene.height) && scene.height > 0)
169
- ) {
170
- throw new Error("`data.scene` needs positive whole-number width and height");
171
- }
172
- if (!Array.isArray(scene.levels) || scene.levels.length === 0) {
173
- throw new Error("`data.scene.levels` needs at least one Level");
174
- }
175
179
  scene._id = fm.id;
176
180
  scene._key = `!scenes!${fm.id}`;
177
181
  if (stats) scene._stats = stats;
178
-
179
- for (const collection of EMBEDDED) {
180
- const docs = scene[collection];
181
- if (docs === undefined) continue;
182
- if (!Array.isArray(docs)) {
183
- throw new Error(`data.scene.${collection} must be an array`);
184
- }
185
- const ids = new Set();
186
- for (const doc of docs) {
187
- keyDocument(doc, collection, [], fm.id);
188
- if (ids.has(doc._id))
189
- throw new Error(`data.scene.${collection} repeats _id ${doc._id}`);
190
- ids.add(doc._id);
191
- if (collection !== "regions" || doc.behaviors === undefined) continue;
192
- if (!Array.isArray(doc.behaviors)) {
193
- throw new Error("data.scene.regions.behaviors must be an array");
194
- }
195
- const behaviorIds = new Set();
196
- for (const behavior of doc.behaviors) {
197
- keyDocument(behavior, "regions.behaviors", [doc._id], fm.id);
198
- if (behaviorIds.has(behavior._id)) {
199
- throw new Error(`data.scene.regions.behaviors repeats _id ${behavior._id}`);
200
- }
201
- behaviorIds.add(behavior._id);
202
- }
203
- }
204
- }
205
- if (scene.initialLevel && !scene.levels.some((level) => level._id === scene.initialLevel)) {
206
- throw new Error(`data.scene.initialLevel "${scene.initialLevel}" names no Level`);
207
- }
182
+ for (const collection of EMBEDDED) keyDocuments(scene[collection], collection, [], fm.id);
208
183
 
209
184
  const pages = new Map();
210
185
  for (const page of splitPages(markdown, fm.name?.full ?? scene.name)) {
@@ -214,7 +189,8 @@ export function buildExportedScene(fm, markdown, { journalEntryId, stats, resolv
214
189
  }
215
190
  pages.set(page.anchorSlug, page);
216
191
  }
217
- for (const pin of scene.notes ?? []) {
192
+ for (const pin of Array.isArray(scene.notes) ? scene.notes : []) {
193
+ if (!pin || typeof pin !== "object") continue;
218
194
  const match = /^#([A-Za-z0-9][A-Za-z0-9_-]*)$/.exec(String(pin.text ?? ""));
219
195
  if (!match) continue;
220
196
  const page = pages.get(match[1]);
@@ -55,6 +55,8 @@
55
55
  */
56
56
 
57
57
  import { isAddressTuple } from "./address.mjs";
58
+ import { listed, nearest } from "./near-miss.mjs";
59
+ import { checkDataKeys } from "./data-keys.mjs";
58
60
  import {
59
61
  authoredNoteKeys,
60
62
  CHARACTER_NAME_KEYS,
@@ -62,7 +64,8 @@ import {
62
64
  NOTE_TOP_LEVEL_KEY_SET,
63
65
  NOTE_TOP_LEVEL_KEYS,
64
66
  } from "./note-frontmatter.mjs";
65
- import { AddressEntries } from "./address-values.mjs";
67
+ import { AddressEntries, AddressLink } from "./address-values.mjs";
68
+ import { resolveEventReference } from "./note-events.mjs";
66
69
  import { addressPositions } from "./note-addresses.mjs";
67
70
  import { authoredFields, readsLegacyKey, readsRetiredTopLevel } from "./field-spec.mjs";
68
71
  import {
@@ -74,11 +77,15 @@ import {
74
77
  SYSTEM_BLOCK_KEYS,
75
78
  unknownBlockKeys,
76
79
  } from "./system-block.mjs";
77
- import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
80
+ import {
81
+ frontmatterValueAt,
82
+ positionInFrontmatter,
83
+ positionOfFrontmatterPath,
84
+ } from "./diagnostics.mjs";
78
85
  import { pathnameProblem } from "./pathnames.mjs";
79
86
  import { checkHomepageAddressFields } from "./homepage.mjs";
80
87
  import { RETIRED_TYPES, RENAMED_TYPES, currentType, renamedTypeMessage } from "./ids.mjs";
81
- import { parseAddress, acceptsType, renderAddress } from "./address.mjs";
88
+ import { parseAddress, acceptsType, renderAddress, hasAnchor } from "./address.mjs";
82
89
  import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
83
90
  import { isAddressSegment } from "./address-charset.mjs";
84
91
  // The one place the "every pack not named" key is spelled. Imported rather
@@ -88,7 +95,6 @@ import { DEFAULT_PARENT } from "./folder-notes.mjs";
88
95
  import {
89
96
  BEING_ARCHETYPES,
90
97
  dataFields,
91
- isLegacyDataField,
92
98
  declaredTags,
93
99
  subTypeCharsetMessage,
94
100
  typeCharsetMessage,
@@ -280,72 +286,6 @@ export { declaredSystems } from "./system-vocabulary.mjs";
280
286
  */
281
287
  export { systemBlocksFor } from "./system-vocabulary.mjs";
282
288
 
283
- /**
284
- * Edit distance, capped — enough to answer "did you mean".
285
- *
286
- * A misspelled property is the failure class this check exists for, and a
287
- * finding that names the key the author *meant* turns a hunt through the
288
- * reference into a one-character fix.
289
- *
290
- * @param {string} a - One string.
291
- * @param {string} b - The other.
292
- * @returns {number} The Levenshtein distance.
293
- */
294
- function distance(a, b) {
295
- const rows = a.length + 1;
296
- const cols = b.length + 1;
297
- let prev = Array.from({ length: cols }, (_, j) => j);
298
- for (let i = 1; i < rows; i += 1) {
299
- const row = [i];
300
- for (let j = 1; j < cols; j += 1) {
301
- row[j] = Math.min(
302
- prev[j] + 1,
303
- row[j - 1] + 1,
304
- prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
305
- );
306
- }
307
- prev = row;
308
- }
309
- return prev[cols - 1];
310
- }
311
-
312
- /**
313
- * A closed vocabulary as an English list, for the message that names it.
314
- *
315
- * Written from the declaration rather than spelled into the message, so a key
316
- * the vocabulary gains cannot go unmentioned by the finding that refuses its
317
- * neighbours.
318
- *
319
- * @param {readonly string[]} keys - The declared keys, in declared order.
320
- * @returns {string} `"a, b, or c"`.
321
- */
322
- function listed(keys) {
323
- if (keys.length < 2) return keys.join("");
324
- return `${keys.slice(0, -1).join(", ")}, or ${keys[keys.length - 1]}`;
325
- }
326
-
327
- /**
328
- * The declared key an unknown one was most likely meant to be.
329
- *
330
- * @param {string} key - The unknown key.
331
- * @param {Iterable<string>} candidates - The declared keys.
332
- * @returns {string|undefined} The nearest, when it is near enough to suggest.
333
- */
334
- function nearest(key, candidates) {
335
- let best;
336
- let bestAt = Infinity;
337
- for (const candidate of candidates) {
338
- const d = distance(key.toLowerCase(), candidate.toLowerCase());
339
- if (d < bestAt) {
340
- bestAt = d;
341
- best = candidate;
342
- }
343
- }
344
- // A third of the key's length, so a suggestion is a plausible typo rather
345
- // than the least-bad of a list of unrelated words.
346
- return bestAt <= Math.max(1, Math.floor(key.length / 3)) ? best : undefined;
347
- }
348
-
349
289
  /**
350
290
  * Whether a value satisfies a declared {@link FieldSpec.kind}.
351
291
  *
@@ -503,7 +443,7 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
503
443
  }
504
444
 
505
445
  for (const key of Object.keys(entries)) {
506
- if (declared.has(key) || isLegacyDataField(type, key)) continue;
446
+ if (declared.has(key)) continue;
507
447
  const current = renamed.get(key);
508
448
  if (current) {
509
449
  findings.push({
@@ -546,7 +486,19 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
546
486
  value = value && typeof value === "object" ? value[segment] : undefined;
547
487
  }
548
488
  if (value === undefined || value === null) continue;
549
- if (!matchesKind(value, field.kind, { ...addressContext, type: field.ref })) {
489
+ if (field.kind === "address" && hasAnchor(value) && !field.anchors) {
490
+ findings.push({
491
+ file: note.file,
492
+ ...positionOfFrontmatterPath(raw, ["data", ...segments]),
493
+ severity: "error",
494
+ message: `\`data.${field.name}\` ${NO_ANCHOR}, but reads ${JSON.stringify(value)}`,
495
+ });
496
+ continue;
497
+ }
498
+ if (
499
+ !(field.kind === "address" && field.anchors && anchored(value)) &&
500
+ !matchesKind(value, field.kind, { ...addressContext, type: field.ref })
501
+ ) {
550
502
  findings.push({
551
503
  file: note.file,
552
504
  ...positionOfFrontmatterPath(raw, ["data", ...segments]),
@@ -570,9 +522,75 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
570
522
  }
571
523
  }
572
524
 
525
+ // One level down and every level below: each field's value is closed to
526
+ // the inner keys its declaration states, as the container is to its own.
527
+ findings.push(...checkDataKeys(note, fields));
528
+
573
529
  return findings;
574
530
  }
575
531
 
532
+ /** What a finding says of an anchor on a field that declares no anchor kind. */
533
+ const NO_ANCHOR = "takes no anchor — it names a whole note, so write the Address without `#…`";
534
+
535
+ /**
536
+ * Whether an Address value names an anchor: written with `#<anchor>`, or read
537
+ * into an {@link AddressLink}.
538
+ *
539
+ * @param {unknown} value - The value as the note holds it.
540
+ * @returns {boolean} Whether it carries an anchor.
541
+ */
542
+ function anchored(value) {
543
+ return value instanceof AddressLink || hasAnchor(value);
544
+ }
545
+
546
+ /**
547
+ * The value as an author wrote it, for a message: a read Address back to its
548
+ * canonical text, with `#<anchor>` where it has one.
549
+ *
550
+ * @param {unknown} value - The value as the note holds it.
551
+ * @returns {unknown} Its written form.
552
+ */
553
+ function writtenForm(value) {
554
+ if (value instanceof AddressLink) return `${renderAddress(value.target)}#${value.anchor}`;
555
+ if (isAddressTuple(value)) return renderAddress(value);
556
+ return value;
557
+ }
558
+
559
+ /**
560
+ * Why an Address on a field declaring anchor kinds names nothing it accepts, or
561
+ * `undefined` when it does.
562
+ *
563
+ * With an index, the Address is resolved as an event's own `follows` is —
564
+ * the anchor must exist and be of a kind the field declares, and a field
565
+ * declaring `single` must name exactly one event. Without one, only its form is
566
+ * checked.
567
+ *
568
+ * @param {unknown} value - The value as the note holds it.
569
+ * @param {object} field - The field's declaration.
570
+ * @param {object} defaults - The Address context, with the field's default type.
571
+ * @param {object} [index] - The link index.
572
+ * @returns {string|undefined} The reason.
573
+ */
574
+ function eventReferenceProblem(value, field, defaults, index) {
575
+ if (index?.addressHit)
576
+ return resolveEventReference(
577
+ value,
578
+ {
579
+ ...(field.ref ? { ref: field.ref } : {}),
580
+ ...(field.accepts ? { accepts: field.accepts } : {}),
581
+ anchors: field.anchors,
582
+ ...(field.single ? { single: true } : {}),
583
+ },
584
+ index,
585
+ ).problem;
586
+ const written = writtenForm(value);
587
+ if (typeof written !== "string") return "should be an Address";
588
+ const { address, anchor } = splitAnchor(written);
589
+ if (anchor !== undefined && !isAddressSegment(anchor))
590
+ return `names the anchor \`${anchor}\`, which is not an address segment`;
591
+ return matchesKind(address, "address", defaults) ? undefined : "should be an Address";
592
+ }
593
+
576
594
  /**
577
595
  * Validate typed entries and resolve declared non-art Address targets.
578
596
  * @param {object} note - The authored note.
@@ -631,7 +649,11 @@ function checkDataReferences(note, field, value, segments, context, index) {
631
649
  )
632
650
  continue;
633
651
  let reason;
634
- if (!matchesKind(check.value, check.kind, defaults))
652
+ if (check.kind === "address" && field.anchors && (field.single || anchored(check.value)))
653
+ reason = eventReferenceProblem(check.value, field, defaults, index);
654
+ else if (check.kind === "address" && hasAnchor(check.value) && !field.anchors)
655
+ reason = NO_ANCHOR;
656
+ else if (!matchesKind(check.value, check.kind, defaults))
635
657
  reason = `should be ${check.kind === "address" ? "an Address" : "a Shortcode"}`;
636
658
  else if (check.kind === "address") {
637
659
  const tuple = parseAddress(check.value, defaults, { declared: true });
@@ -650,7 +672,11 @@ function checkDataReferences(note, field, value, segments, context, index) {
650
672
  file: note.file,
651
673
  ...positionOfFrontmatterPath(note.raw ?? "", path, { key: check.key || check.atKey }),
652
674
  severity: "error",
653
- message: `\`${path.join(".")}\` ${reason}, but reads ${JSON.stringify(check.value)}`,
675
+ // The value as the file states it: the note's own Addresses are
676
+ // already canonical, and a key is quoted as the key it is.
677
+ message: `\`${path.join(".")}\` ${reason}, but reads ${JSON.stringify(
678
+ (!check.key && frontmatterValueAt(note.raw, path)) || writtenForm(check.value),
679
+ )}`,
654
680
  });
655
681
  }
656
682
  return findings;
@@ -981,10 +1007,10 @@ function checkTags(note, { type }) {
981
1007
  *
982
1008
  * {@link module:engine/art-fields.ART_SLOTS} is the declaration; this is that
983
1009
  * list in the shape the checks below read, so the linter states no art key of
984
- * its own and a slot added there is checked here with no second edit. All four
985
- * are authored under `data:`, which is what `inData` records.
1010
+ * its own and a slot added there is checked here with no second edit. Every
1011
+ * slot is authored under `data:`, which is what `inData` records.
986
1012
  *
987
- * `document` separates the three that reach a compiled document from `banner`,
1013
+ * `document` separates the slots that reach a compiled document from `banner`,
988
1014
  * which reaches none by design. The inert-art check reads it: a key that is
989
1015
  * *meant* to reach no document is not an inert key, and reporting every note
990
1016
  * that names a hero image would bury the finding that matters.
@@ -31,6 +31,7 @@
31
31
  // enum cannot drift apart — a value absent from the list is a build
32
32
  // error, never a silent ship.
33
33
  import { AddressEntries } from "./address-values.mjs";
34
+ import { renderAddress } from "./address-render.mjs";
34
35
  import { AFFILIATION_STANDINGS } from "../sohl/affiliation-standings.mjs";
35
36
 
36
37
  /**
@@ -287,9 +288,13 @@ export function resolveRelation(fm, ctx = "item") {
287
288
  for (const [code, value] of entries) {
288
289
  const standing = String(value);
289
290
  if (!AFFILIATION_STANDINGS.includes(standing)) {
290
- throw new Error(
291
- `${ctx}: ${key}["${code}"] must be one of ${AFFILIATION_STANDINGS.join(", ")}, got "${value}"`,
291
+ const error = new Error(
292
+ `${ctx}: ${key}["${typeof code === "string" ? code : renderAddress(code)}"] must be one of ${AFFILIATION_STANDINGS.join(", ")}, got "${value}"`,
292
293
  );
294
+ // Where the compiler reports it: the authored key's value where the
295
+ // key is still its written text, else the map itself.
296
+ error.keyPath = ["data", key, ...(typeof code === "string" ? [code] : [])];
297
+ throw error;
293
298
  }
294
299
  if (typed) typedEntries.push({ target: code, value: standing, sourceKey: "" });
295
300
  else out[code] = standing;
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import { noteAddressContext } from "./note-addresses.mjs";
43
+ import { GENERATED_DRAWING_DIR } from "./generated-sections.mjs";
43
44
  import fs from "fs";
44
45
  import path from "path";
45
46
  import log from "loglevel";
@@ -678,6 +679,56 @@ export function emptyPassErrors(passes) {
678
679
  );
679
680
  }
680
681
 
682
+ /**
683
+ * Where the module ships the drawings its generated sections name, below the
684
+ * staged package root: `assets/generated/`, so a section's
685
+ * `generated/from-<shortcode>.svg` resolves inside the install as every other
686
+ * own-package pathname does — `modules/<id>/assets/generated/…`.
687
+ *
688
+ * The staged package root is the directory holding the compiled packs, which
689
+ * the manifest addresses as `packs/<name>`.
690
+ *
691
+ * @param {object} config - The resolved configuration.
692
+ * @returns {string} The directory, absolute.
693
+ */
694
+ function stagedDrawingDir(config) {
695
+ return path.join(path.dirname(config.paths.stage), "assets", GENERATED_DRAWING_DIR);
696
+ }
697
+
698
+ /**
699
+ * Draw the map from each place given a **From here** section and stage it
700
+ * into the module, where the section's image resolves. Drawn under
701
+ * `build/map/foundry/` and copied, so the `.dot` each drawing is made from
702
+ * stays out of the module.
703
+ *
704
+ * @param {object[]} records - The corpus this compile derived.
705
+ * @param {object} config - The resolved configuration.
706
+ * @returns {Promise<object[]>} What drawing found, as diagnostics.
707
+ */
708
+ async function stageGeneratedDrawings(records, config) {
709
+ // Imported here: the derivation reads the map world, which reaches the
710
+ // content index, and a static import from this module would close a cycle
711
+ // through the compilers.
712
+ const { drawMapsFrom } = await import("./derived-sections.mjs");
713
+ let foreignIndex = new Map();
714
+ try {
715
+ const { loadForeignIndexes } = await import("./metadata-index.mjs");
716
+ foreignIndex = loadForeignIndexes(config, [config.contentPackage]).index;
717
+ } catch {
718
+ foreignIndex = new Map();
719
+ }
720
+ const staged = stagedDrawingDir(config);
721
+ fs.rmSync(staged, { recursive: true, force: true });
722
+ const { maps, findings } = drawMapsFrom(records, {
723
+ outDir: path.join(config.rootDir, "build", "map", "foundry"),
724
+ foreignIndex,
725
+ config,
726
+ });
727
+ if (maps.size) fs.mkdirSync(staged, { recursive: true });
728
+ for (const [name, file] of maps) fs.copyFileSync(file, path.join(staged, name));
729
+ return findings;
730
+ }
731
+
681
732
  /**
682
733
  * Generate the build-only JSON for every pack (or one, when `only` is given).
683
734
  *
@@ -744,6 +795,8 @@ export async function generatePacksJson({ only, config = loadPackConfig() } = {}
744
795
  problems: corpusProblems,
745
796
  });
746
797
  for (const problem of corpusProblems) emitDiagnostic(problem);
798
+ for (const finding of await stageGeneratedDrawings(corpus.records, config))
799
+ emitDiagnostic(finding);
747
800
 
748
801
  // A note whose `type:` no configured pack claims compiles into nothing, and
749
802
  // used to say nothing — no pass got far enough to reject it, so the