@heroiclands/package-build 22.9.2 → 22.10.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 (96) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/CONTENT.md +33 -0
  3. package/bin/content-build.mjs +3 -1
  4. package/docs/api.md +10 -6
  5. package/docs/content-format.md +384 -200
  6. package/engine/actor-compiler.mjs +20 -7
  7. package/engine/address-fields.mjs +404 -0
  8. package/engine/address-values.mjs +80 -0
  9. package/engine/address.mjs +769 -0
  10. package/engine/art-fields.mjs +112 -97
  11. package/engine/art-slots.mjs +81 -0
  12. package/engine/base-compiler.mjs +59 -7
  13. package/engine/bundle-notes.mjs +5 -2
  14. package/engine/bundles.mjs +17 -10
  15. package/engine/content-address.mjs +23 -186
  16. package/engine/content-embeds.mjs +18 -4
  17. package/engine/content-format-check.mjs +50 -22
  18. package/engine/content-index.mjs +85 -43
  19. package/engine/content-links.mjs +105 -104
  20. package/engine/content-lint.mjs +11 -1
  21. package/engine/content-tables.mjs +9 -1
  22. package/engine/field-spec.mjs +20 -4
  23. package/engine/folder-notes.mjs +44 -15
  24. package/engine/foundry-entries.mjs +3 -0
  25. package/engine/frontmatter-lint.mjs +144 -43
  26. package/engine/frontmatter.mjs +55 -10
  27. package/engine/generate.mjs +7 -1
  28. package/engine/helpers.mjs +37 -9
  29. package/engine/holdings.mjs +41 -12
  30. package/engine/index-records.mjs +2 -0
  31. package/engine/infobox-render.mjs +2 -1
  32. package/engine/infobox.mjs +26 -6
  33. package/engine/item-compiler.mjs +5 -0
  34. package/engine/item-docs.mjs +7 -44
  35. package/engine/map-dot.mjs +2 -0
  36. package/engine/map-notes.mjs +7 -2
  37. package/engine/map-places.mjs +42 -10
  38. package/engine/metadata-index.mjs +60 -5
  39. package/engine/note-addresses.mjs +310 -0
  40. package/engine/note-vocabulary.mjs +145 -26
  41. package/engine/pdf-build.mjs +1 -1
  42. package/engine/pdf-toc.mjs +17 -2
  43. package/engine/place-relations.mjs +160 -50
  44. package/engine/populations.mjs +17 -9
  45. package/engine/reference-targets.mjs +54 -0
  46. package/engine/scenes.mjs +5 -2
  47. package/engine/shortcode-references.mjs +68 -0
  48. package/engine/site-build.mjs +39 -15
  49. package/engine/site-index.mjs +57 -75
  50. package/engine/sql-tables.mjs +43 -9
  51. package/engine/system-block.mjs +21 -0
  52. package/engine/system-vocabulary.mjs +46 -0
  53. package/engine/web-wikilinks.mjs +11 -9
  54. package/engine/wikilink-syntax.mjs +21 -0
  55. package/engine/wikilinks.mjs +92 -306
  56. package/hm3/actors.mjs +8 -24
  57. package/hm3/item-fields.mjs +15 -5
  58. package/package.json +1 -1
  59. package/sohl/infobox.mjs +80 -17
  60. package/sohl/item-fields.mjs +114 -41
  61. package/types/engine/address-fields.d.mts +90 -0
  62. package/types/engine/address-values.d.mts +34 -0
  63. package/types/engine/address.d.mts +452 -0
  64. package/types/engine/art-fields.d.mts +59 -53
  65. package/types/engine/art-slots.d.mts +40 -0
  66. package/types/engine/base-compiler.d.mts +24 -2
  67. package/types/engine/bundle-notes.d.mts +2 -2
  68. package/types/engine/content-address.d.mts +3 -139
  69. package/types/engine/content-embeds.d.mts +1 -1
  70. package/types/engine/content-format-check.d.mts +25 -0
  71. package/types/engine/content-index.d.mts +2 -2
  72. package/types/engine/content-links.d.mts +6 -5
  73. package/types/engine/field-spec.d.mts +44 -9
  74. package/types/engine/folder-notes.d.mts +11 -10
  75. package/types/engine/frontmatter-lint.d.mts +7 -97
  76. package/types/engine/frontmatter.d.mts +16 -1
  77. package/types/engine/helpers.d.mts +2 -2
  78. package/types/engine/infobox.d.mts +6 -0
  79. package/types/engine/item-docs.d.mts +1 -35
  80. package/types/engine/map-notes.d.mts +2 -2
  81. package/types/engine/note-addresses.d.mts +41 -0
  82. package/types/engine/note-vocabulary.d.mts +20 -3
  83. package/types/engine/place-relations.d.mts +12 -1
  84. package/types/engine/populations.d.mts +17 -2
  85. package/types/engine/reference-targets.d.mts +9 -0
  86. package/types/engine/shortcode-references.d.mts +14 -0
  87. package/types/engine/site-build.d.mts +3 -3
  88. package/types/engine/site-index.d.mts +9 -30
  89. package/types/engine/sql-tables.d.mts +2 -2
  90. package/types/engine/system-block.d.mts +17 -0
  91. package/types/engine/system-vocabulary.d.mts +16 -0
  92. package/types/engine/wikilink-syntax.d.mts +7 -0
  93. package/types/engine/wikilinks.d.mts +6 -109
  94. package/types/hm3/item-fields.d.mts +5 -0
  95. package/types/sohl/infobox.d.mts +4 -5
  96. package/types/sohl/item-fields.d.mts +8 -0
