import { buildGraph, getBrokenExternalLinks, getFilesLinksDependOn } from '@mintlify/link-rot'; import { addLog, clearLogs, BrokenLinksLog, ErrorLog, SpinnerLog, SuccessLog, WarningLog, } from '@mintlify/previewing'; import fs from 'node:fs/promises'; import path from 'path'; import { autoUpgradeIfNeeded, CMD_EXEC_PATH, findDocsRoot, terminate } from './helpers.js'; import { resolveExplicitFiles } from './resolveFiles.js'; type BrokenLinksArgs = { files?: (string | number)[]; 'check-anchors': boolean; 'check-external': boolean; 'check-snippets': boolean; 'check-redirects': boolean; }; export const brokenLinksHandler = async (argv: BrokenLinksArgs): Promise => { await autoUpgradeIfNeeded(); addLog(); try { const docsRoot = await findDocsRoot(CMD_EXEC_PATH); const fileArgs = (argv.files ?? []).map(String).filter(Boolean); const sourceFiles = fileArgs.length > 0 ? new Set( (await resolveExplicitFiles(CMD_EXEC_PATH, fileArgs)).map((file) => path.relative(docsRoot, path.resolve(CMD_EXEC_PATH, file)) ) ) : undefined; // One unparseable page shouldn't hide every other result: report it and keep going const parseErrorsByFile = new Map(); const graph = await buildGraph(docsRoot, { checkSnippets: argv['check-snippets'], onParseError: (filePath, error) => parseErrorsByFile.set(filePath, error.message), }); graph.precomputeFileResolutions(); // With --files, a failure elsewhere only matters when a selected link needs that // file: an anchor pointing at it, or a snippet the selected page renders const dependencies = sourceFiles ? getFilesLinksDependOn(graph, sourceFiles, { checkAnchors: argv['check-anchors'], checkSnippets: argv['check-snippets'], }) : undefined; const parseErrors = [...parseErrorsByFile] .filter( ([filePath]) => !sourceFiles || sourceFiles.has(filePath) || dependencies?.has(filePath.split(path.sep).join('/')) ) .map(([, message]) => message); const brokenInternalLinks = graph .getBrokenInternalLinks({ checkAnchors: argv['check-anchors'], }) .filter( ({ relativeDir, filename }) => !sourceFiles || sourceFiles.has(path.join(relativeDir, filename)) ); const brokenLinksByFile: Record = {}; brokenInternalLinks.forEach((mdxPath) => { const filename = path.join(mdxPath.relativeDir, mdxPath.filename); const existing = brokenLinksByFile[filename]; if (existing) { existing.push(mdxPath.originalPath); } else { brokenLinksByFile[filename] = [mdxPath.originalPath]; } }); if (argv['check-external']) { const brokenExternalLinks = await getBrokenExternalLinks( graph, sourceFiles ? { sourceFiles } : undefined ); for (const result of brokenExternalLinks) { for (const source of result.sources) { const label = result.status ? `${result.url} (${result.status})` : `${result.url} (${result.error})`; const existing = brokenLinksByFile[source.file]; if (existing) { existing.push(label); } else { brokenLinksByFile[source.file] = [label]; } } } } if (argv['check-redirects']) { const brokenRedirects = graph.getBrokenRedirects(); if (brokenRedirects.length > 0) { const configFilename = await fs .access(path.join(docsRoot, 'docs.json')) .then(() => 'docs.json') .catch(() => 'mint.json'); const labels = brokenRedirects.map( ({ source, destination }) => `${source} → ${destination}` ); const existing = brokenLinksByFile[configFilename]; if (existing) { existing.push(...labels); } else { brokenLinksByFile[configFilename] = labels; } } } const hasBrokenLinks = Object.keys(brokenLinksByFile).length > 0; if (!hasBrokenLinks && parseErrors.length === 0) { clearLogs(); addLog(); await terminate(0); } clearLogs(); if (hasBrokenLinks) { addLog(); } else { addLog(); } for (const message of parseErrors) { addLog(); } if (parseErrors.length > 0) { addLog( ); } } catch (err) { addLog(); await terminate(1); } await terminate(1); };