@heroiclands/package-build 9.0.0 → 10.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 (78) hide show
  1. package/CHANGELOG.md +694 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +437 -7
  4. package/content-config.mjs +259 -28
  5. package/docs/content-format.md +1418 -0
  6. package/engine/address-charset.mjs +62 -0
  7. package/engine/alias-index.mjs +153 -0
  8. package/engine/base-compiler.mjs +194 -4
  9. package/engine/content-address.mjs +4 -4
  10. package/engine/content-format-check.mjs +570 -0
  11. package/engine/content-format.mjs +253 -0
  12. package/engine/content-links.mjs +132 -56
  13. package/engine/content-lint.mjs +8 -1
  14. package/engine/diagnostics.mjs +33 -0
  15. package/engine/document-subtypes.mjs +440 -0
  16. package/engine/field-spec.mjs +49 -43
  17. package/engine/frontmatter-lint.mjs +343 -27
  18. package/engine/generate.mjs +32 -4
  19. package/engine/helpers.mjs +41 -29
  20. package/engine/ids.mjs +19 -1
  21. package/engine/index.mjs +15 -0
  22. package/engine/item-registry.mjs +72 -5
  23. package/engine/kb-manifest.mjs +36 -7
  24. package/engine/map-notes.mjs +34 -18
  25. package/engine/note-claims.mjs +383 -0
  26. package/engine/note-vocabulary.mjs +678 -0
  27. package/engine/pack-config.mjs +39 -22
  28. package/engine/pack-router.mjs +17 -6
  29. package/engine/prose-lint.mjs +55 -3
  30. package/engine/retired-fields.mjs +117 -3
  31. package/engine/scenes.mjs +19 -1
  32. package/engine/schema-check.mjs +347 -3
  33. package/engine/site-build.mjs +1 -1
  34. package/engine/site-index.mjs +38 -21
  35. package/engine/system-block.mjs +513 -0
  36. package/engine/web-wikilinks.mjs +112 -80
  37. package/engine/wikilink-syntax.mjs +30 -0
  38. package/engine/wikilinks.mjs +67 -51
  39. package/package.json +6 -2
  40. package/sohl/actors.mjs +249 -36
  41. package/sohl/document-subtypes.mjs +82 -0
  42. package/sohl/index.mjs +3 -0
  43. package/sohl/items.mjs +110 -14
  44. package/sohl/note-schemas.mjs +11 -7
  45. package/types/content-config.d.mts +48 -4
  46. package/types/engine/address-charset.d.mts +45 -0
  47. package/types/engine/alias-index.d.mts +122 -0
  48. package/types/engine/base-compiler.d.mts +132 -4
  49. package/types/engine/content-address.d.mts +2 -2
  50. package/types/engine/content-format-check.d.mts +163 -0
  51. package/types/engine/content-format.d.mts +101 -0
  52. package/types/engine/content-links.d.mts +16 -1
  53. package/types/engine/content-lint.d.mts +6 -0
  54. package/types/engine/diagnostics.d.mts +29 -0
  55. package/types/engine/document-subtypes.d.mts +233 -0
  56. package/types/engine/field-spec.d.mts +76 -23
  57. package/types/engine/frontmatter-lint.d.mts +47 -2
  58. package/types/engine/generate.d.mts +14 -1
  59. package/types/engine/helpers.d.mts +21 -13
  60. package/types/engine/ids.d.mts +10 -0
  61. package/types/engine/index.d.mts +5 -0
  62. package/types/engine/item-registry.d.mts +21 -2
  63. package/types/engine/kb-manifest.d.mts +35 -8
  64. package/types/engine/map-notes.d.mts +21 -11
  65. package/types/engine/note-claims.d.mts +113 -0
  66. package/types/engine/note-vocabulary.d.mts +251 -0
  67. package/types/engine/pack-config.d.mts +4 -3
  68. package/types/engine/pack-router.d.mts +4 -4
  69. package/types/engine/prose-lint.d.mts +6 -2
  70. package/types/engine/retired-fields.d.mts +73 -2
  71. package/types/engine/schema-check.d.mts +182 -0
  72. package/types/engine/system-block.d.mts +281 -0
  73. package/types/engine/web-wikilinks.d.mts +23 -12
  74. package/types/engine/wikilink-syntax.d.mts +29 -0
  75. package/types/sohl/actors.d.mts +62 -6
  76. package/types/sohl/document-subtypes.d.mts +14 -0
  77. package/types/sohl/index.d.mts +1 -0
  78. package/types/sohl/items.d.mts +21 -0