@@ -48,6 +48,7 @@
48
48
  * @module
49
49
  */
50
50
 
51
+ import { encodeAddresses } from "./address-values.mjs";
51
52
  import { slugify } from "./content-slug.mjs";
52
53
  import { positionOfYamlPath } from "./diagnostics.mjs";
53
54
 
@@ -464,8 +465,18 @@ export function planDocument(nodes, { selections = new Map() } = {}) {
464
465
  sections: entries.filter((e) => e.kind === "section").length,
465
466
  prose: entries.filter((e) => e.kind === "prose").length,
466
467
  notes: entries.filter((e) => e.kind === "note").length,
467
- distinct: links.size,
468
- repeated: selected - links.size,
468
+ distinct: new Set(
469
+ entries
470
+ .filter((e) => e.kind === "note")
471
+ .map((e) => e.record.address?.slug ?? e.record.shortcode),
472
+ ).size,
473
+ repeated:
474
+ selected -
475
+ new Set(
476
+ entries
477
+ .filter((e) => e.kind === "note")
478
+ .map((e) => e.record.address?.slug ?? e.record.shortcode),
479
+ ).size,
469
480
  skipped: nodes.length - entries.filter((e) => e.kind === "section").length,
470
481
  },
471
482
  };
@@ -521,6 +532,10 @@ function emitSection(node, nodes, ctx) {
521
532
  // First occurrence wins the address: however many times the book
522
533
  // prints an entry, `[[weapongear-dagger]]` reaches one page.
523
534
  if (!ctx.links.has(slug)) ctx.links.set(slug, anchor);
535
+ for (const address of [record?.address?.canonical, record?.documentation]) {
536
+ if (address && !ctx.links.has(encodeAddresses(address)))
537
+ ctx.links.set(encodeAddresses(address), anchor);
538
+ }
524
539
  ctx.entries.push({
525
540
  kind: "note",
526
541
  record,
@@ -18,11 +18,21 @@
18
18
  * two structural relations are stated here: `data.borders`, the places it
19
19
  * shares a frontier with, and `data.routes`, the journeys from its centre to
20
20
  * another place. Both are lists of entries naming the other place by
21
- * shortcode, and both are stated **from each end**: A at `NE` of B is the same
21
+ * **Address**, and both are stated **from each end**: A at `NE` of B is the same
22
22
  * fact as B at `SW` of A, so a note stating one side of it and a neighbour
23
23
  * stating a different side is a contradiction the lint reports rather than a
24
24
  * discovery made while writing a third note.
25
25
  *
26
+ * **`to` is an Address, written at whatever length says what it means.** Its
27
+ * `<type>` segment defaults to `place` and `place` is the whole of its accepted
28
+ * set, so `vylar`, `place-vylar` and `thalorna-none-place-vylar` name one place
29
+ * and a `to` naming anything else is an error. The `<package>` segment defaults
30
+ * to **the package doing the building** — wherever the value was written, and
31
+ * whether it was authored here or carried back by a fetched index — so a short
32
+ * `to` always names a place in the package being built, naming a dependency's
33
+ * place is what the fully qualified form is for, and two places sharing a
34
+ * shortcode in different packages never answer for each other.
35
+ *
26
36
  * **The value sets are closed and stated once.** The eight bearings, the three
27
37
  * travel modes, the days markers and the terrain registry are the constants
28
38
  * below, read by the lint, compared against the specification's tables by the
@@ -45,17 +55,36 @@
45
55
  * through the same resolver a wikilink does and a dependency's places take
46
56
  * part: a fetched index entry carries the relation the record stated, and a
47
57
  * border across a package boundary is checked from both ends like any other.
58
+ * Across that boundary the far side states the frontier in the fully qualified
59
+ * form, which is the only form that names this package's place from over there —
60
+ * a short `to` in a dependency's record names the package being built, so the
61
+ * long form is not optional for a cross-package border.
48
62
  *
49
63
  * @module
50
64
  */
51
65
 
66
+ import { encodeAddresses } from "./address-values.mjs";
67
+
52
68
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
53
- import { isAddressSegment } from "./address-charset.mjs";
69
+ import { acceptsType, parseAddress, renderAddress } from "./address.mjs";
70
+ import { NO_SYSTEM } from "./document-subtypes.mjs";
54
71
 
55
72
  /* --------------------------------------------------------------------- */
56
73
  /* The closed sets */
57
74
  /* --------------------------------------------------------------------- */
58
75
 
76
+ /**
77
+ * The type a border's or a route's `to` names.
78
+ *
79
+ * It is both the default its `<type>` segment takes when an author omits it and
80
+ * the whole of the set the position accepts, which is what makes `vylar` and
81
+ * `place-vylar` the same Address and a `to` naming a `lore` note an error rather
82
+ * than a lookup that quietly finds nothing.
83
+ *
84
+ * @type {string}
85
+ */
86
+ export const RELATION_TYPE = "place";
87
+
59
88
  /**
60
89
  * The eight compass bearings, clockwise from north.
61
90
  *
@@ -160,7 +189,7 @@ export function oppositeBearing(bearing) {
160
189
  * directly, copied from the record when the index was loaded.
161
190
  *
162
191
  * @param {object} hit - What the index resolved.
163
- * @returns {{type: string, borders: unknown, routes: unknown, parents: unknown, name: string}}
192
+ * @returns {{type: string, borders: unknown, routes: unknown, parents: unknown}}
164
193
  * The relation it states.
165
194
  */
166
195
  function relationsOf(hit) {
@@ -171,7 +200,6 @@ function relationsOf(hit) {
171
200
  borders: data.borders,
172
201
  routes: data.routes,
173
202
  parents: data.parents,
174
- name: String(hit.fm.shortcode ?? ""),
175
203
  };
176
204
  }
177
205
  return {
@@ -179,53 +207,84 @@ function relationsOf(hit) {
179
207
  borders: hit?.borders,
180
208
  routes: hit?.routes,
181
209
  parents: hit?.parents,
182
- name: "",
183
210
  };
184
211
  }
185
212
 
186
213
  /**
187
- * The shortcode a `parents` entry names.
214
+ * The Address defaults a place's relation values are read against.
215
+ *
216
+ * The vocabularies are the tree's, so every position reads one grammar, and
217
+ * there is **one** set of defaults: the package is always the one being built.
218
+ * That is the whole of the cross-package rule, and it holds for both ends of a
219
+ * frontier — a note's own relations and the relations a fetched index carries
220
+ * back are read the same way, so a short value in a dependency's record names a
221
+ * place in the package being built and a dependency names a place of its own by
222
+ * writing the Address in full. `place` is the type an omitted `<type>` segment
223
+ * takes, and a place is a core document, so the system is `none`.
224
+ *
225
+ * @param {object} [index] - The link index, for the tree's vocabularies and the
226
+ * package being built.
227
+ * @returns {object} The defaults {@link parseAddress} takes.
228
+ */
229
+ function addressDefaults(index) {
230
+ return {
231
+ types: index?.types ?? new Set([RELATION_TYPE]),
232
+ packages: index?.packages,
233
+ noIndexPackages: index?.noIndexPackages,
234
+ package: index?.contentPackage,
235
+ system: NO_SYSTEM,
236
+ type: RELATION_TYPE,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * The canonical Address a written relation value names, or `undefined` where it
242
+ * names no place.
188
243
  *
189
- * A parent is written as a bare shortcode, and may be written as an address;
190
- * a shortcode is the last segment either way, because a segment carries no
191
- * separator.
244
+ * Every comparison between two ends of a relation goes through this, so the two
245
+ * are compared as the Addresses they name rather than as the strings two authors
246
+ * happened to type.
192
247
  *
193
- * @param {unknown} parent - One `parents` entry.
194
- * @returns {string} Its shortcode.
248
+ * @param {unknown} written - The value as authored.
249
+ * @param {object} defaults - What {@link addressDefaults} returns.
250
+ * @returns {string|undefined} The canonical `package-none-place-shortcode`.
195
251
  */
196
- function parentShortcode(parent) {
197
- return String(parent ?? "")
198
- .split("-")
199
- .pop()
200
- .toLowerCase();
252
+ function placeAddress(written, defaults) {
253
+ if (!defaults.package) return undefined;
254
+ const read = parseAddress(written, defaults);
255
+ if (read.reason || !acceptsType(read, [RELATION_TYPE])) return undefined;
256
+ return renderAddress(read);
201
257
  }
202
258
 
203
259
  /**
204
260
  * Whether one place's `parents` names another.
205
261
  *
206
262
  * @param {unknown} parents - The `parents` list.
207
- * @param {string} shortcode - The place asked about.
263
+ * @param {string|undefined} address - The place asked about, as its Address.
264
+ * @param {object} defaults - What {@link addressDefaults} returns.
208
265
  * @returns {boolean} Whether it is named.
209
266
  */
210
- function hasParent(parents, shortcode) {
211
- return Array.isArray(parents) && parents.some((p) => parentShortcode(p) === shortcode);
267
+ function hasParent(parents, address, defaults) {
268
+ if (!address || !Array.isArray(parents)) return false;
269
+ return parents.some((p) => placeAddress(p, defaults) === address);
212
270
  }
213
271
 
214
272
  /**
215
273
  * The entries of a relation list that name one place.
216
274
  *
217
275
  * @param {unknown} list - A `borders` or `routes` value.
218
- * @param {string} shortcode - The place they name.
276
+ * @param {string|undefined} address - The place they name, as its Address.
277
+ * @param {object} defaults - What {@link addressDefaults} returns.
219
278
  * @returns {Record<string, unknown>[]} The entries.
220
279
  */
221
- function entriesTo(list, shortcode) {
222
- if (!Array.isArray(list)) return [];
280
+ function entriesTo(list, address, defaults) {
281
+ if (!address || !Array.isArray(list)) return [];
223
282
  return list.filter(
224
283
  (e) =>
225
284
  e &&
226
285
  typeof e === "object" &&
227
286
  !Array.isArray(e) &&
228
- String(e.to ?? "").toLowerCase() === shortcode,
287
+ placeAddress(e.to, defaults) === address,
229
288
  );
230
289
  }
231
290
 
@@ -289,6 +348,19 @@ function checkRelation(note, { field, index }) {
289
348
 
290
349
  const raw = note.raw ?? "";
291
350
  const self = String(fm.shortcode ?? "").toLowerCase();
351
+ // One set of defaults, read by both ends: an omitted `<package>` segment is
352
+ // always the package being built.
353
+ const here = addressDefaults(index);
354
+ /** This place, as the Address the far end has to name it by. */
355
+ const selfAddress =
356
+ here.package && self ?
357
+ renderAddress({
358
+ package: here.package,
359
+ system: NO_SYSTEM,
360
+ type: RELATION_TYPE,
361
+ shortcode: self,
362
+ })
363
+ : undefined;
292
364
  const isRoute = field === "routes";
293
365
  const keys = isRoute ? ROUTE_KEYS : BORDER_KEYS;
294
366
  const at = (i, key) =>
@@ -327,21 +399,46 @@ function checkRelation(note, { field, index }) {
327
399
  );
328
400
  }
329
401
 
330
- // `to`: a shortcode, and one a place declares.
402
+ // `to`: an Address naming a place, and one a place declares.
331
403
  const to = entry.to;
332
404
  let target;
333
405
  let toIsValid = false;
406
+ /** The one canonical Address `to` names, once it parses. */
407
+ let toAddress;
408
+ const read = parseAddress(to, here);
334
409
  if (to === undefined || to === null || to === "") {
335
410
  error(i, undefined, `${label(i)} must name the other place in \`to\``);
336
- } else if (typeof to !== "string" || !isAddressSegment(to)) {
411
+ } else if (
412
+ read.reason === "no-content-index" ||
413
+ (!read.reason && here.noIndexPackages?.has(read.package))
414
+ ) {
415
+ error(
416
+ i,
417
+ "to",
418
+ `${label(i, "to")} names package "${read.package}", which is declared ` +
419
+ `\`contentIndex: false\` — a Foundry dependency only, with no content ` +
420
+ `index fetched for it, so nothing it publishes can be named here`,
421
+ );
422
+ } else if (read.reason) {
423
+ error(
424
+ i,
425
+ "to",
426
+ `${label(i, "to")} must name a place — the other place's \`shortcode\`, or ` +
427
+ `its address as \`${RELATION_TYPE}-<shortcode>\` or ` +
428
+ `\`<package>-${NO_SYSTEM}-${RELATION_TYPE}-<shortcode>\` for a place in ` +
429
+ `another package — but reads ${JSON.stringify(encodeAddresses(to))}`,
430
+ );
431
+ } else if (!acceptsType(read, [RELATION_TYPE])) {
337
432
  error(
338
433
  i,
339
434
  "to",
340
- `${label(i, "to")} must be a shortcode — the other place's ` +
341
- `\`shortcode\`, not an address — but reads ${JSON.stringify(to)}`,
435
+ `${label(i, "to")} names a \`${read.type}\`, and a ` +
436
+ `${isRoute ? "route" : "border"} is between places, so it names a ` +
437
+ `\`${RELATION_TYPE}\` — but reads ${JSON.stringify(encodeAddresses(to))}`,
342
438
  );
343
439
  } else if (index) {
344
- const hit = index.referenceHit(`place-${to}`);
440
+ toAddress = renderAddress(read);
441
+ const hit = index.addressHit(toAddress);
345
442
  if (hit) {
346
443
  target = relationsOf(hit);
347
444
  toIsValid = true;
@@ -350,14 +447,18 @@ function checkRelation(note, { field, index }) {
350
447
  // slip, and naming it says what was found rather than only
351
448
  // what was not.
352
449
  const other = (index.notes ?? []).find(
353
- (n) => String(n.fm?.shortcode ?? "").toLowerCase() === to.toLowerCase(),
450
+ (n) =>
451
+ String(n.fm?.shortcode ?? "").toLowerCase() === read.shortcode &&
452
+ String(n.type ?? "") !== RELATION_TYPE,
354
453
  );
355
454
  error(
356
455
  i,
357
456
  "to",
358
- `${label(i, "to")} names "${to}", and no place in this package or a ` +
359
- `fetched index declares that shortcode` +
360
- (other ? `; the ${other.type} note "${to}" is not a place` : ""),
457
+ `${label(i, "to")} names "${encodeAddresses(to)}", and no place at ${toAddress} is ` +
458
+ `declared by this package or by a fetched index` +
459
+ (other ?
460
+ `; the ${other.type} note "${read.shortcode}" is not a place`
461
+ : ""),
361
462
  );
362
463
  }
363
464
  } else {
@@ -463,7 +564,11 @@ function checkRelation(note, { field, index }) {
463
564
  }
464
565
 
465
566
  if (!toIsValid) return;
466
- const toKey = String(to).toLowerCase();
567
+ // The pair is keyed on the Address rather than on the written form, so
568
+ // one place named once bare and once qualified is the one pair it is.
569
+ // With no index there is no package to complete the Address with, and
570
+ // the shortcode is all the note itself states.
571
+ const toKey = toAddress ?? read.shortcode;
467
572
 
468
573
  // Once per pair — per mode, on a route.
469
574
  const modes = seen.get(toKey) ?? new Set();
@@ -473,9 +578,9 @@ function checkRelation(note, { field, index }) {
473
578
  i,
474
579
  undefined,
475
580
  isRoute ?
476
- `${label(i)} lists "${to}" by ${mode} twice; a pair appears in \`routes\` ` +
581
+ `${label(i)} lists "${encodeAddresses(to)}" by ${mode} twice; a pair appears in \`routes\` ` +
477
582
  `once per mode`
478
- : `${label(i)} lists "${to}" twice; a pair appears in \`borders\` once`,
583
+ : `${label(i)} lists "${encodeAddresses(to)}" twice; a pair appears in \`borders\` once`,
479
584
  );
480
585
  return;
481
586
  }
@@ -488,7 +593,7 @@ function checkRelation(note, { field, index }) {
488
593
  error(
489
594
  i,
490
595
  "to",
491
- `${label(i, "to")} names "${to}", which is a ${target.type} note; a ` +
596
+ `${label(i, "to")} names "${encodeAddresses(to)}", which is a ${target.type} note; a ` +
492
597
  `${field === "borders" ? "border" : "route"} is between places`,
493
598
  );
494
599
  return;
@@ -497,20 +602,20 @@ function checkRelation(note, { field, index }) {
497
602
  // Containment is `parents`; a border to a parent or a child restates
498
603
  // it as adjacency, and the two cannot both be true.
499
604
  if (!isRoute) {
500
- if (hasParent(data.parents, toKey)) {
605
+ if (hasParent(data.parents, toAddress, here)) {
501
606
  error(
502
607
  i,
503
608
  undefined,
504
- `${label(i)} borders "${to}", which is this place's parent; ` +
609
+ `${label(i)} borders "${encodeAddresses(to)}", which is this place's parent; ` +
505
610
  `containment is \`parents\`, and a place never borders its parent`,
506
611
  );
507
612
  return;
508
613
  }
509
- if (hasParent(target.parents, self)) {
614
+ if (hasParent(target.parents, selfAddress, here)) {
510
615
  error(
511
616
  i,
512
617
  undefined,
513
- `${label(i)} borders "${to}", which is this place's child; ` +
618
+ `${label(i)} borders "${encodeAddresses(to)}", which is this place's child; ` +
514
619
  `containment is \`parents\`, and a place never borders its child`,
515
620
  );
516
621
  return;
@@ -518,8 +623,13 @@ function checkRelation(note, { field, index }) {
518
623
  }
519
624
 
520
625
  if (!bearingIsValid) return;
521
- const back = entriesTo(target[field], self);
626
+ const back = entriesTo(target[field], selfAddress, here);
522
627
  const expected = oppositeBearing(bearing);
628
+ // How the far end writes this place: its bare `shortcode` within the same
629
+ // package, and the full Address across a package boundary, where the far
630
+ // note is authored in a package whose own short forms name its own
631
+ // places.
632
+ const selfThere = read.package === here.package || !selfAddress ? self : selfAddress;
523
633
 
524
634
  if (!isRoute) {
525
635
  if (back.length === 0) {
@@ -528,9 +638,9 @@ function checkRelation(note, { field, index }) {
528
638
  i,
529
639
  undefined,
530
640
  "warning",
531
- `"${self}" borders "${to}" at ${bearing}, but "${to}" states no ` +
641
+ `"${self}" borders "${encodeAddresses(to)}" at ${bearing}, but "${encodeAddresses(to)}" states no ` +
532
642
  `border to "${self}"; it should carry ` +
533
- `\`{ to: ${self}, bearing: ${expected} }\``,
643
+ `\`{ to: ${selfThere}, bearing: ${expected} }\``,
534
644
  ),
535
645
  );
536
646
  return;
@@ -540,7 +650,7 @@ function checkRelation(note, { field, index }) {
540
650
  error(
541
651
  i,
542
652
  "bearing",
543
- `"${self}" borders "${to}" at ${bearing}, so "${to}" should state the ` +
653
+ `"${self}" borders "${encodeAddresses(to)}" at ${bearing}, so "${encodeAddresses(to)}" should state the ` +
544
654
  `border back at bearing ${expected}, but states ${theirs}`,
545
655
  );
546
656
  }
@@ -554,9 +664,9 @@ function checkRelation(note, { field, index }) {
554
664
  i,
555
665
  undefined,
556
666
  "warning",
557
- `"${self}" reaches "${to}" by ${mode} in ${days} days, but "${to}" ` +
667
+ `"${self}" reaches "${encodeAddresses(to)}" by ${mode} in ${days} days, but "${encodeAddresses(to)}" ` +
558
668
  `states no route to "${self}"; it should carry ` +
559
- `\`{ to: ${self}, bearing: ${expected}, mode: ${mode}, days: ${days} }\``,
669
+ `\`{ to: ${selfThere}, bearing: ${expected}, mode: ${mode}, days: ${days} }\``,
560
670
  ),
561
671
  );
562
672
  return;
@@ -573,7 +683,7 @@ function checkRelation(note, { field, index }) {
573
683
  error(
574
684
  i,
575
685
  "mode",
576
- `"${self}" reaches "${to}" by ${mode}, but "${to}" states the route back ` +
686
+ `"${self}" reaches "${encodeAddresses(to)}" by ${mode}, but "${encodeAddresses(to)}" states the route back ` +
577
687
  `by ${oneOf(theirs)} only; a pair is stated by the same mode at ` +
578
688
  `both ends`,
579
689
  );
@@ -584,7 +694,7 @@ function checkRelation(note, { field, index }) {
584
694
  error(
585
695
  i,
586
696
  "bearing",
587
- `"${self}" reaches "${to}" at ${bearing}, so "${to}" should state the route ` +
697
+ `"${self}" reaches "${encodeAddresses(to)}" at ${bearing}, so "${encodeAddresses(to)}" should state the route ` +
588
698
  `back at bearing ${expected}, but states ${theirBearing}`,
589
699
  );
590
700
  }
@@ -593,7 +703,7 @@ function checkRelation(note, { field, index }) {
593
703
  error(
594
704
  i,
595
705
  "days",
596
- `"${self}" reaches "${to}" by ${mode} in ${days} days, but "${to}" states ` +
706
+ `"${self}" reaches "${encodeAddresses(to)}" by ${mode} in ${days} days, but "${encodeAddresses(to)}" states ` +
597
707
  `the route back as ${theirDays}; both ends state the same days`,
598
708
  );
599
709
  }
@@ -605,7 +715,7 @@ function checkRelation(note, { field, index }) {
605
715
  /**
606
716
  * Check a place note's `data.borders`.
607
717
  *
608
- * Every `to` is a shortcode a place declares, every `bearing` is one of the
718
+ * Every `to` is an Address a place declares, every `bearing` is one of the
609
719
  * eight, no pair is listed twice, no target is the note's own parent or child,
610
720
  * and the other end states the pair back at the opposite bearing — a missing
611
721
  * reciprocal is a warning naming both notes, a wrong one an error.
@@ -54,6 +54,7 @@
54
54
  */
55
55
 
56
56
  import { positionOfFrontmatterPath, positionOfLiteral } from "./diagnostics.mjs";
57
+ import { readCanonicalKey } from "./content-address.mjs";
57
58
  import { shortcodesOf } from "./holdings.mjs";
58
59
 
59
60
  /**
@@ -145,8 +146,12 @@ const CORPUS = new WeakMap();
145
146
  * whose `parents` name it.
146
147
  * @property {Map<string, string[]>} holdersOf Place shortcode → the polities
147
148
  * whose `domains` name it.
148
- * @property {Map<string, PopulationNode>} byAddress `type-shortcode` → the node,
149
- * for the citation rule.
149
+ * @property {Map<string, PopulationNode>} byAddress `type/shortcode` → the
150
+ * node, for the citation rule.
151
+ * Package-blind by design: a
152
+ * local note shadows a
153
+ * dependency's, so this is
154
+ * never a canonical Address.
150
155
  */
151
156
 
152
157
  /**
@@ -219,10 +224,10 @@ function localNode(note) {
219
224
  * @param {object} entry - The entry.
220
225
  * @returns {PopulationNode|null} The node.
221
226
  */
222
- function foreignNode(canonical, entry) {
227
+ export function foreignNode(canonical, entry) {
223
228
  const type = String(entry?.type ?? "");
224
229
  if (type !== "place" && type !== "affiliation") return null;
225
- const shortcode = canonical.split("-").pop()?.toLowerCase() ?? "";
230
+ const shortcode = readCanonicalKey(canonical)?.shortcode?.toLowerCase() ?? "";
226
231
  if (!shortcode) return null;
227
232
  return {
228
233
  shortcode,
@@ -259,8 +264,11 @@ function corpusOf(index) {
259
264
  const by = node.type === "place" ? places : affiliations;
260
265
  if (by.has(node.shortcode)) return;
261
266
  by.set(node.shortcode, node);
262
- const address = `${node.type}-${node.shortcode}`;
263
- if (!byAddress.has(address)) byAddress.set(address, node);
267
+ // Not a canonical Address: package-blind by design, so a local place
268
+ // shadows a foreign one of the same type and shortcode. `/` keeps that
269
+ // distinct from the `-`-joined form every canonical Address renders.
270
+ const key = `${node.type}/${node.shortcode}`;
271
+ if (!byAddress.has(key)) byAddress.set(key, node);
264
272
  };
265
273
 
266
274
  for (const note of index.notes ?? []) take(localNode(note));
@@ -499,7 +507,7 @@ export function checkPopulation(note, { index } = {}) {
499
507
  * @type {RegExp}
500
508
  */
501
509
  const CITED =
502
- /\[\[((?:place|affiliation)-[a-z0-9.]+)(?:\\?\|[^\]]*)?\]\]([^[\n]{0,80}?)~([0-9](?:[0-9,]*[0-9])?)(?![\w%]|[.,]\d|[-–—])/g;
510
+ /\[\[(place|affiliation)-([a-z0-9.]+)(?:\\?\|[^\]]*)?\]\]([^[\n]{0,80}?)~([0-9](?:[0-9,]*[0-9])?)(?![\w%]|[.,]\d|[-–—])/g;
503
511
 
504
512
  /**
505
513
  * Check the populations a `doc` note cites against the notes they belong to.
@@ -531,8 +539,8 @@ export function checkCitedPopulations(note, { index } = {}) {
531
539
 
532
540
  CITED.lastIndex = 0;
533
541
  for (const match of body.matchAll(CITED)) {
534
- const [, address, , figure] = match;
535
- const target = corpus.byAddress.get(address.toLowerCase());
542
+ const [, type, shortcode, , figure] = match;
543
+ const target = corpus.byAddress.get(`${type}/${shortcode}`.toLowerCase());
536
544
  if (!target || target.population === undefined) continue;
537
545
  const claimed = Number(figure.replace(/,/g, ""));
538
546
  if (!Number.isFinite(claimed) || claimed === target.population) continue;
@@ -0,0 +1,54 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ * SPDX-License-Identifier: GPL-3.0-or-later
5
+ */
6
+
7
+ import { completeAddress, renderAddress } from "./address.mjs";
8
+ import { systemOf } from "./document-subtypes.mjs";
9
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
10
+
11
+ /**
12
+ * Names and published destinations for reference presentation.
13
+ * Stubs contribute names, never pages or documents. The published index
14
+ * remains separate so body-link validation retains its publication rules.
15
+ * @param {readonly object[]} records - Decoded local or fetched records.
16
+ * @param {Map<string, object>} [published] - Actual published link destinations.
17
+ * @returns {Map<string, object>} Canonical lookup keys and presentation values.
18
+ */
19
+ export function buildReferenceTargets(records, published = new Map()) {
20
+ const targets = new Map();
21
+ for (const record of records) {
22
+ if (!record.package || !record.type || !record.shortcode) continue;
23
+ const value = { name: record.name?.full ?? record.name, subType: record.subType };
24
+ for (const system of new Set([
25
+ systemOf(record.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
26
+ "none",
27
+ ])) {
28
+ const address = completeAddress({
29
+ package: record.package,
30
+ system,
31
+ type: record.type,
32
+ shortcode: record.shortcode,
33
+ });
34
+ const key = renderAddress(address);
35
+ targets.set(key, { ...targets.get(key), ...value });
36
+ }
37
+ for (const [system, entry] of Object.entries(record.foundry ?? {})) {
38
+ const address = completeAddress({
39
+ package: record.package,
40
+ system,
41
+ type: record.type,
42
+ shortcode: record.shortcode,
43
+ });
44
+ const key = renderAddress(address);
45
+ targets.set(key, {
46
+ ...targets.get(key),
47
+ ...value,
48
+ ...(entry.uuid ? { uuid: entry.uuid } : {}),
49
+ });
50
+ }
51
+ }
52
+ for (const [key, value] of published) targets.set(key, { ...targets.get(key), ...value });
53
+ return targets;
54
+ }
package/engine/scenes.mjs CHANGED
@@ -427,8 +427,11 @@ export class Scenes extends BasePackCompiler {
427
427
  const scene = buildScene(fm, {
428
428
  packageId: foundryPackageId(),
429
429
  // The art resolver, so the map pass turns an address into the path
430
- // each surface serves without holding an index of its own.
431
- art: (value, key, type) => this.artPathOf(value, key, type),
430
+ // each surface serves without holding an index of its own. `accepts`
431
+ // is threaded through so `bgImage`, one of the four declared art
432
+ // slots, is held to its own accepted set here exactly as it is
433
+ // through `artPath`.
434
+ art: (value, key, type, accepts) => this.artPathOf(value, key, type, accepts),
432
435
  name,
433
436
  folder,
434
437
  stats: this.stats,
@@ -0,0 +1,68 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ * SPDX-License-Identifier: GPL-3.0-or-later
5
+ */
6
+
7
+ import { completeAddress, isAddressTuple, readCanonicalKey, renderAddress } from "./address.mjs";
8
+
9
+ const nativeIndexes = new WeakMap();
10
+
11
+ /**
12
+ * Index a completed presentation map by native namespace and Shortcode.
13
+ * Callers finish populating a map before resolving references through it.
14
+ * @param {Map<string, object>} index - Completed identity index.
15
+ * @returns {Map<string, object[]>} Native matches grouped by declared namespace.
16
+ */
17
+ function nativeIndex(index) {
18
+ if (nativeIndexes.has(index)) return nativeIndexes.get(index);
19
+ const grouped = new Map();
20
+ for (const [key, value] of index) {
21
+ const address = readCanonicalKey(key);
22
+ if (!isAddressTuple(address)) continue;
23
+ const namespace = `${address.system}/${address.type}/${address.shortcode}`;
24
+ const matches = grouped.get(namespace) ?? [];
25
+ matches.push({ key, address, value });
26
+ grouped.set(namespace, matches);
27
+ }
28
+ nativeIndexes.set(index, grouped);
29
+ return grouped;
30
+ }
31
+
32
+ /**
33
+ * Present a native Shortcode using its unique system/type target.
34
+ * Address properties never use this lookup. Multiple packages claiming the
35
+ * same native identity remain ambiguous. Documentation supplies links; the
36
+ * native target supplies a name even when its documentation is unpublished.
37
+ * @param {readonly Map<string, object>[]} indexes - Published identity indexes.
38
+ * @param {unknown} code - Native Shortcode.
39
+ * @param {{type: string, system: string}} hint - Its declared native namespace.
40
+ * @returns {object|undefined} Documentation presentation, or no unique target.
41
+ */
42
+ export function resolveShortcodeReference(indexes, code, hint) {
43
+ if (typeof code !== "string" || !code || !hint.type || !hint.system) return undefined;
44
+ const matches = new Map();
45
+ const namespace = `${hint.system}/${hint.type}/${code.toLowerCase()}`;
46
+ for (const index of indexes) {
47
+ if (!index) continue;
48
+ for (const match of nativeIndex(index).get(namespace) ?? [])
49
+ if (!matches.has(match.key)) matches.set(match.key, match);
50
+ }
51
+ if (matches.size !== 1) return undefined;
52
+ const { address: native, value } = matches.values().next().value;
53
+ const address = completeAddress({
54
+ package: native.package,
55
+ system: "none",
56
+ type: native.type,
57
+ shortcode: native.shortcode,
58
+ });
59
+ const key = renderAddress(address);
60
+ const documentation = indexes.map((index) => index?.get(key)).find(Boolean);
61
+ return {
62
+ name: documentation?.name ?? value.name,
63
+ subType: documentation?.subType ?? value.subType,
64
+ address,
65
+ ...(documentation?.uuid ? { uuid: documentation.uuid } : {}),
66
+ ...(documentation?.url ? { url: documentation.url } : {}),
67
+ };
68
+ }