183 lines
6.6 KiB
TypeScript
183 lines
6.6 KiB
TypeScript
#!/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<string> {
|
|
const anchors = new Set<string>();
|
|
const seen = new Map<string, number>();
|
|
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<string, string>();
|
|
const anchorByPath = new Map<string, Set<string>>();
|
|
|
|
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<string>();
|
|
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);
|
|
}
|
|
}
|