@@ -0,0 +1,253 @@
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
+ * Reading `docs/content-format.md` as data (#130).
16
+ *
17
+ * The content format — three frontmatter regions, a note vocabulary with its
18
+ * own `type` and `subType`, and a declared map from each note type onto each
19
+ * system's document fields — is prose, because that is the only form in which
20
+ * the *reasons* survive. But two of the things it states are checkable, and
21
+ * were checked by throwaway scripts while it was being drafted:
22
+ *
23
+ * - **every `system.*` target it names** must exist in the naming system's
24
+ * published `schema.json`, or the specification and the system disagree; and
25
+ * - **every authored note** should carry only the keys the format declares for
26
+ * its type, which during the migration (#127) is a progress bar as much as a
27
+ * check.
28
+ *
29
+ * Both need the document as data, and this is the module that supplies it.
30
+ *
31
+ * **It reads the document's own tables rather than a transcription of them.**
32
+ * A hardcoded list of targets and per-type vocabularies would be a second copy
33
+ * of the specification, free to drift from the first the moment either is
34
+ * edited — which is exactly the failure the checks exist to prevent, moved one
35
+ * level up. So the parser knows the *shape* of the tables the document uses and
36
+ * nothing about their contents: no type name, no field name and no system name
37
+ * is written here. Editing the document changes what the checks assert.
38
+ *
39
+ * **Two table shapes carry everything.**
40
+ *
41
+ * | table | recognised by its first header cell | yields |
42
+ * | --- | --- | --- |
43
+ * | the per-type vocabulary | `` `data` property `` | the keys that type's `data:` block may carry |
44
+ * | the per-type mapping | `shared source` | one claim per `system.*` cell |
45
+ *
46
+ * A mapping table's remaining header cells name the systems (`→ sohl`,
47
+ * `→ hm3`), so the system vocabulary comes from the document too. A cell that
48
+ * names no field — `NA`, `**see above**`, a `flags.*` path — is not a claim,
49
+ * and is skipped rather than reported: the check is about `system.*` targets,
50
+ * and a column reading NA is the document saying this type produces no document
51
+ * there.
52
+ *
53
+ * @module
54
+ */
55
+
56
+ import fs from "node:fs";
57
+ import path from "node:path";
58
+ import { fileURLToPath } from "node:url";
59
+
60
+ /**
61
+ * The specification this package ships.
62
+ *
63
+ * Resolved from this module rather than from the working directory: a consumer
64
+ * runs `content-build content-format` inside its own repository, and the
65
+ * document it should be checked against is the one that came with the toolchain
66
+ * version it resolved — the same rule `--version` follows.
67
+ *
68
+ * @type {string}
69
+ */
70
+ export const CONTENT_FORMAT_PATH = path.join(
71
+ path.dirname(fileURLToPath(import.meta.url)),
72
+ "..",
73
+ "docs",
74
+ "content-format.md",
75
+ );
76
+
77
+ /**
78
+ * What one note type's section declares.
79
+ *
80
+ * @typedef {object} TypeSpec
81
+ * @property {string} name - The note type, as the `### type:` heading spells it.
82
+ * @property {number} line - 1-based line of that heading.
83
+ * @property {Set<string>} dataKeys - The head segment of each declared `data`
84
+ * property — what a note actually writes. `appearance.eye_color` is authored
85
+ * as `appearance`, so that is the key recorded.
86
+ * @property {Set<string>} dataPaths - The declared paths, whole.
87
+ */
88
+
89
+ /**
90
+ * One `system.*` target the specification names for one note type.
91
+ *
92
+ * @typedef {object} MappingClaim
93
+ * @property {string} noteType - The type whose section makes the claim.
94
+ * @property {string} system - The system column it sits under, from the header.
95
+ * @property {string} source - The shared source cell, stripped of its backticks.
96
+ * @property {string} target - The dotted path, `system.` prefix included.
97
+ * @property {number} line - 1-based line of the row.
98
+ * @property {number} column - 1-based column of the cell's first character.
99
+ */
100
+
101
+ /**
102
+ * The specification, as data.
103
+ *
104
+ * @typedef {object} ContentFormat
105
+ * @property {string} file - Where it was read from, for diagnostics.
106
+ * @property {Map<string, TypeSpec>} types - Note type → what its section declares.
107
+ * @property {MappingClaim[]} claims - Every `system.*` target, in document order.
108
+ */
109
+
110
+ /** A table row's cells, or `null` when the line is not a table row. */
111
+ function cellsOf(line) {
112
+ const trimmed = line.trim();
113
+ if (!trimmed.startsWith("|")) return null;
114
+ // A trailing `|` closes the row; splitting the interior keeps cell indices
115
+ // aligned with the header's.
116
+ const interior = trimmed.replace(/^\|/, "").replace(/\|$/, "");
117
+ return interior.split("|").map((cell) => cell.trim());
118
+ }
119
+
120
+ /** Whether a row is the `| --- | --- |` rule under a header. */
121
+ function isRule(cells) {
122
+ return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
123
+ }
124
+
125
+ /** The contents of a cell written as a single inline-code span, or `undefined`. */
126
+ function code(cell) {
127
+ const match = /^`([^`]+)`$/.exec(cell);
128
+ return match ? match[1] : undefined;
129
+ }
130
+
131
+ /**
132
+ * Where a cell starts on its line, 1-based.
133
+ *
134
+ * Counted by walking the row's `|` separators rather than searching for the
135
+ * cell's text, which would land on the wrong column whenever two cells in a row
136
+ * hold the same string — and `NA` appears twice on plenty of rows.
137
+ *
138
+ * @param {string} line - The raw line.
139
+ * @param {number} index - Which cell, 0-based, counting from after the first `|`.
140
+ * @returns {number|undefined} The column, or `undefined` when the row has no
141
+ * such cell — dropped rather than guessed.
142
+ */
143
+ function columnOfCell(line, index) {
144
+ // The separator opening the wanted cell is the (index + 1)-th `|`.
145
+ let at = -1;
146
+ for (let i = 0; i <= index; i += 1) {
147
+ at = line.indexOf("|", at + 1);
148
+ if (at === -1) return undefined;
149
+ }
150
+ const rest = line.slice(at + 1);
151
+ const lead = rest.length - rest.trimStart().length;
152
+ return at + lead + 2;
153
+ }
154
+
155
+ /**
156
+ * Parse the specification's tables.
157
+ *
158
+ * Pure: text in, model out, so a test states a miniature document rather than
159
+ * asserting against the real one and its 1,100 lines of prose.
160
+ *
161
+ * @param {string} text - The document's contents.
162
+ * @param {object} [opts]
163
+ * @param {string} [opts.file] - Path recorded on the result, for diagnostics.
164
+ * @returns {ContentFormat} What the document declares.
165
+ */
166
+ export function parseContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
167
+ /** @type {Map<string, TypeSpec>} */
168
+ const types = new Map();
169
+ /** @type {MappingClaim[]} */
170
+ const claims = [];
171
+
172
+ const lines = String(text ?? "").split("\n");
173
+ /** @type {TypeSpec|undefined} */
174
+ let current;
175
+ /** @type {{kind: "data"|"mapping", systems: string[]}|undefined} */
176
+ let table;
177
+
178
+ for (let i = 0; i < lines.length; i += 1) {
179
+ const line = lines[i];
180
+
181
+ const heading = /^#{2,4}\s+type:\s*(\S+)\s*$/.exec(line);
182
+ if (heading) {
183
+ current = {
184
+ name: heading[1],
185
+ line: i + 1,
186
+ dataKeys: new Set(),
187
+ dataPaths: new Set(),
188
+ };
189
+ types.set(current.name, current);
190
+ table = undefined;
191
+ continue;
192
+ }
193
+
194
+ const cells = cellsOf(line);
195
+ if (!cells) {
196
+ // Any non-table line ends the table. A blank line between two
197
+ // tables is what keeps a mapping table's rows from being read
198
+ // under the vocabulary table's header.
199
+ table = undefined;
200
+ continue;
201
+ }
202
+ if (isRule(cells)) continue;
203
+
204
+ // A header row, recognised by its first cell alone.
205
+ if (cells[0] === "`data` property") {
206
+ table = { kind: "data", systems: [] };
207
+ continue;
208
+ }
209
+ if (cells[0] === "shared source") {
210
+ table = {
211
+ kind: "mapping",
212
+ systems: cells.slice(1).map((cell) => cell.replace(/^→\s*/, "").trim()),
213
+ };
214
+ continue;
215
+ }
216
+ if (!table || !current) continue;
217
+
218
+ if (table.kind === "data") {
219
+ const declared = code(cells[0]);
220
+ if (!declared) continue;
221
+ current.dataPaths.add(declared);
222
+ current.dataKeys.add(declared.split(".")[0]);
223
+ continue;
224
+ }
225
+
226
+ for (let c = 1; c < cells.length; c += 1) {
227
+ const target = code(cells[c]);
228
+ if (!target || !target.startsWith("system.")) continue;
229
+ const system = table.systems[c - 1];
230
+ if (!system) continue;
231
+ claims.push({
232
+ noteType: current.name,
233
+ system,
234
+ source: code(cells[0]) ?? cells[0],
235
+ target,
236
+ line: i + 1,
237
+ ...(columnOfCell(line, c) === undefined ? {} : { column: columnOfCell(line, c) }),
238
+ });
239
+ }
240
+ }
241
+
242
+ return { file, types, claims };
243
+ }
244
+
245
+ /**
246
+ * Read and parse the specification from disk.
247
+ *
248
+ * @param {string} [file] - The document. Defaults to {@link CONTENT_FORMAT_PATH}.
249
+ * @returns {ContentFormat} What it declares.
250
+ */
251
+ export function loadContentFormat(file = CONTENT_FORMAT_PATH) {
252
+ return parseContentFormat(fs.readFileSync(file, "utf8"), { file });
253
+ }
@@ -22,12 +22,20 @@
22
22
  * anchor slug; nothing checks that a heading declaring that slug exists. A
23
23
  * link to an anchor nobody declares compiles cleanly, emits an enricher, and
24
24
  * dead-ends for the reader.
25
- * 2. **A dead address.** A *qualified* `type-shortcode` target resolving to no
26
- * note is a typo. A bare `[[Name]]` that finds nothing is not that is a
27
- * worldbuilding placeholder by long-standing convention, and is left alone.
28
- * 3. **A wikilink authored in frontmatter.** Both builds walk a note's *body*
25
+ * 2. **A dead address.** A *piped* target — `[[x|…]]` is an address, and one
26
+ * resolving to no note is a typo. So is one that does not parse as an
27
+ * address at all: the pipe says the author meant one.
28
+ * 3. **A dead alias.** An *unpiped* target `[[x]]` — names a note of the
29
+ * source's own type. One that finds nothing may be a worldbuilding
30
+ * placeholder, so it is reported as a warning rather than a failure; it is a
31
+ * different problem from a dead address and reads differently.
32
+ * 4. **A wikilink authored in frontmatter.** Both builds walk a note's *body*
29
33
  * and copy frontmatter through verbatim, so a link written in a
30
34
  * `description` is never resolved and publishes as literal `[[…]]` text.
35
+ * Frontmatter is data: a `WikiLink` field is parsed by the address grammar
36
+ * and a bracketed link there is a finding naming the note and the field.
37
+ * 5. **An alias two notes of one type both claim.** It used to be deleted
38
+ * silently, so the pair resolved to nothing and nobody was told (#131).
31
39
  *
32
40
  * **This resolves links the way the builds do**, calling the same
33
41
  * {@link readQualifier} and the same {@link parseWikilink} rather than a second
@@ -64,8 +72,9 @@ import {
64
72
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
65
73
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
66
74
  import { RETIRED_TYPES } from "./ids.mjs";
67
- import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
75
+ import { parseWikilink, resolvesAsAddress, WIKILINK } from "./wikilink-syntax.mjs";
68
76
  import { readQualifier } from "./wikilinks.mjs";
77
+ import { aliasesOf, aliasKey, indexAliases } from "./alias-index.mjs";
69
78
 
70
79
  /**
71
80
  * Every `{#anchor}` a note declares on a heading.
@@ -125,8 +134,6 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
125
134
  }
