@heroiclands/package-build 9.0.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +694 -0
- package/CONTENT.md +273 -13
- package/bin/content-build.mjs +437 -7
- package/content-config.mjs +259 -28
- package/docs/content-format.md +1418 -0
- package/engine/address-charset.mjs +62 -0
- package/engine/alias-index.mjs +153 -0
- package/engine/base-compiler.mjs +194 -4
- package/engine/content-address.mjs +4 -4
- package/engine/content-format-check.mjs +570 -0
- package/engine/content-format.mjs +253 -0
- package/engine/content-links.mjs +132 -56
- package/engine/content-lint.mjs +8 -1
- package/engine/diagnostics.mjs +33 -0
- package/engine/document-subtypes.mjs +440 -0
- package/engine/field-spec.mjs +49 -43
- package/engine/frontmatter-lint.mjs +343 -27
- package/engine/generate.mjs +32 -4
- package/engine/helpers.mjs +41 -29
- package/engine/ids.mjs +19 -1
- package/engine/index.mjs +15 -0
- package/engine/item-registry.mjs +72 -5
- package/engine/kb-manifest.mjs +36 -7
- package/engine/map-notes.mjs +34 -18
- package/engine/note-claims.mjs +383 -0
- package/engine/note-vocabulary.mjs +678 -0
- package/engine/pack-config.mjs +39 -22
- package/engine/pack-router.mjs +17 -6
- package/engine/prose-lint.mjs +55 -3
- package/engine/retired-fields.mjs +117 -3
- package/engine/scenes.mjs +19 -1
- package/engine/schema-check.mjs +347 -3
- package/engine/site-build.mjs +1 -1
- package/engine/site-index.mjs +38 -21
- package/engine/system-block.mjs +513 -0
- package/engine/web-wikilinks.mjs +112 -80
- package/engine/wikilink-syntax.mjs +30 -0
- package/engine/wikilinks.mjs +67 -51
- package/package.json +6 -2
- package/sohl/actors.mjs +249 -36
- package/sohl/document-subtypes.mjs +82 -0
- package/sohl/index.mjs +3 -0
- package/sohl/items.mjs +110 -14
- package/sohl/note-schemas.mjs +11 -7
- package/types/content-config.d.mts +48 -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 +14 -1
- package/types/engine/helpers.d.mts +21 -13
- 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/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/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.
|
|
@@ -125,8 +134,6 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
const byKey = new Map();
|
|
128
|
-
const byAlias = new Map();
|
|
129
|
-
const aliasCollide = new Set();
|
|
130
137
|
|
|
131
138
|
// The one package every note in this tree belongs to. Taken from the
|
|
132
139
|
// configuration, never from a note: `package:` is retired, so there is no
|
|
@@ -145,25 +152,23 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
145
152
|
byKey.set(canonicalKey(pkg, `doc${type}`, fm.shortcode), note);
|
|
146
153
|
}
|
|
147
154
|
}
|
|
148
|
-
const aliases = [
|
|
149
|
-
...(Array.isArray(fm.aliases) ? fm.aliases : []),
|
|
150
|
-
...(Array.isArray(fm.name?.aliases) ? fm.name.aliases : []),
|
|
151
|
-
fm.name?.full,
|
|
152
|
-
path.basename(note.file, ".md").replace(/_/g, " "),
|
|
153
|
-
].filter((a) => typeof a === "string" && a);
|
|
154
|
-
for (const a of aliases) {
|
|
155
|
-
const k = `${type}|${a}`.toLowerCase();
|
|
156
|
-
if (aliasCollide.has(k)) continue;
|
|
157
|
-
const cur = byAlias.get(k);
|
|
158
|
-
if (cur && cur !== note) {
|
|
159
|
-
byAlias.delete(k);
|
|
160
|
-
aliasCollide.add(k);
|
|
161
|
-
} else if (!cur) {
|
|
162
|
-
byAlias.set(k, note);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
155
|
}
|
|
166
156
|
|
|
157
|
+
// The alias half of the two namespaces, built by the shared rule so the
|
|
158
|
+
// checker, the pack build and the site build cannot disagree about what a
|
|
159
|
+
// bare `[[…]]` can name (#131).
|
|
160
|
+
const {
|
|
161
|
+
byKey: byAlias,
|
|
162
|
+
claims: aliasClaims,
|
|
163
|
+
collisions: aliasCollisions,
|
|
164
|
+
} = indexAliases(
|
|
165
|
+
notes.map((note) => ({
|
|
166
|
+
type: note.type,
|
|
167
|
+
aliases: aliasesOf(note.fm),
|
|
168
|
+
value: note,
|
|
169
|
+
})),
|
|
170
|
+
);
|
|
171
|
+
|
|
167
172
|
const types = new Set(notes.map((n) => n.type));
|
|
168
173
|
|
|
169
174
|
// A foreign package may use a type this tree has never seen, so its types
|
|
@@ -195,7 +200,9 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
195
200
|
*
|
|
196
201
|
* @param {object} note - A note from this index.
|
|
197
202
|
* @returns {Array<{target: string, anchor: string, text: string,
|
|
198
|
-
* occurrence: number}>} `target` is `""` for a
|
|
203
|
+
* occurrence: number, labelled: boolean}>} `target` is `""` for a
|
|
204
|
+
* same-page `[[#anchor]]`; `labelled` says which namespace the target
|
|
205
|
+
* belongs to (#131).
|
|
199
206
|
*/
|
|
200
207
|
function linksOf(note) {
|
|
201
208
|
let body = note.body;
|
|
@@ -215,39 +222,52 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
215
222
|
// Code is verbatim, so a `[[…]]` inside a fence, an indented block or
|
|
216
223
|
// an inline span is not a link — the compilers make none of it either.
|
|
217
224
|
for (const [all, rawInner] of matchAllOutsideCode(body, new RegExp(WIKILINK.source, "g"))) {
|
|
218
|
-
const
|
|
225
|
+
const parsed = parseWikilink(rawInner);
|
|
226
|
+
const { target, anchor } = parsed;
|
|
219
227
|
const occurrence = (seen.get(all) ?? 0) + 1;
|
|
220
228
|
seen.set(all, occurrence);
|
|
221
229
|
// `text` is the link exactly as authored, which is what locates it
|
|
222
230
|
// in the file. A link a table generated is not in the file at all,
|
|
223
231
|
// so the search simply fails and a finding names the file.
|
|
224
|
-
out.push({
|
|
232
|
+
out.push({
|
|
233
|
+
target,
|
|
234
|
+
anchor,
|
|
235
|
+
text: all,
|
|
236
|
+
occurrence,
|
|
237
|
+
labelled: resolvesAsAddress(parsed),
|
|
238
|
+
});
|
|
225
239
|
}
|
|
226
240
|
return out;
|
|
227
241
|
}
|
|
228
242
|
|
|
229
243
|
/**
|
|
230
|
-
*
|
|
244
|
+
* The note an **alias** names, or `undefined`.
|
|
245
|
+
*
|
|
246
|
+
* Scoped to the *source* note's own type, so one word may be an alias in
|
|
247
|
+
* several types without colliding. An alias two same-type notes claim is
|
|
248
|
+
* absent from the index entirely — see {@link indexAliases} — so this can
|
|
249
|
+
* never resolve to whichever was walked first.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} note - The note the link is written in.
|
|
252
|
+
* @param {string} target - The link target, anchor already removed.
|
|
253
|
+
* @returns {object|undefined} The note it names.
|
|
254
|
+
*/
|
|
255
|
+
function resolveAlias(note, target) {
|
|
256
|
+
return byAlias.get(aliasKey(note.type, target));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The note an **address** names, or `undefined`.
|
|
231
261
|
*
|
|
232
262
|
* The qualifier is read with {@link readQualifier} rather than a second
|
|
233
263
|
* copy of the rule, so this cannot drift from what the builds do — the two
|
|
234
|
-
* separators, the first-hyphen split, and the
|
|
235
|
-
*
|
|
264
|
+
* separators, the first-hyphen split, and the optional leading package
|
|
265
|
+
* segment.
|
|
236
266
|
*
|
|
237
|
-
*
|
|
238
|
-
* reaches only a target of the source's **own** type, so a cross-type
|
|
239
|
-
* `[[type-shortcode#anchor]]` would resolve to nothing and its anchor go
|
|
240
|
-
* unchecked — silently, since an unresolvable target is treated as
|
|
241
|
-
* external.
|
|
242
|
-
*
|
|
243
|
-
* @param {object} note - The note the link is written in.
|
|
244
|
-
* @param {string} target - The link target.
|
|
267
|
+
* @param {string} target - The link target, anchor already removed.
|
|
245
268
|
* @returns {object|undefined} The note it addresses.
|
|
246
269
|
*/
|
|
247
|
-
function
|
|
248
|
-
const direct =
|
|
249
|
-
byAlias.get(`${note.type}|${target}`.toLowerCase()) ?? byKey.get(target.toLowerCase());
|
|
250
|
-
if (direct) return direct;
|
|
270
|
+
function resolveAddress(target) {
|
|
251
271
|
const qualified = readQualifier(target, types, packages);
|
|
252
272
|
if (!qualified || qualified.reason) return undefined;
|
|
253
273
|
return byKey.get(
|
|
@@ -257,6 +277,24 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
257
277
|
);
|
|
258
278
|
}
|
|
259
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Resolve a link target the way both builds do, or `undefined`.
|
|
282
|
+
*
|
|
283
|
+
* **The pipe chooses the namespace, and there is no fallback either way**
|
|
284
|
+
* (#131) — see {@link resolvesAsAddress} for why. The caller therefore has
|
|
285
|
+
* to say which form was authored; it is a required argument rather than a
|
|
286
|
+
* defaulted one, because either default would silently resolve half the
|
|
287
|
+
* corpus through the wrong namespace.
|
|
288
|
+
*
|
|
289
|
+
* @param {object} note - The note the link is written in.
|
|
290
|
+
* @param {string} target - The link target.
|
|
291
|
+
* @param {boolean} labelled - Whether the link carried a `|`.
|
|
292
|
+
* @returns {object|undefined} The note it names.
|
|
293
|
+
*/
|
|
294
|
+
function resolve(note, target, labelled) {
|
|
295
|
+
return labelled ? resolveAddress(target) : resolveAlias(note, target);
|
|
296
|
+
}
|
|
297
|
+
|
|
260
298
|
/**
|
|
261
299
|
* The manifest entry a qualified address names in another package, or null.
|
|
262
300
|
*
|
|
@@ -295,8 +333,14 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
295
333
|
contentPackage: pkg,
|
|
296
334
|
foreign,
|
|
297
335
|
manifests: manifestsComplete(localPackages, foreign.packages),
|
|
336
|
+
/** Every note claiming each type-scoped alias, colliding ones included. */
|
|
337
|
+
aliasClaims,
|
|
338
|
+
/** One entry per alias two or more same-type notes claim (#131). */
|
|
339
|
+
aliasCollisions,
|
|
298
340
|
linksOf,
|
|
299
341
|
resolve,
|
|
342
|
+
resolveAlias,
|
|
343
|
+
resolveAddress,
|
|
300
344
|
manifestHit,
|
|
301
345
|
/** Whether a target reads as a qualified address at all. */
|
|
302
346
|
isAddress: (target) => Boolean(readQualifier(target, types, packages)),
|
|
@@ -611,22 +655,35 @@ export function auditHomepageLinks(index) {
|
|
|
611
655
|
/**
|
|
612
656
|
* Every link in a tree that lands nowhere.
|
|
613
657
|
*
|
|
658
|
+
* **The two failure modes are separate findings, because they are separate
|
|
659
|
+
* problems** (#131). A piped target the author declared to be an address, and
|
|
660
|
+
* which resolves nowhere, is a typo: every package it could name is either
|
|
661
|
+
* built here or vendored, so there is no third possibility. An unpiped target
|
|
662
|
+
* naming no note of the source's type may be exactly that typo — or a
|
|
663
|
+
* worldbuilding placeholder, which is a long-standing convention in the
|
|
664
|
+
* setting trees. So the first is an error and the second a warning, and the
|
|
665
|
+
* caller can tell them apart without parsing a message.
|
|
666
|
+
*
|
|
614
667
|
* @param {ReturnType<typeof buildLinkIndex>} index - The built index.
|
|
615
668
|
* @returns {{deadAnchors: object[], deadAddresses: object[],
|
|
669
|
+
* deadAliases: object[], aliasCollisions: object[],
|
|
616
670
|
* frontmatterLinks: object[], homepageLinks: object[],
|
|
617
671
|
* usedManifest: Set<string>}} The findings, and which addresses a foreign
|
|
618
|
-
* manifest answered.
|
|
672
|
+
* manifest answered. Each `deadAddresses` entry carries a `reason`:
|
|
673
|
+
* `"not-an-address"` when the target does not parse as one at all,
|
|
674
|
+
* `"unknown-type"` when it is qualified but names no known type, and
|
|
675
|
+
* `"unresolved"` when it parses and nothing answers it.
|
|
619
676
|
*/
|
|
620
677
|
export function auditLinks(index) {
|
|
621
678
|
const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
|
|
622
679
|
|
|
623
680
|
const deadAnchors = [];
|
|
624
681
|
for (const note of notes) {
|
|
625
|
-
for (const { target, anchor, text, occurrence } of linksOf(note)) {
|
|
682
|
+
for (const { target, anchor, text, occurrence, labelled } of linksOf(note)) {
|
|
626
683
|
if (!anchor) continue;
|
|
627
|
-
const dest = target ? resolve(note, target) : note;
|
|
628
|
-
// An unresolvable target is
|
|
629
|
-
//
|
|
684
|
+
const dest = target ? resolve(note, target, labelled) : note;
|
|
685
|
+
// An unresolvable target is reported by the address or alias pass
|
|
686
|
+
// below; its anchor has nothing to be checked against.
|
|
630
687
|
if (!dest) continue;
|
|
631
688
|
if (!anchors.get(dest).has(slugify(anchor))) {
|
|
632
689
|
deadAnchors.push({
|
|
@@ -641,28 +698,47 @@ export function auditLinks(index) {
|
|
|
641
698
|
}
|
|
642
699
|
|
|
643
700
|
const deadAddresses = [];
|
|
701
|
+
const deadAliases = [];
|
|
644
702
|
const usedManifest = new Set();
|
|
645
703
|
for (const note of notes) {
|
|
646
|
-
for (const { target, text, occurrence } of linksOf(note)) {
|
|
704
|
+
for (const { target, text, occurrence, labelled } of linksOf(note)) {
|
|
647
705
|
if (!target) continue; // a same-page `[[#anchor]]`
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
706
|
+
const at = { note, target, text, occurrence };
|
|
707
|
+
|
|
708
|
+
if (!labelled) {
|
|
709
|
+
if (index.resolveAlias(note, target)) continue;
|
|
710
|
+
// Kept alongside, so a report can say *why* nothing answered:
|
|
711
|
+
// an alias claimed twice is absent from the index, and blaming
|
|
712
|
+
// this note for it would blame the wrong file.
|
|
713
|
+
const claimants = index.aliasClaims.get(aliasKey(note.type, target)) ?? [];
|
|
714
|
+
deadAliases.push({ ...at, ambiguous: claimants.length > 1, claimants });
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (!isAddress(target)) {
|
|
719
|
+
deadAddresses.push({ ...at, reason: "not-an-address" });
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (index.resolveAddress(target)) continue;
|
|
653
723
|
// A manifest answers with the target package's own build output
|
|
654
724
|
// rather than a reviewed guess.
|
|
655
725
|
if (manifestHit(target)) {
|
|
656
726
|
usedManifest.add(target.toLowerCase());
|
|
657
727
|
continue;
|
|
658
728
|
}
|
|
659
|
-
|
|
729
|
+
const read = readQualifier(target, index.types, index.packages);
|
|
730
|
+
deadAddresses.push({
|
|
731
|
+
...at,
|
|
732
|
+
reason: read?.reason === "unknown-type" ? "unknown-type" : "unresolved",
|
|
733
|
+
});
|
|
660
734
|
}
|
|
661
735
|
}
|
|
662
736
|
|
|
663
737
|
return {
|
|
664
738
|
deadAnchors,
|
|
665
739
|
deadAddresses,
|
|
740
|
+
deadAliases,
|
|
741
|
+
aliasCollisions: index.aliasCollisions,
|
|
666
742
|
frontmatterLinks: index.frontmatterLinks,
|
|
667
743
|
homepageLinks: auditHomepageLinks(index),
|
|
668
744
|
usedManifest,
|
|
@@ -716,9 +792,9 @@ export function walkReachability(index, { root, scope, stopAt = () => false }) {
|
|
|
716
792
|
while (queue.length) {
|
|
717
793
|
const note = queue.shift();
|
|
718
794
|
if (stopAt(note)) continue;
|
|
719
|
-
for (const { target } of index.linksOf(note)) {
|
|
795
|
+
for (const { target, labelled } of index.linksOf(note)) {
|
|
720
796
|
if (!target) continue;
|
|
721
|
-
const dest = index.resolve(note, target);
|
|
797
|
+
const dest = index.resolve(note, target, labelled);
|
|
722
798
|
if (!dest || !scope(dest) || reached.has(dest)) continue;
|
|
723
799
|
reached.add(dest);
|
|
724
800
|
queue.push(dest);
|
package/engine/content-lint.mjs
CHANGED
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
import fs from "node:fs";
|
|
60
60
|
import path from "node:path";
|
|
61
61
|
|
|
62
|
+
import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
|
|
62
63
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
63
64
|
import { walkMarkdownTree } from "./helpers.mjs";
|
|
64
65
|
import { checkHomepageCount, isHomepage } from "./homepage.mjs";
|
|
@@ -66,6 +67,12 @@ import { checkHomepageCount, isHomepage } from "./homepage.mjs";
|
|
|
66
67
|
/**
|
|
67
68
|
* The shape every `shortcode` must match: ASCII letters and digits only.
|
|
68
69
|
*
|
|
70
|
+
* This is {@link ADDRESS_SEGMENT_PATTERN}, not a second copy of it. A shortcode
|
|
71
|
+
* is the last segment of a canonical address, and the rule it is held to is the
|
|
72
|
+
* rule *every* segment is held to — so the two are one constant rather than two
|
|
73
|
+
* free to drift apart (#59). The name survives because this is where the rule
|
|
74
|
+
* is applied to a note.
|
|
75
|
+
*
|
|
69
76
|
* Case is deliberately **not** constrained: hundreds of authored shortcodes are
|
|
70
77
|
* mixed-case and collide with nothing, so tightening that is a separate
|
|
71
78
|
* decision from this one.
|
|
@@ -74,7 +81,7 @@ import { checkHomepageCount, isHomepage } from "./homepage.mjs";
|
|
|
74
81
|
* import a build-time dependency into shipped code — and is expected to pin the
|
|
75
82
|
* two together with a test rather than trust that they still agree.
|
|
76
83
|
*/
|
|
77
|
-
export const SHORTCODE_PATTERN =
|
|
84
|
+
export const SHORTCODE_PATTERN = ADDRESS_SEGMENT_PATTERN;
|
|
78
85
|
|
|
79
86
|
/**
|
|
80
87
|
* Whether a value is a well-formed shortcode.
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -363,3 +363,36 @@ export function yamlKeyPath(field) {
|
|
|
363
363
|
}
|
|
364
364
|
return segments;
|
|
365
365
|
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Where a key sits **inside a note's frontmatter fence**, addressed by path.
|
|
369
|
+
*
|
|
370
|
+
* {@link positionInFrontmatter} searches the fence for a key by name, which is
|
|
371
|
+
* the right tool while a note's vocabulary is flat: a key appears once and the
|
|
372
|
+
* first line matching it is the one. It stops being the right tool the moment
|
|
373
|
+
* the same name is legal in two regions — `data.weight` and a top-level
|
|
374
|
+
* `weight` are different keys, and a search finds whichever comes first.
|
|
375
|
+
*
|
|
376
|
+
* So this is the located form for the closed regions (#128). It reuses
|
|
377
|
+
* {@link positionOfYamlPath} rather than repeating its parse, which is what
|
|
378
|
+
* keeps a finding about `data.weight` pointing at `data.weight`.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} raw - The note's full contents, frontmatter included.
|
|
381
|
+
* @param {ReadonlyArray<string|number>} keyPath - Path to the node, from the
|
|
382
|
+
* top of the frontmatter.
|
|
383
|
+
* @param {object} [opts]
|
|
384
|
+
* @param {boolean} [opts.key=false] - Report where the last segment is
|
|
385
|
+
* *declared* rather than where its value sits.
|
|
386
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields, empty
|
|
387
|
+
* when there is no fence or the path resolves to nothing — dropped rather
|
|
388
|
+
* than guessed.
|
|
389
|
+
*/
|
|
390
|
+
export function positionOfFrontmatterPath(raw, keyPath, { key = false } = {}) {
|
|
391
|
+
if (typeof raw !== "string") return {};
|
|
392
|
+
const fence = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
393
|
+
if (!fence) return {};
|
|
394
|
+
const position = positionOfYamlPath(fence[1], keyPath, { key });
|
|
395
|
+
// +1: the fence's line 1 is the file's line 2, the opening `---` being the
|
|
396
|
+
// first. `positionOfYamlPath` already counts from 1 within the block.
|
|
397
|
+
return position.line === undefined ? {} : { ...position, line: position.line + 1 };
|
|
398
|
+
}
|