Skip to content

Commit eee29eb

Browse files
authored
test(docs): read guide links through linkDestinations instead of a regex scan (#564)
1 parent 2ee3f11 commit eee29eb

1 file changed

Lines changed: 102 additions & 42 deletions

File tree

‎test/docs/guides.test.ts‎

Lines changed: 102 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { describe, expect, test } from "bun:test";
1111
import { existsSync, readdirSync, readFileSync } from "node:fs";
1212
import { join, posix } from "node:path";
13+
import { unescape as percentDecoded } from "node:querystring";
1314
import { DecodingMode, decodeHTML } from "entities";
1415
import { ok } from "neverthrow";
1516
import { parse as parseYaml } from "yaml";
@@ -208,12 +209,50 @@ async function linkDestinations(
208209
return destinations;
209210
}
210211

211-
/** An origin no real link names, under which docs/ is served for the resolution below. */
212+
/** An origin no real link names, under which the repository is served for linkTargets, and docs/ alone, at SITE_ROOT, for linksLeavingDocs. */
212213
const SITE_ORIGIN = "https://docs-site.invalid";
213214

214215
/** The path docs/ is served under, which no real link names either, so a destination that climbs out (even back into a literal "docs/") lands outside it. */
215216
const SITE_ROOT = "/docs-root-7c1e/";
216217

218+
/** One link within the repository, as a browser resolves it against its page: the file it requests and the heading it looks up, each decoded on its own. */
219+
interface LinkTarget {
220+
/** The destination as the renderer wrote it, for a failure message. */
221+
readonly destination: string;
222+
223+
/** The file the link requests, from the repository root, percent-decoded as a server does (a valid escape decodes, a bare % stays); the page itself for a fragment-only link. */
224+
readonly path: string;
225+
226+
/** The heading the link looks up, percent-decoded; null for a destination with no #. */
227+
readonly fragment: string | null;
228+
}
229+
230+
/**
231+
* The links in `markdown`, the page at `page` from the repository root, that lead within the repository, resolved as a
232+
* browser does: padding trimmed, query and #fragment split off before the path is decoded (an encoded %23 stays in
233+
* the path), and a destination on another origin (a scheme, a network path `//host/`) or one no parser accepts skipped.
234+
*/
235+
async function linkTargets(
236+
markdown: string,
237+
page: string,
238+
attributes?: readonly string[],
239+
): Promise<LinkTarget[]> {
240+
const base = `${SITE_ORIGIN}/${page}`;
241+
const targets: LinkTarget[] = [];
242+
for (const destination of await linkDestinations(markdown, attributes)) {
243+
const url = URL.parse(destination, base);
244+
if (url === null || url.origin !== SITE_ORIGIN) {
245+
continue;
246+
}
247+
targets.push({
248+
destination,
249+
path: percentDecoded(url.pathname.slice(1)),
250+
fragment: url.hash === "" ? null : percentDecoded(url.hash.slice(1)),
251+
});
252+
}
253+
return targets;
254+
}
255+
217256
/**
218257
* Relative links in the docs/ page at `page` that resolve outside docs/.
219258
* Resolution is the WHATWG URL parser's, as a browser does it; the published site is built from docs/ alone, so a link
@@ -224,14 +263,7 @@ async function linksLeavingDocs(markdown: string, page: string): Promise<string[
224263
const lines = markdown.split("\n");
225264
const lineOf = (target: string): string => {
226265
const name = posix.basename(target.split("#")[0] ?? "");
227-
const decoded = (text: string): string => {
228-
try {
229-
return decodeURIComponent(text);
230-
} catch {
231-
return text;
232-
}
233-
};
234-
const needles = [target, decoded(target), name, decoded(name)];
266+
const needles = [target, percentDecoded(target), name, percentDecoded(name)];
235267
const index = needles
236268
.map((needle) => lines.findIndex((line) => line.includes(needle)))
237269
.find((found) => found !== -1);
@@ -411,12 +443,9 @@ describe("docs/ guide pages", () => {
411443
const linked = new Set<string>();
412444
for (const file of linkScanFiles()) {
413445
// href only: an image or embed shows the file but takes the reader nowhere.
414-
for (const destination of await linkDestinations(readFileSync(file.path, "utf8"), ["href"])) {
415-
const path = destination.split("#")[0] ?? "";
416-
if (path === "" || /^[a-z]+:/.test(path)) {
417-
continue;
418-
}
419-
const resolved = join(file.path, "..", decodeURIComponent(path));
446+
const links = await linkTargets(readFileSync(file.path, "utf8"), file.label, ["href"]);
447+
for (const target of links) {
448+
const resolved = join(ROOT, target.path);
420449
if (resolved !== file.path) {
421450
linked.add(resolved);
422451
}
@@ -426,49 +455,34 @@ describe("docs/ guide pages", () => {
426455
expect(orphans, "no README, guide, or root page links these docs/ pages").toEqual([]);
427456
});
428457

429-
/** Every link target in `file` outside its fences, the external ones (a scheme, mailto:) skipped. */
430-
function linkTargets(file: { label: string; path: string }): string[] {
431-
const markdown = linesOutsideFences(readFileSync(file.path, "utf8"), file.label).join("\n");
432-
return [...markdown.matchAll(/\]\(([^)]+)\)/g)]
433-
.map((match) => match[1] ?? "")
434-
.filter((target) => !/^[a-z]+:\/\//.test(target) && !target.startsWith("mailto:"));
435-
}
436-
437458
/** Why a target's #fragment matches no heading of the markdown file it names; null for a target with no fragment to check, or a good one. */
438459
function fragmentProblem(
439460
file: { label: string; path: string },
440-
target: string,
461+
target: LinkTarget,
441462
slugsOf: (path: string) => Set<string>,
442463
): string | null {
443-
const hash = target.indexOf("#");
444-
if (hash === -1) {
464+
if (target.fragment === null) {
445465
return null;
446466
}
447-
const fragment = target.slice(hash + 1);
448-
const path = target.slice(0, hash);
449-
const resolved = path === "" ? file.path : join(file.path, "..", path);
467+
const resolved = join(ROOT, target.path);
450468
if (!resolved.endsWith(".md")) {
451469
return null; // only markdown targets have slugified headings
452470
}
453471
if (!existsSync(resolved)) {
454-
return `${file.label}: (${target}) target file does not exist`;
472+
return `${file.label}: (${target.destination}) target file does not exist`;
455473
}
456-
if (!slugsOf(resolved).has(fragment)) {
457-
return `${file.label}: (${target}) fragment matches no heading`;
474+
if (!slugsOf(resolved).has(target.fragment)) {
475+
return `${file.label}: (${target.destination}) fragment matches no heading`;
458476
}
459477
return null;
460478
}
461479

462-
test("every relative link in the guides, README, and CONTRIBUTING resolves to a real file", () => {
480+
test("every relative link in the guides, README, and CONTRIBUTING resolves to a real file", async () => {
463481
const broken: string[] = [];
464482
for (const file of linkScanFiles()) {
465-
for (const target of linkTargets(file)) {
466-
const path = target.split("#")[0] ?? "";
467-
if (path === "") {
468-
continue;
469-
}
470-
if (!existsSync(join(file.path, "..", path))) {
471-
broken.push(`${file.label}: (${target})`);
483+
for (const target of await linkTargets(readFileSync(file.path, "utf8"), file.label)) {
484+
if (!existsSync(join(ROOT, target.path))) {
485+
broken.push(`${file.label}: (${target.destination})`);
472486
}
473487
}
474488
}
@@ -483,7 +497,7 @@ describe("docs/ guide pages", () => {
483497
expect(leaving).toEqual([]);
484498
});
485499

486-
test("every relative link with a #fragment points at a real heading", () => {
500+
test("every relative link with a #fragment points at a real heading", async () => {
487501
const files = linkScanFiles();
488502
const slugCache = new Map<string, Set<string>>();
489503
const slugsOf = (path: string): Set<string> => {
@@ -496,7 +510,7 @@ describe("docs/ guide pages", () => {
496510
};
497511
const broken: string[] = [];
498512
for (const file of files) {
499-
for (const target of linkTargets(file)) {
513+
for (const target of await linkTargets(readFileSync(file.path, "utf8"), file.label)) {
500514
const problem = fragmentProblem(file, target, slugsOf);
501515
if (problem !== null) {
502516
broken.push(problem);
@@ -990,6 +1004,52 @@ describe("links-leaving-docs guard (mutation checks)", () => {
9901004
});
9911005
});
9921006

1007+
describe("link target scan (mutation checks)", () => {
1008+
test("reads each destination as a browser does, where a regex over the source read it differently", async () => {
1009+
// A regex over the source read the first three lines, the tel: link, and the encoded forms differently; the other
1010+
// schemes pin the skip, the two bare percents the tolerant decode, %23 the split before the decode, and the last
1011+
// line the URL parser's own trimming, query split, and origin test (a network path and a non-URL scheme are external).
1012+
const link = (destination: string, path: string, fragment: string | null): LinkTarget => ({
1013+
destination,
1014+
path,
1015+
fragment,
1016+
});
1017+
const page = [
1018+
'[titled](a.md "Title") [bracketed](<a b.md>) [nested](p.md (title))',
1019+
"[reference][ref] [collapsed][] [shortcut]",
1020+
'<a href="raw.md">anchor</a> <img src="raw.png" alt="">',
1021+
"[phone](tel:+15550100) [mail](mailto:x@example.com) [site](https://example.com/)",
1022+
"Not links: `[span](code.md)` and \\[escaped](escaped.md).",
1023+
"[encoded](a%20b.md#frag%20x) [entity](&#46;&#46;/ent.md) [escaped](\\.\\./esc.md)",
1024+
"[bare percent](50%.md) [bare and escape](<50% done.md>) [encoded hash](README.md%23library) [fragment](#top)",
1025+
'<img src="//cdn.example/x.png" alt=""> [clone](git+ssh://git@example.com/r.git) <a href=" guide.md ">padded</a> [query](guide.md?view=1)',
1026+
"",
1027+
"[ref]: ref.md",
1028+
"[collapsed]: collapsed.md",
1029+
"[shortcut]: shortcut.md",
1030+
].join("\n");
1031+
expect(await linkTargets(page, "docs/start/page.md")).toEqual([
1032+
link("a.md", "docs/start/a.md", null),
1033+
link("a%20b.md", "docs/start/a b.md", null),
1034+
link("p.md", "docs/start/p.md", null),
1035+
link("ref.md", "docs/start/ref.md", null),
1036+
link("collapsed.md", "docs/start/collapsed.md", null),
1037+
link("shortcut.md", "docs/start/shortcut.md", null),
1038+
link("raw.md", "docs/start/raw.md", null),
1039+
link("raw.png", "docs/start/raw.png", null),
1040+
link("a%20b.md#frag%20x", "docs/start/a b.md", "frag x"),
1041+
link("../ent.md", "docs/ent.md", null),
1042+
link("../esc.md", "docs/esc.md", null),
1043+
link("50%.md", "docs/start/50%.md", null),
1044+
link("50%%20done.md", "docs/start/50% done.md", null),
1045+
link("README.md%23library", "docs/start/README.md#library", null),
1046+
link("#top", "docs/start/page.md", "top"),
1047+
link(" guide.md ", "docs/start/guide.md", null),
1048+
link("guide.md?view=1", "docs/start/guide.md", null),
1049+
]);
1050+
});
1051+
});
1052+
9931053
describe("refusal table parser (mutation checks)", () => {
9941054
const header = ["| The layer has | The fold says |", "|---|---|"];
9951055
const row = (has: string, says = "`the message`"): string => `| ${has} | ${says} |`;

0 commit comments

Comments
 (0)