@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
@@ -23,7 +23,11 @@
23
23
  * link to an anchor nobody declares compiles cleanly, emits an enricher, and
24
24
  * dead-ends for the reader.
25
25
  * 2. **A dead address.** Every link is an address, and one resolving to no note
26
- * is a typo. So is a target that does not parse as an address at all.
26
+ * is a typo. So is a target that does not parse as an address at all. A
27
+ * written target is a *partial* address — the segments it omits are
28
+ * wildcards, and the package it omits is this one — so a target resolving to
29
+ * *several* notes is an ambiguity rather than a first match, and is reported
30
+ * naming every candidate (#59).
27
31
  * 3. **An unlabelled link.** `[[x]]` addresses nothing: the alias namespace it
28
32
  * used to name is retired (#180), and a shortcode is an address rather than
29
33
  * prose, so the link has neither a resolvable target nor text to show. The
@@ -55,17 +59,19 @@ import path from "node:path";
55
59
 
56
60
  import { matchAllOutsideCode } from "./code-fences.mjs";
57
61
  import { expandContentTables } from "./content-tables.mjs";
58
- import { walkMarkdownTree } from "./helpers.mjs";
62
+ import { collectAnchors } from "./anchors.mjs";
63
+ // The corpus, and everything derived from it, read from the one place that
64
+ // derives it (#243). Nothing in the index's own import graph reaches this
65
+ // module, so this is a plain static import rather than the deferred one
66
+ // `sql-tables` needs to keep out of the compilers' cycle.
67
+ import { authoredFrontmatter, indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
59
68
  import { hasDocEntry } from "./item-docs.mjs";
60
- import { contentPackage } from "./content-package.mjs";
69
+ import { NO_SYSTEM, systemOf } from "./document-subtypes.mjs";
70
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
71
+ import { loadPackConfig } from "./pack-config.mjs";
61
72
  import { searchableFrontmatter } from "./note-package.mjs";
62
- import {
63
- canonicalKey,
64
- loadForeignManifests,
65
- manifestsComplete,
66
- PACKAGE_BASE,
67
- readCanonicalKey,
68
- } from "./kb-manifest.mjs";
73
+ import { canonicalKey, PACKAGE_BASE, readCanonicalKey } from "./content-address.mjs";
74
+ import { loadForeignIndexes } from "./metadata-index.mjs";
69
75
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
70
76
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
71
77
  import { RETIRED_TYPES } from "./ids.mjs";
@@ -75,20 +81,46 @@ import { readQualifier } from "./wikilinks.mjs";
75
81
  /**
76
82
  * Every `{#anchor}` a note declares on a heading.
77
83
  *
84
+ * **Read from the content index's reader, not a second one.** This module kept
85
+ * its own until #243, and the two disagreed: it matched `{#([a-z0-9-]+)}` while
86
+ * {@link module:engine/content-index.collectAnchors} matches `{#([^}]+)}`, so
87
+ * an anchor with a capital in it — `{#CalendarFormat}` — existed for the index
88
+ * and for the compiler and did not exist for the link checker. Nothing links to
89
+ * one today, so the disagreement was latent; the first link to one would have
90
+ * been reported dead against a heading plainly present in the file.
91
+ *
92
+ * The specification puts no charset on the id: "`#id` represents an id anchor
93
+ * named `id`". The narrower pattern was this module's invention, which is the
94
+ * argument for there being one reader rather than a well-chosen one.
95
+ *
78
96
  * @param {string} body - The note's markdown body.
79
97
  * @returns {Set<string>} The declared anchor slugs.
80
98
  */
81
99
  export function anchorsOf(body) {
82
- const found = new Set();
83
- for (const line of String(body ?? "").split("\n")) {
84
- const m = /^#{1,6}\s+.*\{#([a-z0-9-]+)\}\s*$/.exec(line.trim());
85
- if (m) found.add(m[1]);
86
- }
87
- return found;
100
+ return new Set(collectAnchors(body).map((anchor) => anchor.slug));
88
101
  }
89
102
 
90
103
  /**
91
- * Load a content tree and build the index a link resolves against.
104
+ * Read a content tree into the index a link resolves against.
105
+ *
106
+ * **The corpus comes from the content index, not from a walk of this module's
107
+ * own** (#243). Every pass used to answer "which files are the content?" for
108
+ * itself and throw the answer away; this one now reads
109
+ * {@link module:engine/content-index.indexRecordsFor}, which is the same
110
+ * derivation the published artifact and the compilers are driven from. So a
111
+ * note the index records is a note the link check sees, and the addresses and
112
+ * anchors it resolves against are the ones every other pass will emit — rather
113
+ * than a second derivation that agrees with them only by inspection. That was
114
+ * not hypothetical: this module carried its own anchor reader until the anchor
115
+ * half of #243, and the two disagreed about which anchors existed.
116
+ *
117
+ * **The file is opened for its bytes and nothing else.** The index deliberately
118
+ * carries no note *body*, and a link lives in the body — so each note is read
119
+ * once, here, for the prose. Everything *about* the note — its frontmatter, its
120
+ * addresses, its anchors — is already in the record, and none of it is derived
121
+ * a second time. That is one read per note rather than the two this module did
122
+ * before, since the walk read the file and it then read it again for the raw
123
+ * text.
92
124
  *
93
125
  * The index mirrors what both builds construct, including the two addresses a
94
126
  * doc-carrying note answers to: `type/shortcode` for the document, and
@@ -99,57 +131,93 @@ export function anchorsOf(body) {
99
131
  *
100
132
  * @param {string} contentBase - Root of the content tree.
101
133
  * @param {object} [opts]
102
- * @param {string} [opts.manifestDir] - Where vendored foreign manifests live.
103
- * Omitted, no cross-package address resolves.
104
- * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
134
+ * @param {object} [opts.config] - The resolved build configuration, whose
135
+ * fetched dependency indexes foreign addresses resolve through (#239), and
136
+ * whose `contentPackage` every local address is built from. Omitted, the
137
+ * ambient configuration is resolved and no cross-package address resolves.
138
+ * @param {readonly string[]} [opts.skipDirectories] - The walk's scope, passed
139
+ * on to the index rather than defaulted away.
140
+ * @param {Map<string, object[]>} [opts.sqlTables] - Prepared `sql` results, by
141
+ * note path.
142
+ * @param {object[]} [opts.records] - Index records the caller already derived,
143
+ * so a command that also needs them — every one of them does, to answer its
144
+ * `sql` tables — enumerates the corpus once rather than twice.
145
+ * @param {object[]} [opts.problems] - Collects the notes the index cannot
146
+ * record, as diagnostics, instead of letting one of them abort the check
147
+ * before it has reported anything else.
105
148
  * @returns {object} The notes, the index, and the resolvers built over it.
106
149
  */
107
- export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {}) {
150
+ export function buildLinkIndex(
151
+ contentBase,
152
+ { config, skipDirectories, sqlTables, records, problems } = {},
153
+ ) {
108
154
  const notes = [];
109
155
  const frontmatterLinks = [];
110
- const walkOpts = skipDirectories ? { skipDirectories } : undefined;
111
156
 
112
- for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase, walkOpts)) {
113
- if (!fm || typeof fm.type !== "string") continue;
157
+ // The one package every note in this tree belongs to. Taken from the
158
+ // configuration this build resolved never from a note (`package:` is
159
+ // retired, so there is no second source an address could disagree with,
160
+ // #56) and never from the ambient one, which is a different configuration
161
+ // whenever a test injects one, `PACKAGE_BUILD_CONFIG` names one, or the
162
+ // command runs from a worktree (#243).
163
+ const resolved = config ?? loadPackConfig();
164
+ const pkg = resolved.contentPackage;
165
+
166
+ const indexRecords =
167
+ records ?? indexRecordsFor({ contentBase, config: resolved, skipDirectories, problems });
168
+
169
+ const byKey = new Map();
170
+ const anchors = new Map();
171
+
172
+ for (const record of indexRecords) {
173
+ // A documentation journal has a record of its own but no file and no
174
+ // authored frontmatter — it is a document this tree emits, not a note
175
+ // in it. Its addresses are keyed below, from the note it documents.
176
+ if (!isNoteRecord(record) || typeof record.type !== "string") continue;
177
+
178
+ const fm = authoredFrontmatter(record);
179
+ const rel = record.file.path;
180
+ const absPath = noteFile(contentBase, record);
114
181
  // The raw text is kept beside the parsed body: a consumer's own checks
115
182
  // may need what frontmatter carried, which the body has dropped.
116
183
  const raw = fs.readFileSync(absPath, "utf8");
117
184
  const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
118
- const note = {
119
- file: absPath,
120
- rel: path.relative(contentBase, absPath).split(path.sep).join("/"),
121
- fm,
122
- body,
123
- raw,
124
- type: fm.type.toLowerCase(),
125
- };
185
+ const type = record.type.toLowerCase();
186
+ const note = { file: absPath, rel, fm, body, raw, type };
187
+
126
188
  for (const hit of frontmatterWikilinks(fm)) {
127
189
  frontmatterLinks.push({ note, ...hit });
128
190
  }
129
191
  notes.push(note);
130
- }
131
192
 
132
- const byKey = new Map();
193
+ // The anchors the index recorded, rather than a second reading of the
194
+ // same headings — the disagreement #243's anchor half removed.
195
+ anchors.set(note, new Set((record.anchors ?? []).map((a) => a.slug)));
133
196
 
134
- // The one package every note in this tree belongs to. Taken from the
135
- // configuration, never from a note: `package:` is retired, so there is no
136
- // second source an address could disagree with (#56).
137
- const pkg = contentPackage();
138
-
139
- for (const note of notes) {
140
- const { fm, type } = note;
141
197
  if (typeof fm.shortcode === "string" && fm.shortcode) {
142
198
  byKey.set(`${type}/${fm.shortcode}`.toLowerCase(), note);
143
199
  // The canonical, fully qualified address alongside the short one,
144
200
  // so a package-qualified link checks the same way a bare one does.
145
- byKey.set(canonicalKey(pkg, type, fm.shortcode), note);
201
+ // Taken from the record, which is where the address rule is applied
202
+ // once for the whole build.
203
+ const canonical =
204
+ record.address?.canonical ??
205
+ canonicalKey(pkg, systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS), type, fm.shortcode);
206
+ byKey.set(canonical, note);
146
207
  if (hasDocEntry(type)) {
147
208
  byKey.set(`doc${type}/${fm.shortcode}`.toLowerCase(), note);
148
- byKey.set(canonicalKey(pkg, `doc${type}`, fm.shortcode), note);
209
+ // A documentation journal is `none`: no game system defines a
210
+ // JournalEntry, and one note has one of them however many
211
+ // system blocks it carries.
212
+ byKey.set(canonicalKey(pkg, NO_SYSTEM, `doc${type}`, fm.shortcode), note);
149
213
  }
150
214
  }
151
215
  }
152
216
 
217
+ // The types a link may name, which are the ones notes declare. A
218
+ // documentation journal's `doc<type>` is deliberately not among them: it is
219
+ // virtual, and `readQualifier` resolves it from the base type rather than
220
+ // from a type any tree declares.
153
221
  const types = new Set(notes.map((n) => n.type));
154
222
 
155
223
  // A foreign package may use a type this tree has never seen, so its types
@@ -157,8 +225,8 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
157
225
  // is never checked at all.
158
226
  const localPackages = new Set([pkg]);
159
227
  const foreign =
160
- manifestDir ?
161
- loadForeignManifests(manifestDir, localPackages)
228
+ config ?
229
+ loadForeignIndexes(config, localPackages)
162
230
  : { index: new Map(), packages: new Set(), stale: [] };
163
231
  for (const v of foreign.index.values()) if (v.type) types.add(v.type);
164
232
 
@@ -174,8 +242,6 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
174
242
  folder: path.dirname(n.rel).split("/").pop(),
175
243
  }));
