#!/usr/bin/env tsx import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; export const DEFAULT_DOCS_ROOTS = ["README.md", "AGENTS.md", "SECURITY.md", "CHANGELOG.md", "docs", "guides", "llm-docs"]; const MARKDOWN_LINK_PATTERN = /!?\[([^\]\n]*(?:\][^\]\n]*)*)\]\(([^)\n]+)\)/g; const HEADING_PATTERN = /^#{1,6}\s+(.+)$/gm; function normalizeRelativePath(relativePath: string): string { return relativePath.split(path.sep).join("/"); } function lineAndColumnForIndex(content: string, index: number): { line: number; column: number } { const prefix = content.slice(0, index); const lines = prefix.split(/\r?\n/); return { line: lines.length, column: (lines.at(-1) ?? "").length + 1 }; } function collectMarkdownFiles(rootDir: string, docsRoots = DEFAULT_DOCS_ROOTS): { files: string[]; missingRoots: string[] } { const files: string[] = []; const missingRoots: string[] = []; function visit(absolutePath: string): void { const stat = fs.statSync(absolutePath); if (stat.isDirectory()) { for (const entry of fs.readdirSync(absolutePath).sort()) { visit(path.join(absolutePath, entry)); } return; } if (/\.mdx?$/i.test(absolutePath)) { files.push(normalizeRelativePath(path.relative(rootDir, absolutePath))); } } for (const root of docsRoots) { const absolutePath = path.join(rootDir, root); if (fs.existsSync(absolutePath)) { visit(absolutePath); } else { missingRoots.push(root); } } return { files, missingRoots }; } function slugifyHeading(heading: string): string { return heading .replace(/`([^`]+)`/g, "$1") .replace(/<[^>]+>/g, "") .trim() .toLowerCase() .replace(/[^\p{L}\p{N}\s-]/gu, "") .replace(/\s+/g, "-") .replace(/-+/g, "-"); } function anchorsFor(content: string): Set { const anchors = new Set(); const seen = new Map(); for (const match of content.matchAll(HEADING_PATTERN)) { const raw = match[1].replace(/#+\s*$/, "").trim(); const base = slugifyHeading(raw); if (!base) continue; const count = seen.get(base) ?? 0; seen.set(base, count + 1); anchors.add(count === 0 ? base : `${base}-${count}`); } return anchors; } function isExternalOrSpecialTarget(target: string): boolean { return /^(?:[a-z][a-z0-9+.-]*:|#|mailto:|tel:)/i.test(target); } function stripQueryAndHash(target: string): { targetPath: string; anchor: string } { const hashIndex = target.indexOf("#"); const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target; const anchor = hashIndex >= 0 ? decodeURIComponent(target.slice(hashIndex + 1)) : ""; const queryIndex = beforeHash.indexOf("?"); const targetPath = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash; return { targetPath, anchor }; } export function checkDocsLinks(rootDir = process.cwd(), docsRoots = DEFAULT_DOCS_ROOTS): { failures: string[]; checkedFiles: string[] } { const { files: checkedFiles, missingRoots } = collectMarkdownFiles(rootDir, docsRoots); const contentByPath = new Map(); const anchorByPath = new Map>(); for (const relativePath of checkedFiles) { const content = fs.readFileSync(path.join(rootDir, relativePath), "utf8"); contentByPath.set(relativePath, content); anchorByPath.set(relativePath, anchorsFor(content)); } const failures: string[] = []; for (const missingRoot of missingRoots) { failures.push(`docs-roots: missing configured docs root ${missingRoot}`); } if (checkedFiles.length === 0) { failures.push(`docs-roots: checked 0 Markdown files for roots ${docsRoots.join(", ")}`); return { failures, checkedFiles }; } for (const [sourcePath, content] of contentByPath.entries()) { for (const match of content.matchAll(MARKDOWN_LINK_PATTERN)) { const rawTarget = match[2].trim(); if (!rawTarget || isExternalOrSpecialTarget(rawTarget)) continue; const { targetPath, anchor } = stripQueryAndHash(rawTarget); const sourcePosition = lineAndColumnForIndex(content, match.index ?? 0); const sourceContext = `${sourcePath}:${sourcePosition.line}:${sourcePosition.column}`; const resolvedPath = targetPath ? normalizeRelativePath( path.normalize( targetPath.startsWith("/") ? path.join("docs", decodeURIComponent(targetPath).slice(1)) : path.join(path.dirname(sourcePath), decodeURIComponent(targetPath)), ), ) : sourcePath; const absoluteTarget = path.join(rootDir, resolvedPath); const targetExists = !targetPath || fs.existsSync(absoluteTarget); const markdownExtension = path.extname(resolvedPath); const extensionlessDocExists = targetPath && !targetExists && markdownExtension === "" && fs.existsSync(`${absoluteTarget}.md`); if (targetPath && !targetExists && !extensionlessDocExists) { failures.push(`${sourceContext}: broken local link "${rawTarget}" resolves to missing path ${resolvedPath}`); continue; } const finalAbsoluteTarget = extensionlessDocExists ? `${absoluteTarget}.md` : absoluteTarget; const finalResolvedPath = extensionlessDocExists ? `${resolvedPath}.md` : resolvedPath; if (targetPath && fs.statSync(finalAbsoluteTarget).isDirectory()) { continue; } if (anchor) { const targetContent = targetPath ? fs.readFileSync(finalAbsoluteTarget, "utf8") : content; const targetAnchors = targetPath ? anchorsFor(targetContent) : anchorByPath.get(sourcePath) ?? new Set(); if (!targetAnchors.has(anchor)) { failures.push(`${sourceContext}: broken local anchor "#${anchor}" in ${finalResolvedPath}`); } } } } return { failures, checkedFiles }; } export function formatDocsLinkCheckResult(result: { failures: string[]; checkedFiles: string[] }): string { const status = result.failures.length > 0 ? "FAIL" : "PASS"; return [ `${status} docs link check`, `OBSERVE checked ${result.checkedFiles.length} Markdown files`, ...result.failures.map((failure) => `FAIL ${failure}`), `Summary: ${result.failures.length === 0 ? 1 : 0} passed, 1 observed, ${result.failures.length} failed`, "", ].join("\n"); } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isCli) { const result = checkDocsLinks(process.argv[2] ? path.resolve(process.argv[2]) : process.cwd()); const output = formatDocsLinkCheckResult(result); if (result.failures.length > 0) { process.stderr.write(output); process.exitCode = 1; } else { process.stdout.write(output); } }