126
135
 
127
136
  const byKey = new Map();
128
- const byAlias = new Map();
129
- const aliasCollide = new Set();
130
137
 
131
138
  // The one package every note in this tree belongs to. Taken from the
132
139
  // configuration, never from a note: `package:` is retired, so there is no
@@ -145,25 +152,23 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
145
152
  byKey.set(canonicalKey(pkg, `doc${type}`, fm.shortcode), note);
146
153
  }
147
154
  }
148
- const aliases = [
149
- ...(Array.isArray(fm.aliases) ? fm.aliases : []),
150
- ...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
151
- fm.name?.full,
152
- path.basename(note.file, ".md").replace(/_/g, " "),
153
- ].filter((a) => typeof a === "string" && a);
154
- for (const a of aliases) {
155
- const k = `${type}|${a}`.toLowerCase();
156
- if (aliasCollide.has(k)) continue;
157
- const cur = byAlias.get(k);
158
- if (cur && cur !== note) {
159
- byAlias.delete(k);
160
- aliasCollide.add(k);
161
- } else if (!cur) {
162
- byAlias.set(k, note);
163
- }
164
- }
165
155
  }
166
156
 
157
+ // The alias half of the two namespaces, built by the shared rule so the
158
+ // checker, the pack build and the site build cannot disagree about what a
159
+ // bare `[[…]]` can name (#131).
160
+ const {
161
+ byKey: byAlias,
162
+ claims: aliasClaims,
163
+ collisions: aliasCollisions,
164
+ } = indexAliases(
165
+ notes.map((note) => ({
166
+ type: note.type,
167
+ aliases: aliasesOf(note.fm),
168
+ value: note,
169
+ })),
170
+ );
171
+
167
172
  const types = new Set(notes.map((n) => n.type));
