@heroiclands/package-build 10.0.1 → 11.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 +279 -0
- package/CONTENT.md +218 -71
- package/MIGRATING.md +64 -0
- package/bin/content-build.mjs +59 -75
- 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 +26 -13
- 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/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/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
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,
|
package/engine/content-slug.mjs
CHANGED
|
@@ -12,50 +12,39 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* -
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* have predicted. The same goes for a pack filename, which is only ever read
|
|
42
|
-
* back by the unpacker. Shortening either buys nothing and costs the author's
|
|
43
|
-
* ability to guess the key.
|
|
44
|
-
*
|
|
45
|
-
* This header used to claim the opposite — that anchor slugs, filename slugs and
|
|
46
|
-
* this one were deliberately separate operations. Three of them had drifted into
|
|
47
|
-
* dropping non-ASCII letters instead of transliterating them, so `Kûrbúl Helm`
|
|
48
|
-
* addressed a page at `kurbul-helm` while its pack file was `k-rb-l-helm` and a
|
|
49
|
-
* link to a heading of the same name pointed at `#k-rb-l-helm`. Twenty-two of
|
|
50
|
-
* this repository's notes were affected. That was not a design; it was three
|
|
51
|
-
* copies of a regex, and the differences between them were all mistakes.
|
|
15
|
+
* One normalisation, for every slug this build makes.
|
|
16
|
+
*
|
|
17
|
+
* {@link slugify} reduces a piece of prose — a heading, a document name — to a
|
|
18
|
+
* URL-safe token. It is **not** how a page is addressed: a note's URL is its
|
|
19
|
+
* address, `type-shortcode`, derived in `engine/content-address.mjs` and
|
|
20
|
+
* touching no display string at all (#181).
|
|
21
|
+
*
|
|
22
|
+
* That used to be the other way round. This module carried a `contentSlug` that
|
|
23
|
+
* derived a page's URL from `name.full`, abbreviating it through a table of 200
|
|
24
|
+
* words so the result stayed short, and a `findSlugCollisions` to catch the two
|
|
25
|
+
* notes that would then claim one URL. Its own header justified the readability
|
|
26
|
+
* cost with a rename-survival story — *"every change appends to the legacy-URL
|
|
27
|
+
* map, which emits a redirect"* — and no such map was ever written, in this
|
|
28
|
+
* package or in any consumer. So a rename silently 404'd every existing link,
|
|
29
|
+
* a display string was load-bearing, and a uniqueness check was needed to keep
|
|
30
|
+
* it correct. An address has none of those properties, so all three went.
|
|
31
|
+
*
|
|
32
|
+
* What is left is the normalisation the rest of the build still needs, in the
|
|
33
|
+
* two places it was always right for:
|
|
34
|
+
*
|
|
35
|
+
* - **heading anchors**, where an author writes the matching key by hand — a map
|
|
36
|
+
* note pins `locations.stair-foot` at a heading called *Stair Foot*;
|
|
37
|
+
* - **pack filenames**, read back only by the unpacker.
|
|
38
|
+
*
|
|
39
|
+
* Neither is abbreviated, and neither ever was: abbreviation existed solely to
|
|
40
|
+
* shorten a name-derived URL, so it left with it.
|
|
52
41
|
*
|
|
53
42
|
* What the rule does, and why:
|
|
54
43
|
*
|
|
55
44
|
* - **Transliterate, don't discard.** `unidecode` carries every non-ASCII letter
|
|
56
45
|
* to its ASCII sense — `æ` → `ae`, `þ` → `th`, `œ` → `oe`, `ß` → `ss`,
|
|
57
46
|
* `ö` → `o`, `¾` → `3/4`. A rule that merely strips them turns a name into
|
|
58
|
-
* punctuation
|
|
47
|
+
* punctuation, which is how `Kûrbúl Helm` once became `k-rb-l-helm`.
|
|
59
48
|
* - **An apostrophe elides.** `’` and `'` mark a pronunciation break — a glottal
|
|
60
49
|
* stop — inside one word, so `Kenbet’Pat` is `kenbetpat`, not `kenbet-pat`.
|
|
61
50
|
* - **Everything else non-alphanumeric becomes a hyphen**, collapsed and
|
|
@@ -70,31 +59,26 @@
|
|
|
70
59
|
|
|
71
60
|
import unidecode from "unidecode";
|
|
72
61
|
|
|
73
|
-
import { abbreviateTokens } from "./abbreviations.mjs";
|
|
74
|
-
|
|
75
62
|
/**
|
|
76
|
-
* The URL
|
|
63
|
+
* The URL-safe token a piece of prose reduces to.
|
|
77
64
|
*
|
|
78
|
-
* The
|
|
79
|
-
* is carried across rather than dropped
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* `fi`→`fi`, and eth (`ð`) follows the Icelandic convention of a bare `d`.
|
|
65
|
+
* The text is **transliterated** before it is reduced, so an accented character
|
|
66
|
+
* is carried across rather than dropped. Ligatures expand the way a reader would
|
|
67
|
+
* spell them out: `þ`→`th`, `æ`→`ae`, `œ`→`oe`, `ß`→`ss`, `ij`→`ij`, `fi`→`fi`,
|
|
68
|
+
* and eth (`ð`) follows the Icelandic convention of a bare `d`.
|
|
83
69
|
*
|
|
84
70
|
* Two reductions are ours rather than the transliterator's:
|
|
85
71
|
*
|
|
86
72
|
* - **apostrophes are removed**, not treated as separators (`Armorer's Kit` →
|
|
87
|
-
* `armorers-kit`)
|
|
73
|
+
* `armorers-kit`);
|
|
88
74
|
* - **a fraction keeps its digits together** — a vulgar fraction expands to
|
|
89
75
|
* `3/4`, and the solidus would otherwise split it into `3-4`, so a slash
|
|
90
76
|
* *between digits* is closed up (`Kûrbúl ¾-Helm` → `kurbul-34-helm`).
|
|
91
77
|
*
|
|
92
|
-
* @param {string | undefined}
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* characters — either way the note cannot be addressed, which is a content
|
|
97
|
-
* error rather than something to paper over with a fallback.
|
|
78
|
+
* @param {string | undefined} text - The prose to reduce.
|
|
79
|
+
* @returns {string} The token, or `""` when the text carries nothing URL-safe.
|
|
80
|
+
* Empty is an ordinary answer here: nothing is addressed by a slug any more,
|
|
81
|
+
* so an anchor that reduces to nothing is the caller's to judge.
|
|
98
82
|
*/
|
|
99
83
|
export function slugify(text) {
|
|
100
84
|
const raw = typeof text === "string" ? text.trim() : "";
|
|
@@ -102,10 +86,10 @@ export function slugify(text) {
|
|
|
102
86
|
const tokens = unidecode(raw)
|
|
103
87
|
.toLowerCase()
|
|
104
88
|
// An apostrophe marks a pronunciation break, not a word boundary:
|
|
105
|
-
// `Kenbet
|
|
89
|
+
// `Kenbet’Pat` is one name said with a catch in it, so it elides
|
|
106
90
|
// rather than becoming a hyphen.
|
|
107
91
|
.replace(/['\u2019]/g, "")
|
|
108
|
-
// A vulgar fraction transliterates to its digits (
|
|
92
|
+
// A vulgar fraction transliterates to its digits (`¾` → `3/4`); the
|
|
109
93
|
// solidus between them is not a word boundary either.
|
|
110
94
|
.replace(/(\d)\/(\d)/g, "$1$2")
|
|
111
95
|
.split(/[^a-z0-9]+/)
|
|
@@ -113,53 +97,3 @@ export function slugify(text) {
|
|
|
113
97
|
|
|
114
98
|
return tokens.join("-");
|
|
115
99
|
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* The URL segment a content note publishes at.
|
|
119
|
-
*
|
|
120
|
-
* {@link slugify} with the rule that a document *must* be addressable: a note
|
|
121
|
-
* that yields no slug is a content error, not something to paper over with a
|
|
122
|
-
* fallback, because the alternative is a page nobody can reach.
|
|
123
|
-
*
|
|
124
|
-
* @param {string | undefined} name - The note's display name (`name.full`),
|
|
125
|
-
* which a malformed note may not have at all.
|
|
126
|
-
* @returns {string} The URL segment (never empty).
|
|
127
|
-
* @throws {Error} When there is no name, or the name carries no URL-safe
|
|
128
|
-
* characters.
|
|
129
|
-
*/
|
|
130
|
-
export function contentSlug(name) {
|
|
131
|
-
const raw = typeof name === "string" ? name.trim() : "";
|
|
132
|
-
if (!raw) {
|
|
133
|
-
throw new Error("content note has no name, so it has no URL");
|
|
134
|
-
}
|
|
135
|
-
const normalised = slugify(raw);
|
|
136
|
-
const slug = abbreviateTokens(normalised.split("-").filter(Boolean)).join("-");
|
|
137
|
-
if (!slug) {
|
|
138
|
-
throw new Error(`name "${raw}" has no URL-safe characters, so it cannot address a page`);
|
|
139
|
-
}
|
|
140
|
-
return slug;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Find pages that would publish to the same URL.
|
|
145
|
-
*
|
|
146
|
-
* Nothing constrains two notes in one section from sharing a name, and a
|
|
147
|
-
* collision silently overwrites one page with the other. This turns it into a
|
|
148
|
-
* build failure that names every claimant, so the fix is a more specific title.
|
|
149
|
-
* (The content tree has no collisions today.)
|
|
150
|
-
*
|
|
151
|
-
* @param {Array<{sec: string, slug: string, src: string}>} pages
|
|
152
|
-
* @returns {Array<{url: string, sources: string[]}>} One entry per collision, in
|
|
153
|
-
* first-claim order; empty when every URL is unique.
|
|
154
|
-
*/
|
|
155
|
-
export function findSlugCollisions(pages) {
|
|
156
|
-
const byUrl = new Map();
|
|
157
|
-
for (const { sec, slug, src } of pages) {
|
|
158
|
-
const url = `/${sec}/${slug}/`;
|
|
159
|
-
if (!byUrl.has(url)) byUrl.set(url, []);
|
|
160
|
-
byUrl.get(url).push(src);
|
|
161
|
-
}
|
|
162
|
-
return [...byUrl.entries()]
|
|
163
|
-
.filter(([, sources]) => sources.length > 1)
|
|
164
|
-
.map(([url, sources]) => ({ url, sources }));
|
|
165
|
-
}
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -181,16 +181,27 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
|
|
|
181
181
|
* a line of prose — sending the reader to a position that is not the problem,
|
|
182
182
|
* which is the one thing the located form exists to prevent.
|
|
183
183
|
*
|
|
184
|
+
* The key match tolerates leading whitespace by default, so a nested key of the
|
|
185
|
+
* same name answers when no top-level one is present — which is usually what a
|
|
186
|
+
* reader wants, the key being unique in nearly every note. Pass `topLevel` where
|
|
187
|
+
* it is not: `aliases` is both a retired top-level field and a **permitted**
|
|
188
|
+
* `name.aliases` (#180), and a finding about the first must never open on the
|
|
189
|
+
* second, which would tell an author to delete a field they are allowed to
|
|
190
|
+
* write.
|
|
191
|
+
*
|
|
184
192
|
* @param {string} raw - The file's full contents, frontmatter included.
|
|
185
193
|
* @param {string} key - The top-level frontmatter key.
|
|
186
194
|
* @param {string} [value] - When given, prefer the occurrence whose line also
|
|
187
195
|
* carries this text. A list-valued key (`aliases`) is reported at the entry
|
|
188
196
|
* that is wrong, not at the key that introduces it.
|
|
197
|
+
* @param {object} [options] - Options.
|
|
198
|
+
* @param {boolean} [options.topLevel=false] - Require the key at column 1, so
|
|
199
|
+
* an identically named nested key cannot answer for it.
|
|
189
200
|
* @returns {{line?: number, column?: number}} Spreadable position fields, empty
|
|
190
201
|
* when the key cannot be located — dropped rather than guessed, as
|
|
191
202
|
* {@link formatDiagnostic} requires.
|
|
192
203
|
*/
|
|
193
|
-
export function positionInFrontmatter(raw, key, value = undefined) {
|
|
204
|
+
export function positionInFrontmatter(raw, key, value = undefined, { topLevel = false } = {}) {
|
|
194
205
|
if (typeof raw !== "string" || !key) return {};
|
|
195
206
|
const fence = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
196
207
|
if (!fence) return {};
|
|
@@ -205,7 +216,10 @@ export function positionInFrontmatter(raw, key, value = undefined) {
|
|
|
205
216
|
keyLine = i;
|
|
206
217
|
break;
|
|
207
218
|
}
|
|
208
|
-
if (
|
|
219
|
+
if (
|
|
220
|
+
keyLine === -1 &&
|
|
221
|
+
new RegExp(`^${topLevel ? "" : "\\s*"}${escape(key)}\\s*:`).test(lines[i])
|
|
222
|
+
) {
|
|
209
223
|
keyLine = i;
|
|
210
224
|
if (wanted == null) break;
|
|
211
225
|
}
|