import { spawnSync } from "child_process"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { glob } from "glob"; import { checkEssentialContent } from "./lib/essential-content.js"; import type { PageInput } from "./lib/essential-content.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // showcase/scripts/ → showcase/ → repo root. Different from // validate-parity.ts (which stops at showcase/) because `nx build shell-docs` // must run from the monorepo root. const REPO_ROOT = path.resolve(__dirname, "..", ".."); export type CheckStatus = "pass" | "fail" | "skipped"; export interface CheckResult { name: string; status: CheckStatus; messages: string[]; } export interface BuildCheckOptions { skipExecution?: boolean; } export function runBuildCheck(opts: BuildCheckOptions = {}): CheckResult { if (opts.skipExecution) { return { name: "nx-build-shell-docs", status: "skipped", messages: ["skipExecution=true; no build run"], }; } const out = spawnSync("npx", ["nx", "build", "shell-docs"], { cwd: REPO_ROOT, encoding: "utf-8", }); if (out.status === 0) { return { name: "nx-build-shell-docs", status: "pass", messages: [] }; } return { name: "nx-build-shell-docs", status: "fail", messages: [out.stdout || "", out.stderr || ""].filter(Boolean), }; } interface RegistryDemo { id: string; } interface RegistryIntegrationLite { slug: string; demos: RegistryDemo[]; } interface RegistryLite { integrations: RegistryIntegrationLite[]; } // Strip fenced code blocks (``` ... ```) before scanning for component // references / links / imports. Without this, every regex below false- // positives on example code inside tutorial pages — e.g. a docs page // that shows `` in a fenced code // sample would report "unknown demo id" even though it's literal // documentation, not a live component reference. const FENCED_CODE_RE = /```[\s\S]*?```/g; function strip(body: string): string { return body.replace(FENCED_CODE_RE, ""); } const INLINE_DEMO_RE = /]*demo=["']([^"']+)["']/g; export function checkInlineDemoRefs(input: { pages: PageInput[]; registry: RegistryLite; }): CheckResult { const known = new Set(); for (const i of input.registry.integrations) { for (const d of i.demos) { known.add(d.id); } } const failures: string[] = []; for (const page of input.pages) { const body = strip(page.body); INLINE_DEMO_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = INLINE_DEMO_RE.exec(body)) !== null) { if (!known.has(m[1])) { failures.push(`${page.path}: unknown demo id "${m[1]}"`); } } } return { name: "inline-demo-refs", status: failures.length === 0 ? "pass" : "fail", messages: failures, }; } interface DemoRegion { file: string; startLine: number; endLine: number; code: string; language: string; } interface DemoFile { filename: string; language: string; content: string; } interface DemoRecord { regions?: Record; files?: DemoFile[]; } interface DemoContent { demos: Record; } const SNIPPET_RE = /]*region=["']([^"']+)["']/g; export function checkSnippetRegions(input: { pages: PageInput[]; demoContent: DemoContent; }): CheckResult { const allRegions = new Set(); for (const record of Object.values(input.demoContent.demos)) { for (const regionName of Object.keys(record.regions ?? {})) { allRegions.add(regionName); } } const failures: string[] = []; for (const page of input.pages) { const body = strip(page.body); SNIPPET_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = SNIPPET_RE.exec(body)) !== null) { if (!allRegions.has(m[1])) { failures.push(`${page.path}: unknown snippet region "${m[1]}"`); } } } return { name: "snippet-regions", status: failures.length === 0 ? "pass" : "fail", messages: failures, }; } const MD_LINK_RE = /\[[^\]]*\]\((\/[^)\s]*)\)/g; export function checkInternalLinks(input: { pages: PageInput[]; knownRoutes: Set; }): CheckResult { const failures: string[] = []; for (const page of input.pages) { const body = strip(page.body); MD_LINK_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = MD_LINK_RE.exec(body)) !== null) { const raw = m[1]; const cleaned = raw.split("#")[0].split("?")[0]; if (!input.knownRoutes.has(cleaned)) { failures.push(`${page.path}: dead link "${raw}"`); } } } return { name: "internal-links", status: failures.length === 0 ? "pass" : "fail", messages: failures, }; } const ALIAS_IMPORT_RE = /^\s*import\s+[^"';]+from\s+["'](@\/[^"']+)["']\s*;?\s*$/gm; export function checkImportPaths(input: { pages: PageInput[]; existsOnDisk: (importPath: string) => boolean; }): CheckResult { const failures: string[] = []; for (const page of input.pages) { const body = strip(page.body); ALIAS_IMPORT_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = ALIAS_IMPORT_RE.exec(body)) !== null) { if (!input.existsOnDisk(m[1])) { failures.push(`${page.path}: unresolved import "${m[1]}"`); } } } return { name: "import-paths", status: failures.length === 0 ? "pass" : "fail", messages: failures, }; } export function runEssentialContentCheck(pages: PageInput[]): CheckResult { const messages: string[] = []; for (const page of pages) { const r = checkEssentialContent(page); if (r.status === "fail") messages.push(...r.messages); } return { name: "essential-content", status: messages.length === 0 ? "pass" : "fail", messages, }; } function loadPages(): PageInput[] { const docsRoot = path.join( REPO_ROOT, "showcase", "shell-docs", "src", "content", "docs", ); const files = glob.sync("**/*.mdx", { cwd: docsRoot }); return files.map((rel) => ({ path: rel, body: fs.readFileSync(path.join(docsRoot, rel), "utf-8"), })); } function loadRegistry(): RegistryLite { const p = path.join( REPO_ROOT, "showcase", "shell-docs", "src", "data", "registry.json", ); return JSON.parse(fs.readFileSync(p, "utf-8")) as RegistryLite; } function loadDemoContent(): DemoContent { const p = path.join( REPO_ROOT, "showcase", "shell-docs", "src", "data", "demo-content.json", ); return JSON.parse(fs.readFileSync(p, "utf-8")) as DemoContent; } function loadKnownRoutes(): Set { const docsRoot = path.join( REPO_ROOT, "showcase", "shell-docs", "src", "content", "docs", ); const files = glob.sync("**/*.mdx", { cwd: docsRoot }); const routes = new Set(); for (const rel of files) { const noExt = rel.replace(/\.mdx$/, ""); const noIndex = noExt.endsWith("/index") ? noExt.slice(0, -"/index".length) : noExt; // Strip route-group segments like (other), (foo) — they are removed // from the URL by Fumadocs/Next.js. Mirrors normalizeSlugForUrl in // showcase/shell-docs/src/lib/sitemap-helpers.ts. const normalized = noIndex .split("/") .filter((seg) => !/^\(.+\)$/.test(seg)) .join("/"); routes.add(normalized === "" ? "/" : "/" + normalized); } routes.add("/"); // root return routes; } function aliasExists(importPath: string): boolean { const stripped = importPath.replace(/^@\//, ""); const root = path.join(REPO_ROOT, "showcase", "shell-docs", "src"); return ( fs.existsSync(path.join(root, stripped)) || fs.existsSync(path.join(root, "content", stripped)) ); } export function loadSnippetComponentNames(): Set { const docsRenderPath = path.join( REPO_ROOT, "showcase/shell-docs/src/lib/docs-render.tsx", ); const src = fs.readFileSync(docsRenderPath, "utf-8"); const mapMatch = src.match(/export const SNIPPET_MAP[^{]*\{([^}]+)\}/s); if (!mapMatch) throw new Error("Could not find SNIPPET_MAP in docs-render.tsx"); const keys = [...mapMatch[1].matchAll(/^\s*(\w+)\s*:/gm)].map((m) => m[1]); return new Set(keys); } const SNIPPET_WITH_PROPS_RE = /<([A-Z]\w*)\s+[^>]*(?:>|\/>)/g; // Matches the same shape that inlineSnippets() in docs-render.tsx resolves at // render time. Per-match check (not per-name) so a page with both and // correctly flags only the latter. const INLINE_HANDLED_RE = /^<[A-Z]\w*\s*(?:components=\{[^}]*\}\s*)?\/>/; const MDX_IMPORT_NAME_RE = /^\s*import\s+(\w+)\s+from\s+["']@\/snippets\//gm; export function checkComponentImports({ pages, }: { pages: PageInput[]; }): CheckResult { const snippetComponents = loadSnippetComponentNames(); const failures: string[] = []; for (const page of pages) { const body = strip(page.body); const imported = new Set(); MDX_IMPORT_NAME_RE.lastIndex = 0; let im: RegExpExecArray | null; while ((im = MDX_IMPORT_NAME_RE.exec(page.body)) !== null) { imported.add(im[1]); } SNIPPET_WITH_PROPS_RE.lastIndex = 0; const flagged = new Set(); let m: RegExpExecArray | null; while ((m = SNIPPET_WITH_PROPS_RE.exec(body)) !== null) { const name = m[1]; if ( snippetComponents.has(name) && !imported.has(name) && !INLINE_HANDLED_RE.test(m[0]) && !flagged.has(name) ) { flagged.add(name); failures.push( `${page.path}: <${name}> used with props but missing snippet import`, ); } } } return { name: "component-imports", status: failures.length === 0 ? "pass" : "fail", messages: failures, }; } async function main() { const skipBuild = process.argv.includes("--skip-build"); const pages = loadPages(); const registry = loadRegistry(); const demoContent = loadDemoContent(); const knownRoutes = loadKnownRoutes(); const results: CheckResult[] = [ runBuildCheck({ skipExecution: skipBuild }), checkInlineDemoRefs({ pages, registry }), checkSnippetRegions({ pages, demoContent }), checkInternalLinks({ pages, knownRoutes }), checkImportPaths({ pages, existsOnDisk: aliasExists }), checkComponentImports({ pages }), runEssentialContentCheck(pages), ]; let failed = false; for (const r of results) { const tag = r.status === "pass" ? "PASS" : r.status === "fail" ? "FAIL" : "SKIP"; console.log(`[${tag}] ${r.name}`); for (const msg of r.messages) { console.log(` ${msg}`); } if (r.status === "fail") failed = true; } process.exit(failed ? 1 : 0); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((e) => { console.error(e); process.exit(1); }); }