@heroiclands/package-build 10.0.1 → 11.1.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 +375 -0
- package/CONTENT.md +234 -71
- package/MIGRATING.md +64 -0
- package/bin/content-build.mjs +79 -75
- package/content-config.mjs +28 -0
- package/docs/content-format.md +90 -67
- package/engine/base-compiler.mjs +7 -1
- package/engine/content-address.mjs +71 -18
- package/engine/content-format-check.mjs +1 -1
- package/engine/content-links.mjs +93 -112
- package/engine/content-lint.mjs +14 -10
- package/engine/content-slug.mjs +39 -105
- package/engine/diagnostics.mjs +16 -2
- package/engine/frontmatter-lint.mjs +161 -18
- package/engine/helpers.mjs +31 -68
- package/engine/homepage.mjs +131 -86
- package/engine/index.mjs +2 -5
- package/engine/manifest-emit.mjs +23 -4
- package/engine/note-vocabulary.mjs +58 -1
- package/engine/retired-fields.mjs +117 -6
- package/engine/site-build.mjs +182 -59
- package/engine/site-index.mjs +57 -102
- package/engine/web-wikilinks.mjs +183 -127
- package/engine/wikilink-syntax.mjs +174 -34
- package/engine/wikilinks.mjs +159 -117
- package/package.json +1 -1
- package/types/content-config.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +1 -1
- package/types/engine/content-address.d.mts +46 -14
- package/types/engine/content-links.d.mts +13 -17
- package/types/engine/content-slug.d.mts +11 -48
- package/types/engine/diagnostics.d.mts +14 -1
- package/types/engine/frontmatter-lint.d.mts +27 -2
- package/types/engine/helpers.d.mts +4 -3
- package/types/engine/homepage.d.mts +96 -60
- package/types/engine/index.d.mts +0 -1
- package/types/engine/note-vocabulary.d.mts +43 -0
- package/types/engine/retired-fields.d.mts +78 -1
- package/types/engine/site-build.d.mts +70 -17
- package/types/engine/site-index.d.mts +19 -21
- package/types/engine/web-wikilinks.d.mts +29 -28
- package/types/engine/wikilink-syntax.d.mts +126 -40
- package/types/engine/wikilinks.d.mts +29 -24
- package/engine/abbreviations.mjs +0 -0
- package/engine/alias-index.mjs +0 -153
- package/types/engine/abbreviations.d.mts +0 -44
- package/types/engine/alias-index.d.mts +0 -122
|
@@ -18,9 +18,17 @@
|
|
|
18
18
|
* knowledgebase build renders the page, and the link manifest records the
|
|
19
19
|
* address other packages link to. Stating it twice is how a manifest comes to
|
|
20
20
|
* assert a URL that resolves at build time and 404s for the reader.
|
|
21
|
+
*
|
|
22
|
+
* **A page's URL is its address** — `<package>/<type>-<shortcode>/` (#181). It
|
|
23
|
+
* used to be derived from `name.full`, which made a display string load-bearing
|
|
24
|
+
* in three separate ways: a rename moved the URL and nothing redirected, two
|
|
25
|
+
* notes in one section could derive the same URL so a uniqueness check had to
|
|
26
|
+
* run, and a long name had to be abbreviated through a word table to keep the
|
|
27
|
+
* result short. `(type, shortcode)` is unique within a package by rule
|
|
28
|
+
* (`engine/content-lint.mjs`), so the address is **unique by construction** —
|
|
29
|
+
* there is no check to run, and no rename to survive.
|
|
21
30
|
*/
|
|
22
31
|
|
|
23
|
-
import { contentSlug } from "./content-slug.mjs";
|
|
24
32
|
// The scheme vocabulary is part of the configuration contract — a
|
|
25
33
|
// repository names its scheme in `package-build.config.yaml` — so it is
|
|
26
34
|
// declared beside the rest of that vocabulary rather than here, and this
|
|
@@ -49,22 +57,54 @@ export function sectionOf(fm) {
|
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/**
|
|
52
|
-
*
|
|
60
|
+
* The single path segment a note is addressed by: `type-shortcode`.
|
|
61
|
+
*
|
|
62
|
+
* Lowercased, so it is exactly the tail of the note's canonical key
|
|
63
|
+
* (`canonicalKey` in `engine/kb-manifest.mjs` lowercases too) — which is what
|
|
64
|
+
* makes a manifest entry's `path` derivable from the key it is filed under
|
|
65
|
+
* rather than transported beside it.
|
|
66
|
+
*
|
|
67
|
+
* The hyphen is a separator and never occurs inside a segment: a shortcode is
|
|
68
|
+
* `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced by `content-lint.mjs`)
|
|
69
|
+
* and a type is a bare word. That is the same charset guarantee positional key
|
|
70
|
+
* parsing rests on, so the address and the key cannot disagree about where one
|
|
71
|
+
* ends and the other begins.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} fm - Parsed frontmatter.
|
|
74
|
+
* @returns {string} The address segment, e.g. `weapongear-dagger`.
|
|
75
|
+
* @throws {Error} When the note declares no type or no shortcode — either way
|
|
76
|
+
* it has no address, which is reported rather than papered over: inventing
|
|
77
|
+
* one would publish a page nothing can link to and record a manifest entry
|
|
78
|
+
* pointing at it.
|
|
79
|
+
*/
|
|
80
|
+
export function addressSlug(fm) {
|
|
81
|
+
const type = typeof fm?.type === "string" ? fm.type.trim() : "";
|
|
82
|
+
if (!type) {
|
|
83
|
+
throw new Error("note declares no type, so it has no address");
|
|
84
|
+
}
|
|
85
|
+
const shortcode = typeof fm?.shortcode === "string" ? fm.shortcode.trim() : "";
|
|
86
|
+
if (!shortcode) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`note declares no shortcode, so it has no address — a page is ` +
|
|
89
|
+
`addressed as "${type}-<shortcode>"`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return `${type}-${shortcode}`.toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A note's address below the knowledgebase mount, e.g. `affliction-aconite/`.
|
|
53
97
|
*
|
|
54
98
|
* 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
|
|
99
|
+
* so it addresses the section itself and has no address of its own.
|
|
56
100
|
*
|
|
57
101
|
* @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
102
|
* @param {boolean} isReadme - Whether the file is a `README.md`.
|
|
62
|
-
* @returns {string} The
|
|
63
|
-
* @throws {Error} When the
|
|
103
|
+
* @returns {string} The mount-relative address, with a trailing slash.
|
|
104
|
+
* @throws {Error} When the note has no address.
|
|
64
105
|
*/
|
|
65
|
-
export function contentAddress(fm,
|
|
66
|
-
|
|
67
|
-
return isReadme ? `${sec}/` : `${sec}/${contentSlug(name)}/`;
|
|
106
|
+
export function contentAddress(fm, isReadme) {
|
|
107
|
+
return isReadme ? `${sectionOf(fm)}/` : `${addressSlug(fm)}/`;
|
|
68
108
|
}
|
|
69
109
|
|
|
70
110
|
/**
|
|
@@ -92,17 +132,30 @@ function landingOf(fm, isReadme, landing) {
|
|
|
92
132
|
}
|
|
93
133
|
|
|
94
134
|
/**
|
|
95
|
-
* A note's address relative to its **package**, e.g. `
|
|
135
|
+
* A note's address relative to its **package**, e.g. `affliction-aconite/`.
|
|
96
136
|
*
|
|
97
137
|
* This is the form the link manifest records and the site build emits pages at,
|
|
98
138
|
* and it is one function because those two must agree — a manifest asserting an
|
|
99
139
|
* address the site does not publish resolves at build time and 404s for the
|
|
100
140
|
* reader, which is the failure this module exists to prevent.
|
|
101
141
|
*
|
|
142
|
+
* **The prefix does not apply to a page's own address.** `prefix` says where the
|
|
143
|
+
* content tree *mounts inside the package* — where its section directories and
|
|
144
|
+
* their landing pages live — and a landing page is addressed by that mount
|
|
145
|
+
* (`kb/rules/`). An ordinary page is addressed by `(type, shortcode)`, which is
|
|
146
|
+
* a package-wide identity and takes no mount: `sohl` publishes
|
|
147
|
+
* `/sohl/affliction-aconite/` while its section landings stay at
|
|
148
|
+
* `/sohl/kb/affliction/`. The `type-` half is what keeps that flat namespace
|
|
149
|
+
* clear of the package's fixed mounts — `/<package>/` for the landing,
|
|
150
|
+
* `/<package>/api/` for generated API docs, neither of which contains a hyphen
|
|
151
|
+
* or names a type.
|
|
152
|
+
*
|
|
153
|
+
* **The section still decides where the *file* is written**, which is why a
|
|
154
|
+
* note without one still has no address: Hugo derives a section from a page's
|
|
155
|
+
* directory rather than from its URL, so a page with nowhere to be filed is a
|
|
156
|
+
* page with no section landing, no `.CurrentSection` and no per-section layout.
|
|
157
|
+
*
|
|
102
158
|
* @param {object} fm - Parsed frontmatter.
|
|
103
|
-
* @param {string} name - The note's display name; a page slug derives from it
|
|
104
|
-
* (#1278), never from the shortcode, which is identity rather than
|
|
105
|
-
* presentation.
|
|
106
159
|
* @param {object} [options] - Options.
|
|
107
160
|
* @param {boolean} [options.isReadme] - Whether the file is a `README.md`.
|
|
108
161
|
* @param {{prefix?: string, landing?: string}} [options.scheme] - The
|
|
@@ -110,11 +163,11 @@ function landingOf(fm, isReadme, landing) {
|
|
|
110
163
|
* @returns {string} The package-relative address, with a trailing slash and no
|
|
111
164
|
* leading one.
|
|
112
165
|
* @throws {Error} When the note has no address — no section, a landing page
|
|
113
|
-
* naming no section, or
|
|
166
|
+
* naming no section, or no shortcode to be addressed by. Each is a note that
|
|
114
167
|
* is not published, and inventing an address for one would put a dead entry
|
|
115
168
|
* in the manifest.
|
|
116
169
|
*/
|
|
117
|
-
export function packageAddress(fm,
|
|
170
|
+
export function packageAddress(fm, { isReadme = false, scheme } = {}) {
|
|
118
171
|
const { prefix, landing } = { ...DEFAULT_ADDRESS_SCHEME, ...scheme };
|
|
119
172
|
if (!LANDING_RULES.includes(landing)) {
|
|
120
173
|
throw new Error(
|
|
@@ -133,5 +186,5 @@ export function packageAddress(fm, name, { isReadme = false, scheme } = {}) {
|
|
|
133
186
|
if (typeof sec !== "string" || !sec) {
|
|
134
187
|
throw new Error(`type "${fm.type}" has no section`);
|
|
135
188
|
}
|
|
136
|
-
return `${
|
|
189
|
+
return `${addressSlug(fm)}/`;
|
|
137
190
|
}
|
|
@@ -269,7 +269,7 @@ function nearest(key, candidates) {
|
|
|
269
269
|
* @type {ReadonlySet<string>}
|
|
270
270
|
*/
|
|
271
271
|
export const NOTE_LEVEL_KEYS = Object.freeze(
|
|
272
|
-
new Set(["id", "type", "subType", "shortcode", "description", "tags", "name"
|
|
272
|
+
new Set(["id", "type", "subType", "shortcode", "description", "tags", "name"]),
|
|
273
273
|
);
|
|
274
274
|
|
|
275
275
|
/** Whether a value is a plain object a block could be written as. */
|
package/engine/content-links.mjs
CHANGED
|
@@ -22,20 +22,17 @@
|
|
|
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.**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* different problem from a dead address and reads differently.
|
|
25
|
+
* 2. **A dead address.** Every link is an address, and one resolving to no note
|
|
26
|
+
* is a typo. So is a target that does not parse as an address at all.
|
|
27
|
+
* 3. **An unlabelled link.** `[[x]]` addresses nothing: the alias namespace it
|
|
28
|
+
* used to name is retired (#180), and a shortcode is an address rather than
|
|
29
|
+
* prose, so the link has neither a resolvable target nor text to show. The
|
|
30
|
+
* correction is always `[[type-shortcode|Text]]`.
|
|
32
31
|
* 4. **A wikilink authored in frontmatter.** Both builds walk a note's *body*
|
|
33
32
|
* and copy frontmatter through verbatim, so a link written in a
|
|
34
33
|
* `description` is never resolved and publishes as literal `[[…]]` text.
|
|
35
34
|
* Frontmatter is data: a `WikiLink` field is parsed by the address grammar
|
|
36
35
|
* 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).
|
|
39
36
|
*
|
|
40
37
|
* **This resolves links the way the builds do**, calling the same
|
|
41
38
|
* {@link readQualifier} and the same {@link parseWikilink} rather than a second
|
|
@@ -72,9 +69,8 @@ import {
|
|
|
72
69
|
import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
|
|
73
70
|
import { homepageAddresses, isHomepage } from "./homepage.mjs";
|
|
74
71
|
import { RETIRED_TYPES } from "./ids.mjs";
|
|
75
|
-
import { parseWikilink,
|
|
72
|
+
import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
|
|
76
73
|
import { readQualifier } from "./wikilinks.mjs";
|
|
77
|
-
import { aliasesOf, aliasKey, indexAliases } from "./alias-index.mjs";
|
|
78
74
|
|
|
79
75
|
/**
|
|
80
76
|
* Every `{#anchor}` a note declares on a heading.
|
|
@@ -154,21 +150,6 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
154
150
|
}
|
|
155
151
|
}
|
|
156
152
|
|
|
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
|
-
|
|
172
153
|
const types = new Set(notes.map((n) => n.type));
|
|
173
154
|
|
|
174
155
|
// A foreign package may use a type this tree has never seen, so its types
|
|
@@ -201,8 +182,8 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
201
182
|
* @param {object} note - A note from this index.
|
|
202
183
|
* @returns {Array<{target: string, anchor: string, text: string,
|
|
203
184
|
* occurrence: number, labelled: boolean}>} `target` is `""` for a
|
|
204
|
-
* same-page `[[#anchor]]`; `labelled` says
|
|
205
|
-
*
|
|
185
|
+
* same-page `[[#anchor]]`; `labelled` says whether the link carries the
|
|
186
|
+
* `|` every link must have (#180).
|
|
206
187
|
*/
|
|
207
188
|
function linksOf(note) {
|
|
208
189
|
let body = note.body;
|
|
@@ -234,28 +215,12 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
234
215
|
anchor,
|
|
235
216
|
text: all,
|
|
236
217
|
occurrence,
|
|
237
|
-
labelled:
|
|
218
|
+
labelled: parsed.labelled,
|
|
238
219
|
});
|
|
239
220
|
}
|
|
240
221
|
return out;
|
|
241
222
|
}
|
|
242
223
|
|
|
243
|
-
/**
|
|
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
224
|
/**
|
|
260
225
|
* The note an **address** names, or `undefined`.
|
|
261
226
|
*
|
|
@@ -278,45 +243,47 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
278
243
|
}
|
|
279
244
|
|
|
280
245
|
/**
|
|
281
|
-
*
|
|
246
|
+
* Every foreign manifest entry an address names, in package order.
|
|
282
247
|
*
|
|
283
|
-
* **
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
248
|
+
* A **package-qualified** address names at most one, by construction. An
|
|
249
|
+
* unqualified one names no package, so it resolves against any foreign one
|
|
250
|
+
* that publishes it — and only when exactly one does. Two claimants make it
|
|
251
|
+
* ambiguous, which is a different finding from resolving nowhere and has a
|
|
252
|
+
* different fix, so the count is returned rather than collapsed here
|
|
253
|
+
* (#184).
|
|
288
254
|
*
|
|
289
|
-
* @param {object} note - The note the link is written in.
|
|
290
255
|
* @param {string} target - The link target.
|
|
291
|
-
* @
|
|
292
|
-
* @returns {object|undefined} The note it names.
|
|
256
|
+
* @returns {object[]} The foreign entries, each carrying its `package`.
|
|
293
257
|
*/
|
|
294
|
-
function
|
|
295
|
-
|
|
258
|
+
function foreignHits(target) {
|
|
259
|
+
const q = readQualifier(target, types, packages);
|
|
260
|
+
if (!q || q.reason) return [];
|
|
261
|
+
if (q.package) {
|
|
262
|
+
const one = foreign.index.get(canonicalKey(q.package, q.type, q.shortcode));
|
|
263
|
+
return one ? [one] : [];
|
|
264
|
+
}
|
|
265
|
+
const type = String(q.type).toLowerCase();
|
|
266
|
+
const shortcode = String(q.shortcode).toLowerCase();
|
|
267
|
+
return [...foreign.index]
|
|
268
|
+
.filter(([k]) => {
|
|
269
|
+
const parts = readCanonicalKey(k);
|
|
270
|
+
return parts?.type === type && parts.shortcode === shortcode;
|
|
271
|
+
})
|
|
272
|
+
.map(([, v]) => v);
|
|
296
273
|
}
|
|
297
274
|
|
|
298
275
|
/**
|
|
299
276
|
* The manifest entry a qualified address names in another package, or null.
|
|
300
277
|
*
|
|
278
|
+
* The single-hit reading of {@link foreignHits}: an address two packages
|
|
279
|
+
* publish names neither.
|
|
280
|
+
*
|
|
301
281
|
* @param {string} target - The link target.
|
|
302
282
|
* @returns {object|null} The foreign entry.
|
|
303
283
|
*/
|
|
304
284
|
function manifestHit(target) {
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
if (q.package) {
|
|
308
|
-
return foreign.index.get(canonicalKey(q.package, q.type, q.shortcode)) ?? null;
|
|
309
|
-
}
|
|
310
|
-
// A bare address names no package, so it resolves against any foreign
|
|
311
|
-
// one that publishes it. Claimed by two, it is ambiguous and the author
|
|
312
|
-
// must write the qualified form.
|
|
313
|
-
const type = String(q.type).toLowerCase();
|
|
314
|
-
const shortcode = String(q.shortcode).toLowerCase();
|
|
315
|
-
const hits = [...foreign.index].filter(([k]) => {
|
|
316
|
-
const parts = readCanonicalKey(k);
|
|
317
|
-
return parts?.type === type && parts.shortcode === shortcode;
|
|
318
|
-
});
|
|
319
|
-
return hits.length === 1 ? hits[0][1] : null;
|
|
285
|
+
const hits = foreignHits(target);
|
|
286
|
+
return hits.length === 1 ? hits[0] : null;
|
|
320
287
|
}
|
|
321
288
|
|
|
322
289
|
return {
|
|
@@ -333,15 +300,16 @@ export function buildLinkIndex(contentBase, { manifestDir, skipDirectories } = {
|
|
|
333
300
|
contentPackage: pkg,
|
|
334
301
|
foreign,
|
|
335
302
|
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,
|
|
340
303
|
linksOf,
|
|
341
|
-
|
|
342
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Resolve a link target the way both builds do, or `undefined`. Every
|
|
306
|
+
* link is an address, so this is {@link resolveAddress} under the name
|
|
307
|
+
* the walkers use (#180).
|
|
308
|
+
*/
|
|
309
|
+
resolve: resolveAddress,
|
|
343
310
|
resolveAddress,
|
|
344
311
|
manifestHit,
|
|
312
|
+
foreignHits,
|
|
345
313
|
/** Whether a target reads as a qualified address at all. */
|
|
346
314
|
isAddress: (target) => Boolean(readQualifier(target, types, packages)),
|
|
347
315
|
};
|
|
@@ -655,24 +623,21 @@ export function auditHomepageLinks(index) {
|
|
|
655
623
|
/**
|
|
656
624
|
* Every link in a tree that lands nowhere.
|
|
657
625
|
*
|
|
658
|
-
* **
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
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.
|
|
626
|
+
* **How the link is *written* is a separate finding from where it points**, and
|
|
627
|
+
* the two are kept apart because the corrections differ. An unlabelled link
|
|
628
|
+
* (#180) has to become `[[type-shortcode|Text]]`; a labelled one whose target
|
|
629
|
+
* resolves nowhere has a shortcode to fix. Reporting a bare `[[Name]]` as a
|
|
630
|
+
* dead address would send an author hunting for a note that was never named.
|
|
666
631
|
*
|
|
667
632
|
* @param {ReturnType<typeof buildLinkIndex>} index - The built index.
|
|
668
633
|
* @returns {{deadAnchors: object[], deadAddresses: object[],
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
* `"not-an-address"`
|
|
674
|
-
* `"
|
|
675
|
-
*
|
|
634
|
+
* unlabelledLinks: object[], frontmatterLinks: object[],
|
|
635
|
+
* homepageLinks: object[], usedManifest: Set<string>}} The findings, and
|
|
636
|
+
* which addresses a foreign manifest answered. Each `deadAddresses` entry
|
|
637
|
+
* carries a `reason` from {@link LINK_FINDING_REASONS} —
|
|
638
|
+
* `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
|
|
639
|
+
* `packages`), or `"unresolved"` — and every one of them is an **error**:
|
|
640
|
+
* the three resolvers agree on severity for every class (#184).
|
|
676
641
|
*/
|
|
677
642
|
export function auditLinks(index) {
|
|
678
643
|
const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
|
|
@@ -680,10 +645,10 @@ export function auditLinks(index) {
|
|
|
680
645
|
const deadAnchors = [];
|
|
681
646
|
for (const note of notes) {
|
|
682
647
|
for (const { target, anchor, text, occurrence, labelled } of linksOf(note)) {
|
|
683
|
-
if (!anchor) continue;
|
|
684
|
-
const dest = target ? resolve(
|
|
685
|
-
// An unresolvable target is reported by the
|
|
686
|
-
//
|
|
648
|
+
if (!anchor || !labelled) continue;
|
|
649
|
+
const dest = target ? resolve(target) : note;
|
|
650
|
+
// An unresolvable target is reported by the pass below; its anchor
|
|
651
|
+
// has nothing to be checked against.
|
|
687
652
|
if (!dest) continue;
|
|
688
653
|
if (!anchors.get(dest).has(slugify(anchor))) {
|
|
689
654
|
deadAnchors.push({
|
|
@@ -698,22 +663,26 @@ export function auditLinks(index) {
|
|
|
698
663
|
}
|
|
699
664
|
|
|
700
665
|
const deadAddresses = [];
|
|
701
|
-
const
|
|
666
|
+
const unlabelledLinks = [];
|
|
702
667
|
const usedManifest = new Set();
|
|
703
668
|
for (const note of notes) {
|
|
704
|
-
for (const { target, text, occurrence, labelled } of linksOf(note)) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
669
|
+
for (const { target, anchor, text, occurrence, labelled } of linksOf(note)) {
|
|
670
|
+
// The label is required whatever the link part is, an anchor
|
|
671
|
+
// included — so this is tested before the same-page form (#180).
|
|
708
672
|
if (!labelled) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
673
|
+
unlabelledLinks.push({
|
|
674
|
+
note,
|
|
675
|
+
target: target || (anchor ? `#${anchor}` : ""),
|
|
676
|
+
text,
|
|
677
|
+
occurrence,
|
|
678
|
+
// Carried like every other finding's, so a reporter reads
|
|
679
|
+
// one field rather than knowing which list it drew from.
|
|
680
|
+
reason: "unlabelled",
|
|
681
|
+
});
|
|
715
682
|
continue;
|
|
716
683
|
}
|
|
684
|
+
if (!target) continue; // a same-page `[[#anchor|Text]]`
|
|
685
|
+
const at = { note, target, text, occurrence };
|
|
717
686
|
|
|
718
687
|
if (!isAddress(target)) {
|
|
719
688
|
deadAddresses.push({ ...at, reason: "not-an-address" });
|
|
@@ -722,10 +691,23 @@ export function auditLinks(index) {
|
|
|
722
691
|
if (index.resolveAddress(target)) continue;
|
|
723
692
|
// A manifest answers with the target package's own build output
|
|
724
693
|
// rather than a reviewed guess.
|
|
725
|
-
|
|
694
|
+
const hits = index.foreignHits(target);
|
|
695
|
+
if (hits.length === 1) {
|
|
726
696
|
usedManifest.add(target.toLowerCase());
|
|
727
697
|
continue;
|
|
728
698
|
}
|
|
699
|
+
if (hits.length > 1) {
|
|
700
|
+
// Two packages publish the short address, so it names neither.
|
|
701
|
+
// Reported as its own class: "no document has that identity" is
|
|
702
|
+
// false here — two do — and the fix is the qualified form
|
|
703
|
+
// rather than a corrected shortcode (#184).
|
|
704
|
+
deadAddresses.push({
|
|
705
|
+
...at,
|
|
706
|
+
reason: "ambiguous",
|
|
707
|
+
packages: hits.map((h) => h.package).filter(Boolean),
|
|
708
|
+
});
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
729
711
|
const read = readQualifier(target, index.types, index.packages);
|
|
730
712
|
deadAddresses.push({
|
|
731
713
|
...at,
|
|
@@ -737,8 +719,7 @@ export function auditLinks(index) {
|
|
|
737
719
|
return {
|
|
738
720
|
deadAnchors,
|
|
739
721
|
deadAddresses,
|
|
740
|
-
|
|
741
|
-
aliasCollisions: index.aliasCollisions,
|
|
722
|
+
unlabelledLinks,
|
|
742
723
|
frontmatterLinks: index.frontmatterLinks,
|
|
743
724
|
homepageLinks: auditHomepageLinks(index),
|
|
744
725
|
usedManifest,
|
|
@@ -793,8 +774,8 @@ export function walkReachability(index, { root, scope, stopAt = () => false }) {
|
|
|
793
774
|
const note = queue.shift();
|
|
794
775
|
if (stopAt(note)) continue;
|
|
795
776
|
for (const { target, labelled } of index.linksOf(note)) {
|
|
796
|
-
if (!target) continue;
|
|
797
|
-
const dest = index.resolve(
|
|
777
|
+
if (!target || !labelled) continue;
|
|
778
|
+
const dest = index.resolve(target);
|
|
798
779
|
if (!dest || !scope(dest) || reached.has(dest)) continue;
|
|
799
780
|
reached.add(dest);
|
|
800
781
|
queue.push(dest);
|
package/engine/content-lint.mjs
CHANGED
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
* its own `type-shortcode` address in the top-level `aliases:` list. That
|
|
42
42
|
* served exactly one reader — **Obsidian**, so `[[type-shortcode]]` resolved in
|
|
43
43
|
* the editor — and nothing else ever read it: both resolvers parse the hyphen
|
|
44
|
-
* qualifier themselves
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* qualifier themselves. The project no longer authors in Obsidian, so the rule
|
|
45
|
+
* required a line of frontmatter per note for a reader that does not exist. The
|
|
46
|
+
* field itself is retired now (#180), refused from `retired-fields.mjs`. Removing it was
|
|
47
47
|
* verified output-neutral beforehand: across 1,735 stripped notes,
|
|
48
48
|
* `package compile` produced byte-identical `build/packs-json` and the site
|
|
49
49
|
* build byte-identical `site/content`.
|
|
@@ -178,13 +178,17 @@ export function lintContentTree(contentBase, { skipDirectories, contentPackage }
|
|
|
178
178
|
// green on the one state it most needs to catch.
|
|
179
179
|
//
|
|
180
180
|
// The state that catches is an **empty walk**, not an empty key set (#77).
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
181
|
+
// Notes may be keyless: a folder document carries no `shortcode`, and a
|
|
182
|
+
// tree of them is populated, correct, and unkeyed. Reporting that as a
|
|
183
|
+
// missing checkout trains its author to stop reading the output — the one
|
|
184
|
+
// thing this guard needs them to do. A tree holding notes is therefore a
|
|
185
|
+
// tree; only a tree holding none is the absent one.
|
|
186
|
+
//
|
|
187
|
+
// The homepage used to be the headline example, because it was addressed
|
|
188
|
+
// by the package rather than by a slug — so a `publish.site: homepage`
|
|
189
|
+
// package had a tree with exactly one note and no key at all. It carries an
|
|
190
|
+
// address like every other note now (#182); the guard is unchanged, because
|
|
191
|
+
// what it reads was never the key count.
|
|
188
192
|
if (notes.length === 0) {
|
|
189
193
|
findings.push({
|
|
190
194
|
file: path.relative(process.cwd(), contentBase) || contentBase,
|