Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/docs-link-check.ts
T

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);
}
}