@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.
- package/CHANGELOG.md +748 -0
- package/CONTENT.md +273 -13
- package/bin/content-build.mjs +479 -123
- package/bin/package-build.mjs +27 -69
- package/bin/report.mjs +1 -2
- package/bundle.mjs +2 -10
- package/config.mjs +31 -106
- package/container.mjs +13 -57
- package/content-config.mjs +300 -188
- package/coverage.mjs +14 -55
- package/deploy.mjs +4 -13
- package/docs/content-format.md +1418 -0
- package/e2e.mjs +16 -55
- package/engine/address-charset.mjs +62 -0
- package/engine/address-diff.mjs +1 -4
- package/engine/alias-index.mjs +153 -0
- package/engine/base-compiler.mjs +203 -31
- package/engine/code-fences.mjs +4 -13
- package/engine/compendiums.mjs +13 -37
- package/engine/content-address.mjs +6 -10
- package/engine/content-format-check.mjs +570 -0
- package/engine/content-format.mjs +253 -0
- package/engine/content-links.mjs +144 -99
- package/engine/content-lint.mjs +12 -16
- package/engine/content-slug.mjs +2 -6
- package/engine/content-tables.mjs +26 -79
- package/engine/diagnostics.mjs +37 -15
- package/engine/document-subtypes.mjs +440 -0
- package/engine/field-reference.mjs +6 -20
- package/engine/field-spec.mjs +49 -45
- package/engine/foreign-catalog.mjs +7 -22
- package/engine/foreign-manifests.mjs +1 -4
- package/engine/frontmatter-lint.mjs +347 -43
- package/engine/frontmatter.mjs +3 -8
- package/engine/generate.mjs +36 -20
- package/engine/helpers.mjs +54 -81
- package/engine/homepage.mjs +4 -15
- package/engine/ids.mjs +21 -12
- package/engine/index.mjs +15 -0
- package/engine/item-registry.mjs +72 -9
- package/engine/journals.mjs +4 -14
- package/engine/kb-manifest.mjs +41 -24
- package/engine/macros.mjs +2 -10
- package/engine/manifest-emit.mjs +6 -17
- package/engine/map-notes.mjs +53 -87
- package/engine/note-claims.mjs +383 -0
- package/engine/note-package.mjs +1 -4
- package/engine/note-vocabulary.mjs +678 -0
- package/engine/pack-config.mjs +56 -60
- package/engine/pack-router.mjs +18 -8
- package/engine/prose-config.mjs +20 -4
- package/engine/prose-lint.mjs +61 -17
- package/engine/region-events.mjs +1 -3
- package/engine/retired-fields.mjs +117 -3
- package/engine/scene-levels.mjs +8 -22
- package/engine/scenes.mjs +31 -47
- package/engine/schema-check.mjs +348 -7
- package/engine/schema-extract.mjs +11 -39
- package/engine/site-build.mjs +13 -38
- package/engine/site-index.mjs +40 -35
- package/engine/system-block.mjs +513 -0
- package/engine/web-wikilinks.mjs +115 -92
- package/engine/wikilink-syntax.mjs +30 -0
- package/engine/wikilinks.mjs +147 -183
- package/index.mjs +1 -5
- package/lang.mjs +1 -3
- package/manifest.mjs +10 -37
- package/markdownlint-config.mjs +1 -5
- package/package.json +6 -2
- package/sohl/actors.mjs +251 -68
- package/sohl/being-info.mjs +3 -6
- package/sohl/document-subtypes.mjs +82 -0
- package/sohl/index.mjs +4 -6
- package/sohl/item-builders.mjs +1 -3
- package/sohl/item-fields.mjs +16 -34
- package/sohl/items.mjs +111 -17
- package/sohl/kb-passes.mjs +29 -39
- package/sohl/note-schemas.mjs +11 -7
- package/sohl/skill-base.mjs +7 -23
- package/stage.mjs +3 -13
- package/templates.mjs +4 -15
- package/types/bundle.d.mts +1 -1
- package/types/container.d.mts +2 -2
- package/types/content-config.d.mts +48 -4
- package/types/coverage.d.mts +1 -1
- package/types/e2e.d.mts +4 -4
- package/types/engine/address-charset.d.mts +45 -0
- package/types/engine/alias-index.d.mts +122 -0
- package/types/engine/base-compiler.d.mts +132 -4
- package/types/engine/content-address.d.mts +2 -2
- package/types/engine/content-format-check.d.mts +163 -0
- package/types/engine/content-format.d.mts +101 -0
- package/types/engine/content-links.d.mts +16 -1
- package/types/engine/content-lint.d.mts +6 -0
- package/types/engine/diagnostics.d.mts +29 -0
- package/types/engine/document-subtypes.d.mts +233 -0
- package/types/engine/field-spec.d.mts +76 -23
- package/types/engine/frontmatter-lint.d.mts +47 -2
- package/types/engine/generate.d.mts +15 -2
- package/types/engine/helpers.d.mts +22 -14
- package/types/engine/ids.d.mts +10 -0
- package/types/engine/index.d.mts +5 -0
- package/types/engine/item-registry.d.mts +21 -2
- package/types/engine/kb-manifest.d.mts +35 -8
- package/types/engine/map-notes.d.mts +21 -11
- package/types/engine/note-claims.d.mts +113 -0
- package/types/engine/note-vocabulary.d.mts +251 -0
- package/types/engine/pack-config.d.mts +4 -3
- package/types/engine/pack-router.d.mts +4 -4
- package/types/engine/prose-lint.d.mts +6 -2
- package/types/engine/retired-fields.d.mts +73 -2
- package/types/engine/schema-check.d.mts +182 -0
- package/types/engine/schema-extract.d.mts +1 -1
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/system-block.d.mts +281 -0
- package/types/engine/web-wikilinks.d.mts +23 -12
- package/types/engine/wikilink-syntax.d.mts +29 -0
- package/types/manifest.d.mts +1 -1
- package/types/sohl/actors.d.mts +62 -6
- package/types/sohl/document-subtypes.d.mts +14 -0
- package/types/sohl/index.d.mts +1 -0
- 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
|
+
}
|
package/engine/content-links.mjs
CHANGED
|
@@ -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 *
|
|
26
|
-
* note is a typo.
|
|
27
|
-
*
|
|
28
|
-
* 3. **A
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
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({
|
|
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
|
-
*
|
|
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
|
|
247
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
658
|
-
//
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
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);
|