#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const approvedRootDocumentation = new Set([ "README.md", "CHANGELOG.md", "LICENSE", "VISION.md", ].map((relativePath) => path.join(repoRoot, relativePath))); const readme = readText("README.md"); const readmeLinks = [ ...markdownLinks(readme), ...markdownImageLinks(readme), ...htmlLinks(readme), ].filter(isRepositoryDocReference); assert(readmeLinks.length > 0, "README.md has no local documentation links"); for (const link of readmeLinks) validateLocalDocLink(link, repoRoot, "README.md"); const providerLinks = inlineCodeDocLinks(readText("docs/providers.md")); assert(providerLinks.length > 0, "docs/providers.md has no provider detail links"); for (const link of providerLinks) validateLocalDocLink(link, repoRoot, "docs/providers.md"); const docsLinks = markdownFiles("docs").flatMap((relativePath) => { const markdown = readText(relativePath); const links = [ ...markdownLinks(markdown), ...markdownImageLinks(markdown), ...htmlLinks(markdown), ].filter(isLocalDocumentationReference); return links.map((link) => ({ link, relativePath })); }); for (const { link, relativePath } of docsLinks) { validateLocalDocLink(link, path.join(repoRoot, path.dirname(relativePath)), relativePath); } console.log( `documentation links OK: ${readmeLinks.length + providerLinks.length + docsLinks.length} local links`, ); function readText(relativePath) { return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); } function markdownLinks(markdown) { const source = markdownTextOutsideCode(markdown); const links = []; const inlinePattern = /(?\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g; for (const match of source.matchAll(inlinePattern)) { links.push(encodeSpaces(match[1] ?? match[2])); } const referencePattern = /^\s*\[[^\]\n]+]:\s*(?:<([^>\n]+)>|([^\s]+))/gm; for (const match of source.matchAll(referencePattern)) { links.push(encodeSpaces(match[1] ?? match[2])); } return links; } function markdownImageLinks(markdown) { const source = markdownTextOutsideCode(markdown); const pattern = /!\[(?:\\.|[^\]\\])*\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g; return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2]); } function htmlLinks(markdown) { const source = markdownTextOutsideCode(markdown); const pattern = /<\s*(?:a|img)\b[^>]*?\b(?:href|src)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi; return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2] ?? match[3]); } function inlineCodeDocLinks(markdown) { return markdown.split("\n").flatMap((line) => { const trimmed = line.trim(); const prefix = "- Details: `"; if (!trimmed.startsWith(prefix)) return []; const rest = trimmed.slice(prefix.length); const end = rest.indexOf("`"); return end === -1 ? [] : [rest.slice(0, end)]; }); } function validateLocalDocLink(rawLink, baseDirectory, sourceLabel) { const sourcePath = path.join(repoRoot, sourceLabel); const { absolutePath, fragment } = localDocPath(rawLink, baseDirectory, sourcePath); assert(fs.existsSync(absolutePath), `${sourceLabel}: missing documentation target: ${rawLink}`); if (path.extname(absolutePath).toLowerCase() !== ".md" || !fragment) return; const anchors = markdownHeadingAnchors(readText(path.relative(repoRoot, absolutePath))); assert(anchors.has(fragment), `${sourceLabel}: missing documentation anchor: ${rawLink}`); } function isRepositoryDocReference(rawLink) { const parsed = parseRelativeURL(rawLink); if (!parsed || parsed.protocol || parsed.host) return false; let pathname = parsed.pathname; while (pathname.startsWith("./")) pathname = pathname.slice(2); return pathname === "docs" || pathname.startsWith("docs/"); } function isLocalDocumentationReference(rawLink) { const parsed = parseRelativeURL(rawLink); if (!parsed || parsed.protocol || parsed.host) return false; return Boolean(parsed.pathname || parsed.hash); } function localDocPath(rawLink, baseDirectory, sourcePath) { const parsed = parseRelativeURL(rawLink); assert( parsed && !parsed.protocol && !parsed.host && (parsed.pathname || parsed.hash), `invalid documentation URL: ${rawLink}`, ); const rawPath = rawLink.split("#", 1)[0].split("?", 1)[0]; const decodedPath = decodeURIComponent(rawPath); const absolutePath = decodedPath ? path.resolve(baseDirectory, decodedPath) : sourcePath; const docsRoot = path.resolve(repoRoot, "docs"); const isInDocsTree = absolutePath === docsRoot || absolutePath.startsWith(`${docsRoot}${path.sep}`); assert( isInDocsTree || approvedRootDocumentation.has(absolutePath), `documentation link escapes approved documentation roots: ${rawLink}`, ); return { absolutePath, fragment: parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : "" }; } function markdownFiles(relativeDir) { const dir = path.join(repoRoot, relativeDir); return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { if (entry.name.startsWith(".") || entry.name === "node_modules") return []; const relativePath = path.join(relativeDir, entry.name); if (entry.isDirectory()) return markdownFiles(relativePath); return entry.isFile() && entry.name.endsWith(".md") ? [relativePath] : []; }).sort((a, b) => a.localeCompare(b)); } function parseRelativeURL(rawLink) { try { const parsed = new URL(rawLink, "relative://repo/"); const isRelative = parsed.protocol === "relative:" && parsed.host === "repo"; return { protocol: isRelative ? "" : parsed.protocol, host: isRelative ? "" : parsed.host, pathname: isRelative ? parsed.pathname.replace(/^\//, "") : parsed.pathname, hash: parsed.hash, }; } catch { return null; } } function markdownHeadingAnchors(markdown) { const occurrences = new Map(); const anchors = new Set(); const source = markdownTextOutsideFencedCode(markdown); for (const line of source.split("\n")) { const trimmed = line.replace(/^[ \t]+/, ""); const match = /^(#{1,6})\s+(.+?)\s*$/.exec(trimmed); if (!match) continue; const base = markdownHeadingSlug(match[2]); if (!base) continue; const occurrence = occurrences.get(base) ?? 0; anchors.add(occurrence === 0 ? base : `${base}-${occurrence}`); occurrences.set(base, occurrence + 1); } return anchors; } function markdownHeadingSlug(heading) { const text = removeMarkdownFormatting(heading).toLowerCase(); let slug = ""; for (const char of text) { if (/[\p{Letter}\p{Number}_-]/u.test(char)) { slug += char; } else if (/\s/u.test(char)) { slug += "-"; } } return slug; } function removeMarkdownFormatting(text) { return text .replace(/`([^`]*)`/g, "$1") .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") .replace(/[*_~]/g, ""); } function markdownTextOutsideCode(markdown) { return markdownTextOutsideFencedCode(markdown) .split("\n") .map(removeInlineCode) .join("\n"); } function markdownTextOutsideFencedCode(markdown) { let fence = null; return markdown.split("\n").map((line) => { if (fence) { if (isClosingFence(line, fence.marker, fence.count)) fence = null; return ""; } const openingFence = parseOpeningFence(line); if (openingFence) { fence = openingFence; return ""; } return line; }).join("\n"); } function parseOpeningFence(line) { const match = /^( {0,3})([`~]{3,})(.*)$/.exec(line); if (!match) return null; const marker = match[2][0]; if (marker === "`" && match[3].includes("`")) return null; return { marker, count: match[2].length }; } function isClosingFence(line, marker, minimumCount) { const escaped = marker === "`" ? "`" : "~"; const pattern = new RegExp(`^ {0,3}${escaped}{${minimumCount},}\\s*$`); return pattern.test(line); } function removeInlineCode(line) { return line.replace(/(?