@heroiclands/package-build 0.6.1 → 3.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 (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +58 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -35
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,141 @@
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
+ * Where a content note publishes on the web.
16
+ *
17
+ * One rule, in one place, because two builds need the same answer: the
18
+ * knowledgebase build renders the page, and the link manifest records the
19
+ * address other packages link to. Stating it twice is how a manifest comes to
20
+ * assert a URL that resolves at build time and 404s for the reader.
21
+ */
22
+
23
+ import { contentSlug } from "./content-slug.mjs";
24
+ // The scheme vocabulary is part of the configuration contract — a
25
+ // repository names its scheme in `package-build.config.yaml` — so it is
26
+ // declared beside the rest of that vocabulary rather than here, and this
27
+ // module reads it. `config.mjs` is the leaf entry point and imports nothing
28
+ // but `node:path` and `engine/ids.mjs`, so the direction cannot close a
29
+ // cycle (see `engine/pack-config.mjs`).
30
+ import { DEFAULT_ADDRESS_SCHEME, LANDING_RULES } from "../content-config.mjs";
31
+
32
+ export { DEFAULT_ADDRESS_SCHEME, LANDING_RULES };
33
+
34
+ /** The knowledgebase's mount within this package's site (#1470). */
35
+ export const KB_PREFIX = "kb/";
36
+
37
+ /**
38
+ * The URL section a note routes to.
39
+ *
40
+ * A `doc` is narrative content whose only identity is its subtype label, so it
41
+ * routes by `category`; every other type names its own section.
42
+ *
43
+ * @param {object} fm - Parsed frontmatter.
44
+ * @returns {string|undefined} The section, or `undefined` when the note has
45
+ * none — a `doc` with no category has no address and is not published.
46
+ */
47
+ export function sectionOf(fm) {
48
+ return fm.type === "doc" ? fm.category : fm.type;
49
+ }
50
+
51
+ /**
52
+ * A note's address below the knowledgebase mount, e.g. `affliction/aconite/`.
53
+ *
54
+ * A `README.md` **is** its section's landing page rather than a page within it,
55
+ * so it addresses the section itself and has no slug of its own.
56
+ *
57
+ * @param {object} fm - Parsed frontmatter.
58
+ * @param {string} name - The note's display name; the slug derives from it
59
+ * (#1278), never from the shortcode, which is identity rather than
60
+ * presentation.
61
+ * @param {boolean} isReadme - Whether the file is a `README.md`.
62
+ * @returns {string} The section-relative address, with a trailing slash.
63
+ * @throws {Error} When the name yields no usable slug.
64
+ */
65
+ export function contentAddress(fm, name, isReadme) {
66
+ const sec = sectionOf(fm);
67
+ return isReadme ? `${sec}/` : `${sec}/${contentSlug(name)}/`;
68
+ }
69
+
70
+ /**
71
+ * Whether a note is a landing page under a scheme, and what it lands at.
72
+ *
73
+ * @param {object} fm - Parsed frontmatter.
74
+ * @param {boolean} isReadme - Whether the file is a `README.md`.
75
+ * @param {string} landing - The landing rule, one of {@link LANDING_RULES}.
76
+ * @returns {{landing: true, segment: string}|{landing: false}|{landing: true, segment: undefined}}
77
+ * `segment` is the single path segment the note addresses. A `collection`
78
+ * note that declares no `section` is a landing page with no segment — an
79
+ * error rather than a page, since it names nowhere to land.
80
+ */
81
+ function landingOf(fm, isReadme, landing) {
82
+ if (landing === "readme") {
83
+ return isReadme ?
84
+ { landing: true, segment: sectionOf(fm) }
85
+ : { landing: false };
86
+ }
87
+ // `collection`. The section is authored rather than derived: it is the
88
+ // identity of the section being introduced, and the note's own title
89
+ // ("Creatures") is presentation, which would slug to something else.
90
+ if (fm.type === "doc" && fm.category === "collection") {
91
+ return { landing: true, segment: fm.section || fm.slug };
92
+ }
93
+ return { landing: false };
94
+ }
95
+
96
+ /**
97
+ * A note's address relative to its **package**, e.g. `kb/affliction/aconite/`.
98
+ *
99
+ * This is the form the link manifest records and the site build emits pages at,
100
+ * and it is one function because those two must agree — a manifest asserting an
101
+ * address the site does not publish resolves at build time and 404s for the
102
+ * reader, which is the failure this module exists to prevent.
103
+ *
104
+ * @param {object} fm - Parsed frontmatter.
105
+ * @param {string} name - The note's display name; a page slug derives from it
106
+ * (#1278), never from the shortcode, which is identity rather than
107
+ * presentation.
108
+ * @param {object} [options] - Options.
109
+ * @param {boolean} [options.isReadme] - Whether the file is a `README.md`.
110
+ * @param {{prefix?: string, landing?: string}} [options.scheme] - The
111
+ * repository's address scheme; defaults to {@link DEFAULT_ADDRESS_SCHEME}.
112
+ * @returns {string} The package-relative address, with a trailing slash and no
113
+ * leading one.
114
+ * @throws {Error} When the note has no address — no section, a landing page
115
+ * naming no section, or a name yielding no usable slug. Each is a note that
116
+ * is not published, and inventing an address for one would put a dead entry
117
+ * in the manifest.
118
+ */
119
+ export function packageAddress(fm, name, { isReadme = false, scheme } = {}) {
120
+ const { prefix, landing } = { ...DEFAULT_ADDRESS_SCHEME, ...scheme };
121
+ if (!LANDING_RULES.includes(landing)) {
122
+ throw new Error(
123
+ `unknown landing rule ${JSON.stringify(landing)} — expected one ` +
124
+ `of ${LANDING_RULES.join(", ")}`,
125
+ );
126
+ }
127
+ const land = landingOf(fm, isReadme, landing);
128
+ if (land.landing) {
129
+ if (typeof land.segment !== "string" || !land.segment) {
130
+ throw new Error(
131
+ `landing note declares no section, so it lands nowhere`,
132
+ );
133
+ }
134
+ return `${prefix}${land.segment}/`;
135
+ }
136
+ const sec = sectionOf(fm);
137
+ if (typeof sec !== "string" || !sec) {
138
+ throw new Error(`type "${fm.type}" has no section`);
139
+ }
140
+ return `${prefix}${sec}/${contentSlug(name)}/`;
141
+ }
@@ -0,0 +1,438 @@
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
+ * Resolving every link in a content tree, and reporting the ones that land
16
+ * nowhere.
17
+ *
18
+ * Three link defects survive both content builds silently, so neither the pack
19
+ * compilers nor a site build catches them:
20
+ *
21
+ * 1. **A dead `#anchor`.** A page id is derived by hashing the note id and the
22
+ * anchor slug; nothing checks that a heading declaring that slug exists. A
23
+ * link to an anchor nobody declares compiles cleanly, emits an enricher, and
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*
29
+ * and copy frontmatter through verbatim, so a link written in a
30
+ * `description` is never resolved and publishes as literal `[[…]]` text.
31
+ *
32
+ * **This resolves links the way the builds do**, calling the same
33
+ * {@link readQualifier} and the same {@link parseWikilink} rather than a second
34
+ * copy of either. It did carry its own copy of the wikilink pattern — the third
35
+ * in this codebase, and the same drifted one that let an unclosed bracket
36
+ * swallow a document — so the checker parsed more loosely than the compilers it
37
+ * was checking.
38
+ *
39
+ * **What this deliberately does not do.** Corpus reachability — "every rules
40
+ * document is reachable from the book's root" — is a statement about what one
41
+ * package publishes, not about the note format, so it belongs with the
42
+ * publishing it describes; so does a retired hostname. Both are served by the
43
+ * link graph returned here rather than implemented here (#20).
44
+ *
45
+ * @module
46
+ */
47
+
48
+ import fs from "node:fs";
49
+ import path from "node:path";
50
+
51
+ import { matchAllOutsideCode } from "./code-fences.mjs";
52
+ import { expandContentTables } from "./content-tables.mjs";
53
+ import { walkMarkdownTree } from "./helpers.mjs";
54
+ import { hasDocEntry } from "./item-docs.mjs";
55
+ import {
56
+ canonicalKey,
57
+ loadForeignManifests,
58
+ manifestsComplete,
59
+ readCanonicalKey,
60
+ } from "./kb-manifest.mjs";
61
+ import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
62
+ import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
63
+ import { readQualifier } from "./wikilinks.mjs";
64
+
65
+ /**
66
+ * Every `{#anchor}` a note declares on a heading.
67
+ *
68
+ * @param {string} body - The note's markdown body.
69
+ * @returns {Set<string>} The declared anchor slugs.
70
+ */
71
+ export function anchorsOf(body) {
72
+ const found = new Set();
73
+ for (const line of String(body ?? "").split("\n")) {
74
+ const m = /^#{1,6}\s+.*\{#([a-z0-9-]+)\}\s*$/.exec(line.trim());
75
+ if (m) found.add(m[1]);
76
+ }
77
+ return found;
78
+ }
79
+
80
+ /**
81
+ * Load a content tree and build the index a link resolves against.
82
+ *
83
+ * The index mirrors what both builds construct, including the two addresses a
84
+ * doc-carrying note answers to: `type/shortcode` for the document, and
85
+ * `doc<type>/shortcode` for the JournalEntry its prose compiles into. Once a
86
+ * manifest publishes `doc<type>` entries that prefix is a *known type*, and the
87
+ * virtual reading that used to answer for it no longer fires — a real type owns
88
+ * its own name — so the note is indexed under both.
89
+ *
90
+ * @param {string} contentBase - Root of the content tree.
91
+ * @param {object} [opts]
92
+ * @param {string} [opts.manifestDir] - Where vendored foreign manifests live.
93
+ * Omitted, no cross-package address resolves.
94
+ * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
95
+ * @returns {object} The notes, the index, and the resolvers built over it.
96
+ */
97
+ export function buildLinkIndex(
98
+ contentBase,
99
+ { manifestDir, skipDirectories } = {},
100
+ ) {
101
+ const notes = [];
102
+ const frontmatterLinks = [];
103
+ const walkOpts = skipDirectories ? { skipDirectories } : undefined;
104
+
105
+ for (const { frontmatter: fm, absPath } of walkMarkdownTree(
106
+ contentBase,
107
+ walkOpts,
108
+ )) {
109
+ if (!fm || typeof fm.type !== "string") continue;
110
+ // The raw text is kept beside the parsed body: a consumer's own checks
111
+ // may need what frontmatter carried, which the body has dropped.
112
+ const raw = fs.readFileSync(absPath, "utf8");
113
+ const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
114
+ const note = {
115
+ file: absPath,
116
+ rel: path.relative(contentBase, absPath).split(path.sep).join("/"),
117
+ fm,
118
+ body,
119
+ raw,
120
+ type: fm.type.toLowerCase(),
121
+ };
122
+ for (const hit of frontmatterWikilinks(fm)) {
123
+ frontmatterLinks.push({ note, ...hit });
124
+ }
125
+ notes.push(note);
126
+ }
127
+
128
+ const byKey = new Map();
129
+ const byAlias = new Map();
130
+ const aliasCollide = new Set();
131
+
132
+ for (const note of notes) {
133
+ const { fm, type } = note;
134
+ if (typeof fm.shortcode === "string" && fm.shortcode) {
135
+ byKey.set(`${type}/${fm.shortcode}`.toLowerCase(), note);
136
+ // The canonical, fully qualified address alongside the short one,
137
+ // so a package-qualified link checks the same way a bare one does.
138
+ if (fm.package) {
139
+ byKey.set(canonicalKey(fm.package, type, fm.shortcode), note);
140
+ }
141
+ if (hasDocEntry(type)) {
142
+ byKey.set(`doc${type}/${fm.shortcode}`.toLowerCase(), note);
143
+ if (fm.package) {
144
+ byKey.set(
145
+ canonicalKey(fm.package, `doc${type}`, fm.shortcode),
146
+ note,
147
+ );
148
+ }
149
+ }
150
+ }
151
+ const aliases = [
152
+ ...(Array.isArray(fm.aliases) ? fm.aliases : []),
153
+ ...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
154
+ fm.name?.full,
155
+ path.basename(note.file, ".md").replace(/_/g, " "),
156
+ ].filter((a) => typeof a === "string" && a);
157
+ for (const a of aliases) {
158
+ const k = `${type}|${a}`.toLowerCase();
159
+ if (aliasCollide.has(k)) continue;
160
+ const cur = byAlias.get(k);
161
+ if (cur && cur !== note) {
162
+ byAlias.delete(k);
163
+ aliasCollide.add(k);
164
+ } else if (!cur) {
165
+ byAlias.set(k, note);
166
+ }
167
+ }
168
+ }
169
+
170
+ const types = new Set(notes.map((n) => n.type));
171
+
172
+ // A foreign package may use a type this tree has never seen, so its types
173
+ // join `types` — otherwise `readQualifier` reads the link as prose and it
174
+ // is never checked at all.
175
+ const localPackages = new Set(
176
+ notes.map((n) => n.fm?.package).filter(Boolean),
177
+ );
178
+ const foreign =
179
+ manifestDir ?
180
+ loadForeignManifests(manifestDir, localPackages)
181
+ : { index: new Map(), packages: new Set(), stale: [] };
182
+ for (const v of foreign.index.values()) if (v.type) types.add(v.type);
183
+
184
+ const packages = new Set([
185
+ ...[...byKey.values()].map((n) => n.fm?.package).filter(Boolean),
186
+ ...foreign.packages,
187
+ ]);
188
+
189
+ /** The searchable universe a `dataview` table draws its rows from. */
190
+ const tableDocs = notes.map((n) => ({
191
+ fm: n.fm,
192
+ path: n.rel,
193
+ tld: n.rel.split("/")[0],
194
+ folder: path.dirname(n.rel).split("/").pop(),
195
+ }));
196
+
197
+ const anchors = new Map(notes.map((n) => [n, anchorsOf(n.body)]));
198
+
199
+ /**
200
+ * Every wikilink in a note body, with its `dataview` tables expanded.
201
+ *
202
+ * @param {object} note - A note from this index.
203
+ * @returns {Array<{target: string, anchor: string, text: string,
204
+ * occurrence: number}>} `target` is `""` for a same-page `[[#anchor]]`.
205
+ */
206
+ function linksOf(note) {
207
+ let body = note.body;
208
+ if (/^[ \t]*(?:`{3,}|~{3,})[ \t]*dataview\b/im.test(body)) {
209
+ body = expandContentTables(body, {
210
+ docs: tableDocs.filter((d) => d.fm.package === note.fm.package),
211
+ linkable: (d) => Boolean(d.fm.shortcode),
212
+ source: note.file,
213
+ }).markdown;
214
+ }
215
+ const out = [];
216
+ // How many times each authored link has been seen, so two identical
217
+ // links in one note are reported at their own positions.
218
+ const seen = new Map();
219
+ // Code is verbatim, so a `[[…]]` inside a fence, an indented block or
220
+ // an inline span is not a link — the compilers make none of it either.
221
+ for (const [all, rawInner] of matchAllOutsideCode(
222
+ body,
223
+ new RegExp(WIKILINK.source, "g"),
224
+ )) {
225
+ const { target, anchor } = parseWikilink(rawInner);
226
+ const occurrence = (seen.get(all) ?? 0) + 1;
227
+ seen.set(all, occurrence);
228
+ // `text` is the link exactly as authored, which is what locates it
229
+ // in the file. A link a table generated is not in the file at all,
230
+ // so the search simply fails and a finding names the file.
231
+ out.push({ target, anchor, text: all, occurrence });
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /**
237
+ * Resolve a link target the way both builds do, or `undefined`.
238
+ *
239
+ * The qualifier is read with {@link readQualifier} rather than a second
240
+ * copy of the rule, so this cannot drift from what the builds do — the two
241
+ * separators, the first-hyphen split, and the known-type condition that
242
+ * keeps a hyphenated *name* an alias.
243
+ *
244
+ * That condition is why the type-scoped alias index is not enough alone: it
245
+ * reaches only a target of the source's **own** type, so a cross-type
246
+ * `[[type-shortcode#anchor]]` would resolve to nothing and its anchor go
247
+ * unchecked — silently, since an unresolvable target is treated as
248
+ * external.
249
+ *
250
+ * @param {object} note - The note the link is written in.
251
+ * @param {string} target - The link target.
252
+ * @returns {object|undefined} The note it addresses.
253
+ */
254
+ function resolve(note, target) {
255
+ const direct =
256
+ byAlias.get(`${note.type}|${target}`.toLowerCase()) ??
257
+ byKey.get(target.toLowerCase());
258
+ if (direct) return direct;
259
+ const qualified = readQualifier(target, types, packages);
260
+ if (!qualified || qualified.reason) return undefined;
261
+ return byKey.get(
262
+ qualified.package ?
263
+ canonicalKey(
264
+ qualified.package,
265
+ qualified.type,
266
+ qualified.shortcode,
267
+ )
268
+ : `${qualified.type}/${qualified.shortcode}`.toLowerCase(),
269
+ );
270
+ }
271
+
272
+ /**
273
+ * The manifest entry a qualified address names in another package, or null.
274
+ *
275
+ * @param {string} target - The link target.
276
+ * @returns {object|null} The foreign entry.
277
+ */
278
+ function manifestHit(target) {
279
+ const q = readQualifier(target, types, packages);
280
+ if (!q || q.reason) return null;
281
+ if (q.package) {
282
+ return (
283
+ foreign.index.get(
284
+ canonicalKey(q.package, q.type, q.shortcode),
285
+ ) ?? null
286
+ );
287
+ }
288
+ // A bare address names no package, so it resolves against any foreign
289
+ // one that publishes it. Claimed by two, it is ambiguous and the author
290
+ // must write the qualified form.
291
+ const type = String(q.type).toLowerCase();
292
+ const shortcode = String(q.shortcode).toLowerCase();
293
+ const hits = [...foreign.index].filter(([k]) => {
294
+ const parts = readCanonicalKey(k);
295
+ return parts?.type === type && parts.shortcode === shortcode;
296
+ });
297
+ return hits.length === 1 ? hits[0][1] : null;
298
+ }
299
+
300
+ return {
301
+ notes,
302
+ frontmatterLinks,
303
+ anchors,
304
+ types,
305
+ packages,
306
+ foreign,
307
+ manifests: manifestsComplete(localPackages, foreign.packages),
308
+ linksOf,
309
+ resolve,
310
+ manifestHit,
311
+ /** Whether a target reads as a qualified address at all. */
312
+ isAddress: (target) => Boolean(readQualifier(target, types, packages)),
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Every link in a tree that lands nowhere.
318
+ *
319
+ * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
320
+ * @returns {{deadAnchors: object[], deadAddresses: object[],
321
+ * frontmatterLinks: object[], usedManifest: Set<string>}} The findings, and
322
+ * which addresses a foreign manifest answered.
323
+ */
324
+ export function auditLinks(index) {
325
+ const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
326
+
327
+ const deadAnchors = [];
328
+ for (const note of notes) {
329
+ for (const { target, anchor, text, occurrence } of linksOf(note)) {
330
+ if (!anchor) continue;
331
+ const dest = target ? resolve(note, target) : note;
332
+ // An unresolvable target is an external reference, not this
333
+ // check's business.
334
+ if (!dest) continue;
335
+ if (!anchors.get(dest).has(slugify(anchor))) {
336
+ deadAnchors.push({
337
+ note,
338
+ link: `${target}#${anchor}`,
339
+ dest,
340
+ text,
341
+ occurrence,
342
+ });
343
+ }
344
+ }
345
+ }
346
+
347
+ const deadAddresses = [];
348
+ const usedManifest = new Set();
349
+ for (const note of notes) {
350
+ for (const { target, text, occurrence } of linksOf(note)) {
351
+ if (!target) continue; // a same-page `[[#anchor]]`
352
+ // Only a *qualified* target is an address. A bare `[[Name]]` that
353
+ // finds nothing is a worldbuilding placeholder by long-standing
354
+ // convention, and is deliberately left alone.
355
+ if (!isAddress(target)) continue;
356
+ if (resolve(note, target)) continue;
357
+ // A manifest answers with the target package's own build output
358
+ // rather than a reviewed guess.
359
+ if (manifestHit(target)) {
360
+ usedManifest.add(target.toLowerCase());
361
+ continue;
362
+ }
363
+ deadAddresses.push({ note, target, text, occurrence });
364
+ }
365
+ }
366
+
367
+ return {
368
+ deadAnchors,
369
+ deadAddresses,
370
+ frontmatterLinks: index.frontmatterLinks,
371
+ usedManifest,
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Walk a corpus from its root and report what nothing links to.
377
+ *
378
+ * A documentation set is a **book, not a pile of notes**: it has a page one,
379
+ * and everything in it should follow from that page by reading. A note with no
380
+ * inbound link still compiles into a pack and still publishes — it is simply
381
+ * impossible to arrive at. Nothing else in either build notices, because every
382
+ * other check asks whether a link *lands*, never whether a document is
383
+ * *reached*.
384
+ *
385
+ * **Which documents belong to the corpus is the caller's to say.** A
386
+ * repository's corpora are its own — one publishes rules and a user guide,
387
+ * another a setting gazetteer — so `scope` decides membership and this decides
388
+ * only reachability. Links out of the corpus are followed as real links; they
389
+ * are simply not pages of it.
390
+ *
391
+ * **`stopAt` marks a page walked *to* but not *through*.** An index page links
392
+ * to nearly everything it covers, so traversing one makes the whole check
393
+ * vacuous: a chapter could stop linking one of its own pages and the walk would
394
+ * still reach it by way of the index. Reachability has to hold along the
395
+ * reading path, which is why the exception exists and why it is deliberately
396
+ * narrow.
397
+ *
398
+ * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
399
+ * @param {object} opts
400
+ * @param {string} opts.root - The corpus's entry page, as a tree-relative path.
401
+ * @param {(note: object) => boolean} opts.scope - Whether a note belongs to the
402
+ * corpus.
403
+ * @param {(note: object) => boolean} [opts.stopAt] - Whether a note is walked
404
+ * to but not through.
405
+ * @returns {{root: object, reached: Set<object>, orphans: object[]}} The root,
406
+ * everything reached from it, and the corpus members that were not.
407
+ * @throws {Error} When no note sits at `root` — a corpus with no page one
408
+ * cannot be walked, and silently reporting every page as an orphan would
409
+ * bury the actual mistake.
410
+ */
411
+ export function walkReachability(index, { root, scope, stopAt = () => false }) {
412
+ const rootNote = index.notes.find((n) => n.rel === root);
413
+ if (!rootNote) {
414
+ throw new Error(
415
+ `no note at ${root}, so the corpus has no page to be read from`,
416
+ );
417
+ }
418
+
419
+ const reached = new Set([rootNote]);
420
+ const queue = [rootNote];
421
+ while (queue.length) {
422
+ const note = queue.shift();
423
+ if (stopAt(note)) continue;
424
+ for (const { target } of index.linksOf(note)) {
425
+ if (!target) continue;
426
+ const dest = index.resolve(note, target);
427
+ if (!dest || !scope(dest) || reached.has(dest)) continue;
428
+ reached.add(dest);
429
+ queue.push(dest);
430
+ }
431
+ }
432
+
433
+ return {
434
+ root: rootNote,
435
+ reached,
436
+ orphans: index.notes.filter((n) => scope(n) && !reached.has(n)),
437
+ };
438
+ }