@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
@@ -43,7 +43,19 @@
43
43
  * @module
44
44
  */
45
45
 
46
+ import { isAddressTuple } from "./address.mjs";
46
47
  import { positionInFrontmatter } from "./diagnostics.mjs";
48
+ import { parseAddress } from "./address.mjs";
49
+ import { readCanonicalKey } from "./content-address.mjs";
50
+
51
+ /**
52
+ * The one type a `parents` or `domains` entry may name — the default
53
+ * {@link module:engine/address.parseAddress} fills in when the segment is
54
+ * omitted, and the whole of what this position accepts.
55
+ *
56
+ * @type {ReadonlySet<string>}
57
+ */
58
+ const PLACE_TYPES = Object.freeze(new Set(["place"]));
47
59
 
48
60
  /**
49
61
  * The keys this module writes, and which a note cannot author: `contains`
@@ -103,22 +115,39 @@ export const HELD_SUBTYPES = Object.freeze(["settlement", "site", "structure"]);
103
115
  /**
104
116
  * The shortcode a `parents` or `domains` entry names, lower case.
105
117
  *
106
- * An entry is written as a bare shortcode, as an address, or as a wikilink
107
- * carrying either; the shortcode is the last segment of the address in every
108
- * case, because a segment carries no separator.
118
+ * **A `parents` or `domains` entry is an Address** — the specification types
119
+ * both `Address[]`, targeting `place` — read at whatever length says what it
120
+ * means, `place` the default and the whole of the accepted set. Frontmatter
121
+ * never holds a Wikilink, so no bracket, `|` or `#` is stripped here; a
122
+ * qualified form is read structurally by {@link readCanonicalKey} for its
123
+ * literal four segments, since this position asks neither its package nor its
124
+ * system, only the shortcode {@link module:engine/address} would resolve it
125
+ * to anyway.
126
+ *
127
+ * **The match this shortcode feeds is deliberately package-blind.** `places`
128
+ * and `affiliations` merge this package's own notes with every fetched
129
+ * dependency's into one shortcode space, first declaration wins — the way a
130
+ * dependency's generic geography attaches to whatever a consumer calls the
131
+ * same shortcode, which is the point of _A dependency's places and
132
+ * affiliations take part_ above. Comparing the full Address instead would
133
+ * ask an entry's package to agree with the citing note's, and a fetched
134
+ * entry's own `parents` or `domains` is written in *its* package while the
135
+ * blend it takes part in is *this* one's — see
136
+ * `tests/site-holdings.test.ts`'s `empire.domains: ["mill", "abroad"]`, where
137
+ * `mill` is local and `abroad` is the dependency's own, both bare, both
138
+ * correct only because neither package is asked.
109
139
  *
110
140
  * @param {unknown} value - One entry.
111
141
  * @returns {string} Its shortcode, or `""` for an entry that names nothing.
112
142
  */
113
143
  function namedShortcode(value) {
114
- const text = String(value ?? "")
115
- .trim()
116
- .replace(/^\[\[/, "")
117
- .replace(/\]\]$/, "")
118
- .split("|")[0]
119
- .split("#")[0]
120
- .trim();
121
- return text.split("-").pop()?.toLowerCase() ?? "";
144
+ if (isAddressTuple(value)) return value.shortcode.toLowerCase();
145
+ const text = String(value ?? "").trim();
146
+ if (!text) return "";
147
+ const qualified = readCanonicalKey(text);
148
+ if (qualified) return qualified.shortcode.toLowerCase();
149
+ const read = parseAddress(text, { type: "place", types: PLACE_TYPES });
150
+ return read.reason ? "" : read.shortcode.toLowerCase();
122
151
  }
123
152
 
124
153
  /**
@@ -244,7 +273,7 @@ export function foreignHoldingsNodes(foreignIndex) {
244
273
  for (const [canonical, entry] of foreignIndex) {
245
274
  const type = String(entry?.type ?? "");
246
275
  if (type !== "place" && type !== "affiliation") continue;
247
- const shortcode = canonical.split("-").pop()?.toLowerCase() ?? "";
276
+ const shortcode = readCanonicalKey(canonical)?.shortcode?.toLowerCase() ?? "";
248
277
  if (!shortcode) continue;
249
278
  out.push({
250
279
  shortcode,
@@ -32,6 +32,7 @@
32
32
  */
33
33
 
34
34
  import path from "node:path";
35
+ import { isAddressTuple } from "./address.mjs";
35
36
 