176
244
 
177
- const anchors = new Map(notes.map((n) => [n, anchorsOf(n.body)]));
178
-
179
245
  /**
180
246
  * Every wikilink in a note body, with its `dataview` tables expanded.
181
247
  *
@@ -187,13 +253,17 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
187
253
  */
188
254
  function linksOf(note) {
189
255
  let body = note.body;
190
- if (/^[ \t]*(?:`{3,}|~{3,})[ \t]*dataview\b/im.test(body)) {
256
+ if (/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:dataview|sql)\b/im.test(body)) {
191
257
  body = expandContentTables(body, {
192
258
  // Unfiltered: every note in the tree is this package's, so
193
259
  // there is no other package's note to exclude (#56).
194
260
  docs: tableDocs,
195
261
  linkable: (d) => Boolean(d.fm.shortcode),
196
262
  source: note.file,
263
+ // A `sql` table's links are checked like an authored one's, so
264
+ // its rows are prepared ahead of this walk — see
265
+ // {@link module:engine/sql-tables.prepareTreeSqlTables}.
266
+ sqlTables: sqlTables?.get(note.file),
197
267
  }).markdown;
198
268
  }
199
269
  const out = [];
@@ -232,25 +302,75 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
232
302
  * @param {string} target - The link target, anchor already removed.
233
303
  * @returns {object|undefined} The note it addresses.
234
304
  */
305
+ /**
306
+ * Every indexed entry an address names, matching only the segments it
307
+ * supplies.
308
+ *
309
+ * This is the whole of #59's resolution rule in one place: a written
310
+ * address is a *partial* one, unsupplied segments are wildcards, and the
311
+ * caller requires exactly one hit. Nothing here decides an ambiguity — zero
312
+ * and many are different findings with different fixes, so the count is
313
+ * returned rather than collapsed.
314
+ *
315
+ * The **system** is wildcarded unless stated. Defaulting it to `none` would
316
+ * exclude every link to an item, which is most of them.
317
+ *
318
+ * @param {Array<[string, any]>} pairs - Indexed `[canonicalKey, value]`.
319
+ * @param {object} q - The parsed qualifier.
320
+ * @returns {Array<[string, any]>} The matching pairs.
321
+ */
322
+ function matchAddress(pairs, q) {
323
+ const type = String(q.type).toLowerCase();
324
+ const shortcode = String(q.shortcode).toLowerCase();
325
+ return pairs.filter(([k]) => {
326
+ const parts = readCanonicalKey(k);
327
+ if (!parts) return false;
328
+ if (q.package && parts.package !== String(q.package).toLowerCase()) return false;
329
+ if (q.system && parts.system !== String(q.system).toLowerCase()) return false;
330
+ return parts.type === type && parts.shortcode === shortcode;
331
+ });
332
+ }
333
+
334
+ /**
335
+ * The local notes an address names, by the same rule.
336
+ *
337
+ * @param {object} q - The parsed qualifier.
338
+ * @returns {object[]} The notes.
339
+ */
340
+ function matchLocal(q) {
341
+ return matchAddress([...byKey], q).map(([, v]) => v);
342
+ }
343
+
235
344
  function resolveAddress(target) {
236
345
  const qualified = readQualifier(target, types, packages);
237
346
  if (!qualified || qualified.reason) return undefined;
238
- return byKey.get(
239
- qualified.package ?
240
- canonicalKey(qualified.package, qualified.type, qualified.shortcode)
241
- : `${qualified.type}/${qualified.shortcode}`.toLowerCase(),
242
- );
347
+ // A target naming neither package nor system stays the system-blind
348
+ // short key, which is already the wildcard an author writing
349
+ // `[[skill-melee]]` means.
350
+ //
351
+ // Anything that *does* state one is matched by the segments it supplied
352
+ // rather than by an exact key (#59), and exactly one hit is required —
353
+ // two systems' documents legitimately share a
354
+ // `(package, type, shortcode)`, so a target that names no system may
355
+ // name two notes, and naming two is not resolving.
356
+ if (!qualified.package && !qualified.system) {
357
+ return byKey.get(`${qualified.type}/${qualified.shortcode}`.toLowerCase());
358
+ }
359
+ const hits = matchLocal(qualified);
360
+ return hits.length === 1 ? hits[0] : undefined;
243
361
  }
244
362
 
245
363
  /**
246
364
  * Every foreign manifest entry an address names, in package order.
247
365
  *
248
- * A **package-qualified** address names at most one, by construction. An
249
- * unqualified one names no package, so it resolves against any foreign one
250
- * that publishes it and only when exactly one does. Two claimants make it
251
- * ambiguous, which is a different finding from resolving nowhere and has a
252
- * different fix, so the count is returned rather than collapsed here
253
- * (#184).
366
+ * A written target is a **partial** address, so this matches on the
367
+ * segments it supplies and wildcards the rest (#59). A target naming a
368
+ * package necessarily names its system too omission runs left to right
369
+ * so the fully qualified form matches at most one entry; a shorter one
370
+ * names no package, and resolves against any foreign package that
371
+ * publishes it. Either way only exactly one hit resolves. Two claimants make it ambiguous, which is a different finding
372
+ * from resolving nowhere and has a different fix, so the count is returned
373
+ * rather than collapsed here (#184).
254
374
  *
255
375
  * @param {string} target - The link target.
256
376
  * @returns {object[]} The foreign entries, each carrying its `package`.
@@ -258,18 +378,7 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
258
378
  function foreignHits(target) {
259
379
  const q = readQualifier(target, types, packages);
260
380
  if (!q || q.reason) return [];
261
- if (q.package) {
262
- const one = foreign.index.get(canonicalKey(q.package, q.type, q.shortcode));
263
- return one ? [one] : [];
264
- }
265
- const type = String(q.type).toLowerCase();
266
- const shortcode = String(q.shortcode).toLowerCase();
267
- return [...foreign.index]
268
- .filter(([k]) => {
269
- const parts = readCanonicalKey(k);
270
- return parts?.type === type && parts.shortcode === shortcode;
271
- })
272
- .map(([, v]) => v);
381
+ return matchAddress([...foreign.index], q).map(([, v]) => v);
273
382
  }
274
383
 
275
384
  /**
@@ -299,7 +408,6 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
299
408
  */
300
409
  contentPackage: pkg,
301
410
  foreign,
302
- manifests: manifestsComplete(localPackages, foreign.packages),
303
411
  linksOf,
304
412
  /**
305
413
  * Resolve a link target the way both builds do, or `undefined`. Every
@@ -338,17 +446,17 @@ const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
338
446
  * is that a landing therefore cannot be addressed. It does not follow: a
339
447
  * landing's address is not a *note's* address but the **package's**, and
340
448
  * {@link PACKAGE_BASE} already records where each package is served. That is a
341
- * frozen constant vendored into every repository, so consulting it walks no
449
+ * frozen constant compiled into every build, so consulting it walks no
342
450
  * tree, reads no manifest and builds no index — which is precisely why the
343
451
  * mechanism survives `homepage` mode, where the licensing fence means none of
344
452
  * those exist.
345
453
  *
346
454
  * The roster is consulted **for landings only**. Widening the package set the
347
455
  * other rules read would make them offer manifest-based advice about packages
348
- * no manifest is vendored for.
456
+ * no index has been fetched for.
349
457
  *
350
458
  * @param {string} ownPackage - The package this build publishes.
351
- * @param {Iterable<string>} manifestPackages - Packages a vendored manifest
459
+ * @param {Iterable<string>} manifestPackages - Packages a fetched index
352
460
  * names, which are addressable whether or not the roster lists them.
353
461
  * @returns {Map<string, string>} Package to base, each base slash-terminated.
354
462
  */
@@ -458,7 +566,7 @@ function readAddress(url, packages) {
458
566
  * and what replaced it, so this is a fact rather than a guess — and it is
459
567
  * exactly the SoHL defect.
460
568
  * - A **hardcoded absolute URL** into this package's own prefix, or into one a
461
- * vendored manifest names. Every one of them has a better form to write, which
569
+ * a fetched index names. Every one of them has a better form to write, which
462
570
  * is why every one is reported — including a bare `/<package>/`, which names
463
571
  * another package's landing (#87).
464
572
  *
@@ -533,7 +641,7 @@ export function auditHomepageLinks(index) {
533
641
 
534
642
  // Landings first, and by the roster rather than by the manifest
535
643
  // package set: a landing is addressable in a repository that
536
- // vendors no manifest at all, which is the case the fence creates
644
+ // has fetched no index at all, which is the case the fence creates
537
645
  // and the case this rule exists for (#87).
538
646
  const landing = landingTarget(url, bases);
539
647
  if (landing) {