@heroiclands/package-build 22.9.2 → 22.11.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 (97) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTENT.md +54 -0
  3. package/bin/content-build.mjs +3 -1
  4. package/docs/api.md +11 -6
  5. package/docs/commands.md +1 -1
  6. package/docs/content-format.md +411 -201
  7. package/engine/actor-compiler.mjs +20 -7
  8. package/engine/address-fields.mjs +426 -0
  9. package/engine/address-values.mjs +80 -0
  10. package/engine/address.mjs +769 -0
  11. package/engine/art-fields.mjs +112 -97
  12. package/engine/art-slots.mjs +81 -0
  13. package/engine/base-compiler.mjs +61 -7
  14. package/engine/bundle-notes.mjs +5 -2
  15. package/engine/bundles.mjs +17 -10
  16. package/engine/content-address.mjs +23 -186
  17. package/engine/content-embeds.mjs +18 -4
  18. package/engine/content-format-check.mjs +50 -22
  19. package/engine/content-index.mjs +85 -43
  20. package/engine/content-links.mjs +105 -104
  21. package/engine/content-lint.mjs +11 -1
  22. package/engine/content-tables.mjs +9 -1
  23. package/engine/field-spec.mjs +21 -4
  24. package/engine/folder-notes.mjs +44 -15
  25. package/engine/foundry-entries.mjs +3 -0
  26. package/engine/frontmatter-lint.mjs +150 -47
  27. package/engine/frontmatter.mjs +55 -10
  28. package/engine/generate.mjs +7 -1
  29. package/engine/helpers.mjs +37 -9
  30. package/engine/holdings.mjs +41 -12
  31. package/engine/index-records.mjs +2 -0
  32. package/engine/infobox-render.mjs +2 -1
  33. package/engine/infobox.mjs +26 -6
  34. package/engine/item-compiler.mjs +5 -0
  35. package/engine/item-docs.mjs +7 -44
  36. package/engine/map-dot.mjs +2 -0
  37. package/engine/map-notes.mjs +7 -2
  38. package/engine/map-places.mjs +42 -10
  39. package/engine/metadata-index.mjs +60 -5
  40. package/engine/note-addresses.mjs +325 -0
  41. package/engine/note-vocabulary.mjs +140 -28
  42. package/engine/pdf-build.mjs +1 -1
  43. package/engine/pdf-toc.mjs +17 -2
  44. package/engine/place-relations.mjs +160 -50
  45. package/engine/populations.mjs +17 -9
  46. package/engine/reference-targets.mjs +54 -0
  47. package/engine/scenes.mjs +5 -2
  48. package/engine/shortcode-references.mjs +68 -0
  49. package/engine/site-build.mjs +39 -15
  50. package/engine/site-index.mjs +57 -75
  51. package/engine/sql-tables.mjs +43 -9
  52. package/engine/system-block.mjs +21 -0
  53. package/engine/system-vocabulary.mjs +46 -0
  54. package/engine/web-wikilinks.mjs +11 -9
  55. package/engine/wikilink-syntax.mjs +21 -0
  56. package/engine/wikilinks.mjs +92 -306
  57. package/hm3/actors.mjs +8 -24
  58. package/hm3/item-fields.mjs +15 -5
  59. package/package.json +1 -1
  60. package/sohl/infobox.mjs +111 -17
  61. package/sohl/item-fields.mjs +131 -41
  62. package/types/engine/address-fields.d.mts +95 -0
  63. package/types/engine/address-values.d.mts +34 -0
  64. package/types/engine/address.d.mts +452 -0
  65. package/types/engine/art-fields.d.mts +59 -53
  66. package/types/engine/art-slots.d.mts +40 -0
  67. package/types/engine/base-compiler.d.mts +24 -2
  68. package/types/engine/bundle-notes.d.mts +2 -2
  69. package/types/engine/content-address.d.mts +3 -139
  70. package/types/engine/content-embeds.d.mts +1 -1
  71. package/types/engine/content-format-check.d.mts +25 -0
  72. package/types/engine/content-index.d.mts +2 -2
  73. package/types/engine/content-links.d.mts +6 -5
  74. package/types/engine/field-spec.d.mts +46 -9
  75. package/types/engine/folder-notes.d.mts +11 -10
  76. package/types/engine/frontmatter-lint.d.mts +7 -97
  77. package/types/engine/frontmatter.d.mts +16 -1
  78. package/types/engine/helpers.d.mts +2 -2
  79. package/types/engine/infobox.d.mts +6 -0
  80. package/types/engine/item-docs.d.mts +1 -35
  81. package/types/engine/map-notes.d.mts +2 -2
  82. package/types/engine/note-addresses.d.mts +41 -0
  83. package/types/engine/note-vocabulary.d.mts +20 -3
  84. package/types/engine/place-relations.d.mts +12 -1
  85. package/types/engine/populations.d.mts +17 -2
  86. package/types/engine/reference-targets.d.mts +9 -0
  87. package/types/engine/shortcode-references.d.mts +14 -0
  88. package/types/engine/site-build.d.mts +3 -3
  89. package/types/engine/site-index.d.mts +9 -30
  90. package/types/engine/sql-tables.d.mts +2 -2
  91. package/types/engine/system-block.d.mts +17 -0
  92. package/types/engine/system-vocabulary.d.mts +16 -0
  93. package/types/engine/wikilink-syntax.d.mts +7 -0
  94. package/types/engine/wikilinks.d.mts +6 -109
  95. package/types/hm3/item-fields.d.mts +5 -0
  96. package/types/sohl/infobox.d.mts +6 -5
  97. package/types/sohl/item-fields.d.mts +8 -0
