@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
@@ -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,404 @@
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 Shortcode.**
16
+ *
17
+ * A declared field may hold an Address where the document it compiles into
18
+ * holds a **Shortcode**. An affiliation's `seat` is the worked 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
+ * So the builder resolves the one into the other, and this module is that step.
25
+ * Storing the written form instead would store a value plus a context that does
26
+ * not travel with it: a four-segment string reaches a runtime that looks up one
27
+ * segment and returns nothing, silently.
28
+ *
29
+ * ## The two questions stay separate
30
+ *
31
+ * A position declares both halves, and they are not the same statement:
32
+ *
33
+ * - **`type`** is what an omitted `<type>` segment takes — the default
34
+ * {@link module:engine/address.parseAddress} fills in.
35
+ * - **`accepts`** is the set of types the position takes at all, tested by
36
+ * {@link module:engine/address.acceptsType} once the value parses.
37
+ *
38
+ * They coincide at every position here, and that is the common case rather than
39
+ * the rule: an art slot defaults to `icon` and accepts `icon` or `image`. So the
40
+ * set is read as its own input and never derived from the default, and a
41
+ * position accepting several types is expressible without touching this module.
42
+ *
43
+ * The distinction is what a value like `being-foobar` turns on. With the package
44
+ * and the system supplied from context it parses to a perfectly good Address —
45
+ * and it is unacceptable to a field that takes a place. The parser does not
46
+ * refuse it, because it is an Address; the **field** refuses it, with an error
47
+ * naming what it accepts.
48
+ *
49
+ * ## Authored defaults belong to the read boundary
50
+ *
51
+ * Parsed Address tuples retain the enclosing block's system. This reduction
52
+ * projects their Shortcode without changing that identity. Direct callers
53
+ * supplying strings provide the position's package and type vocabulary.
54
+ *
55
+ * ## The reduction is lossy, and the collision it loses is legal
56
+ *
57
+ * A shortcode is unique only within one `package-system-type` space, so
58
+ * `foo-sohl-affiliation-b` and `bar-sohl-affiliation-b` are both valid and are
59
+ * two different affiliations. Reducing either to `b` throws away the only thing
60
+ * that told them apart, and the emitted field cannot represent both.
61
+ *
62
+ * That is **an error naming both Addresses**, rather than a silent merge:
63
+ * shipping a standing nobody authored is the worse outcome. It fires on content
64
+ * that is entirely valid, so the message says so instead of implying a mistake.
65
+ *
66
+ * One Address written twice is a different finding, and only where the position
67
+ * holds a map: two keys reducing to one emitted key claim two values for one
68
+ * target, and the field carries one. In a **list** the same Address twice is an
69
+ * ordinary duplicate — nothing is lost, so nothing is reported.
70
+ *
71
+ * ## What this module does not do
72
+ *
73
+ * It resolves nothing against the index: the shortcode is a segment of the
74
+ * Address, so reducing one needs the grammar and the position's declarations
75
+ * and nothing else. Whether the target exists is a separate question, asked
76
+ * where the index is.
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
+ */
111
+
112
+ /**
113
+ * What a reduction could not do, for a caller to report.
114
+ *
115
+ * @typedef {object} AddressFinding
116
+ * @property {string} key - The field, named as an author writes it.
117
+ * @property {string} written - The authored value, so the caller can locate the
118
+ * finding at the line carrying it rather than at the key.
119
+ * @property {string} message - One sentence, naming the fix.
120
+ */
121
+
122
+ /**
123
+ * The declarations that state an Address position.
124
+ *
125
+ * @param {readonly object[]} fields - A field declaration.
126
+ * @returns {object[]} Only the fields carrying an `address` statement.
127
+ */
128
+ export function addressFields(fields) {
129
+ return (fields ?? []).filter((field) => Boolean(field?.address));
130
+ }
131
+
132
+ /**
133
+ * Reduce every Address a compiled block holds to the Shortcode it names.
134
+ *
135
+ * The block is edited in place, as {@link
136
+ * module:engine/system-block.mergeSystemData} edits it: what the caller holds is
137
+ * the document being assembled, and a field the reduction cannot read is left
138
+ * exactly as authored — the findings stop the build, so nothing ships either
139
+ * way, and an untouched value is what a reader of the failing note sees.
140
+ *
141
+ * @param {object} emitted - The `system` block, already built.
142
+ * @param {readonly object[]} fields - The type's field declaration.
143
+ * @param {import("./address.mjs").AddressVocabulary & {package?: string}}
144
+ * vocabulary - The tree's vocabularies and the package the values are written
145
+ * in.
146
+ * @returns {AddressFinding[]} Every value that could not be reduced.
147
+ */
148
+ export function reduceAddressFields(emitted, fields, vocabulary = {}) {
149
+ const findings = [];
150
+ for (const field of addressFields(fields)) {
151
+ const value = getFrontmatter(emitted, field.to, undefined);
152
+ const reduced = reduceOne(value, field, vocabulary, findings);
153
+ if (reduced !== undefined) setPath(emitted, field.to, reduced);
154
+ }
155
+ return findings;
156
+ }
157
+
158
+ /**
159
+ * Reduce one field's value, by the shape its position holds.
160
+ *
161
+ * @param {unknown} value - The emitted value.
162
+ * @param {object} field - The declaration.
163
+ * @param {object} vocabulary - As {@link reduceAddressFields} takes it.
164
+ * @param {AddressFinding[]} findings - Collected, in the order they are found.
165
+ * @returns {unknown} The reduced value, or `undefined` to leave it alone.
166
+ */
167
+ function reduceOne(value, field, vocabulary, findings) {
168
+ const position = field.address;
169
+ const defaults = positionDefaults(position, vocabulary);
170
+ const shape = position.holds ?? "value";
171
+ // Two keys, or two list entries, reducing to one emitted shortcode: the
172
+ // Address each of them named, by the shortcode all of them land on.
173
+ const seen = new Map();
174
+ const read = (written) => readOne(written, field, defaults, seen, findings, shape);
175
+
176
+ if (shape === "keys") {
177
+ if (!isMapping(value)) return undefined;
178
+ const out = {};
179
+ for (const [written, standing] of value instanceof AddressEntries ?
180
+ value.entries.map((e) => [e.target, e.value])
181
+ : Object.entries(value)) {
182
+ const code = read(written);
183
+ // The first standing stands, so the emitted map does not depend on
184
+ // which key the reduction reached first.
185
+ if (code !== undefined && !Object.hasOwn(out, code)) out[code] = standing;
186
+ }
187
+ return out;
188
+ }
189
+
190
+ if (shape === "items") {
191
+ if (!Array.isArray(value)) return undefined;
192
+ return value.map((written) => read(written) ?? written);
193
+ }
194
+
195
+ // One Address, and the two empties of a scalar field: `null` and an absent
196
+ // key mean *names nothing*, and `""` is the same statement a property editor
197
+ // writes. Neither is an Address, and neither is a mistake.
198
+ if (value == null || value === "") return undefined;
199
+ return read(value);
200
+ }
201
+
202
+ /**
203
+ * Read one written Address, reporting what stops it.
204
+ *
205
+ * @param {unknown} written - The value as authored.
206
+ * @param {object} field - The declaration.
207
+ * @param {object} defaults - What {@link positionDefaults} returns.
208
+ * @param {Map<string, {address: string, written: string}>} seen - The Addresses
209
+ * already reduced at this position, by emitted shortcode.
210
+ * @param {AddressFinding[]} findings - Collected.
211
+ * @param {string} shape - What the position holds.
212
+ * @returns {string|undefined} The shortcode, or `undefined` when nothing is
213
+ * emitted for this value.
214
+ */
215
+ function readOne(written, field, defaults, seen, findings, shape) {
216
+ const key = findingKey(field);
217
+ const value =
218
+ isAddressTuple(written) ? renderAddress(written)
219
+ : typeof written === "string" ? written
220
+ : String(written ?? "");
221
+ const report = (message) => void findings.push({ key, written: value, message });
222
+
223
+ const tuple = parseAddress(written, defaults);
224
+ if (tuple.reason) {
225
+ report(unreadableMessage(key, value, tuple, defaults));
226
+ return undefined;
227
+ }
228
+ if (!acceptsType(tuple, defaults.accepts)) {
229
+ report(unacceptedMessage(key, value, defaults.accepts));
230
+ return undefined;
231
+ }
232
+
233
+ const address = renderAddress(tuple);
234
+ const code = tuple.shortcode;
235
+ const first = seen.get(code);
236
+ if (first && first.address !== address) {
237
+ report(collidingMessage(key, first.address, address, code));
238
+ return undefined;
239
+ }
240
+ if (first && shape === "keys") {
241
+ report(repeatedMessage(key, address, first.written, value));
242
+ return undefined;
243
+ }
244
+ if (!first) seen.set(code, { address, written: value });
245
+ return code;
246
+ }
247
+
248
+ /**
249
+ * The defaults and vocabularies a position reads its values against.
250
+ *
251
+ * **The position's own types belong in the vocabulary.** A position declares
252
+ * what it names, so a tree that happens to carry no note of that type still
253
+ * reads the value: without this, an affiliation naming a `seat` in a tree whose
254
+ * places are all in a dependency stops parsing, and the refusal describes the
255
+ * tree rather than the value.
256
+ *
257
+ * @param {AddressPosition} position - The declaration's `address` statement.
258
+ * @param {object} vocabulary - As {@link reduceAddressFields} takes it.
259
+ * @returns {object} The defaults {@link parseAddress} takes, plus the accepted
260
+ * set the acceptability step reads.
261
+ */
262
+ function positionDefaults(position, vocabulary) {
263
+ const accepts = position.accepts ?? [position.type];
264
+ return {
265
+ ...vocabulary,
266
+ types: new Set([...(vocabulary.types ?? []), position.type, ...accepts]),
267
+ // Derived from the type rather than taken from the block the value sits
268
+ // in: an affiliation address names a SoHL Item, a place address names a
269
+ // core document, and both are written inside the same `sohl:` block.
270
+ system: systemOf(position.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
271
+ type: position.type,
272
+ accepts,
273
+ };
274
+ }
275
+
276
+ /** Whether a value is an authored map rather than a list or a scalar. */
277
+ function isMapping(value) {
278
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
279
+ }
280
+
281
+ /**
282
+ * The key a finding names, as the note's own line spells it.
283
+ *
284
+ * A declaration's shared source is a path into the `data:` container —
285
+ * `data.seat` — and the note writes `seat:` one level inside it. The finding
286
+ * names that key for two reasons at once: it is the word on the line the
287
+ * diagnostic opens, and it is what {@link
288
+ * module:engine/retired-fields.locateFrontmatterKey} matches when the authored
289
+ * value cannot be found in the fence, which a dotted path never would.
290
+ *
291
+ * @param {object} field - The declaration.
292
+ * @returns {string} The key, without the container holding it.
293
+ */
294
+ function findingKey(field) {
295
+ return authoredKey(field.name ?? field.to);
296
+ }
297
+
298
+ /**
299
+ * Format a closed set for a message: `a, b or c`.
300
+ *
301
+ * @param {readonly unknown[]} values - The set.
302
+ * @returns {string} The values, joined.
303
+ */
304
+ function oneOf(values) {
305
+ const all = [...values].map((value) => `\`${String(value)}\``);
306
+ return all.length < 2 ?
307
+ all.join("")
308
+ : `${all.slice(0, -1).join(", ")} or ${all[all.length - 1]}`;
309
+ }
310
+
311
+ /**
312
+ * What a value the grammar cannot read is reported as.
313
+ *
314
+ * @param {string} key - The field, as an author writes it.
315
+ * @param {string} written - The authored value.
316
+ * @param {import("./address.mjs").AddressProblem} problem - Why it does not
317
+ * parse.
318
+ * @param {object} defaults - What {@link positionDefaults} returns.
319
+ * @returns {string} The message.
320
+ */
321
+ function unreadableMessage(key, written, problem, defaults) {
322
+ if (problem.reason === "no-content-index") {
323
+ return (
324
+ `\`${key}\` names package "${problem.package}", which is declared ` +
325
+ `\`contentIndex: false\` — a Foundry dependency only, with no content index ` +
326
+ `fetched for it, so nothing it publishes can be named here`
327
+ );
328
+ }
329
+ if (problem.reason === "not-lowercase") {
330
+ return (
331
+ `\`${key}\` names \`${written}\`, whose package, system or type segment ` +
332
+ `carries a capital — those segments are lowercase, an address having one ` +
333
+ `spelling`
334
+ );
335
+ }
336
+ return (
337
+ `\`${key}\` must name ${oneOf(defaults.accepts)} — its \`shortcode\`, or that ` +
338
+ `address qualified by type, by system and type, or by package, system and ` +
339
+ `type — but reads \`${written}\``
340
+ );
341
+ }
342
+
343
+ /**
344
+ * What a value naming a type the position does not take is reported as.
345
+ *
346
+ * An **error**: the value is a real Address, so no default repairs it, and the
347
+ * position cannot do anything with what it names.
348
+ *
349
+ * **The value states its own type, and the message leaves it there.** A value
350
+ * omitting the `<type>` segment takes the position's default and is therefore
351
+ * acceptable, so this fires only where the author wrote a type — and naming the
352
+ * parsed one instead would name the `doc<type>` a system-bearing type is
353
+ * redirected to under `none`, sending a reader to look for a segment nobody
354
+ * wrote.
355
+ *
356
+ * @param {string} key - The field, as an author writes it.
357
+ * @param {string} written - The authored value.
358
+ * @param {readonly string[]} accepts - The types the position accepts.
359
+ * @returns {string} The message.
360
+ */
361
+ function unacceptedMessage(key, written, accepts) {
362
+ return (
363
+ `\`${key}\` names \`${written}\`, whose type this field does not accept — it ` +
364
+ `accepts ${oneOf(accepts)}`
365
+ );
366
+ }
367
+
368
+ /**
369
+ * What two Addresses reducing to one shortcode are reported as.
370
+ *
371
+ * The message states that both values are valid, because they are: a shortcode
372
+ * is unique within one package's address space and these are two packages'. The
373
+ * fault is in what the emitted field can hold, which is why the finding names
374
+ * both Addresses rather than one of them.
375
+ *
376
+ * @param {string} key - The field, as an author writes it.
377
+ * @param {string} first - The Address already reduced.
378
+ * @param {string} second - The Address colliding with it.
379
+ * @param {string} code - The shortcode they share.
380
+ * @returns {string} The message.
381
+ */
382
+ function collidingMessage(key, first, second, code) {
383
+ return (
384
+ `\`${key}\` names both \`${first}\` and \`${second}\`, which share the shortcode ` +
385
+ `"${code}" — both addresses are valid, and the compiled field is keyed by the ` +
386
+ `shortcode alone, so it carries one of the two and the other is lost`
387
+ );
388
+ }
389
+
390
+ /**
391
+ * What one Address written twice at a keyed position is reported as.
392
+ *
393
+ * @param {string} key - The field, as an author writes it.
394
+ * @param {string} address - The Address both keys name.
395
+ * @param {string} first - How the first key was written.
396
+ * @param {string} second - How the second was written.
397
+ * @returns {string} The message.
398
+ */
399
+ function repeatedMessage(key, address, first, second) {
400
+ return (
401
+ `\`${key}\` names \`${address}\` twice, as \`${first}\` and as \`${second}\` — the ` +
402
+ `compiled field carries one value per target, so the second is lost`
403
+ );
404
+ }
@@ -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
+ }