@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
@@ -0,0 +1,769 @@
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
+ * The Address: one value, one parser, one renderer.
16
+ *
17
+ * An **Address** is the four-tuple `<package>-<system>-<type>-<shortcode>`. It
18
+ * is the identity of everything this toolchain compiles — how a note names
19
+ * another note, how a compendium entry is keyed, how a page's URL is derived,
20
+ * how an asset's file is found, how a Foundry UUID is built, and what the
21
+ * published index records.
22
+ *
23
+ * ## Three data types, and this module is about one of them
24
+ *
25
+ * - An **Address** is the four-tuple. An author may write any suffix of it, and
26
+ * it is always stored and rendered in full. That is what this module reads and
27
+ * writes.
28
+ * - A **Shortcode** is one segment: an identity, or a key a runtime lookup
29
+ * takes. A note's own `shortcode:`, a custom embedded entry's
30
+ * `system.shortcode`, the `*Code` fields and an affiliation's
31
+ * `system.relations` keys are all shortcodes. **A shortcode is not a short
32
+ * Address.** It names nothing to expand — it is resolved among the items
33
+ * embedded on one actor, where packages do not exist — so it never reaches
34
+ * this module and nothing here qualifies one. A field the corpus writes at
35
+ * both lengths is an Address; a field never written qualified is a shortcode.
36
+ * - A **Wikilink** is `[[<Address>[#<anchor>]|<text>]]` — a different thing that
37
+ * *contains* an Address. The anchor and the label belong to the Wikilink and
38
+ * are stripped before anything here sees the value; a frontmatter field holds
39
+ * the same tuple with no Wikilink around it.
40
+ *
41
+ * **How an author writes an Address is any suffix of the tuple. At the code
42
+ * level there is one shape.**
43
+ *
44
+ * ```text
45
+ * [[[<package>-]<system>-]<type>-]<shortcode> what an author may write
46
+ * parseAddress(written, defaults) → AddressTuple what the code holds
47
+ * ```
48
+ *
49
+ * {@link parseAddress} takes the written value and the defaults the position
50
+ * supplies, and returns **all four segments** or a reason it cannot. No caller
51
+ * asks which parts were written, and no caller does string surgery on an
52
+ * address.
53
+ *
54
+ * ## Two questions, and they are separate
55
+ *
56
+ * 1. **Does it parse, and what does it name?** Uniform and universal. The
57
+ * position contributes the defaults and the vocabularies, nothing else.
58
+ * 2. **Is what it names acceptable *here*?** {@link acceptsType}, taking the
59
+ * **set** of types the position accepts.
60
+ *
61
+ * The set is not the default. An art slot's default type is `icon` while its
62
+ * accepted set is `icon` and `image`, because a faith tradition's profile art
63
+ * is a full illustration rather than a game icon — so a rule of "the written
64
+ * type must equal the default" would refuse authored content that is correct.
65
+ *
66
+ * ## Why counting segments is sound
67
+ *
68
+ * Parsing is plain positional counting: split on the separator and assign each
69
+ * position its field. That rests on the charset — every segment matches
70
+ * `ADDRESS_SEGMENT_PATTERN` (`engine/address-charset.mjs`), so a hyphen is
71
+ * *purely* a separator and the count alone determines every field. The rule is
72
+ * enforced at each source rather than assumed of the data: shortcodes by
73
+ * `content-lint.mjs`, `contentPackage` by `defineConfig`, and types are bare
74
+ * words.
75
+ *
76
+ * `sohl` is both a package and a system, and positional counting is what makes
77
+ * that harmless: three segments is `<system>-<type>-<shortcode>` whatever the
78
+ * first segment could also have named, and four is the full form. Nothing has
79
+ * to guess which sense was meant. The cost is that `<package>-<type>-<shortcode>`
80
+ * is **not** a written form, so a link into another package states its system.
81
+ *
82
+ * ## The three rules a complete tuple applies
83
+ *
84
+ * - **`system` is derived from the type, then defaulted.** An asset type and a
85
+ * documentation journal are {@link NO_SYSTEM} because a file and a
86
+ * JournalEntry belong to no game system, and the segment is therefore a
87
+ * property of the type rather than of where the reference was written.
88
+ * Everything else takes the written system, then the position's default.
89
+ * - **`type` may not survive unchanged.** A system-bearing type reached at
90
+ * `none` is redirected to `doc<type>`, because a note's `none` address *is*
91
+ * its documentation journal — the Item is the one with a system.
92
+ * - **Acceptability accepts a type's documentation form.** A position that
93
+ * accepts `weapongear` accepts `docweapongear`, so a target addressed by its
94
+ * documentation journal is acceptable wherever the note itself would be.
95
+ *
96
+ * ## The tuple keeps no memory of how it was written
97
+ *
98
+ * An abbreviation is a *rendering*, not a different value: `being-kaldor` in a
99
+ * `thalorna` note and `thalorna-none-being-kaldor` are the same Address, and
100
+ * they parse to the same four fields. So does the pair `weapongear-dgr` in prose
101
+ * and `docweapongear-dgr` anywhere, because both name the one documentation
102
+ * journal. Nothing here records what the author typed — a tuple that did would
103
+ * make identity depend on a spelling, and two notes naming one document would
104
+ * compare unequal.
105
+ *
106
+ * The converse matters just as much: **one written string is two Addresses at
107
+ * two positions**, because the positions supply different defaults.
108
+ * `skill-wpnc` under `sohl.items` defaults its system from the block and names
109
+ * the **Item**; the same string in body prose defaults to `none`, is redirected,
110
+ * and names the **page**. Only the resolved tuple can tell them apart.
111
+ *
112
+ * That is why {@link acceptsType} widens the accepted set rather than reading a
113
+ * remembered pre-redirect type off the tuple. The redirect is not reversible
114
+ * from the four segments alone: `docmacro-autoattack` addresses a `macro`'s
115
+ * journal while `macro-autoattack` addresses the Macro, so `doc`-stripping is a
116
+ * question about the type vocabulary, not about the tuple.
117
+ *
118
+ * ## What this module does not do
119
+ *
120
+ * A failure is a **reason**, never a diagnostic: callers build the finding and
121
+ * locate it in the file, because only the caller knows which key or which
122
+ * wikilink occurrence it is reporting. And package-blindness is a *projection*
123
+ * rather than a partial tuple — a reference to an embedded item resolves on
124
+ * `(type, shortcode)` because an actor assembles items from several packages
125
+ * and the system matches that pair at runtime, which is a fact about the
126
+ * runtime stated at the one call site that needs it, not about the grammar.
127
+ *
128
+ * @module
129
+ */
130
+
131
+ // The system vocabulary is the `<system>` segment's own registry, and
132
+ // `engine/systems.mjs` imports nothing but `engine/address-charset.mjs`, so the
133
+ // direction is toward the leaf and cannot close a cycle.
134
+ import { isAddressSegment } from "./address-charset.mjs";
135
+ import { NO_SYSTEM, assertSystemSegment, isSystemSegment } from "./systems.mjs";
136
+ // The asset vocabulary, for the one thing the address grammar asks of it:
137
+ // whether a type's `<system>` segment is fixed. `engine/asset-types.mjs` is a
138
+ // leaf, so the direction cannot close a cycle.
139
+ import { isAssetType } from "./asset-types.mjs";
140
+ import { systemOf } from "./document-subtypes.mjs";
141
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
142
+ // `ids.mjs` is a leaf with no local imports, and `pack-config.mjs` reaches only
143
+ // the configuration loader and the diagnostics writer — neither reads an
144
+ // address, so the grammar sits below everything that consumes it.
145
+ import { currentType, ITEM_PACK, packForType } from "./ids.mjs";
146
+ import { loadPackConfig } from "./pack-config.mjs";
147
+
148
+ export { NO_SYSTEM };
149
+
150
+ /**
151
+ * The one character that separates an Address's segments.
152
+ *
153
+ * Named because it is the *grammar*, not an implementation detail: it is what a
154
+ * change to the address form would move, and what a reader of that change has
155
+ * to find.
156
+ *
157
+ * @type {string}
158
+ */
159
+ export const ADDRESS_SEPARATOR = "-";
160
+
161
+ /**
162
+ * How many segments a canonical Address has, and therefore how many
163
+ * {@link readCanonicalKey} counts.
164
+ *
165
+ * @type {number}
166
+ */
167
+ export const CANONICAL_KEY_SEGMENTS = 4;
168
+
169
+ /**
170
+ * The qualifier prefix that addresses a note's **documentation** rather than
171
+ * the note's own document: `docskill-wpnc` is the JournalEntry that
172
+ * `skill-wpnc`'s prose compiled into.
173
+ *
174
+ * Every doc-carrying type has a `doc<type>` counterpart, formed by prefix and
175
+ * never enumerated, so the prefix itself is the whole of that vocabulary.
176
+ *
177
+ * @type {string}
178
+ */
179
+ export const ITEM_DOC_PREFIX = "doc";
180
+
181
+ /** Package, system and type are one spelling each, so they fold to lowercase. */
182
+ const norm = (s) => String(s).toLowerCase().trim();
183
+
184
+ /**
185
+ * A complete Address: four segments, and nothing else.
186
+ *
187
+ * `type` is the segment the canonical string carries, after the `doc<type>`
188
+ * redirect. There is no fifth field recording the written form, because the
189
+ * written form is a rendering rather than part of the value.
190
+ *
191
+ * @typedef {object} AddressTuple
192
+ * @property {string} package - The owning **content** package.
193
+ * @property {string} system - The system whose document this addresses, or
194
+ * `none`.
195
+ * @property {string} type - The type segment of the canonical Address.
196
+ * @property {string} shortcode - The note's `shortcode`.
197
+ */
198
+
199
+ /**
200
+ * Why a written value is not an Address.
201
+ *
202
+ * The vocabulary is shared with the resolvers, so one authored mistake gets one
203
+ * verdict however many surfaces report it.
204
+ *
205
+ * - `not-an-address` — the value does not parse as one, which for body prose is
206
+ * an ordinary note name rather than a mistake.
207
+ * - `no-type` — the `<type>` segment is omitted and the position declares no
208
+ * default to fill it in, so nothing says what the value names.
209
+ * - `unknown-type` — the value is definitely qualified and names no known type.
210
+ * - `no-content-index` — the package named publishes no content index, so there
211
+ * is nothing to resolve the rest of the target against.
212
+ * - `not-lowercase` — a package, system or type segment carries a capital.
213
+ * - `invalid-segment` — a declared Address contains a malformed segment.
214
+ * - `no-package` — a declared Address cannot supply its package.
215
+ * - `invalid-system` — a declared Address cannot supply a registered system.
216
+ *
217
+ * @typedef {{reason: string, package?: string}} AddressProblem
218
+ */
219
+
220
+ /**
221
+ * Every content type whose **prose compiles into a JournalEntry of its own**,
222
+ * addressed by the virtual `doc<type>` qualifier.
223
+ *
224
+ * Every item type, every actor type, every map type, plus `macro` — a macro
225
+ * note's body documents the script the note also compiles into a Macro, which
226
+ * is the same shape as an item and its description: one note, two documents,
227
+ * the prose living in the journals pack.
228
+ *
229
+ * **One set, read by the compilers and by the grammar alike.** The journals
230
+ * pass decides what to compile from it, the link manifest decides what to
231
+ * publish a `doc<type>` entry for, and the parser decides whether `doc<type>`
232
+ * is a legal type segment. Held apart, the three drift into a manifest that
233
+ * asserts documentation nothing compiled, a compiled entry no consumer can
234
+ * address, or an authored `doc<type>` link that stops parsing. It is composed
235
+ * exactly once, in `defineConfig`, and read from there — never recomposed at a
236
+ * call site.
237
+ *
238
+ * `doc` notes are absent, for the reason that applies to them alone: a `doc`
239
+ * note's single document *is* its prose, so there is no second document for a
240
+ * `doc<type>` address to name.
241
+ *
242
+ * An accessor rather than a hoisted constant, so that importing this module
243
+ * needs no configuration.
244
+ *
245
+ * @returns {ReadonlySet<string>} The configured doc-carrying types.
246
+ */
247
+ export function docEntryTypes() {
248
+ return loadPackConfig().docEntryTypes;
249
+ }
250
+
251
+ /**
252
+ * Whether a content note's type is one whose prose becomes a JournalEntry of
253
+ * its own.
254
+ *
255
+ * @param {string} type - The note's `type` frontmatter.
256
+ * @returns {boolean} True for an item type, an actor type, a map type and
257
+ * `macro`; false for `doc`.
258
+ */
259
+ export function hasDocEntry(type) {
260
+ // Through {@link currentType}, because the set is derived from the item
261
+ // registry's keys and those are the *current* spelling of a note type. A
262
+ // note still on a renamed one carries its documentation journal exactly as
263
+ // before — this is the one lookup between an item compiling and its prose
264
+ // silently compiling into nothing.
265
+ return docEntryTypes().has(String(currentType(type)));
266
+ }
267
+
268
+ /**
269
+ * Whether a note type's **own** document carries a game system.
270
+ *
271
+ * True for the types some shipped map compiles into an Item or an Actor; false
272
+ * for the core-document types — `doc`, `lore`, `place`, `scenario`, `macro` and
273
+ * the map types — whose documents Foundry itself defines and which therefore
274
+ * already live at `none`.
275
+ *
276
+ * It is what the `doc<type>` redirect tests rather than {@link hasDocEntry}: a
277
+ * `macro` has a documentation journal *and* a `none` address of its own, so
278
+ * redirecting on "has a doc entry" would collapse two live addresses into one
279
+ * and a `[[macro-autoattack|]]` would stop naming the Macro.
280
+ *
281
+ * @param {string} type - The note type.
282
+ * @returns {boolean} True when the type compiles into a system document.
283
+ */
284
+ function isSystemBearing(type) {
285
+ return systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS) !== NO_SYSTEM;
286
+ }
287
+
288
+ /**
289
+ * Reads a qualifier as the **virtual `doc<type>`** form, or reports that it is
290
+ * not one.
291
+ *
292
+ * A document and its documentation are two documents in two packs, so they need
293
+ * two addresses. `skill-wpnc` is the item; `docskill-wpnc` is the JournalEntry
294
+ * its prose compiled into, and `docmacro-autoattack` is the same arrangement
295
+ * for a macro.
296
+ *
297
+ * The virtual form exists for a type that carries separate documentation
298
+ * ({@link docEntryTypes} — the set the journals compiler and the link manifest
299
+ * read too), **or** for one that routes to the items pack. The second clause
300
+ * covers the open, unenumerated set: types that compile into items are not
301
+ * enumerated anywhere, and a foreign package may publish an item type this
302
+ * build has never heard of. Dropping it would silently unlink every `doc<type>`
303
+ * address into such a package.
304
+ *
305
+ * A **real** type of the same name always wins: the virtual reading is only
306
+ * consulted for a qualifier no authored note claims.
307
+ *
308
+ * @param {string} qualifier - The already-normalised type segment.
309
+ * @param {Set<string>} types - Every type the content tree contains.
310
+ * @returns {string|null} The underlying document type, or `null` when the
311
+ * qualifier is not a virtual one.
312
+ */
313
+ export function resolveItemDocType(qualifier, types) {
314
+ if (types.has(qualifier)) return null; // a real type owns its own name
315
+ if (!qualifier.startsWith(ITEM_DOC_PREFIX)) return null;
316
+ const base = qualifier.slice(ITEM_DOC_PREFIX.length);
317
+ if (!base || !types.has(base)) return null;
318
+ if (hasDocEntry(base)) return base;
319
+ return packForType(base).docType === ITEM_PACK.docType ? base : null;
320
+ }
321
+
322
+ /**
323
+ * The vocabularies a written Address is read against.
324
+ *
325
+ * These are the tree's, not the position's — every position reads against the
326
+ * same ones — and they travel beside the defaults so that reading an Address is
327
+ * one call.
328
+ *
329
+ * @typedef {object} AddressVocabulary
330
+ * @property {Set<string>} [types] - Every type the content tree contains.
331
+ * @property {Set<string>} [packages] - Every package an Address may name.
332
+ * Omitted by callers that resolve within one package, where the fully
333
+ * qualified form cannot occur.
334
+ * @property {Set<string>} [noIndexPackages] - Packages declared
335
+ * `contentIndex: false` — a Foundry dependency only, with no fetched index. A
336
+ * fully qualified target naming one is refused with `no-content-index` before
337
+ * its type is even considered.
338
+ */
339
+
340
+ /**
341
+ * What a position contributes: the defaults for the segments an author may omit.
342
+ *
343
+ * `package` and `system` are always supplied; `type` is supplied only by a
344
+ * position that accepts exactly one, and a position accepting several declares
345
+ * none, so a value omitting the `<type>` segment is refused rather than guessed
346
+ * at.
347
+ *
348
+ * @typedef {AddressVocabulary & {package?: string, system?: string,
349
+ * type?: string}} AddressDefaults
350
+ */
351
+
352
+ /**
353
+ * Parse a written Address into the one complete tuple it names.
354
+ *
355
+ * **An omitted segment defaults from where the value is written** — it is not a
356
+ * wildcard, and resolution is not a search. So every short form has exactly one
357
+ * expansion, computed before anything is looked up, and there is no candidate
358
+ * set to disambiguate.
359
+ *
360
+ * @param {unknown} written - The value as authored, with any Wikilink anchor and
361
+ * label already removed.
362
+ * @param {AddressDefaults} [defaults] - The position's defaults and the tree's
363
+ * vocabularies.
364
+ * @param {{declared?: boolean, legacyShortcodeCase?: boolean}} [options] - A declared Address validates every
365
+ * segment and requires complete defaults. Package membership is a separate
366
+ * resolution question in this mode; prose recognition keeps its vocabulary checks.
367
+ * `legacyShortcodeCase` accepts case-insensitive model Shortcodes while the
368
+ * completed tuple contains their canonical lowercase identity.
369
+ * @returns {AddressTuple|AddressProblem} The complete Address, or why there is
370
+ * none.
371
+ */
372
+ export function parseAddress(
373
+ written,
374
+ defaults = {},
375
+ { declared = false, legacyShortcodeCase = false } = {},
376
+ ) {
377
+ if (isAddressTuple(written)) return written;
378
+ if (declared) {
379
+ if (typeof written !== "string") return { reason: "not-an-address" };
380
+ const segments =
381
+ written.includes("/") ? written.split("/") : written.split(ADDRESS_SEPARATOR);
382
+ if (
383
+ !segments.every((segment, index) =>
384
+ isAddressSegment(
385
+ legacyShortcodeCase && index === segments.length - 1 ? norm(segment) : segment,
386
+ ),
387
+ )
388
+ )
389
+ return { reason: "invalid-segment" };
390
+ }
391
+ const read = readWritten(written, defaults, declared);
392
+ if (read.reason) return read;
393
+ const tuple = completeAddress(read, defaults);
394
+ if (declared && !isAddressSegment(tuple.package)) return { reason: "no-package" };
395
+ if (declared && !isSystemSegment(tuple.system)) return { reason: "invalid-system" };
396
+ return tuple;
397
+ }
398
+
399
+ /** The values created by the Address reader, distinct from authored mappings. */
400
+ const TUPLES = new WeakSet();
401
+
402
+ /**
403
+ * Whether a value is an internally parsed Address.
404
+ * @param {unknown} value - A property value.
405
+ * @returns {boolean} Whether it is a typed tuple.
406
+ */
407
+ export function isAddressTuple(value) {
408
+ return value !== null && typeof value === "object" && TUPLES.has(value);
409
+ }
410
+
411
+ /**
412
+ * Construct a complete, immutable Address value.
413
+ * @param {AddressTuple} value - The four segments.
414
+ * @returns {AddressTuple} The typed value.
415
+ */
416
+ function addressTuple(value) {
417
+ if (
418
+ !isAddressSegment(value.package) ||
419
+ !isSystemSegment(value.system) ||
420
+ !isAddressSegment(value.type) ||
421
+ !isAddressSegment(value.shortcode)
422
+ )
423
+ return value;
424
+ TUPLES.add(value);
425
+ return Object.freeze(value);
426
+ }
427
+
428
+ /**
429
+ * Render a complete Address as its canonical string.
430
+ *
431
+ * Lowercased, because an Address has one spelling: two names differing only in
432
+ * case are two names nobody can tell apart.
433
+ *
434
+ * @param {AddressTuple} tuple - The complete Address.
435
+ * @returns {string} `package-system-type-shortcode`.
436
+ */
437
+ export function renderAddress(tuple) {
438
+ const { package: pkg, system, type, shortcode } = tuple;
439
+ // Checked where an address is *written*, not where one is read: a fetched
440
+ // index naming a system this build has never heard of is data to report,
441
+ // while emitting one is a defect in this build. The registry is closed, so
442
+ // an unknown value here can only be a typo or a system nobody declared.
443
+ assertSystemSegment(system, `the address of ${type}-${shortcode}`);
444
+ return `${pkg}-${system}-${type}-${shortcode}`.toLowerCase();
445
+ }
446
+
447
+ /**
448
+ * Whether the type an Address names is one this position accepts.
449
+ *
450
+ * The second of the two questions, and separate from the first because the
451
+ * accepted **set** is a property of the position while the grammar is not.
452
+ *
453
+ * **An accepted type carries its documentation form with it.** A position
454
+ * accepting `weapongear` accepts `docweapongear`, because the journal that
455
+ * documents a weapon is the same thing to a position that wanted a weapon — and
456
+ * a prose address of one *is* the journal, so refusing it would refuse the form
457
+ * an author most often writes. It is the widening `defineConfig` applies to the
458
+ * types a package may claim, applied here to the types a position may take.
459
+ *
460
+ * @param {AddressTuple} tuple - A parsed Address.
461
+ * @param {Iterable<string>} allowed - The types this position accepts.
462
+ * @returns {boolean} True when the Address names one of them.
463
+ */
464
+ export function acceptsType(tuple, allowed) {
465
+ const type = norm(tuple?.type ?? "");
466
+ if (!type) return false;
467
+ for (const one of allowed ?? []) {
468
+ const want = norm(one);
469
+ if (!want) continue;
470
+ if (type === want || type === `${ITEM_DOC_PREFIX}${want}`) return true;
471
+ }
472
+ return false;
473
+ }
474
+
475
+ /**
476
+ * Apply the defaults to a partial reading, giving a complete Address.
477
+ *
478
+ * The three rules of the module note, applied in the order they depend on each
479
+ * other: the package, then the system the type may fix, then the redirect the
480
+ * system may force.
481
+ *
482
+ * @param {{type: string, shortcode: string, package?: string, system?: string,
483
+ * itemDoc?: boolean}} read - A partial reading, as {@link readQualifier}
484
+ * returns one.
485
+ * @param {{package?: string, system?: string}} [where] - The citing context: the
486
+ * tree's own content package, and the system of the block the value sits in.
487
+ * @returns {AddressTuple} The complete Address.
488
+ */
489
+ export function completeAddress(read, where = {}) {
490
+ if (isAddressTuple(read)) return read;
491
+ const written = read.type;
492
+ const pkg = read.package ?? where.package;
493
+ // A documentation journal is a core document, so it is `none` however it was
494
+ // reached, and an **asset** is `none` for a stronger reason: a file belongs
495
+ // to no game system, so the segment is a property of the type rather than of
496
+ // where the reference was written. Without that, a `sohl:` block naming
497
+ // `icon-anvil` would expand to `sohl-sohl-icon-anvil` and resolve to
498
+ // nothing, which is exactly where an embedded item's art is written.
499
+ // Otherwise the block's system, which body prose reports as `none`.
500
+ const system =
501
+ read.itemDoc || isAssetType(written) ?
502
+ NO_SYSTEM
503
+ : (read.system ?? where.system ?? NO_SYSTEM);
504
+ // **Under `none`, a system-bearing type addresses its documentation
505
+ // journal.** A note's `none` address *is* its `doc<type>` entry — the Item
506
+ // is the one with a system — so a prose `[[affiliation-sirvadar|…]]` names
507
+ // the page, which is almost always what prose means. A value that means the
508
+ // Item states the system and gets it. A `doc<type>` written explicitly is
509
+ // `none` wherever it appears, even inside a system block: no game system
510
+ // defines a JournalEntry.
511
+ const redirected = system === NO_SYSTEM && isSystemBearing(written);
512
+ const type = read.itemDoc || redirected ? `${ITEM_DOC_PREFIX}${written}` : written;
513
+ return addressTuple({ package: pkg, system, type, shortcode: read.shortcode });
514
+ }
515
+
516
+ /**
517
+ * Expand a written Address to the one canonical Address it names.
518
+ *
519
+ * {@link completeAddress} and {@link renderAddress} under one name, for the
520
+ * index keys and cache keys that hold an Address as a string.
521
+ *
522
+ * @param {{type: string, shortcode: string, package?: string, system?: string,
523
+ * itemDoc?: boolean}} read - A partial reading, as {@link readQualifier}
524
+ * returns one.
525
+ * @param {{package: string, system?: string}} where - The citing context.
526
+ * @returns {string} The canonical `package-system-type-shortcode`.
527
+ */
528
+ export function expandAddress(read, where) {
529
+ return renderAddress(completeAddress(read, where));
530
+ }
531
+
532
+ /**
533
+ * The canonical Address of a document, from its four segments.
534
+ *
535
+ * Global uniqueness is what lets a dependency's index merge straight into a
536
+ * local one: the keys cannot collide by accident, so a key already present on
537
+ * merge is a real conflict rather than an artefact of two packages sharing a
538
+ * namespace. `(type, shortcode)` alone is unique only *within* a package, and
539
+ * two independently authored packages reaching for the same short string is a
540
+ * matter of time.
541
+ *
542
+ * **The system segment.** A package may ship content for more than one system,
543
+ * and one note then compiles into a document per system — an actor in
544
+ * `actors-sohl` *and* an actor in `actors-hm3`. Without a system segment both
545
+ * land on one key, so the address cannot name either of them.
546
+ *
547
+ * The value is a system id, or the literal **`none`** for a document no game
548
+ * system defines: a journal, a macro, a scene, and an item's documentation
549
+ * journal — which is `none` however many systems the item itself declares,
550
+ * because it is one journal. `none` rather than `any`, because every segment of
551
+ * an address is an exact literal and `any` reads as a wildcard, which a
552
+ * resolver written to that misreading would act on silently; and not `null` or
553
+ * `~`, both of which are YAML nulls that parse to an absent value and drop the
554
+ * segment entirely.
555
+ *
556
+ * @param {string} pkg - The owning **content** package (`sohl`, `thalorna`) —
557
+ * not the Foundry package, which varies per compilation target.
558
+ * @param {string} system - The system whose document this addresses, or `none`.
559
+ * @param {string} type - The note's `type`.
560
+ * @param {string} shortcode - The note's `shortcode`.
561
+ * @returns {string} `package-system-type-shortcode`, lowercased.
562
+ */
563
+ export function canonicalKey(pkg, system, type, shortcode) {
564
+ return renderAddress({ package: pkg, system, type, shortcode });
565
+ }
566
+
567
+ /**
568
+ * Reads a canonical Address back into its segments.
569
+ *
570
+ * **Nothing to read and nothing readable are different answers.** An Address
571
+ * that cannot be canonical — `harn-adventures-sohl-skill-melee`, five segments,
572
+ * because the package name carries the separator — yields `null`, while an
573
+ * absent or blank input yields `undefined`. Both are falsy, so every call site
574
+ * is unaffected; the distinction is there so a caller reporting "this address is
575
+ * unreadable" can tell that it has one to report about.
576
+ *
577
+ * @param {unknown} key - A canonical Address, or nothing.
578
+ * @returns {{package: string, system: string, type: string, shortcode: string}
579
+ * |null|undefined}
580
+ * The segments; `null` when there is a string that is not in canonical form;
581
+ * `undefined` when there is no Address at all.
582
+ */
583
+ export function readCanonicalKey(key) {
584
+ if (isAddressTuple(key)) return key;
585
+ if (key == null || key === "") return undefined;
586
+ const parts = String(key).split(ADDRESS_SEPARATOR);
587
+ if (parts.length !== CANONICAL_KEY_SEGMENTS) return null;
588
+ const [pkg, system, type, shortcode] = parts;
589
+ if (!pkg || !system || !type || !shortcode) return null;
590
+ return addressTuple({ package: pkg, system, type, shortcode });
591
+ }
592
+
593
+ /**
594
+ * Read a written Address as the **partial** reading, before any default applies.
595
+ *
596
+ * The first of the two questions, giving the segments the author supplied and
597
+ * nothing more. It exists for the callers that still hold a partial; the Address
598
+ * is completed by {@link completeAddress}, and **nothing an omitted segment does
599
+ * here is a wildcard.** Every omitted segment expands from the position's
600
+ * defaults, exactly as the specification's table states, so a short form names
601
+ * exactly one canonical Address and a lookup finds one entry or none. There is no
602
+ * candidate set and no ambiguity by construction.
603
+ *
604
+ * Two separators are accepted, and they are **not** interchangeable in how
605
+ * confidently they mark a value as qualified:
606
+ *
607
+ * - **`type-shortcode`** and its qualified forms — the canonical spelling.
608
+ * Obsidian reads `/` inside a wikilink as a *path* and resolves it against the
609
+ * vault's folders, so a slash-qualified link is a broken link in the editor
610
+ * where the content is authored.
611
+ * - **`type/shortcode`** — resolved so that a link written before the vault
612
+ * migrated does not silently die. A slash is *unconditionally* a qualifier:
613
+ * nothing else uses one, so an unknown type before it is reported rather than
614
+ * guessed at. The split is at the **last** slash.
615
+ *
616
+ * **Package, system and type are lowercase; the shortcode is not.** A shortcode
617
+ * is routinely mixed — `Clb`, `LtShoe`, `HsTunic` — and is folded rather than
618
+ * refused. The three segments in front of it are closed vocabularies with one
619
+ * spelling each, and accepting `Skill` beside `skill` would bless two ways of
620
+ * writing one address, so a capital there is reported. It is tested only once
621
+ * the value *parses*: a note name is full of capitals (`[[Shock State]]`), and
622
+ * calling that a badly-cased address rather than not an address would name the
623
+ * wrong mistake.
624
+ *
625
+ * @param {string} target - The value as authored, anchor already removed.
626
+ * @param {Set<string>} types - Every type the content tree contains.
627
+ * @param {Set<string>} [packages] - Every package an Address may name.
628
+ * @param {Set<string>} [noIndexPackages] - Packages declared
629
+ * `contentIndex: false`.
630
+ * @returns {{type: string, shortcode: string, itemDoc: boolean,
631
+ * package?: string, system?: string, reason?: undefined}
632
+ * | AddressProblem | null}
633
+ * The partial reading, carrying only the segments written; a `reason` when the
634
+ * value is definitely qualified but names no known type, no fetched index or a
635
+ * badly-cased segment; or `null` when it is not an Address at all.
636
+ */
637
+ export function readQualifier(target, types, packages, noIndexPackages) {
638
+ if (isAddressTuple(target)) return target;
639
+ const read = readWritten(target, { types, packages, noIndexPackages });
640
+ // A value naming no type at all is a note name, not a badly written
641
+ // address, and the resolvers depend on telling the two apart: prose is full
642
+ // of names, and reporting each one as a dead link would bury the real ones.
643
+ if (read.reason === "not-an-address" || read.reason === "no-type") return null;
644
+ return read;
645
+ }
646
+
647
+ /**
648
+ * The grammar and the vocabularies, with every failure carrying a reason.
649
+ *
650
+ * @param {unknown} written - The value as authored.
651
+ * @param {AddressVocabulary & {type?: string}} vocabulary - The tree's
652
+ * vocabularies and the position's default type.
653
+ * @param {boolean} [declared] - Whether package membership is checked separately.
654
+ * @returns {object|AddressProblem} The partial reading, or a reason.
655
+ */
656
+ function readWritten(written, vocabulary, declared = false) {
657
+ const target = typeof written === "string" ? written : String(written ?? "");
658
+ if (!target) return { reason: "not-an-address" };
659
+ const read = readWrittenCased(target, vocabulary, declared);
660
+ if (!read.reason && qualifyingSegments(target).some((s) => /[A-Z]/.test(s))) {
661
+ return { reason: "not-lowercase" };
662
+ }
663
+ return read;
664
+ }
665
+
666
+ /**
667
+ * The segments of a written value that must be lowercase — everything but the
668
+ * shortcode, which keeps whatever the note declares.
669
+ *
670
+ * @param {string} target - The value as authored.
671
+ * @returns {string[]} The package / system / type segments, as written.
672
+ */
673
+ function qualifyingSegments(target) {
674
+ const slash = target.lastIndexOf("/");
675
+ // The `type/shortcode` form states only a type.
676
+ if (slash > 0) return [target.slice(0, slash)];
677
+ const parts = target.split(ADDRESS_SEPARATOR);
678
+ return parts.slice(0, -1);
679
+ }
680
+
681
+ /**
682
+ * {@link readWritten} without the lowercase rule — the grammar alone.
683
+ *
684
+ * @param {string} target - The value as authored.
685
+ * @param {AddressVocabulary & {type?: string}} vocabulary - The vocabularies and
686
+ * the position's default type.
687
+ * @param {boolean} [declared] - Whether package membership is checked separately.
688
+ * @returns {object|AddressProblem} The partial reading, or a reason.
689
+ */
690
+ function readWrittenCased(target, vocabulary, declared = false) {
691
+ const { types = new Set(), packages, noIndexPackages, type: defaultType } = vocabulary;
692
+
693
+ // The slash form states neither package nor system, so it is read first and
694
+ // separately. A slash is unconditionally a qualifier — nothing else uses one
695
+ // — which is why an unknown type before it is *reported* rather than read as
696
+ // prose.
697
+ const slash = target.lastIndexOf("/");
698
+ if (slash > 0) {
699
+ const read = readTypeAndCode(target.slice(0, slash), target.slice(slash + 1), types);
700
+ return read ?? { reason: "unknown-type" };
701
+ }
702
+
703
+ const parts = target.split(ADDRESS_SEPARATOR);
704
+ switch (parts.length) {
705
+ // `<shortcode>` — the `<type>` segment is omitted, so only a position
706
+ // that accepts exactly one type can say what it names. A position
707
+ // accepting several declares no default, and the value is refused
708
+ // rather than resolved against a guess.
709
+ case 1: {
710
+ if (typeof defaultType !== "string" || !defaultType) return { reason: "no-type" };
711
+ return readTypeAndCode(defaultType, parts[0], types) ?? { reason: "not-an-address" };
712
+ }
713
+
714
+ // `<type>-<shortcode>`
715
+ case 2:
716
+ return readTypeAndCode(parts[0], parts[1], types) ?? { reason: "not-an-address" };
717
+
718
+ // `<system>-<type>-<shortcode>` — the package defaults to local.
719
+ case 3: {
720
+ const system = norm(parts[0]);
721
+ if (!isSystemSegment(system)) return { reason: "not-an-address" };
722
+ const read = readTypeAndCode(parts[1], parts[2], types);
723
+ return read ? { ...read, system } : { reason: "not-an-address" };
724
+ }
725
+
726
+ // `<package>-<system>-<type>-<shortcode>` — the only form that names
727
+ // another package, and the reason a cross-package Address states its
728
+ // system.
729
+ case 4: {
730
+ const pkg = norm(parts[0]);
731
+ // Checked before the type: a package with no fetched index has no
732
+ // vocabulary to resolve the rest of the target against, and the fix
733
+ // is the config declaration, not the shortcode.
734
+ if (!declared && noIndexPackages?.has(pkg))
735
+ return { reason: "no-content-index", package: pkg };
736
+ if (!declared && !packages?.has(pkg)) return { reason: "not-an-address" };
737
+ const system = norm(parts[1]);
738
+ if (!isSystemSegment(system)) return { reason: "not-an-address" };
739
+ const read = readTypeAndCode(parts[2], parts[3], types);
740
+ return read ? { ...read, system, package: pkg } : { reason: "not-an-address" };
741
+ }
742
+
743
+ // Five or more segments is not a hyphenated shortcode but a name that
744
+ // happens to carry separators, since no segment may contain one.
745
+ default:
746
+ return { reason: "not-an-address" };
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Resolve a type segment and a shortcode, honouring the virtual `doc<type>`
752
+ * form.
753
+ *
754
+ * @param {string} rawType - The type segment, as written.
755
+ * @param {string} rawCode - The shortcode, as written.
756
+ * @param {Set<string>} types - Every type the content tree contains.
757
+ * @returns {{type: string, shortcode: string, itemDoc: boolean} | null}
758
+ * `null` when the segment names no known type, or the shortcode is empty.
759
+ */
760
+ function readTypeAndCode(rawType, rawCode, types) {
761
+ const shortcode = norm(rawCode);
762
+ if (!shortcode) return null;
763
+
764
+ const type = norm(rawType);
765
+ const base = resolveItemDocType(type, types);
766
+ if (base) return { type: base, shortcode, itemDoc: true };
767
+ if (!types.has(type)) return null;
768
+ return { type, shortcode, itemDoc: false };
769
+ }