36
37
  /**
37
38
  * The keys the content index adds to a record, which a note therefore may not
@@ -72,6 +73,7 @@ export const DERIVED_KEYS = Object.freeze([
72
73
  * @returns {unknown} The value with every plain object's keys in sorted order.
73
74
  */
74
75
  export function sortKeysDeep(value) {
76
+ if (isAddressTuple(value)) return value;
75
77
  if (Array.isArray(value)) return value.map(sortKeysDeep);
76
78
  if (value === null || typeof value !== "object") return value;
77
79
  // A Date or any other exotic object would lose itself in a rebuild from
@@ -34,6 +34,7 @@
34
34
  * @module
35
35
  */
36
36
 
37
+ import { encodeAddresses } from "./address-values.mjs";
37
38
  import { sectionHolds } from "./infobox.mjs";
38
39
  import { escapeTypst, escapeTypstString } from "./pdf-render.mjs";
39
40
 
@@ -227,7 +228,7 @@ export function sectionHasContent(section) {
227
228
  */
228
229
  export function linkToTypst(value, links, labelFor) {
229
230
  const text = escapeTypst(value?.text ?? "");
230
- const anchor = value?.address ? links?.get(value.address) : undefined;
231
+ const anchor = value?.address ? links?.get(encodeAddresses(value.address)) : undefined;
231
232
  if (anchor) return `#link(<${labelFor(anchor)}>)[${text}]`;
232
233
  if (value?.url) return `#link("${escapeTypstString(value.url)}")[${text}]`;
233
234
  return text;
@@ -112,11 +112,13 @@
112
112
  * @module
113
113
  */
114
114
 
115
+ import { isAddressTuple, renderAddress } from "./address.mjs";
115
116
  import { getFrontmatter } from "./frontmatter.mjs";
116
117
  import { currentType } from "./ids.mjs";
117
118
  import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
118
119
  import { readAliasedField } from "./retired-fields.mjs";
119
120
  import { subtypeRow } from "./document-subtypes.mjs";
121
+ import { authoredKey } from "./system-block.mjs";
120
122
 
121
123
  /**
122
124
  * How a section arranges what it holds.
@@ -326,13 +328,20 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
326
328
  * `<type>.<field>` wins over the bare name, so a spelling two types use for two
327
329
  * quantities can be said differently on each without splitting the overlay.
328
330
  *
331
+ * **Keyed by the key an author writes, not by the path a declaration names.**
332
+ * One fact has two declarations — the note vocabulary's `weight` and an item
333
+ * field's `data.weight` — and the overlay says what a *reader* is shown, which
334
+ * is one word either way. So the container is dropped before the lookup, and
335
+ * `GEAR_UNITS` answers for both sides with one entry.
336
+ *
329
337
  * @param {Readonly<Record<string, object>>} presentation - The overlay.
330
338
  * @param {string|undefined} type - The note's type.
331
339
  * @param {string} name - The field's declared name.
332
340
  * @returns {object} The entry, or an empty one.
333
341
  */
334
342
  export function overlayFor(presentation, type, name) {
335
- return presentation?.[`${currentType(type)}.${name}`] ?? presentation?.[name] ?? {};
343
+ const key = authoredKey(name);
344
+ return presentation?.[`${currentType(type)}.${key}`] ?? presentation?.[key] ?? {};
336
345
  }
337
346
 
338
347
  /** What a composed group's row is called. @type {Readonly<Record<string, string>>} */
@@ -418,6 +427,7 @@ export function isUnsetSentinel(value) {
418
427
  * @returns {boolean} Whether to emit it.
419
428
  */
420
429
  export function hasValue(value) {
430
+ if (isAddressTuple(value)) return true;
421
431
  if (value == null) return false;
422
432
  if (typeof value === "string") return value.trim() !== "" && !isUnsetSentinel(value);
423
433
  if (Array.isArray(value)) return value.some((entry) => hasValue(entry));
@@ -454,6 +464,7 @@ export function humanizeFieldName(name) {
454
464
  * @returns {string} The text.
455
465
  */
456
466
  export function humanizeValue(value) {
467
+ if (isAddressTuple(value)) value = value.shortcode;
457
468
  return String(value ?? "")
458
469
  .replace(/[_-]+/g, " ")
459
470
  .trim();
@@ -472,6 +483,7 @@ export function humanizeValue(value) {
472
483
  * @returns {string} The text.
473
484
  */
474
485
  export function presentValue(value) {
486
+ if (isAddressTuple(value)) value = value.shortcode;
475
487
  const text = humanizeValue(value);
476
488
  if (!/^[a-z][a-z0-9_-]*$/.test(String(value ?? ""))) return text;
477
489
  return text.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
@@ -507,8 +519,12 @@ export function hasRenderableValue(kind, value) {
507
519
  * @returns {string} One of {@link INFOBOX_VALUE_KINDS}.
508
520
  */
509
521
  export function valueKindOf(field, value) {
510
- if (field?.shape === "a wikilink") return "link";
511
- if (field?.shape === "list of wikilinks") return "links";
522
+ if (field?.kind === "address" || field?.shape === "a wikilink") return "link";
523
+ if (
524
+ (field?.kind === "list" && field?.entryKind === "address") ||
525
+ field?.shape === "list of wikilinks"
526
+ )
527
+ return "links";
512
528
  if (field?.kind === "number") return "number";
513
529
  if (field?.kind === "list") return "list";
514
530
  if (field?.kind === "string") return "text";
@@ -814,19 +830,23 @@ export function systemRowsSection(
814
830
  ) {
815
831
  const rows = [];
816
832
  for (const field of fields ?? []) {
817
- if (!field?.name || taken.has(field.name)) continue;
833
+ // Asked on the authored key: the note box records what it showed by the
834
+ // key a note writes, and a field whose shared source is a path into
835
+ // `data:` names the container as well — so comparing the declared names
836
+ // would put one fact on two surfaces.
837
+ if (!field?.name || taken.has(authoredKey(field.name))) continue;
818
838
  const overlay = overlayFor(presentation, fm?.type, field.name);
819
839
  if (overlay.withheld) continue;
820
840
  const { value: raw, from } = resolveField(field, fm, { block });
821
841
  if (from === "default" || from === "value") continue;
822
842
  if (isDeclaredDefault(field, raw)) continue;
823
843
  if (!hasValue(raw)) continue;
824
- const declaredKind = field.ref ? "link" : valueKindOf(field, raw);
844
+ const declaredKind = field.code ? "link" : valueKindOf(field, raw);
825
845
  const built = rowValue(
826
846
  declaredKind,
827
847
  raw,
828
848
  resolve,
829
- field.ref ? { type: field.ref } : undefined,
849
+ field.code ? { kind: "shortcode", type: field.code, system: block } : undefined,
830
850
  );
831
851
  if (!hasRenderableValue(declaredKind, built)) continue;
832
852
  const { kind, value } = applyUnit(declaredKind, built, overlay.unit);
@@ -355,6 +355,11 @@ export class SystemItemCompiler extends BasePackCompiler {
355
355
  block: system,
356
356
  claimed: claimedPaths(itemFields(type, system)),
357
357
  });
358
+ // Every field whose authored value is an Address and whose emitted value
359
+ // is a Shortcode, reduced once the block is whole — so a value the field
360
+ // read and one authored straight at the destination go through the same
361
+ // step.
362
+ this.reduceEmittedAddresses(built, itemFields(type, system));
358
363
  this.reportUndeclaredSystemData(fm, system, "Item", subType);
359
364
  // And what *this* pass wrote on its own initiative, which no field
360
365
  // declaration states and so no other check can see. Read off the
@@ -43,8 +43,6 @@
43
43
  */
44
44
 
45
45
  import { compendiumUuid, makeId, pageUuid } from "./ids.mjs";
46
- import { loadPackConfig } from "./pack-config.mjs";
47
- import { currentType } from "./ids.mjs";
48
46
  import { itemTypes } from "./item-registry.mjs";
49
47
  import { packRouter } from "./pack-router.mjs";
50
48
 
@@ -61,50 +59,15 @@ import { packRouter } from "./pack-router.mjs";
61
59
  export { itemTypes };
62
60
 
63
61
  /**
64
- * Every content type whose **prose compiles into a JournalEntry of its own**,
65
- * addressed by the virtual `doc<type>` qualifier.
62
+ * The doc-carrying types, under the names the compilers hold them by.
66
63
  *
67
- * Every item type, every actor type, every map type, plus `macro` — a macro
68
- * note's body documents the script the note also compiles into a Macro, which
69
- * is the same shape as an item and its description: one note, two documents,
70
- * the prose living in the journals pack.
71
- *
72
- * **One set, read by the compiler and the emitter alike.** The journals pass
73
- * decides what to compile from it, and the link manifest decides what to
74
- * publish a `doc<type>` entry for. Held apart, the two drift into a manifest
75
- * that asserts documentation nothing compiled — or a compiled entry no
76
- * consumer can address. It is composed exactly once, in `defineConfig`, and
77
- * read from there — never recomposed at a call site.
78
- *
79
- * `doc` notes are absent, for the reason that applies to them alone: a `doc`
80
- * note's single document *is* its prose, so there is no second document for a
81
- * `doc<type>` address to name.
82
- *
83
- * An accessor rather than a hoisted constant, so that importing this module
84
- * needs no configuration (#2).
85
- *
86
- * @returns {ReadonlySet<string>} The configured doc-carrying types.
87
- */
88
- export function docEntryTypes() {
89
- return loadPackConfig().docEntryTypes;
90
- }
91
-
92
- /**
93
- * Whether a content note's type is one whose prose becomes a JournalEntry of
94
- * its own.
95
- *
96
- * @param {string} type - The note's `type` frontmatter.
97
- * @returns {boolean} True for an item type, an actor type, a map type and
98
- * `macro`; false for `doc`.
64
+ * The set is what decides whether `doc<type>` is a legal type segment, so it is
65
+ * stated in `engine/address.mjs` beside the grammar that reads one and read from
66
+ * there by the journals pass and the link manifest — one set, so an authored
67
+ * `doc<type>` address and the entry it names cannot disagree about which types
68
+ * have one.
99
69
  */
100
- export function hasDocEntry(type) {
101
- // Through {@link currentType}, because the set is derived from the item
102
- // registry's keys and those are the *current* spelling of a note type. A
103
- // note still on a renamed one carries its documentation journal exactly as
104
- // before — this is the one lookup between an item compiling and its prose
105
- // silently compiling into nothing.
106
- return docEntryTypes().has(String(currentType(type)));
107
- }
70
+ export { docEntryTypes, hasDocEntry } from "./address.mjs";
108
71
 
109
72
  /**
110
73
  * The id of the JournalEntry a note's prose compiles into — an item's, or a
@@ -31,6 +31,7 @@
31
31
  * @module
32
32
  */
33
33
 
34
+ import { isAddressTuple, renderAddress } from "./address.mjs";
34
35
  import { subtreeOf } from "./map-places.mjs";
35
36
 
36
37
  /* --------------------------------------------------------------------- */
@@ -200,6 +201,7 @@ const LEGEND = Object.freeze([
200
201
  * @returns {string} Quoted and escaped, newlines folded to spaces.
201
202
  */
202
203
  export function dotString(value) {
204
+ if (isAddressTuple(value)) value = renderAddress(value);
203
205
  return (
204
206
  '"' +
205
207
  String(value ?? "")
@@ -59,6 +59,11 @@ import { CURATED_REGION_EVENTS, EXCLUDED_REGION_EVENTS } from "./region-events.m
59
59
  // A map's background art is `img`, as every other note type's art is. `image`,
60
60
  // the spelling a map alone once used, is retired and gone.
61
61
  import { sohlField } from "./frontmatter.mjs";
62
+ // `bgImage` is one of the four declared art slots, so its accepted set comes
63
+ // from there rather than being restated: the overlay, a tile's texture and an
64
+ // ambient sound's `audio` are map-specific address fields, not slots, and take
65
+ // no set beyond their own type.
66
+ import { artSlot } from "./art-fields.mjs";
62
67
 
63
68
  /* -------------------------------------------------------------------- */
64
69
  /* Note types and their canvas profiles */
@@ -908,7 +913,7 @@ export function buildScene(fm, ctx) {
908
913
  // A Scene has no `img`, so a map's background is its own slot, authored
909
914
  // under `data:` with the other art. It is an `image` address like every
910
915
  // other art reference, and the record it resolves to carries the path.
911
- const img = ctx.art(fm.data?.bgImage, "bgImage", "image");
916
+ const img = ctx.art(fm.data?.bgImage, "bgImage", "image", artSlot("bgImage")?.accepts);
912
917
  if (!img) throw new Error("a map note needs a `bgImage`");
913
918
 
914
919
  const warn = (message) => {
@@ -984,7 +989,7 @@ export function buildScene(fm, ctx) {
984
989
  * `bgImage` address by {@link buildScene}. Passed rather than resolved here,
985
990
  * because an address is answered by the compile's index and this function
986
991
  * takes none.
987
- * @param {(value: unknown, key: string, type: string) => string|null} [art] -
992
+ * @param {(value: unknown, key: string, type: string, accepts?: Iterable<string>) => string|null} [art] -
988
993
  * The art resolver, for the foreground overlay. Omitted, a note naming one
989
994
  * gets no overlay rather than a path nothing serves.
990
995
  * @returns {object} The Level document, keyed for the pack.
@@ -39,7 +39,17 @@ import { indexRecordsFor, isNoteRecord } from "./content-index.mjs";
39
39
  import { noteFile } from "./index-records.mjs";
40
40
  import { cachedIndexPath, loadForeignIndexes } from "./metadata-index.mjs";
41
41
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
42
- import { resolvePackageUrl } from "./content-address.mjs";
42
+ import { parseAddress, isAddressTuple } from "./address.mjs";
43
+ import { readCanonicalKey, resolvePackageUrl } from "./content-address.mjs";
44
+
45
+ /**
46
+ * The one type a `parents` entry may name — the default
47
+ * {@link module:engine/address.parseAddress} fills in when the segment is
48
+ * omitted, and the whole of what this position accepts.
49
+ *
50
+ * @type {ReadonlySet<string>}
51
+ */
52
+ const PLACE_TYPES = Object.freeze(new Set(["place"]));
43
53
 
44
54
  /**
45
55
  * A place as the map reads it, from either source.
@@ -89,17 +99,36 @@ import { resolvePackageUrl } from "./content-address.mjs";
89
99
  */
90
100
 
91
101
  /**
92
- * The shortcode a `parents` entry names, lower case: the last segment of
93
- * either a bare shortcode or an address.
102
+ * The shortcode a `parents` entry names, lower case.
103
+ *
104
+ * **A `parents` entry is an Address** — the specification types it
105
+ * `Address[]`, and the description ("enclosing places") is what makes `place`
106
+ * its default and its only accepted type. A qualified form is read
107
+ * structurally by {@link readCanonicalKey} for its literal four segments,
108
+ * since this position asks neither its package nor its system, only the
109
+ * shortcode {@link module:engine/address} would resolve it to anyway.
110
+ *
111
+ * **The match this shortcode feeds is deliberately package-blind.**
112
+ * `world.places` merges this package's own places with every fetched
113
+ * dependency's into one shortcode space, the way a dependency's place
114
+ * attaches to the local containment tree it is drawn beside — see
115
+ * `tests/map.test.ts`'s _loads a dependency's places beside the package's
116
+ * own_, where a fetched place's bare `parents: ["world"]` reaches this
117
+ * package's own root. Comparing the full Address instead would ask the
118
+ * dependency's own package to agree with this one's, which the feature is
119
+ * built to cross.
94
120
  *
95
121
  * @param {unknown} parent - One `parents` entry.
96
- * @returns {string} Its shortcode.
122
+ * @returns {string} Its shortcode, or `""` for an entry that names nothing.
97
123
  */
98
124
  function parentShortcode(parent) {
99
- return String(parent ?? "")
100
- .split("-")
101
- .pop()
102
- .toLowerCase();
125
+ if (isAddressTuple(parent)) return parent.shortcode.toLowerCase();
126
+ const text = String(parent ?? "").trim();
127
+ if (!text) return "";
128
+ const qualified = readCanonicalKey(text);
129
+ if (qualified) return qualified.shortcode.toLowerCase();
130
+ const read = parseAddress(text, { type: "place", types: PLACE_TYPES });
131
+ return read.reason ? "" : read.shortcode.toLowerCase();
103
132
  }
104
133
 
105
134
  /**
@@ -127,7 +156,10 @@ function relationList(value) {
127
156
  if (!Array.isArray(value)) return [];
128
157
  return value
129
158
  .filter((e) => e && typeof e === "object" && !Array.isArray(e))
130
- .map((e) => ({ ...e, to: String(e.to ?? "").toLowerCase() }));
159
+ .map((e) => ({
160
+ ...e,
161
+ to: isAddressTuple(e.to) ? e.to.shortcode : String(e.to ?? "").toLowerCase(),
162
+ }));
131
163
  }
132
164
 
133
165
  /**
@@ -209,7 +241,7 @@ export function placesFromRecords(records, { contentBase, base }) {
209
241
  function addForeignPlaces(world, foreignIndex) {
210
242
  for (const [canonical, entry] of foreignIndex) {
211
243
  if (entry?.type !== "place") continue;
212
- const shortcode = canonical.split("-").pop()?.toLowerCase() ?? "";
244
+ const shortcode = readCanonicalKey(canonical)?.shortcode?.toLowerCase() ?? "";
213
245
  if (!shortcode || world.places.has(shortcode)) continue;
214
246
  world.places.set(shortcode, {
215
247
  shortcode,
@@ -39,6 +39,11 @@
39
39
  * @module
40
40
  */
41
41
 
42
+ import { buildReferenceTargets } from "./reference-targets.mjs";
43
+
44
+ import { completeAddress, renderAddress } from "./address.mjs";
45
+
46
+ import { decodeIndexAddresses, noteAddressContext, encodeAddresses } from "./note-addresses.mjs";
42
47
  import fs from "node:fs";
43
48
  import path from "node:path";
44
49
 
@@ -330,6 +335,7 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
330
335
  const index = new Map();
331
336
  const packages = new Set();
332
337
  const stale = [];
338
+ const referenceRecords = [];
333
339
 
334
340
  /**
335
341
  * Fold one package's records into the index.
@@ -338,7 +344,7 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
338
344
  * @param {Array<Record<string, any>>} records - Its index records.
339
345
  * @returns {void}
340
346
  */
341
- const ingest = (pkg, records) => {
347
+ const ingest = (pkg, records, sourceFile) => {
342
348
  // A base is only needed to resolve a page *URL*, so a pack-only
343
349
  // dependency — Foundry addresses and no site, which `kethira` is by
344
350
  // licensing rather than by accident — needs none. Demanding one would
@@ -351,8 +357,20 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
351
357
  const base = bases?.[pkg];
352
358
  const web = typeof base === "string" && base.length > 0;
353
359
 
354
- for (const record of records) {
355
- const key = record?.address?.canonical;
360
+ const pageAliases = [];
361
+ for (const [recordIndex, record] of records.entries()) {
362
+ try {
363
+ decodeIndexAddresses(record, noteAddressContext(config));
364
+ } catch (error) {
365
+ error.file = sourceFile;
366
+ const lines = sourceFile ? fs.readFileSync(sourceFile, "utf8").split("\n") : [];
367
+ let nonempty = -1;
368
+ const line = lines.findIndex((text) => text.trim() && ++nonempty === recordIndex);
369
+ error.position = line >= 0 ? { line: line + 1 } : {};
370
+ throw error;
371
+ }
372
+ referenceRecords.push(record);
373
+ const key = encodeAddresses(record?.address?.canonical);
356
374
  if (!key) continue;
357
375
  const parts = readCanonicalKey(key);
358
376
  if (!parts) continue;
@@ -398,7 +416,44 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
398
416
  domains: record.data?.domains ?? undefined,
399
417
  package: pkg,
400
418
  });
419
+ const entry = index.get(key);
420
+ for (const [system, published] of Object.entries(record.foundry ?? {})) {
421
+ const identity = completeAddress({
422
+ package: record.package,
423
+ system,
424
+ type: record.type,
425
+ shortcode: record.shortcode,
426
+ });
427
+ const publishedKey = renderAddress(identity);
428
+ if (!index.has(publishedKey))
429
+ index.set(publishedKey, {
430
+ ...entry,
431
+ uuid: published.uuid,
432
+ anchors: published.anchors,
433
+ type: identity.type,
434
+ });
435
+ }
436
+ if (entry.url) {
437
+ const identity = completeAddress({
438
+ package: record.package,
439
+ system: "none",
440
+ type: record.type,
441
+ shortcode: record.shortcode,
442
+ });
443
+ if (identity.type !== record.type)
444
+ pageAliases.push([
445
+ renderAddress(identity),
446
+ {
447
+ ...entry,
448
+ uuid: undefined,
449
+ anchors: undefined,
450
+ doc: undefined,
451
+ type: identity.type,
452
+ },
453
+ ]);
454
+ }
401
455
  }
456
+ for (const [key, entry] of pageAliases) if (!index.has(key)) index.set(key, entry);
402
457
  packages.add(pkg);
403
458
  };
404
459
 
@@ -424,10 +479,10 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
424
479
  }
425
480
  const pkg = records[0]?.package ?? packageOfCache(file);
426
481
  if (local.has(pkg)) continue;
427
- ingest(pkg, records);
482
+ ingest(pkg, records, file);
428
483
  }
429
484
 
430
- return { index, packages, stale };
485
+ return { index, references: buildReferenceTargets(referenceRecords, index), packages, stale };
431
486
  }
432
487
 
433
488
  /**