168
173
 
169
174
  // A foreign package may use a type this tree has never seen, so its types
@@ -195,7 +200,9 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
195
200
  *
196
201
  * @param {object} note - A note from this index.
197
202
  * @returns {Array<{target: string, anchor: string, text: string,
198
- * occurrence: number}>} `target` is `""` for a same-page `[[#anchor]]`.
203
+ * occurrence: number, labelled: boolean}>} `target` is `""` for a
204
+ * same-page `[[#anchor]]`; `labelled` says which namespace the target
205
+ * belongs to (#131).
199
206
  */
200
207
  function linksOf(note) {
201
208
  let body = note.body;
@@ -215,39 +222,52 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
215
222
  // Code is verbatim, so a `[[…]]` inside a fence, an indented block or
216
223
  // an inline span is not a link — the compilers make none of it either.
217
224
  for (const [all, rawInner] of matchAllOutsideCode(body, new RegExp(WIKILINK.source, "g"))) {
218
- const { target, anchor } = parseWikilink(rawInner);
225
+ const parsed = parseWikilink(rawInner);
226
+ const { target, anchor } = parsed;
219
227
  const occurrence = (seen.get(all) ?? 0) + 1;
220
228
  seen.set(all, occurrence);
221
229
  // `text` is the link exactly as authored, which is what locates it
222
230
  // in the file. A link a table generated is not in the file at all,
223
231
  // so the search simply fails and a finding names the file.
224
- out.push({ target, anchor, text: all, occurrence });
232
+ out.push({
233
+ target,
234
+ anchor,
235
+ text: all,
236
+ occurrence,
237
+ labelled: resolvesAsAddress(parsed),
238
+ });
225
239
  }
226
240
  return out;
227
241
  }
228
242
 
229
243
  /**
230
- * Resolve a link target the way both builds do, or `undefined`.
244
+ * The note an **alias** names, or `undefined`.
245
+ *
246
+ * Scoped to the *source* note's own type, so one word may be an alias in
247
+ * several types without colliding. An alias two same-type notes claim is
248
+ * absent from the index entirely — see {@link indexAliases} — so this can
249
+ * never resolve to whichever was walked first.
250
+ *
251
+ * @param {object} note - The note the link is written in.
252
+ * @param {string} target - The link target, anchor already removed.
253
+ * @returns {object|undefined} The note it names.
254
+ */
255
+ function resolveAlias(note, target) {
256
+ return byAlias.get(aliasKey(note.type, target));
257
+ }
258
+
259
+ /**
260
+ * The note an **address** names, or `undefined`.
231
261
  *
232
262
  * The qualifier is read with {@link readQualifier} rather than a second
233
263
  * copy of the rule, so this cannot drift from what the builds do — the two
234
- * separators, the first-hyphen split, and the known-type condition that
235
- * keeps a hyphenated *name* an alias.
264
+ * separators, the first-hyphen split, and the optional leading package
265
+ * segment.
236
266
  *
237
- * That condition is why the type-scoped alias index is not enough alone: it
238
- * reaches only a target of the source's **own** type, so a cross-type
239
- * `[[type-shortcode#anchor]]` would resolve to nothing and its anchor go
240
- * unchecked — silently, since an unresolvable target is treated as
241
- * external.
242
- *
243
- * @param {object} note - The note the link is written in.
244
- * @param {string} target - The link target.
267
+ * @param {string} target - The link target, anchor already removed.
245
268
  * @returns {object|undefined} The note it addresses.
246
269
  */
247
- function resolve(note, target) {
248
- const direct =
249
- byAlias.get(`${note.type}|${target}`.toLowerCase()) ?? byKey.get(target.toLowerCase());
250
- if (direct) return direct;
270
+ function resolveAddress(target) {
251
271
  const qualified = readQualifier(target, types, packages);
252
272
  if (!qualified || qualified.reason) return undefined;
253
273
  return byKey.get(
@@ -257,6 +277,24 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
257
277
  );
258
278
  }
259
279
 
280
+ /**
281
+ * Resolve a link target the way both builds do, or `undefined`.
282
+ *
283
+ * **The pipe chooses the namespace, and there is no fallback either way**
284
+ * (#131) — see {@link resolvesAsAddress} for why. The caller therefore has
285
+ * to say which form was authored; it is a required argument rather than a
286
+ * defaulted one, because either default would silently resolve half the
287
+ * corpus through the wrong namespace.
288
+ *
289
+ * @param {object} note - The note the link is written in.
290
+ * @param {string} target - The link target.
291
+ * @param {boolean} labelled - Whether the link carried a `|`.
292
+ * @returns {object|undefined} The note it names.
293
+ */
294
+ function resolve(note, target, labelled) {
295
+ return labelled ? resolveAddress(target) : resolveAlias(note, target);
296
+ }
297
+
260
298
  /**
261
299
  * The manifest entry a qualified address names in another package, or null.
262
300
  *
@@ -295,8 +333,14 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
295
333
  contentPackage: pkg,
296
334
  foreign,
297
335
  manifests: manifestsComplete(localPackages, foreign.packages),
336
+ /** Every note claiming each type-scoped alias, colliding ones included. */
337
+ aliasClaims,
338
+ /** One entry per alias two or more same-type notes claim (#131). */
339
+ aliasCollisions,
298
340
  linksOf,
299
341
  resolve,
342
+ resolveAlias,
343
+ resolveAddress,
300
344
  manifestHit,
301
345
  /** Whether a target reads as a qualified address at all. */
302
346
  isAddress: (target) => Boolean(readQualifier(target, types, packages)),
@@ -611,22 +655,35 @@ export function auditHomepageLinks(index) {
611
655
  /**
612
656
  * Every link in a tree that lands nowhere.
613
657
  *
658
+ * **The two failure modes are separate findings, because they are separate
659
+ * problems** (#131). A piped target the author declared to be an address, and
660
+ * which resolves nowhere, is a typo: every package it could name is either
661
+ * built here or vendored, so there is no third possibility. An unpiped target
662
+ * naming no note of the source's type may be exactly that typo — or a
663
+ * worldbuilding placeholder, which is a long-standing convention in the
664
+ * setting trees. So the first is an error and the second a warning, and the
665
+ * caller can tell them apart without parsing a message.
666
+ *
614
667
  * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
615
668
  * @returns {{deadAnchors: object[], deadAddresses: object[],
669
+ * deadAliases: object[], aliasCollisions: object[],
616
670
  * frontmatterLinks: object[], homepageLinks: object[],
617
671
  * usedManifest: Set<string>}} The findings, and which addresses a foreign
618
- * manifest answered.
672
+ * manifest answered. Each `deadAddresses` entry carries a `reason`:
673
+ * `"not-an-address"` when the target does not parse as one at all,
674
+ * `"unknown-type"` when it is qualified but names no known type, and
675
+ * `"unresolved"` when it parses and nothing answers it.
619
676
  */
620
677
  export function auditLinks(index) {
621
678
  const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
622
679
 
623
680
  const deadAnchors = [];
624
681
  for (const note of notes) {
625
- for (const { target, anchor, text, occurrence } of linksOf(note)) {
682
+ for (const { target, anchor, text, occurrence, labelled } of linksOf(note)) {
626
683
  if (!anchor) continue;
627
- const dest = target ? resolve(note, target) : note;
628
- // An unresolvable target is an external reference, not this
629
- // check's business.
684
+ const dest = target ? resolve(note, target, labelled) : note;
685
+ // An unresolvable target is reported by the address or alias pass
686
+ // below; its anchor has nothing to be checked against.
630
687
  if (!dest) continue;
631
688
  if (!anchors.get(dest).has(slugify(anchor))) {
632
689
  deadAnchors.push({
@@ -641,28 +698,47 @@ export function auditLinks(index) {
641
698
  }
642
699
 
643
700
  const deadAddresses = [];
701
+ const deadAliases = [];
644
702
  const usedManifest = new Set();
645
703
  for (const note of notes) {
646
- for (const { target, text, occurrence } of linksOf(note)) {
704
+ for (const { target, text, occurrence, labelled } of linksOf(note)) {
647
705
  if (!target) continue; // a same-page `[[#anchor]]`
648
- // Only a *qualified* target is an address. A bare `[[Name]]` that
649
- // finds nothing is a worldbuilding placeholder by long-standing
650
- // convention, and is deliberately left alone.
651
- if (!isAddress(target)) continue;
652
- if (resolve(note, target)) continue;
706
+ const at = { note, target, text, occurrence };
707
+
708
+ if (!labelled) {
709
+ if (index.resolveAlias(note, target)) continue;
710
+ // Kept alongside, so a report can say *why* nothing answered:
711
+ // an alias claimed twice is absent from the index, and blaming
712
+ // this note for it would blame the wrong file.
713
+ const claimants = index.aliasClaims.get(aliasKey(note.type, target)) ?? [];
714
+ deadAliases.push({ ...at, ambiguous: claimants.length > 1, claimants });
715
+ continue;
716
+ }
717
+
718
+ if (!isAddress(target)) {
719
+ deadAddresses.push({ ...at, reason: "not-an-address" });
720
+ continue;
721
+ }
722
+ if (index.resolveAddress(target)) continue;
653
723
  // A manifest answers with the target package's own build output
654
724
  // rather than a reviewed guess.
655
725
  if (manifestHit(target)) {
656
726
  usedManifest.add(target.toLowerCase());
657
727
  continue;
658
728
  }
659
- deadAddresses.push({ note, target, text, occurrence });
729
+ const read = readQualifier(target, index.types, index.packages);
730
+ deadAddresses.push({
731
+ ...at,
732
+ reason: read?.reason === "unknown-type" ? "unknown-type" : "unresolved",
733
+ });
660
734
  }
661
735
  }
662
736
 
663
737
  return {
664
738
  deadAnchors,
665
739
  deadAddresses,
740
+ deadAliases,
741
+ aliasCollisions: index.aliasCollisions,
666
742
  frontmatterLinks: index.frontmatterLinks,
667
743
  homepageLinks: auditHomepageLinks(index),
668
744
  usedManifest,
@@ -716,9 +792,9 @@ export function walkReachability(index, { root, scope, stopAt = () => false }) {
716
792
  while (queue.length) {
717
793
  const note = queue.shift();
718
794
  if (stopAt(note)) continue;
719
- for (const { target } of index.linksOf(note)) {
795
+ for (const { target, labelled } of index.linksOf(note)) {
720
796
  if (!target) continue;
721
- const dest = index.resolve(note, target);
797
+ const dest = index.resolve(note, target, labelled);
722
798
  if (!dest || !scope(dest) || reached.has(dest)) continue;
723
799
  reached.add(dest);
724
800
  queue.push(dest);
@@ -59,6 +59,7 @@
59
59
  import fs from "node:fs";
60
60
  import path from "node:path";
61
61
 
62
+ import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
62
63
  import { positionInFrontmatter } from "./diagnostics.mjs";
63
64
  import { walkMarkdownTree } from "./helpers.mjs";
64
65
  import { checkHomepageCount, isHomepage } from "./homepage.mjs";
@@ -66,6 +67,12 @@ import { checkHomepageCount, isHomepage } from "./homepage.mjs";
66
67
  /**
67
68
  * The shape every `shortcode` must match: ASCII letters and digits only.
68
69
  *
70
+ * This is {@link ADDRESS_SEGMENT_PATTERN}, not a second copy of it. A shortcode
71
+ * is the last segment of a canonical address, and the rule it is held to is the
72
+ * rule *every* segment is held to — so the two are one constant rather than two
73
+ * free to drift apart (#59). The name survives because this is where the rule
74
+ * is applied to a note.
75
+ *
69
76
  * Case is deliberately **not** constrained: hundreds of authored shortcodes are
70
77
  * mixed-case and collide with nothing, so tightening that is a separate
71
78
  * decision from this one.
@@ -74,7 +81,7 @@ import { checkHomepageCount, isHomepage } from "./homepage.mjs";
74
81
  * import a build-time dependency into shipped code — and is expected to pin the
75
82
  * two together with a test rather than trust that they still agree.
76
83
  */
77
- export const SHORTCODE_PATTERN = /^[A-Za-z0-9]+$/;
84
+ export const SHORTCODE_PATTERN = ADDRESS_SEGMENT_PATTERN;
78
85
 
79
86
  /**
80
87
  * Whether a value is a well-formed shortcode.
@@ -363,3 +363,36 @@ export function yamlKeyPath(field) {
363
363
  }
364
364
  return segments;
365
365
  }
366
+
367
+ /**
368
+ * Where a key sits **inside a note's frontmatter fence**, addressed by path.
369
+ *
370
+ * {@link positionInFrontmatter} searches the fence for a key by name, which is
371
+ * the right tool while a note's vocabulary is flat: a key appears once and the
372
+ * first line matching it is the one. It stops being the right tool the moment
373
+ * the same name is legal in two regions — `data.weight` and a top-level
374
+ * `weight` are different keys, and a search finds whichever comes first.
375
+ *
376
+ * So this is the located form for the closed regions (#128). It reuses
377
+ * {@link positionOfYamlPath} rather than repeating its parse, which is what
378
+ * keeps a finding about `data.weight` pointing at `data.weight`.
379
+ *
380
+ * @param {string} raw - The note's full contents, frontmatter included.
381
+ * @param {ReadonlyArray<string|number>} keyPath - Path to the node, from the
382
+ * top of the frontmatter.
383
+ * @param {object} [opts]
384
+ * @param {boolean} [opts.key=false] - Report where the last segment is
385
+ * *declared* rather than where its value sits.
386
+ * @returns {{line?: number, column?: number}} Spreadable position fields, empty
387
+ * when there is no fence or the path resolves to nothing — dropped rather
388
+ * than guessed.
389
+ */
390
+ export function positionOfFrontmatterPath(raw, keyPath, { key = false } = {}) {
391
+ if (typeof raw !== "string") return {};
392
+ const fence = raw.match(/^---\n([\s\S]*?)\n---/);
393
+ if (!fence) return {};
394
+ const position = positionOfYamlPath(fence[1], keyPath, { key });
395
+ // +1: the fence's line 1 is the file's line 2, the opening `---` being the
396
+ // first. `positionOfYamlPath` already counts from 1 within the block.
397
+ return position.line === undefined ? {} : { ...position, line: position.line + 1 };
398
+ }