Skip to content

Commit 7971bed

Browse files
authored
ci: check local links across docs pages (#1746)
Co-authored-by: kiranmagic7 <262980978+kiranmagic7@users.noreply.github.com>
1 parent 240f429 commit 7971bed

1 file changed

Lines changed: 58 additions & 13 deletions

File tree

Scripts/check-documentation-links.mjs

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import path from "node:path";
44
import { fileURLToPath } from "node:url";
55

66
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7+
const approvedRootDocumentation = new Set([
8+
"README.md",
9+
"CHANGELOG.md",
10+
"LICENSE",
11+
"VISION.md",
12+
].map((relativePath) => path.join(repoRoot, relativePath)));
713

814
const readme = readText("README.md");
915
const readmeLinks = [
@@ -13,13 +19,30 @@ const readmeLinks = [
1319
].filter(isRepositoryDocReference);
1420

1521
assert(readmeLinks.length > 0, "README.md has no local documentation links");
16-
for (const link of readmeLinks) validateLocalDocLink(link);
22+
for (const link of readmeLinks) validateLocalDocLink(link, repoRoot, "README.md");
1723

1824
const providerLinks = inlineCodeDocLinks(readText("docs/providers.md"));
1925
assert(providerLinks.length > 0, "docs/providers.md has no provider detail links");
20-
for (const link of providerLinks) validateLocalDocLink(link);
26+
for (const link of providerLinks) validateLocalDocLink(link, repoRoot, "docs/providers.md");
2127

22-
console.log(`documentation links OK: ${readmeLinks.length + providerLinks.length} local links`);
28+
const docsLinks = markdownFiles("docs").flatMap((relativePath) => {
29+
const markdown = readText(relativePath);
30+
const links = [
31+
...markdownLinks(markdown),
32+
...markdownImageLinks(markdown),
33+
...htmlLinks(markdown),
34+
].filter(isLocalDocumentationReference);
35+
36+
return links.map((link) => ({ link, relativePath }));
37+
});
38+
39+
for (const { link, relativePath } of docsLinks) {
40+
validateLocalDocLink(link, path.join(repoRoot, path.dirname(relativePath)), relativePath);
41+
}
42+
43+
console.log(
44+
`documentation links OK: ${readmeLinks.length + providerLinks.length + docsLinks.length} local links`,
45+
);
2346

2447
function readText(relativePath) {
2548
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
@@ -63,13 +86,14 @@ function inlineCodeDocLinks(markdown) {
6386
});
6487
}
6588

66-
function validateLocalDocLink(rawLink) {
67-
const { absolutePath, fragment } = localDocPath(rawLink);
68-
assert(fs.existsSync(absolutePath), `missing documentation target: ${rawLink}`);
89+
function validateLocalDocLink(rawLink, baseDirectory, sourceLabel) {
90+
const sourcePath = path.join(repoRoot, sourceLabel);
91+
const { absolutePath, fragment } = localDocPath(rawLink, baseDirectory, sourcePath);
92+
assert(fs.existsSync(absolutePath), `${sourceLabel}: missing documentation target: ${rawLink}`);
6993

7094
if (path.extname(absolutePath).toLowerCase() !== ".md" || !fragment) return;
7195
const anchors = markdownHeadingAnchors(readText(path.relative(repoRoot, absolutePath)));
72-
assert(anchors.has(fragment), `missing documentation anchor: ${rawLink}`);
96+
assert(anchors.has(fragment), `${sourceLabel}: missing documentation anchor: ${rawLink}`);
7397
}
7498

7599
function isRepositoryDocReference(rawLink) {
@@ -80,20 +104,41 @@ function isRepositoryDocReference(rawLink) {
80104
return pathname === "docs" || pathname.startsWith("docs/");
81105
}
82106

83-
function localDocPath(rawLink) {
107+
function isLocalDocumentationReference(rawLink) {
108+
const parsed = parseRelativeURL(rawLink);
109+
if (!parsed || parsed.protocol || parsed.host) return false;
110+
return Boolean(parsed.pathname || parsed.hash);
111+
}
112+
113+
function localDocPath(rawLink, baseDirectory, sourcePath) {
84114
const parsed = parseRelativeURL(rawLink);
85-
assert(parsed && !parsed.protocol && !parsed.host && parsed.pathname, `invalid documentation URL: ${rawLink}`);
115+
assert(
116+
parsed && !parsed.protocol && !parsed.host && (parsed.pathname || parsed.hash),
117+
`invalid documentation URL: ${rawLink}`,
118+
);
86119

87-
const decodedPath = decodeURIComponent(parsed.pathname);
88-
const absolutePath = path.resolve(repoRoot, decodedPath);
120+
const rawPath = rawLink.split("#", 1)[0].split("?", 1)[0];
121+
const decodedPath = decodeURIComponent(rawPath);
122+
const absolutePath = decodedPath ? path.resolve(baseDirectory, decodedPath) : sourcePath;
89123
const docsRoot = path.resolve(repoRoot, "docs");
124+
const isInDocsTree = absolutePath === docsRoot || absolutePath.startsWith(`${docsRoot}${path.sep}`);
90125
assert(
91-
absolutePath === docsRoot || absolutePath.startsWith(`${docsRoot}${path.sep}`),
92-
`documentation link escapes docs root: ${rawLink}`,
126+
isInDocsTree || approvedRootDocumentation.has(absolutePath),
127+
`documentation link escapes approved documentation roots: ${rawLink}`,
93128
);
94129
return { absolutePath, fragment: parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : "" };
95130
}
96131

132+
function markdownFiles(relativeDir) {
133+
const dir = path.join(repoRoot, relativeDir);
134+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
135+
if (entry.name.startsWith(".")) return [];
136+
const relativePath = path.join(relativeDir, entry.name);
137+
if (entry.isDirectory()) return markdownFiles(relativePath);
138+
return entry.isFile() && entry.name.endsWith(".md") ? [relativePath] : [];
139+
}).sort((a, b) => a.localeCompare(b));
140+
}
141+
97142
function parseRelativeURL(rawLink) {
98143
try {
99144
const parsed = new URL(rawLink, "relative://repo/");

0 commit comments

Comments
 (0)