@heroiclands/package-build 17.1.0 → 18.0.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 (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -28,7 +28,7 @@
28
28
  * | `<system>.flags` | `document.flags` |
29
29
  * | `<system>.pack` | *nothing on the document* — a build directive naming the compendium |
30
30
  *
31
- * Everything else a system declares — `archetype`, `kbcat`, and the
31
+ * Everything else a system declares — `templatePriority`, `kbcat`, and the
32
32
  * *generators* `items` and `attributes`, which expand into embedded documents
33
33
  * rather than mapping anywhere — sits directly under the block, which is why it
34
34
  * has to be somewhere the schema cannot claim.
@@ -46,12 +46,35 @@
46
46
  * order for a system `S` is:
47
47
  *
48
48
  * 1. `S.system.<to>` — authored directly, wins outright;
49
- * 2. `S.<name>` — the legacy in-block position the corpus still writes, kept
50
- * until #126 moves it;
49
+ * 2. `S.<legacyKey>` — the legacy in-block position the corpus still writes,
50
+ * kept until #126 moves it;
51
51
  * 3. the shared top-level property the field **declares** as its source, which
52
52
  * may be a dotted path (`data.portrait`) rather than a sibling key;
53
53
  * 4. the field's own default.
54
54
  *
55
+ * ## Steps 2 and 3 are two declarations, because they are two positions
56
+ *
57
+ * They used to be one: both were keyed on `name`, which was fine only while the
58
+ * shared source and the in-block key were the same word. `data:` (#128) ended
59
+ * that — a shared source is now a path *into* a container, so `data.species`
60
+ * and `species` are two spellings of two different places, and no single value
61
+ * of `name` reached both. `name: "species"` read `hm3.species` and could not
62
+ * see `data.species`; `name: "data.species"` read the shared source and could
63
+ * not see `hm3.species`. Each yielded the field's **default** wherever only the
64
+ * other position was authored — silently, since the field compiles and the
65
+ * document is emitted with the value simply gone (#305).
66
+ *
67
+ * That made every move into `data:` a flag day. Each of this package's other
68
+ * retirements — `package:`, `image`, `archetype`, `relation` — works because
69
+ * *both spellings are read while the corpus moves*, and one property could not
70
+ * offer that here.
71
+ *
72
+ * So a field declares `legacyKey` beside `name`: the key it is authored at
73
+ * inside the block, which step 2 reads and which **falls back to `name`** when
74
+ * absent, so every declaration written before this resolves unchanged. A field
75
+ * that declares one is mid-sweep by construction, which is what
76
+ * {@link module:engine/field-spec.readsLegacyKey} reports on.
77
+ *
55
78
  * ## A name that collides across the two vocabularies skips step 3
56
79
  *
57
80
  * A field's `name` is both its identity and the shared property it draws from,
@@ -148,7 +171,7 @@ export const BLOCK_DIRECTIVES = Object.freeze(["pack"]);
148
171
  /**
149
172
  * Every key any system block may carry, whatever the system.
150
173
  *
151
- * A system adds its own on top — `archetype`, `kbcat` and the generators — and
174
+ * A system adds its own on top — `templatePriority`, `kbcat` and the generators — and
152
175
  * names them where it declares its vocabulary; see {@link unknownBlockKeys}.
153
176
  *
154
177
  * @type {ReadonlySet<string>}
@@ -312,6 +335,27 @@ export function blockProperty(fm, block, key, defaultValue = undefined) {
312
335
  return shared === undefined || shared === null ? defaultValue : shared;
313
336
  }
314
337
 
338
+ /**
339
+ * The key a field is authored at **inside** a system block — step 2.
340
+ *
341
+ * `legacyKey` when the field declares one, and `name` otherwise. The fallback
342
+ * is what makes this change invisible to every declaration written before it:
343
+ * a field whose shared source and in-block key are the same word says so once,
344
+ * as it always did.
345
+ *
346
+ * Named and exported rather than spelled inline because three readers ask the
347
+ * question and must agree — the resolver here, the frontmatter lint building
348
+ * the set of keys a block may carry, and the author-facing surfaces naming
349
+ * where a value was written.
350
+ *
351
+ * @param {{name?: string, legacyKey?: string}} field - The declaration.
352
+ * @returns {string|undefined} The in-block key, or `undefined` for a field that
353
+ * is not authored at all.
354
+ */
355
+ export function legacyKeyOf(field) {
356
+ return field?.legacyKey ?? field?.name;
357
+ }
358
+
315
359
  /**
316
360
  * Where a declared field's value came from.
317
361
  *
@@ -356,16 +400,19 @@ export function resolveFieldValue(field, fm, { block = "sohl" } = {}) {
356
400
  if (own !== undefined) return { value: own, from: "system" };
357
401
  }
358
402
 
359
- // 2. The legacy in-block position. Every note in every tree writes here
360
- // today, and will until #126 moves them; dropping it would be a corpus
361
- // migration disguised as a mechanism change.
403
+ // 2. The legacy in-block position, keyed on `legacyKey` the shared
404
+ // source is a path into `data:` and the in-block key is a bare word, so
405
+ // the two are declared separately (#305). Every note in every tree
406
+ // writes here today, and will until #126 moves them; dropping it would
407
+ // be a corpus migration disguised as a mechanism change.
362
408
  const declared = systemBlock(fm, block);
363
- if (declared) {
364
- if (field.name in declared) {
365
- const value = declared[field.name];
409
+ const legacyKey = legacyKeyOf(field);
410
+ if (declared && legacyKey) {
411
+ if (legacyKey in declared) {
412
+ const value = declared[legacyKey];
366
413
  return { value: value ?? field.default, from: "block" };
367
414
  }
368
- const nested = getFrontmatter(declared, field.name, undefined);
415
+ const nested = getFrontmatter(declared, legacyKey, undefined);
369
416
  if (nested !== undefined) return { value: nested, from: "block" };
370
417
  }
371
418
 
@@ -421,6 +468,24 @@ export function systemDataPaths(data, prefix = "") {
421
468
  * this is the difference between "the field is lost at load" and "the build
422
469
  * told you where".
423
470
  *
471
+ * **A declared *leaf* holds values, not fields.** A schema declares a path that
472
+ * has no children of its own for two ordinary reasons — a map with **dynamic
473
+ * keys** (`mystery.skillAptitudes` is skill selector → modifier) and a
474
+ * **TypedSchemaField** (`strikeModes`, discriminated by `type`) — and in both
475
+ * the contents are data an author wrote, not paths the schema names. Walking
476
+ * into one reports every entry as an undeclared field: one finding per skill
477
+ * aptitude, per strike mode, per standing.
478
+ *
479
+ * It stayed invisible while those maps were authored *outside* `<system>.system`
480
+ * and so were never walked. The moment the corpus moves them to the destination
481
+ * (#126) every one of them lights up — 62 findings on `sohl-thalorna` alone,
482
+ * none of them a defect.
483
+ *
484
+ * So descent is conditional on the schema declaring something *beneath* the
485
+ * path. `body.structure` declares `parts` and `zones`, so it is a real
486
+ * container and an undeclared `adjacent` under it is a real finding;
487
+ * `skillAptitudes` declares nothing beneath it, so what is beneath is a value.
488
+ *
424
489
  * @param {Record<string, unknown>} data - The authored `system` data.
425
490
  * @param {ReadonlySet<string>} declared - Every field path the schema declares
426
491
  * for this subtype, inherited ones included.
@@ -436,7 +501,7 @@ export function undeclaredPaths(data, declared, prefix = "") {
436
501
  out.push(path);
437
502
  continue;
438
503
  }
439
- if (isMapping(value) && Object.keys(value).length) {
504
+ if (isMapping(value) && Object.keys(value).length && declaresChildren(declared, path)) {
440
505
  out.push(
441
506
  ...undeclaredPaths(/** @type {Record<string, unknown>} */ (value), declared, path),
442
507
  );
@@ -445,6 +510,25 @@ export function undeclaredPaths(data, declared, prefix = "") {
445
510
  return out;
446
511
  }
447
512
 
513
+ /**
514
+ * Whether the schema declares any path beneath this one.
515
+ *
516
+ * The test for "container, not leaf" — see {@link undeclaredPaths}. Asked of
517
+ * the declared set rather than of the authored value, because it is a question
518
+ * about the *schema*: an author can nest a map under either.
519
+ *
520
+ * @param {ReadonlySet<string>} declared - The declared field paths.
521
+ * @param {string} path - The path to test.
522
+ * @returns {boolean} True when something is declared beneath it.
523
+ */
524
+ function declaresChildren(declared, path) {
525
+ const prefix = `${path}.`;
526
+ for (const candidate of declared) {
527
+ if (candidate.startsWith(prefix)) return true;
528
+ }
529
+ return false;
530
+ }
531
+
448
532
  /**
449
533
  * Keys directly under a system block that neither this format nor the system
450
534
  * recognizes.
@@ -0,0 +1,201 @@
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 closed registry of system ids, and the `none` that stands for no system
16
+ * at all (#59).
17
+ *
18
+ * A canonical address carries the system in a fixed position —
19
+ * `harnadventures-none-being-grod` — so "which systems exist" is a question the
20
+ * address grammar asks on every note, and the issue's answer is that it comes
21
+ * from a **registry rather than from a hardcoded set**: known system ids are
22
+ * declared here, once, an unknown value is an error, and _adding a system is a
23
+ * data change_. Before this module the same fact was spelled in as many places
24
+ * as needed it — a `NO_SYSTEM` constant in the content index, a `map.system`
25
+ * per document-subtype map, a system-keyed table per compiler — none of which
26
+ * could refuse a value none of the others had heard of.
27
+ *
28
+ * **`none` is a word, deliberately.** It is not a YAML null (`null`, `~`, or an
29
+ * empty value), because those spell "nobody filled this in", and a note that
30
+ * belongs to no system has been answered rather than skipped — the distinction
31
+ * the whole address grammar rests on, since a null cannot occupy a segment.
32
+ * And it is not `any`, which reads as a wildcard: a `place` does not compile
33
+ * into every system's document, it compiles into a Foundry core document that
34
+ * belongs to none of them.
35
+ *
36
+ * **A leaf, so that anything may read it.** The only import is the address
37
+ * charset, itself a leaf with no local imports, so this module can be named by
38
+ * the configuration loader, by a compiler, and by a lint without closing a
39
+ * cycle around any of them. In particular the ids are *declared* here rather
40
+ * than derived from `KNOWN_DOCUMENT_SUBTYPE_MAPS` — deriving them would drag
41
+ * `sohl/` and `hm3/` behind every import of this file, for a list of two
42
+ * words. `tests/systems.test.ts` holds the two in step instead, which is where
43
+ * the agreement between a registry and its implementations belongs.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ // The one charset, read rather than restated — a system id is an address
49
+ // segment like any other, and a second spelling of the pattern is how the
50
+ // disagreements in #202/#203 happened.
51
+ import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs";
52
+
53
+ /**
54
+ * The `<system>` segment of a note that belongs to no system.
55
+ *
56
+ * Most notes are that: a `doc`, a `place`, a `macro` or a map compiles into a
57
+ * JournalEntry, a Scene or a Macro, which Foundry defines and no game system
58
+ * does. `none` is the format's word for it, in the address and in the content
59
+ * index alike, so the two say "no system" the same way.
60
+ *
61
+ * @type {string}
62
+ */
63
+ export const NO_SYSTEM = "none";
64
+
65
+ /**
66
+ * Every game system this toolchain compiles for.
67
+ *
68
+ * The registry, and the only place the list is written down. Adding a system
69
+ * is an edit to this set plus the map and compilers that make it real — a data
70
+ * change, not a search for every place a name was spelled.
71
+ *
72
+ * `none` is **not** a member: it marks the absence of a system, and a caller
73
+ * asking "is this a system" and a caller asking "is this a well-formed
74
+ * segment" want different answers. See {@link isSystemId} and
75
+ * {@link isSystemSegment}.
76
+ *
77
+ * @type {ReadonlySet<string>}
78
+ */
79
+ export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3"]));
80
+
81
+ /**
82
+ * Everything the `<system>` segment of an address may say.
83
+ *
84
+ * The systems, plus {@link NO_SYSTEM}. Derived from the registry rather than
85
+ * listed again, so the two cannot drift.
86
+ *
87
+ * @type {ReadonlySet<string>}
88
+ */
89
+ export const SYSTEM_SEGMENTS = Object.freeze(new Set([...SYSTEM_IDS, NO_SYSTEM]));
90
+
91
+ /**
92
+ * Whether a value names a game system this toolchain knows.
93
+ *
94
+ * `none` is rejected: it is a real answer to "which system", but it is not a
95
+ * system, and a pack, a document-subtype map or a compiler table keyed by it
96
+ * would be keyed by nothing.
97
+ *
98
+ * @param {unknown} value - The candidate id.
99
+ * @returns {boolean} `true` when {@link SYSTEM_IDS} declares it.
100
+ */
101
+ export function isSystemId(value) {
102
+ return typeof value === "string" && SYSTEM_IDS.has(value);
103
+ }
104
+
105
+ /**
106
+ * Whether a value is something the `<system>` segment may hold.
107
+ *
108
+ * The predicate an address parser wants: a declared system, or `none`.
109
+ *
110
+ * @param {unknown} value - The candidate segment.
111
+ * @returns {boolean} `true` when {@link SYSTEM_SEGMENTS} declares it.
112
+ */
113
+ export function isSystemSegment(value) {
114
+ return typeof value === "string" && SYSTEM_SEGMENTS.has(value);
115
+ }
116
+
117
+ /**
118
+ * What a caller writing an unknown system is told.
119
+ *
120
+ * Separate from {@link assertSystemSegment} so that a pass collecting findings
121
+ * can report the same sentence it would have thrown — the message is the part
122
+ * worth sharing, and a reporter that had to invent its own wording is how two
123
+ * halves of a build come to explain one rule two ways.
124
+ *
125
+ * The nullish case is called out by name because it is the likely mistake and
126
+ * the least legible failure: a YAML `null`, a `~`, or a key written with no
127
+ * value at all reaches here as `undefined`, and "expected one of sohl, hm3,
128
+ * none" would leave a reader hunting for the value they cannot see.
129
+ *
130
+ * @param {unknown} value - The offending value.
131
+ * @param {string} [where] - What carried it, for the message — a note path, a
132
+ * configuration key, an address.
133
+ * @returns {string} The message, with no trailing period, in the style of the
134
+ * other vocabulary messages.
135
+ */
136
+ export function unknownSystemMessage(value, where = "the system segment") {
137
+ const known = [...SYSTEM_SEGMENTS].map((id) => `\`${id}\``).join(", ");
138
+ if (value == null || value === "") {
139
+ return (
140
+ `${where} names no system — an absent value is not how "no system" ` +
141
+ `is written. A note that belongs to no system says so, with ` +
142
+ `\`${NO_SYSTEM}\`: the segment is positional, so a null cannot ` +
143
+ `occupy it, and "answered as none" and "nobody filled this in" ` +
144
+ `have to stay distinguishable. Known: ${known}`
145
+ );
146
+ }
147
+ return (
148
+ `${where} names \`${String(value)}\`, which is not a known system. ` +
149
+ `Known systems come from a closed registry, so adding one is a data ` +
150
+ `change rather than a value that starts working: ${known}. \`any\` is ` +
151
+ `not among them either — it reads as a wildcard, and a note outside ` +
152
+ `every system compiles into a core document rather than into all of them`
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Refuse a value the `<system>` segment may not hold.
158
+ *
159
+ * Throws rather than returning a finding, matching the nearest neighbour —
160
+ * `assertVocabularyCharset` in `engine/note-vocabulary.mjs`, and the
161
+ * `configFromData` validators — because every caller of this is validating a
162
+ * declaration rather than surveying content. A note's bad value belongs in a
163
+ * report; a build asking to route, address or compile an undeclared system has
164
+ * nowhere to continue to.
165
+ *
166
+ * @param {unknown} value - The candidate segment.
167
+ * @param {string} [where] - What carried it, for the message.
168
+ * @returns {string} The value, unchanged, so a caller may validate inline.
169
+ * @throws {Error} Naming the value and the whole known vocabulary.
170
+ */
171
+ export function assertSystemSegment(value, where = "the system segment") {
172
+ if (!isSystemSegment(value)) throw new Error(`${unknownSystemMessage(value, where)}.`);
173
+ return /** @type {string} */ (value);
174
+ }
175
+
176
+ /**
177
+ * Refuse a registry declaring an id that could not be an address segment.
178
+ *
179
+ * Run over {@link SYSTEM_SEGMENTS} as this module loads, so a declaration that
180
+ * breaks the charset cannot be imported, let alone shipped. Same reasoning as
181
+ * `assertVocabularyCharset`: a bad value in one note is one author's mistake,
182
+ * while a bad *declaration* puts an unreadable segment into every address that
183
+ * names the system.
184
+ *
185
+ * @param {Iterable<string>} segments - The declared ids, including `none`.
186
+ * @param {string} [where] - What declares them, for the message.
187
+ * @throws {Error} Naming every offending id at once — a reader fixing a list
188
+ * wants the whole list.
189
+ */
190
+ export function assertSystemCharset(segments, where = "the system registry") {
191
+ const bad = [...(segments ?? [])].filter((id) => !isAddressSegment(id));
192
+ if (!bad.length) return;
193
+ throw new Error(
194
+ `${where} declares ${bad.map((id) => `"${id}"`).join(", ")}, which ` +
195
+ `${bad.length === 1 ? "is" : "are"} not ${ADDRESS_SEGMENT_PATTERN.source}. ` +
196
+ `A system id is a segment of every address that names it, and the ` +
197
+ `hyphen separates segments rather than occurring inside one.`,
198
+ );
199
+ }
200
+
201
+ assertSystemCharset(SYSTEM_SEGMENTS);
@@ -43,9 +43,10 @@
43
43
  // name (#1409).
44
44
  import { readQualifier } from "./wikilinks.mjs";
45
45
  import { replaceOutsideCode } from "./code-fences.mjs";
46
- // The canonical `package-type-shortcode` key, so a package-qualified address
47
- // is looked up the way a vendored manifest publishes it.
48
- import { canonicalKey } from "./kb-manifest.mjs";
46
+ // The canonical `package-system-type-shortcode` key, so a package-qualified
47
+ // address is matched the way a vendored manifest publishes it — by the
48
+ // segments the target supplies, with the system wildcarded unless stated (#59).
49
+ import { canonicalKey, readCanonicalKey } from "./content-address.mjs";
49
50
  // The one rule about a link's shape both builds share: it carries a label, and
50
51
  // {@link unlabelledLinkMessage} is the one place that says so (#180).
51
52
  import { unlabelledLinkMessage } from "./wikilink-syntax.mjs";
@@ -84,14 +85,32 @@ import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-s
84
85
  * @param {object|null} read - From {@link readQualifier}.
85
86
  * @returns {string | null} The index key, or `null` when not an address.
86
87
  */
87
- function keyOfRead(read) {
88
- if (!read || read.reason) return null;
89
- // A package-qualified address keeps its package: the canonical key is what
90
- // a vendored manifest publishes, and dropping the segment would resolve
91
- // another package's address against this one's short key.
92
- return read.package ?
93
- canonicalKey(read.package, read.type, read.shortcode)
94
- : `${read.type}/${read.shortcode}`.toLowerCase();
88
+ function lookupRead(index, read) {
89
+ if (!read || read.reason) return undefined;
90
+ // An unqualified address stays the system-blind short key, which is the
91
+ // wildcard an author writing `[[skill-melee]]` means, and resolves within
92
+ // this package only.
93
+ if (!read.package) return index.get(`${read.type}/${read.shortcode}`.toLowerCase());
94
+
95
+ // A package-qualified one is matched by the segments it supplies, with the
96
+ // system wildcarded unless stated (#59) — an exact `get` cannot express
97
+ // that, and would silently miss every address whose system it did not
98
+ // guess. Exactly one hit resolves; two are an ambiguity for the caller to
99
+ // report rather than a pick to make here.
100
+ const type = String(read.type).toLowerCase();
101
+ const shortcode = String(read.shortcode).toLowerCase();
102
+ const pkg = String(read.package).toLowerCase();
103
+ let found;
104
+ for (const [key, value] of index) {
105
+ const parts = readCanonicalKey(key);
106
+ if (!parts) continue;
107
+ if (parts.package !== pkg) continue;
108
+ if (read.system && parts.system !== String(read.system).toLowerCase()) continue;
109
+ if (parts.type !== type || parts.shortcode !== shortcode) continue;
110
+ if (found) return undefined;
111
+ found = value;
112
+ }
113
+ return found;
95
114
  }
96
115
 
97
116
  /**
@@ -242,8 +261,10 @@ function isPlainMap(value) {
242
261
  *
243
262
  * **Every target is an address**, parsed by {@link readQualifier} and looked up
244
263
  * case-insensitively in the KB-wide `ctx.index` (the canonical
245
- * `package-type-shortcode`, `type/shortcode`, and the site's own
246
- * `section/slug`), then in the vendored `ctx.foreign` manifests. A link written
264
+ * `package-system-type-shortcode`, `type/shortcode`, and the site's own
265
+ * `section/slug`), then in the vendored `ctx.foreign` manifests. A target is a
266
+ * *partial* address: an omitted package means this package, an omitted system
267
+ * is a wildcard, and exactly one match resolves — two are `ambiguous`. A link written
247
268
  * without a label addresses nothing at all and is reported as such (#180) —
248
269
  * there is no second namespace left for it to name.
249
270
  *
@@ -343,10 +364,9 @@ export function resolveWebWikilinks(body, ctx) {
343
364
  // recognised. `null` here means the target is not an address at all,
344
365
  // which is a defect: there is no other namespace to try.
345
366
  const read = readQualifier(target, ctx.contentTypes ?? new Set(), ctx.packages);
346
- const hyphenKey = keyOfRead(read);
347
367
  const rawKey = target.toLowerCase();
348
368
  const hit =
349
- (hyphenKey ? ctx.index.get(hyphenKey) : undefined) ??
369
+ lookupRead(ctx.index, read) ??
350
370
  // `section/slug` is the site's own address for a page, and it is in
351
371
  // the same map. Admitted only when the target carries a slash, so
352
372
  // a page's bare slug cannot answer for an address.
@@ -355,7 +375,7 @@ export function resolveWebWikilinks(body, ctx) {
355
375
  // local one (#1446), so a cross-package hit needs no special case
356
376
  // below. Local wins: a live build is authoritative and a vendored
357
377
  // manifest can only be staler.
358
- (hyphenKey ? ctx.foreign?.get(hyphenKey) : undefined);
378
+ (ctx.foreign ? lookupRead(ctx.foreign, read) : undefined);
359
379
  if (hit) {
360
380
  // An address with an *empty* label has no prose to show (a
361
381
  // shortcode is not display text), so the document's **current**
@@ -384,12 +404,12 @@ export function resolveWebWikilinks(body, ctx) {
384
404
  // above already consulted. It parses as no `type/shortcode`, but it did
385
405
  // address something and nothing answered — so it is unresolved, not
386
406
  // unaddressable. (A prefix that is a content *type* never reaches here:
387
- // it yields a `hyphenKey`.)
407
+ // it parses as an address.)
388
408
  const siteAddress = prefix !== null && ctx.sections.has(prefix);
389
409
 
390
410
  // **An address resolving nowhere is a failure, unconditionally** (#184).
391
411
  //
392
- // It was gated on `manifestsComplete` — while any linkable package was
412
+ // It was gated on a manifest-completeness check — while any linkable package was
393
413
  // invisible here, `Rules/Bestiary.md` addressing `being-grkrahk` in the
394
414
  // `thalorna` package was indistinguishable from a typo, so the form
395
415
  // stayed unguarded rather than fail correct content. The gate has
@@ -401,9 +421,19 @@ export function resolveWebWikilinks(body, ctx) {
401
421
  // manifest calls for is in the message instead — {@link
402
422
  // unresolvedAddressMessage} names both corrections — which informs the
403
423
  // author without excusing the link.
424
+ // The bucket an ambiguity is recorded under is the *short* address —
425
+ // the only form two packages can collide on, since a qualified one
426
+ // names its package. A qualified target that matched more than one
427
+ // document is reported by the matcher instead, not by this set.
428
+ const collideKey =
429
+ read && !read.reason && !read.package ?
430
+ `${read.type}/${read.shortcode}`.toLowerCase()
431
+ : rawKey;
404
432
  const reason =
405
- ctx.collide?.has(hyphenKey ?? rawKey) ? "ambiguous"
406
- : hyphenKey !== null || siteAddress ? "unresolved"
433
+ ctx.collide?.has(collideKey) ? "ambiguous"
434
+ // "It parsed as an address" is a property of the parse, not of
435
+ // a key: a partial address has no single key to be non-null.
436
+ : (read && !read.reason) || siteAddress ? "unresolved"
407
437
  : read?.reason === "unknown-type" ? "unknown-type"
408
438
  // Every link is an address, and this is not one. Distinct from
409
439
  // a dead address, because the fix is different: a name has to
@@ -194,11 +194,11 @@ export const LINK_FINDING_REASONS = Object.freeze(
194
194
  *
195
195
  * **Both corrections, because the author cannot tell which applies.** An
196
196
  * address lands nowhere either because the shortcode is wrong or because the
197
- * package publishing it has no manifest vendored here, and the link itself
198
- * looks identical in the two cases.
197
+ * package publishing it is not a declared dependency or is one whose index
198
+ * has not been fetched — and the link itself looks identical in every case.
199
199
  *
200
200
  * This used to be a **warning** in the checker and, in the site build, nothing
201
- * at all until every linkable package had vendored a manifest — on the
201
+ * at all until every linkable package's manifest was accounted for — on the
202
202
  * reasoning that a bare `[[Name]]` might be a placeholder for a note nobody had
203
203
  * written yet. That reasoning was a property of the bare form, which is retired
204
204
  * (#180); the intent behind it now has a real spelling, a `draft`-tagged note
@@ -211,8 +211,8 @@ export const LINK_FINDING_REASONS = Object.freeze(
211
211
  export function unresolvedAddressMessage(target) {
212
212
  return (
213
213
  `address [[${target}]] resolves to no note — no package publishes ` +
214
- `it. Fix the shortcode, or vendor the link manifest of the package ` +
215
- `that does`
214
+ `it. Fix the shortcode, or declare the package that does as a ` +
215
+ `dependency and run \`content-build deps fetch\``
216
216
  );
217
217
  }
218
218
 
@@ -225,6 +225,13 @@ export function unresolvedAddressMessage(target) {
225
225
  * message names the claimants so the author can choose between them without
226
226
  * going looking.
227
227
  *
228
+ * **The correction is the canonical form**, all four segments (#59). Omission
229
+ * runs strictly left to right, so there is no `package-type-shortcode` to offer
230
+ * — naming a package means naming the system before the type. That used to be
231
+ * the correction here, back when a written target could state three segments at
232
+ * most; the grammar is positional now, and a three-segment target names a
233
+ * *system*, not a package.
234
+ *
228
235
  * @param {string} target - The address as authored.
229
236
  * @param {Iterable<string>} [packages] - The packages that publish it.
230
237
  * @returns {string} The message.
@@ -234,8 +241,8 @@ export function ambiguousAddressMessage(target, packages = []) {
234
241
  return (
235
242
  `address [[${target}]] is published by ` +
236
243
  (named.length ? `${named.join(" and ")}` : `more than one package`) +
237
- `, so it names neither — write the package-qualified ` +
238
- `[[package-type-shortcode|Text]]`
244
+ `, so it names neither — write the fully qualified ` +
245
+ `[[package-system-type-shortcode|Text]]`
239
246
  );
240
247
  }
241
248
 
@@ -265,7 +272,9 @@ export function linkFindingMessage({ reason, target, packages, anchor }) {
265
272
  case "not-an-address":
266
273
  return (
267
274
  `"${target}" is not an address — the "|" says one was meant, ` +
268
- `so write [[type-shortcode|Text]]`
275
+ `so write [[type-shortcode|Text]], or ` +
276
+ `[[package-system-type-shortcode|Text]] for a note in another ` +
277
+ `package`
269
278
  );
270
279
  case "unknown-type":
271
280
  return `address [[${target}]] names no known content type`;