import chalk from 'chalk'; import * as cheerio from 'cheerio'; import { IApi } from 'dumi'; import * as fs from 'fs'; import * as glob from 'glob'; import merge from 'lodash.merge'; import pLimit from 'p-limit'; import * as path from 'path'; interface DeadLinkOptions { // 是否开启死链检查,默认是 true enable?: boolean; // 构建输出目录,默认是 dist distDir?: string; // 检查外部链接,默认是 true checkExternalLinks?: boolean; // 忽略的链接,默认是 ['^#', '^mailto:', '^tel:', '^javascript:', '^data:', '.*stackblitz\\.com.*'] ignorePatterns?: (string | RegExp)[]; // 检查的文件扩展名,默认是 ['.html'] fileExtensions?: string[]; // 失败时是否退出,默认是 false failOnError?: boolean; // 外部链接超时时间,默认是 10000 externalLinkTimeout?: number; // 最大并发请求数,默认是 5 maxConcurrentRequests?: number; } interface DeadLinkConfig extends Omit { ignorePatterns: RegExp[]; } interface LinkInfo { url: string; text: string; sourceFile: string; isExternal: boolean; } interface DeadLink extends LinkInfo { reason: string; } interface CheckResult { totalLinks: number; deadLinks: DeadLink[]; success: boolean; } const defaultConfig: DeadLinkOptions = { enable: true, distDir: 'dist', checkExternalLinks: false, ignorePatterns: ['^#', '^mailto:', '^tel:', '^javascript:', '^data:', '.*stackoverflow\\.com.*'], fileExtensions: ['.html'], failOnError: false, externalLinkTimeout: 10000, maxConcurrentRequests: 5, }; // 在文件顶部添加缓存对象声明 const tempCache: Record = {}; /** * 处理配置,转换正则表达式 */ function processConfig(options: DeadLinkOptions): DeadLinkConfig { return { ...options, ignorePatterns: options.ignorePatterns.map((pattern) => typeof pattern === 'string' ? new RegExp(pattern) : pattern, ), }; } /** * 收集HTML文件中的所有链接 */ function collectLinks(htmlFiles: string[], distDir: string): LinkInfo[] { const links: LinkInfo[] = []; htmlFiles.forEach((htmlFile) => { const filePath = path.join(distDir, htmlFile); const content = fs.readFileSync(filePath, 'utf-8'); const $ = cheerio.load(content); $('a').each((_, element) => { const url = $(element).attr('href'); if (!url) return; links.push({ url, text: $(element).text().trim() || '[No text]', sourceFile: htmlFile, isExternal: url.startsWith('http://') || url.startsWith('https://'), }); }); }); return links; } /** * 过滤掉被忽略的链接 */ function filterIgnoredLinks(links: LinkInfo[], ignorePatterns: RegExp[]): LinkInfo[] { return links.filter((link) => { return !ignorePatterns.some((pattern) => pattern.test(link.url)); }); } /** * 检查内部链接 */ function checkInternalLinks(links: LinkInfo[], existingFiles: Set): DeadLink[] { const deadLinks: DeadLink[] = []; links.forEach((link) => { if (!link.url.startsWith('/') || link.url.startsWith('//')) return; // 移除URL中的锚点部分 let normalizedLink = link.url.split('#')[0]; // 移除URL中的查询参数部分 normalizedLink = normalizedLink.split('?')[0]; if (normalizedLink.endsWith('/')) { normalizedLink += 'index.html'; } const exists = existingFiles.has(normalizedLink) || (path.extname(normalizedLink) === '' && (existingFiles.has(normalizedLink + '/') || existingFiles.has(normalizedLink + '/index.html') || existingFiles.has(normalizedLink + '.html'))); if (!exists) { deadLinks.push({ ...link, reason: 'File not found', }); } }); return deadLinks; } /** * 检查外部链接 */ async function checkExternalLinks(links: LinkInfo[], config: DeadLinkConfig): Promise { const deadLinks: DeadLink[] = []; const limit = pLimit(config.maxConcurrentRequests); // 分离需要检查的链接和已缓存的链接 const uncachedLinks: LinkInfo[] = []; links.forEach((link) => { // 检查缓存中是否已有结果 if (tempCache[link.url]) { // 使用缓存结果 if (!tempCache[link.url].success) { deadLinks.push({ ...link, reason: tempCache[link.url].reason || '未知错误', }); } console.log(chalk.gray(` [cached] ${link.url}`)); } else { uncachedLinks.push(link); } }); // 只检查未缓存的链接 const promises = uncachedLinks.map((link) => { return limit(async () => { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.externalLinkTimeout); const response = await fetch(link.url); clearTimeout(timeoutId); // 存入缓存 if (response.status >= 400) { tempCache[link.url] = { success: false, reason: `Status code ${response.status}`, }; deadLinks.push({ ...link, reason: `Status code ${response.status}`, }); } else { tempCache[link.url] = { success: true }; } } catch (error) { const reason = error instanceof Error ? error.message : String(error); // 存入缓存 tempCache[link.url] = { success: false, reason }; deadLinks.push({ ...link, reason, }); } }); }); await Promise.all(promises); return deadLinks; } /** * 执行死链检查 */ async function runCheck(config: DeadLinkConfig): Promise { const distDir = path.resolve(process.cwd(), config.distDir); if (!fs.existsSync(distDir)) { return { totalLinks: 0, deadLinks: [], success: false, }; } const htmlFiles = glob.sync(`**/*+(${config.fileExtensions.join('|')})`, { cwd: distDir }); const existingFiles = new Set(); glob.sync('**/*', { cwd: distDir, nodir: true }).forEach((file) => { existingFiles.add('/' + file); }); const allLinks = collectLinks(htmlFiles, distDir); const linksToCheck = filterIgnoredLinks(allLinks, config.ignorePatterns); const internalDeadLinks = checkInternalLinks( linksToCheck.filter((link) => !link.isExternal), existingFiles, ); const externalDeadLinks = config.checkExternalLinks ? await checkExternalLinks( linksToCheck.filter((link) => link.isExternal), config, ) : []; const deadLinks = [...internalDeadLinks, ...externalDeadLinks]; return { totalLinks: allLinks.length, deadLinks, success: deadLinks.length === 0, }; } /** * 生成死链检查报告 */ function generateReport(result: CheckResult): void { if (result.deadLinks.length === 0) { console.log(chalk.green(`✓ Check completed: All ${result.totalLinks} links are valid`)); console.log(); return; } const reportFile = path.join(process.cwd(), 'dead-links-report.log'); const linksByFile = result.deadLinks.reduce((acc, link) => { if (!acc[link.sourceFile]) { acc[link.sourceFile] = []; } acc[link.sourceFile].push(link); return acc; }, {} as Record); // 准备详细报告内容 const reportLines = [ `Dead Links Report (${new Date().toISOString()})`, `Found ${result.deadLinks.length}/${result.totalLinks} dead links in ${Object.keys(linksByFile).length} files`, '', ]; Object.entries(linksByFile).forEach(([file, links]) => { reportLines.push(`File: ${file}`); links.forEach((link) => { reportLines.push(` ✗ ${link.url}`); reportLines.push(` • Text: ${link.text}`); reportLines.push(` • Reason: ${link.reason}`); }); reportLines.push(''); }); // 写入详细报告到文件 try { fs.writeFileSync(reportFile, reportLines.join('\n'), 'utf-8'); // 确保 .gitignore 包含报告文件 const gitignorePath = path.join(process.cwd(), '.gitignore'); const gitignoreContent = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : ''; if (!gitignoreContent.includes('dead-links-report.log')) { fs.appendFileSync(gitignorePath, '\n# Dead links report\ndead-links-report.log\n'); } // 控制台只输出简要信息 console.log(); console.log(chalk.yellow('📊 Dead Links Summary:')); console.log(chalk.yellow(`Found ${result.deadLinks.length} dead links in ${Object.keys(linksByFile).length} files`)); console.log(); // 每个文件只显示概要信息 Object.entries(linksByFile).forEach(([file, links]) => { console.log( chalk.red(`✗ ${file}`), chalk.gray(`(${links.length} dead ${links.length === 1 ? 'link' : 'links'})`) ); }); console.log(); console.log(chalk.cyan(`💡 Detailed report: ${reportFile}`)); console.log(); } catch (error) { // 写入失败时的错误处理 console.error(chalk.red('Failed to write report file:'), error); // 回退到控制台完整输出 console.log(reportLines.join('\n')); } } /** * dumi 死链检查插件 */ export default (api: IApi) => { // 从 themeConfig 中获取配置 const getConfig = (): DeadLinkConfig => { const themeConfig = (api.config.themeConfig || {}) as any; let userConfig = themeConfig?.deadLinkChecker; if (!userConfig) { userConfig = { enable: false }; } const config = merge({}, defaultConfig, userConfig); // 检查是否禁用 if (!config.enable) { return processConfig({ ...config, // 设置为空数组,使插件不执行任何检查 fileExtensions: [], }); } // 合并默认配置和用户配置 return processConfig(config); }; const checkLinks = async (onBeforeCheck?: () => void) => { const config = getConfig(); // 如果禁用了功能或文件扩展名为空,则跳过检查 if (config.fileExtensions.length === 0) { return; } onBeforeCheck?.(); console.log(chalk.gray('🔍 Checking for dead links...')); const result = await runCheck(config); generateReport(result); // 只有在发现死链接且配置为失败时退出 if (!result.success && config.failOnError) { process.exit(1); } }; return checkLinks; };