@heroiclands/package-build 8.1.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 (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. 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.
@@ -99,18 +108,12 @@ export function anchorsOf(body) {
99
108
  * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
100
109
  * @returns {object} The notes, the index, and the resolvers built over it.
101
110
  */
102
- export function buildLinkIndex(
103
- contentBase,
104
- { manifestDir, skipDirectories } = {},
105
- ) {
111
+ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {}) {
106
112
  const notes = [];
107
113
  const frontmatterLinks = [];
108
114
  const walkOpts = skipDirectories ? { skipDirectories } : undefined;
109
115
 
110
- for (const { frontmatter: fm, absPath } of walkMarkdownTree(
111
- contentBase,
112
- walkOpts,
113
- )) {
116
+ for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase, walkOpts)) {
114
117
  if (!fm || typeof fm.type !== "string") continue;
115
118
  // The raw text is kept beside the parsed body: a consumer's own checks
116
119
  // may need what frontmatter carried, which the body has dropped.
@@ -131,8 +134,6 @@ export function buildLinkIndex(
131
134
  }
132
135
 
133
136
  const byKey = new Map();
134
- const byAlias = new Map();
135
- const aliasCollide = new Set();
136
137
 
137
138
  // The one package every note in this tree belongs to. Taken from the
138
139
  // configuration, never from a note: `package:` is retired, so there is no
@@ -151,25 +152,23 @@ export function buildLinkIndex(
151
152
  byKey.set(canonicalKey(pkg, `doc${type}`, fm.shortcode), note);
152
153
  }
153
154
  }
154
- const aliases = [
155
- ...(Array.isArray(fm.aliases) ? fm.aliases : []),
156
- ...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
157
- fm.name?.full,
158
- path.basename(note.file, ".md").replace(/_/g, " "),
159
- ].filter((a) => typeof a === "string" && a);
160
- for (const a of aliases) {
161
- const k = `${type}|${a}`.toLowerCase();
162
- if (aliasCollide.has(k)) continue;
163
- const cur = byAlias.get(k);
164
- if (cur && cur !== note) {
165
- byAlias.delete(k);
166
- aliasCollide.add(k);
167
- } else if (!cur) {
168
- byAlias.set(k, note);
169
- }
170
- }
171
155
  }
172
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
+
173
172
  const types = new Set(notes.map((n) => n.type));
174
173
 
175
174
  // A foreign package may use a type this tree has never seen, so its types
@@ -182,10 +181,7 @@ export function buildLinkIndex(
182
181
  : { index: new Map(), packages: new Set(), stale: [] };
183
182
  for (const v of foreign.index.values()) if (v.type) types.add(v.type);
184
183
 
185
- const packages = new Set([
186
- ...(byKey.size ? [pkg] : []),
187
- ...foreign.packages,
188
- ]);
184
+ const packages = new Set([...(byKey.size ? [pkg] : []), ...foreign.packages]);
189
185
 
190
186
  /** The searchable universe a `dataview` table draws its rows from. */
191
187
  const tableDocs = notes.map((n) => ({
@@ -204,7 +200,9 @@ export function buildLinkIndex(
204
200
  *
205
201
  * @param {object} note - A note from this index.
206
202
  * @returns {Array<{target: string, anchor: string, text: string,
207
- * 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).
208
206
  */
209
207
  function linksOf(note) {
210
208
  let body = note.body;
@@ -223,57 +221,80 @@ export function buildLinkIndex(
223
221
  const seen = new Map();
224
222
  // Code is verbatim, so a `[[…]]` inside a fence, an indented block or
225
223
  // an inline span is not a link — the compilers make none of it either.
226
- for (const [all, rawInner] of matchAllOutsideCode(
227
- body,
228
- new RegExp(WIKILINK.source, "g"),
229
- )) {
230
- const { target, anchor } = parseWikilink(rawInner);
224
+ for (const [all, rawInner] of matchAllOutsideCode(body, new RegExp(WIKILINK.source, "g"))) {
225
+ const parsed = parseWikilink(rawInner);
226
+ const { target, anchor } = parsed;
231
227
  const occurrence = (seen.get(all) ?? 0) + 1;
232
228
  seen.set(all, occurrence);
233
229
  // `text` is the link exactly as authored, which is what locates it
234
230
  // in the file. A link a table generated is not in the file at all,
235
231
  // so the search simply fails and a finding names the file.
236
- out.push({ target, anchor, text: all, occurrence });
232
+ out.push({
233
+ target,
234
+ anchor,
235
+ text: all,
236
+ occurrence,
237
+ labelled: resolvesAsAddress(parsed),
238
+ });
237
239
  }
238
240
  return out;
239
241
  }
240
242
 
241
243
  /**
242
- * 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`.
243
261
  *
244
262
  * The qualifier is read with {@link readQualifier} rather than a second
245
263
  * copy of the rule, so this cannot drift from what the builds do — the two
246
- * separators, the first-hyphen split, and the known-type condition that
247
- * keeps a hyphenated *name* an alias.
248
- *
249
- * That condition is why the type-scoped alias index is not enough alone: it
250
- * reaches only a target of the source's **own** type, so a cross-type
251
- * `[[type-shortcode#anchor]]` would resolve to nothing and its anchor go
252
- * unchecked — silently, since an unresolvable target is treated as
253
- * external.
264
+ * separators, the first-hyphen split, and the optional leading package
265
+ * segment.
254
266
  *
255
- * @param {object} note - The note the link is written in.
256
- * @param {string} target - The link target.
267
+ * @param {string} target - The link target, anchor already removed.
257
268
  * @returns {object|undefined} The note it addresses.
258
269
  */
259
- function resolve(note, target) {
260
- const direct =
261
- byAlias.get(`${note.type}|${target}`.toLowerCase()) ??
262
- byKey.get(target.toLowerCase());
263
- if (direct) return direct;
270
+ function resolveAddress(target) {
264
271
  const qualified = readQualifier(target, types, packages);
265
272
  if (!qualified || qualified.reason) return undefined;
266
273
  return byKey.get(
267
274
  qualified.package ?
268
- canonicalKey(
269
- qualified.package,
270
- qualified.type,
271
- qualified.shortcode,
272
- )
275
+ canonicalKey(qualified.package, qualified.type, qualified.shortcode)
273
276
  : `${qualified.type}/${qualified.shortcode}`.toLowerCase(),
274
277
  );
275
278
  }
276
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
+
277
298
  /**
278
299
  * The manifest entry a qualified address names in another package, or null.
279
300
  *
@@ -284,11 +305,7 @@ export function buildLinkIndex(
284
305
  const q = readQualifier(target, types, packages);
285
306
  if (!q || q.reason) return null;
286
307
  if (q.package) {
287
- return (
288
- foreign.index.get(
289
- canonicalKey(q.package, q.type, q.shortcode),
290
- ) ?? null
291
- );
308
+ return foreign.index.get(canonicalKey(q.package, q.type, q.shortcode)) ?? null;
292
309
  }
293
310
  // A bare address names no package, so it resolves against any foreign
294
311
  // one that publishes it. Claimed by two, it is ambiguous and the author
@@ -316,8 +333,14 @@ export function buildLinkIndex(
316
333
  contentPackage: pkg,
317
334
  foreign,
318
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,
319
340
  linksOf,
320
341
  resolve,
342
+ resolveAlias,
343
+ resolveAddress,
321
344
  manifestHit,
322
345
  /** Whether a target reads as a qualified address at all. */
323
346
  isAddress: (target) => Boolean(readQualifier(target, types, packages)),
@@ -397,8 +420,7 @@ function landingTarget(url, bases) {
397
420
  }
398
421
  if (!/^https?:$/.test(parsed.protocol)) return null;
399
422
  if (!SITE_HOST.test(parsed.hostname)) return null;
400
- const pathname =
401
- parsed.pathname.endsWith("/") ? parsed.pathname : `${parsed.pathname}/`;
423
+ const pathname = parsed.pathname.endsWith("/") ? parsed.pathname : `${parsed.pathname}/`;
402
424
  for (const [pkg, base] of bases) {
403
425
  if (pathname === base) return { pkg, base };
404
426
  }
@@ -444,8 +466,7 @@ function readAddress(url, packages) {
444
466
  segments = value.split("?")[0].split("#")[0].split("/").filter(Boolean);
445
467
  }
446
468
 
447
- const prefix =
448
- shape !== "relative" && packages.has(segments[0]) ? segments[0] : null;
469
+ const prefix = shape !== "relative" && packages.has(segments[0]) ? segments[0] : null;
449
470
  return { shape, segments, prefix };
450
471
  }
451
472
 
@@ -533,10 +554,7 @@ export function auditHomepageLinks(index) {
533
554
  );
534
555
  }
535
556
 
536
- for (const { field, url, kind } of homepageAddresses(
537
- note.fm,
538
- note.body,
539
- )) {
557
+ for (const { field, url, kind } of homepageAddresses(note.fm, note.body)) {
540
558
  // Counted for every address, checked or not, so the count is
541
559
  // the literal's nth appearance in the file rather than the nth
542
560
  // *finding* about it — two rules can fire on one address.
@@ -583,8 +601,7 @@ export function auditHomepageLinks(index) {
583
601
  `relocation does not leave this page behind`,
584
602
  );
585
603
  } else if (shape === "rooted" && kind === "url") {
586
- const rest =
587
- prefix ? segments.slice(1).join("/") : segments.join("/");
604
+ const rest = prefix ? segments.slice(1).join("/") : segments.join("/");
588
605
  report(
589
606
  field,
590
607
  url,
@@ -596,9 +613,7 @@ export function auditHomepageLinks(index) {
596
613
  // `href:` is the field for an address already resolved.
597
614
  !rest ?
598
615
  `url "${url}" addresses ` +
599
- (prefix ?
600
- `package "${prefix}"'s landing`
601
- : `the site root`) +
616
+ (prefix ? `package "${prefix}"'s landing` : `the site root`) +
602
617
  `, but a landing's url: is package-relative and ` +
603
618
  `cannot leave this package — write ` +
604
619
  `href: "${url}", which is used verbatim`
@@ -640,22 +655,35 @@ export function auditHomepageLinks(index) {
640
655
  /**
641
656
  * Every link in a tree that lands nowhere.
642
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
+ *
643
667
  * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
644
668
  * @returns {{deadAnchors: object[], deadAddresses: object[],
669
+ * deadAliases: object[], aliasCollisions: object[],
645
670
  * frontmatterLinks: object[], homepageLinks: object[],
646
671
  * usedManifest: Set<string>}} The findings, and which addresses a foreign
647
- * 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.
648
676
  */
649
677
  export function auditLinks(index) {
650
678
  const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
651
679
 
652
680
  const deadAnchors = [];
653
681
  for (const note of notes) {
654
- for (const { target, anchor, text, occurrence } of linksOf(note)) {
682
+ for (const { target, anchor, text, occurrence, labelled } of linksOf(note)) {
655
683
  if (!anchor) continue;
656
- const dest = target ? resolve(note, target) : note;
657
- // An unresolvable target is an external reference, not this
658
- // 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.
659
687
  if (!dest) continue;
660
688
  if (!anchors.get(dest).has(slugify(anchor))) {
661
689
  deadAnchors.push({
@@ -670,28 +698,47 @@ export function auditLinks(index) {
670
698
  }
671
699
 
672
700
  const deadAddresses = [];
701
+ const deadAliases = [];
673
702
  const usedManifest = new Set();
674
703
  for (const note of notes) {
675
- for (const { target, text, occurrence } of linksOf(note)) {
704
+ for (const { target, text, occurrence, labelled } of linksOf(note)) {
676
705
  if (!target) continue; // a same-page `[[#anchor]]`
677
- // Only a *qualified* target is an address. A bare `[[Name]]` that
678
- // finds nothing is a worldbuilding placeholder by long-standing
679
- // convention, and is deliberately left alone.
680
- if (!isAddress(target)) continue;
681
- 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;
682
723
  // A manifest answers with the target package's own build output
683
724
  // rather than a reviewed guess.
684
725
  if (manifestHit(target)) {
685
726
  usedManifest.add(target.toLowerCase());
686
727
  continue;
687
728
  }
688
- 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
+ });
689
734
  }
690
735
  }
691
736
 
692
737
  return {
693
738
  deadAnchors,
694
739
  deadAddresses,
740
+ deadAliases,
741
+ aliasCollisions: index.aliasCollisions,
695
742
  frontmatterLinks: index.frontmatterLinks,
696
743
  homepageLinks: auditHomepageLinks(index),
697
744
  usedManifest,
@@ -737,9 +784,7 @@ export function auditLinks(index) {
737
784
  export function walkReachability(index, { root, scope, stopAt = () => false }) {
738
785
  const rootNote = index.notes.find((n) => n.rel === root);
739
786
  if (!rootNote) {
740
- throw new Error(
741
- `no note at ${root}, so the corpus has no page to be read from`,
742
- );
787
+ throw new Error(`no note at ${root}, so the corpus has no page to be read from`);
743
788
  }
744
789
 
745
790
  const reached = new Set([rootNote]);
@@ -747,9 +792,9 @@ export function walkReachability(index, { root, scope, stopAt = () => false }) {
747
792
  while (queue.length) {
748
793
  const note = queue.shift();
749
794
  if (stopAt(note)) continue;
750
- for (const { target } of index.linksOf(note)) {
795
+ for (const { target, labelled } of index.linksOf(note)) {
751
796
  if (!target) continue;
752
- const dest = index.resolve(note, target);
797
+ const dest = index.resolve(note, target, labelled);
753
798
  if (!dest || !scope(dest) || reached.has(dest)) continue;
754
799
  reached.add(dest);
755
800
  queue.push(dest);