@@ -50,6 +50,7 @@ import fs from "fs";
50
50
  import path from "path";
51
51
  import log from "loglevel";
52
52
 
53
+ import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
53
54
  import { makeId } from "./helpers.mjs";
54
55
  import { emitDiagnostic } from "./diagnostics.mjs";
55
56
  // The `{#appearance}` / `{#dossier}` convention is the note format's, so the
@@ -96,6 +97,7 @@ export function stripCompendiumFields(item) {
96
97
  */
97
98
  export function isPlainObject(v) {
98
99
  return (
100
+ !isAddressTuple(v) &&
99
101
  v !== null &&
100
102
  typeof v === "object" &&
101
103
  !Array.isArray(v) &&
@@ -705,7 +707,7 @@ export class SystemActorCompiler extends BasePackCompiler {
705
707
  const key = `${this.documentSubtypes.block}.items`;
706
708
  const where = () =>
707
709
  locateFrontmatterKey(this.currentNote?.absPath, "items", String(model ?? ""));
708
- if (typeof model !== "string" || !model.trim()) {
710
+ if (!isAddressTuple(model) && (typeof model !== "string" || !model.trim())) {
709
711
  this.noteError(`${ctx}: ${key}[${index}] \`model\` must be an address`, where());
710
712
  this.errorCount++;
711
713
  return null;
@@ -715,7 +717,11 @@ export class SystemActorCompiler extends BasePackCompiler {
715
717
  // dependency whose item catalogue was loaded.
716
718
  const types = new Set(Object.keys(this.documentSubtypes.types));
717
719
  const packages = new Set([contentPackage(), ...this.foreignPackages]);
718
- const read = readQualifier(model.trim(), types, packages);
720
+ const read = parseAddress(
721
+ model,
722
+ { types, packages, package: contentPackage(), system: this.system },
723
+ { declared: true, legacyShortcodeCase: true },
724
+ );
719
725
  if (!read || read.reason) {
720
726
  const why =
721
727
  read?.reason === "not-lowercase" ?
@@ -728,7 +734,15 @@ export class SystemActorCompiler extends BasePackCompiler {
728
734
  this.errorCount++;
729
735
  return null;
730
736
  }
731
- return { type: read.type, shortcode: read.shortcode, package: read.package ?? null };
737
+ if (read.system !== this.system || !types.has(read.type)) {
738
+ this.noteError(
739
+ `${ctx}: ${key}[${index}] model must name a ${this.system} item, but names ${renderAddress(read)}`,
740
+ where(),
741
+ );
742
+ this.errorCount++;
743
+ return null;
744
+ }
745
+ return read;
732
746
  }
733
747
 
734
748
  /**
@@ -789,10 +803,8 @@ export class SystemActorCompiler extends BasePackCompiler {
789
803
  this.errorCount++;
790
804
  return null;
791
805
  }
792
- // A `model:` may name the package its template comes from. Where
793
- // it does, the packaged address is used and nothing local can shadow
794
- // it; where it does not, the unqualified one is, and a local definition
795
- // still wins over a dependency's as it always has.
806
+ // A model names one package. Omitted packages are completed at the
807
+ // read boundary; native embedded identities use the local catalogue.
796
808
  const address = catalogueKey(
797
809
  /** @type {string} */ (subType),
798
810
  shortcode ?? "",
@@ -854,6 +866,7 @@ export class SystemActorCompiler extends BasePackCompiler {
854
866
  const { data: _authored, ...fields } = overlay ?? {};
855
867
  const merged = overlay ? deepMerge(base, fields) : base;
856
868
  merged.type = subType;
869
+ if (this.reduceEmittedAddresses(merged.system, itemFields(type, this.system))) return null;
857
870
  // The two art rules an item note compiles under, applied to an entry
858
871
  // that is one in every respect but where it is written: the address it
859
872
  // names wins, the template it copies answers next, and the type's own
@@ -0,0 +1,426 @@
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
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **An authored Address, an emitted native reference.**
16
+ *
17
+ * A declared field may hold an Address where the document it compiles into
18
+ * holds a **Shortcode** or a published Item UUID. An affiliation's `seat` is a Shortcode case: the note
19
+ * names a place at whatever length says what it means — `tashal`,
20
+ * `place-tashal`, `none-place-tashal`, `kethira-none-place-tashal` — and the
21
+ * compiled Item carries `tashal`, which is what the runtime resolves among the
22
+ * items one actor holds, where packages do not exist.
23
+ *
24
+ * The `emit: "uuid"` declaration resolves an exact native target from the
25
+ * local or declared dependency index. The target supplies its published UUID.
26
+ * This module performs both conversions at the Foundry output boundary.
27
+ * Storing the written form instead would store a value plus a context that does
28
+ * not travel with it: a four-segment string reaches a runtime that looks up one
29
+ * segment and returns nothing, silently.
30
+ *
31
+ * ## The two questions stay separate
32
+ *
33
+ * A position declares both halves, and they are not the same statement:
34
+ *
35
+ * - **`type`** is what an omitted `<type>` segment takes — the default
36
+ * {@link module:engine/address.parseAddress} fills in.
37
+ * - **`accepts`** is the set of types the position takes at all, tested by
38
+ * {@link module:engine/address.acceptsType} once the value parses.
39
+ *
40
+ * They coincide at every position here, and that is the common case rather than
41
+ * the rule: an art slot defaults to `icon` and accepts `icon` or `image`. So the
42
+ * set is read as its own input and never derived from the default, and a
43
+ * position accepting several types is expressible without touching this module.
44
+ *
45
+ * The distinction is what a value like `being-foobar` turns on. With the package
46
+ * and the system supplied from context it parses to a perfectly good Address —
47
+ * and it is unacceptable to a field that takes a place. The parser does not
48
+ * refuse it, because it is an Address; the **field** refuses it, with an error
49
+ * naming what it accepts.
50
+ *
51
+ * ## Authored defaults belong to the read boundary
52
+ *
53
+ * Parsed Address tuples retain the enclosing block's system. This reduction
54
+ * projects their Shortcode without changing that identity. Direct callers
55
+ * supplying strings provide the position's package and type vocabulary.
56
+ *
57
+ * ## The reduction is lossy, and the collision it loses is legal
58
+ *
59
+ * A shortcode is unique only within one `package-system-type` space, so
60
+ * `foo-sohl-affiliation-b` and `bar-sohl-affiliation-b` are both valid and are
61
+ * two different affiliations. Reducing either to `b` throws away the only thing
62
+ * that told them apart, and the emitted field cannot represent both.
63
+ *
64
+ * That is **an error naming both Addresses**, rather than a silent merge:
65
+ * shipping a standing nobody authored is the worse outcome. It fires on content
66
+ * that is entirely valid, so the message says so instead of implying a mistake.
67
+ *
68
+ * One Address written twice is a different finding, and only where the position
69
+ * holds a map: two keys reducing to one emitted key claim two values for one
70
+ * target, and the field carries one. In a **list** the same Address twice is an
71
+ * ordinary duplicate — nothing is lost, so nothing is reported.
72
+ *
73
+ * ## What this module does not do
74
+ *
75
+ * Shortcode output needs only the Address grammar. UUID output requires the
76
+ * exact target in the published index and accepts only Item UUIDs.
77
+ *
78
+ * A failure is a **finding**, never a diagnostic: the caller owns the file and
79
+ * the severity, and locates the finding at the value inside the note.
80
+ *
81
+ * @module
82
+ */
83
+
84
+ import { acceptsType, parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
85
+ import { AddressEntries } from "./address-values.mjs";
86
+ import { systemOf } from "./document-subtypes.mjs";
87
+ import { getFrontmatter } from "./frontmatter.mjs";
88
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
89
+ import { authoredKey, setPath } from "./system-block.mjs";
90
+
91
+ /**
92
+ * How many values a position holds.
93
+ *
94
+ * - `value` — the field's value is one Address.
95
+ * - `items` — every element of a list is one.
96
+ * - `keys` — every **key** of a map is one, and the values are untouched.
97
+ *
98
+ * @type {readonly string[]}
99
+ */
100
+ export const ADDRESS_FIELD_SHAPES = Object.freeze(["value", "items", "keys"]);
101
+
102
+ /**
103
+ * An Address position, as a field declaration states it.
104
+ *
105
+ * @typedef {object} AddressPosition
106
+ * @property {string} type - The type an omitted `<type>` segment takes.
107
+ * @property {readonly string[]} accepts - Every type the position accepts.
108
+ * @property {"value"|"items"|"keys"} [holds] - Where the Addresses are.
109
+ * Defaults to `value`.
110
+ * @property {"uuid"} [emit] - Resolve each exact target to its published Item UUID.
111
+ */
112
+
113
+ /**
114
+ * What a reduction could not do, for a caller to report.
115
+ *
116
+ * @typedef {object} AddressFinding
117
+ * @property {string} key - The field, named as an author writes it.
118
+ * @property {string} written - The authored value, so the caller can locate the
119
+ * finding at the line carrying it rather than at the key.
120
+ * @property {string} message - One sentence, naming the fix.
121
+ */
122
+
123
+ /**
124
+ * The declarations that state an Address position.
125
+ *
126
+ * @param {readonly object[]} fields - A field declaration.
127
+ * @returns {object[]} Only the fields carrying an `address` statement.
128
+ */
129
+ export function addressFields(fields) {
130
+ return (fields ?? []).filter((field) => Boolean(field?.address));
131
+ }
132
+
133
+ /**
134
+ * Reduce every Address a compiled block holds to the Shortcode it names.
135
+ *
136
+ * The block is edited in place, as {@link
137
+ * module:engine/system-block.mergeSystemData} edits it: what the caller holds is
138
+ * the document being assembled, and a field the reduction cannot read is left
139
+ * exactly as authored — the findings stop the build, so nothing ships either
140
+ * way, and an untouched value is what a reader of the failing note sees.
141
+ *
142
+ * @param {object} emitted - The `system` block, already built.
143
+ * @param {readonly object[]} fields - The type's field declaration.
144
+ * @param {import("./address.mjs").AddressVocabulary & {package?: string}}
145
+ * vocabulary - The tree's vocabularies and the package the values are written
146
+ * in.
147
+ * @returns {AddressFinding[]} Every value that could not be reduced.
148
+ */
149
+ export function reduceAddressFields(emitted, fields, vocabulary = {}) {
150
+ const findings = [];
151
+ for (const field of addressFields(fields)) {
152
+ const value = getFrontmatter(emitted, field.to, undefined);
153
+ const reduced = reduceOne(value, field, vocabulary, findings);
154
+ if (reduced !== undefined) setPath(emitted, field.to, reduced);
155
+ }
156
+ return findings;
157
+ }
158
+
159
+ /**
160
+ * Reduce one field's value, by the shape its position holds.
161
+ *
162
+ * @param {unknown} value - The emitted value.
163
+ * @param {object} field - The declaration.
164
+ * @param {object} vocabulary - As {@link reduceAddressFields} takes it.
165
+ * @param {AddressFinding[]} findings - Collected, in the order they are found.
166
+ * @returns {unknown} The reduced value, or `undefined` to leave it alone.
167
+ */
168
+ function reduceOne(value, field, vocabulary, findings) {
169
+ const position = field.address;
170
+ const defaults = positionDefaults(position, vocabulary);
171
+ const shape = position.holds ?? "value";
172
+ // Two keys, or two list entries, reducing to one emitted shortcode: the
173
+ // Address each of them named, by the shortcode all of them land on.
174
+ const seen = new Map();
175
+ const read = (written) => readOne(written, field, defaults, seen, findings, shape, vocabulary);
176
+
177
+ if (shape === "keys") {
178
+ if (!isMapping(value)) return undefined;
179
+ const out = {};
180
+ for (const [written, standing] of value instanceof AddressEntries ?
181
+ value.entries.map((e) => [e.target, e.value])
182
+ : Object.entries(value)) {
183
+ const code = read(written);
184
+ // The first standing stands, so the emitted map does not depend on
185
+ // which key the reduction reached first.
186
+ if (code !== undefined && !Object.hasOwn(out, code)) out[code] = standing;
187
+ }
188
+ return out;
189
+ }
190
+
191
+ if (shape === "items") {
192
+ if (!Array.isArray(value)) return undefined;
193
+ return value.map((written) => read(written) ?? written);
194
+ }
195
+
196
+ // One Address, and the two empties of a scalar field: `null` and an absent
197
+ // key mean *names nothing*, and `""` is the same statement a property editor
198
+ // writes. Neither is an Address, and neither is a mistake.
199
+ if (value == null || value === "") return undefined;
200
+ return read(value);
201
+ }
202
+
203
+ /**
204
+ * Read one written Address, reporting what stops it.
205
+ *
206
+ * @param {unknown} written - The value as authored.
207
+ * @param {object} field - The declaration.
208
+ * @param {object} defaults - What {@link positionDefaults} returns.
209
+ * @param {Map<string, {address: string, written: string}>} seen - The Addresses
210
+ * already reduced at this position, by emitted shortcode.
211
+ * @param {AddressFinding[]} findings - Collected.
212
+ * @param {string} shape - What the position holds.
213
+ * @returns {string|undefined} The shortcode, or `undefined` when nothing is
214
+ * emitted for this value.
215
+ */
216
+ function readOne(written, field, defaults, seen, findings, shape, vocabulary) {
217
+ const key = findingKey(field);
218
+ // An embedded Item may inherit an already compiled reference from its model.
219
+ if (
220
+ field.address.emit === "uuid" &&
221
+ typeof written === "string" &&
222
+ /^Compendium\.[^.]+\.[^.]+\.Item\.[^.]+$/.test(written)
223
+ )
224
+ return written;
225
+ const value =
226
+ isAddressTuple(written) ? renderAddress(written)
227
+ : typeof written === "string" ? written
228
+ : String(written ?? "");
229
+ const report = (message) => void findings.push({ key, written: value, message });
230
+
231
+ const tuple = parseAddress(written, defaults);
232
+ if (tuple.reason) {
233
+ report(unreadableMessage(key, value, tuple, defaults));
234
+ return undefined;
235
+ }
236
+ if (!acceptsType(tuple, defaults.accepts)) {
237
+ report(unacceptedMessage(key, value, defaults.accepts));
238
+ return undefined;
239
+ }
240
+
241
+ const address = renderAddress(tuple);
242
+ if (field.address.emit === "uuid") {
243
+ const target =
244
+ tuple.package === vocabulary.package ?
245
+ vocabulary.referenceTargets?.get(address)
246
+ : vocabulary.foreignReferences?.get(address);
247
+ const uuid = target?.uuid;
248
+ if (!uuid || !/^Compendium\.[^.]+\.[^.]+\.Item\.[^.]+$/.test(uuid)) {
249
+ report(
250
+ `\`${key}\` names \`${address}\`, but its exact native Item has no published Item UUID`,
251
+ );
252
+ return undefined;
253
+ }
254
+ return uuid;
255
+ }
256
+ const code = tuple.shortcode;
257
+ const first = seen.get(code);
258
+ if (first && first.address !== address) {
259
+ report(collidingMessage(key, first.address, address, code));
260
+ return undefined;
261
+ }
262
+ if (first && shape === "keys") {
263
+ report(repeatedMessage(key, address, first.written, value));
264
+ return undefined;
265
+ }
266
+ if (!first) seen.set(code, { address, written: value });
267
+ return code;
268
+ }
269
+
270
+ /**
271
+ * The defaults and vocabularies a position reads its values against.
272
+ *
273
+ * **The position's own types belong in the vocabulary.** A position declares
274
+ * what it names, so a tree that happens to carry no note of that type still
275
+ * reads the value: without this, an affiliation naming a `seat` in a tree whose
276
+ * places are all in a dependency stops parsing, and the refusal describes the
277
+ * tree rather than the value.
278
+ *
279
+ * @param {AddressPosition} position - The declaration's `address` statement.
280
+ * @param {object} vocabulary - As {@link reduceAddressFields} takes it.
281
+ * @returns {object} The defaults {@link parseAddress} takes, plus the accepted
282
+ * set the acceptability step reads.
283
+ */
284
+ function positionDefaults(position, vocabulary) {
285
+ const accepts = position.accepts ?? [position.type];
286
+ return {
287
+ ...vocabulary,
288
+ types: new Set([...(vocabulary.types ?? []), position.type, ...accepts]),
289
+ // Derived from the type rather than taken from the block the value sits
290
+ // in: an affiliation address names a SoHL Item, a place address names a
291
+ // core document, and both are written inside the same `sohl:` block.
292
+ system: systemOf(position.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
293
+ type: position.type,
294
+ accepts,
295
+ };
296
+ }
297
+
298
+ /** Whether a value is an authored map rather than a list or a scalar. */
299
+ function isMapping(value) {
300
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
301
+ }
302
+
303
+ /**
304
+ * The key a finding names, as the note's own line spells it.
305
+ *
306
+ * A declaration's shared source is a path into the `data:` container —
307
+ * `data.seat` — and the note writes `seat:` one level inside it. The finding
308
+ * names that key for two reasons at once: it is the word on the line the
309
+ * diagnostic opens, and it is what {@link
310
+ * module:engine/retired-fields.locateFrontmatterKey} matches when the authored
311
+ * value cannot be found in the fence, which a dotted path never would.
312
+ *
313
+ * @param {object} field - The declaration.
314
+ * @returns {string} The key, without the container holding it.
315
+ */
316
+ function findingKey(field) {
317
+ return authoredKey(field.name ?? field.to);
318
+ }
319
+
320
+ /**
321
+ * Format a closed set for a message: `a, b or c`.
322
+ *
323
+ * @param {readonly unknown[]} values - The set.
324
+ * @returns {string} The values, joined.
325
+ */
326
+ function oneOf(values) {
327
+ const all = [...values].map((value) => `\`${String(value)}\``);
328
+ return all.length < 2 ?
329
+ all.join("")
330
+ : `${all.slice(0, -1).join(", ")} or ${all[all.length - 1]}`;
331
+ }
332
+
333
+ /**
334
+ * What a value the grammar cannot read is reported as.
335
+ *
336
+ * @param {string} key - The field, as an author writes it.
337
+ * @param {string} written - The authored value.
338
+ * @param {import("./address.mjs").AddressProblem} problem - Why it does not
339
+ * parse.
340
+ * @param {object} defaults - What {@link positionDefaults} returns.
341
+ * @returns {string} The message.
342
+ */
343
+ function unreadableMessage(key, written, problem, defaults) {
344
+ if (problem.reason === "no-content-index") {
345
+ return (
346
+ `\`${key}\` names package "${problem.package}", which is declared ` +
347
+ `\`contentIndex: false\` — a Foundry dependency only, with no content index ` +
348
+ `fetched for it, so nothing it publishes can be named here`
349
+ );
350
+ }
351
+ if (problem.reason === "not-lowercase") {
352
+ return (
353
+ `\`${key}\` names \`${written}\`, whose package, system or type segment ` +
354
+ `carries a capital — those segments are lowercase, an address having one ` +
355
+ `spelling`
356
+ );
357
+ }
358
+ return (
359
+ `\`${key}\` must name ${oneOf(defaults.accepts)} — its \`shortcode\`, or that ` +
360
+ `address qualified by type, by system and type, or by package, system and ` +
361
+ `type — but reads \`${written}\``
362
+ );
363
+ }
364
+
365
+ /**
366
+ * What a value naming a type the position does not take is reported as.
367
+ *
368
+ * An **error**: the value is a real Address, so no default repairs it, and the
369
+ * position cannot do anything with what it names.
370
+ *
371
+ * **The value states its own type, and the message leaves it there.** A value
372
+ * omitting the `<type>` segment takes the position's default and is therefore
373
+ * acceptable, so this fires only where the author wrote a type — and naming the
374
+ * parsed one instead would name the `doc<type>` a system-bearing type is
375
+ * redirected to under `none`, sending a reader to look for a segment nobody
376
+ * wrote.
377
+ *
378
+ * @param {string} key - The field, as an author writes it.
379
+ * @param {string} written - The authored value.
380
+ * @param {readonly string[]} accepts - The types the position accepts.
381
+ * @returns {string} The message.
382
+ */
383
+ function unacceptedMessage(key, written, accepts) {
384
+ return (
385
+ `\`${key}\` names \`${written}\`, whose type this field does not accept — it ` +
386
+ `accepts ${oneOf(accepts)}`
387
+ );
388
+ }
389
+
390
+ /**
391
+ * What two Addresses reducing to one shortcode are reported as.
392
+ *
393
+ * The message states that both values are valid, because they are: a shortcode
394
+ * is unique within one package's address space and these are two packages'. The
395
+ * fault is in what the emitted field can hold, which is why the finding names
396
+ * both Addresses rather than one of them.
397
+ *
398
+ * @param {string} key - The field, as an author writes it.
399
+ * @param {string} first - The Address already reduced.
400
+ * @param {string} second - The Address colliding with it.
401
+ * @param {string} code - The shortcode they share.
402
+ * @returns {string} The message.
403
+ */
404
+ function collidingMessage(key, first, second, code) {
405
+ return (
406
+ `\`${key}\` names both \`${first}\` and \`${second}\`, which share the shortcode ` +
407
+ `"${code}" — both addresses are valid, and the compiled field is keyed by the ` +
408
+ `shortcode alone, so it carries one of the two and the other is lost`
409
+ );
410
+ }
411
+
412
+ /**
413
+ * What one Address written twice at a keyed position is reported as.
414
+ *
415
+ * @param {string} key - The field, as an author writes it.
416
+ * @param {string} address - The Address both keys name.
417
+ * @param {string} first - How the first key was written.
418
+ * @param {string} second - How the second was written.
419
+ * @returns {string} The message.
420
+ */
421
+ function repeatedMessage(key, address, first, second) {
422
+ return (
423
+ `\`${key}\` names \`${address}\` twice, as \`${first}\` and as \`${second}\` — the ` +
424
+ `compiled field carries one value per target, so the second is lost`
425
+ );
426
+ }
@@ -0,0 +1,80 @@
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
+ *
5
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ */
7
+
8
+ import { isAddressTuple, renderAddress } from "./address.mjs";
9
+
10
+ /** Address-keyed properties hold typed entries rather than string object keys. */
11
+ export class AddressEntries {
12
+ /** @param {Array<{target: object, value: unknown, sourceKey: string}>} entries - Typed entries and diagnostic source keys. */
13
+ constructor(entries) {
14
+ if (!entries.every((entry) => isAddressTuple(entry.target)))
15
+ throw new TypeError("Address map keys must be complete tuples");
16
+ this.entries = entries;
17
+ }
18
+ }
19
+
20
+ /** An Address and the section within its document. */
21
+ export class AddressLink {
22
+ /** @param {object} target - Complete tuple. @param {string} anchor - Section slug. */
23
+ constructor(target, anchor) {
24
+ if (!isAddressTuple(target)) throw new TypeError("An Address link needs a complete tuple");
25
+ this.target = target;
26
+ this.anchor = anchor;
27
+ Object.freeze(this);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Render typed properties at a generated-output boundary.
33
+ * @param {any} value - A tuple-bearing tree.
34
+ * @param {WeakMap<object, object>} [seen] - Shared containers in this output.
35
+ * @returns {any} A serializable tree containing full Addresses.
36
+ */
37
+ export function encodeAddresses(value, seen = new WeakMap()) {
38
+ if (value && typeof value === "object" && seen.has(value)) return seen.get(value);
39
+ if (isAddressTuple(value)) return renderAddress(value);
40
+ if (value instanceof AddressLink) return `${renderAddress(value.target)}#${value.anchor}`;
41
+ if (value instanceof AddressEntries) {
42
+ const out = {};
43
+ seen.set(value, out);
44
+ for (const { target, value: entry } of value.entries) {
45
+ const key = renderAddress(target);
46
+ if (Object.hasOwn(out, key)) throw new Error(`Repeated Address map key: ${key}`);
47
+ out[key] = encodeAddresses(entry, seen);
48
+ }
49
+ return out;
50
+ }
51
+ if (Array.isArray(value)) {
52
+ const out = [];
53
+ seen.set(value, out);
54
+ for (const entry of value) out.push(encodeAddresses(entry, seen));
55
+ return out;
56
+ }
57
+ if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype)
58
+ return value;
59
+ const out = {};
60
+ seen.set(value, out);
61
+ for (const [key, entry] of Object.entries(value)) out[key] = encodeAddresses(entry, seen);
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Clone mutable note state while retaining immutable Address identities.
67
+ * @param {any} value - Typed note state.
68
+ * @returns {any} An independent mutable container tree.
69
+ */
70
+ export function cloneAddressState(value) {
71
+ if (isAddressTuple(value)) return value;
72
+ if (value instanceof AddressEntries)
73
+ return new AddressEntries(
74
+ value.entries.map((entry) => ({ ...entry, value: cloneAddressState(entry.value) })),
75
+ );
76
+ if (Array.isArray(value)) return value.map(cloneAddressState);
77
+ if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype)
78
+ return value;
79
+ return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, cloneAddressState(v)]));
80
+ }