import { readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; import type { FileObject, PopulateParams } from "next-validate-link"; import { printErrors, scanURLs, validateFiles } from "next-validate-link"; import { remark } from "remark"; import { visit } from "unist-util-visit"; import { docsVersionSources } from "../lib/docs-version-sources.ts"; import { docsVersions, versionedDocsHref } from "../lib/docs-versions.ts"; import { scopeDocsContent } from "../lib/scope-docs-content.ts"; const routeEntries: NonNullable = []; const files: FileObject[] = []; interface MarkdownNode { type: string; value?: string; children?: MarkdownNode[]; } function getNodeText(node: MarkdownNode): string { if (node.value !== undefined) return node.value; if (!node.children) return ""; return node.children.map((child) => getNodeText(child)).join(""); } function createHeadingSlugger() { const occurrences = new Map(); return (value: string) => { const base = value .trim() .toLowerCase() .replace(/[^\p{L}\p{N}\s_-]/gu, "") .replace(/\s+/g, "-"); const count = occurrences.get(base) ?? 0; occurrences.set(base, count + 1); return count === 0 ? base : `${base}-${count}`; }; } function getHeadingIds(content: string) { const slug = createHeadingSlugger(); const headings: string[] = []; visit(remark().parse(content), "heading", (node) => { headings.push(slug(getNodeText(node))); }); return headings; } function getMdxFiles(directory: string): string[] { const mdxFiles: string[] = []; for (const item of readdirSync(directory, { withFileTypes: true })) { const itemPath = join(directory, item.name); if (item.isDirectory()) mdxFiles.push(...getMdxFiles(itemPath)); else if (item.name.endsWith(".mdx")) mdxFiles.push(itemPath); } return mdxFiles; } function normalizeTrailingSlashAnchors(content: string) { return content.replace(/(\/docs\/[^\s"'()<>{}\]]+?)\/#([\w-]+)/g, "$1#$2"); } for (const version of docsVersions) { const contentDirectory = join( "content", docsVersionSources[version.id].contentDirectory, ); const mdxFiles = getMdxFiles(contentDirectory); if (mdxFiles.length === 0) { throw new Error( `${contentDirectory}: no MDX files found. Run sync-versions first.`, ); } for (const filePath of mdxFiles) { const segments = relative(contentDirectory, filePath) .replace(/\.mdx$/, "") .split(/[\\/]/); if (segments[0] === "examples") continue; if (segments.at(-1) === "index") segments.pop(); const canonicalUrl = `/docs/${segments.join("/")}`.replace(/\/$/, ""); const url = versionedDocsHref(canonicalUrl, version); const rawContent = readFileSync(filePath, "utf8"); const versionedContent = version.id !== "latest" ? scopeDocsContent(rawContent, version) : rawContent; const content = normalizeTrailingSlashAnchors(versionedContent); routeEntries.push({ value: { slug: version.id !== "latest" ? [version.id, ...segments] : segments, }, hashes: getHeadingIds(rawContent), }); files.push({ path: filePath, content, url }); } } const scanned = await scanURLs({ preset: "next", cwd: process.cwd(), populate: { "docs/[[...slug]]": routeEntries, }, }); printErrors( await validateFiles(files, { scanned, markdown: { components: { Card: { attributes: ["href"] }, Link: { attributes: ["href"] }, }, }, checkRelativePaths: "as-url", whitelist: (url) => url === "/docs" || url === "/dashboard" || url === "/llms.txt" || url.startsWith("chrome://"), }), true, ); console.log(`[validate-links] ${files.length} documents passed`);