import { register } from 'node:module'; import { pathToFileURL } from 'node:url'; import { join, dirname, resolve } from 'node:path'; import { writeFileSync, existsSync, writeSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; register('./scripts/loader.mjs', pathToFileURL('./')); register('fumadocs-mdx/node/loader', import.meta.url); import { type FileObject, printErrors, validateFiles, } from 'next-validate-link'; try { // Dynamic import to ensure loader is registered before importing source const { source, versionSource } = await import('../lib/source'); const PUBLIC_ROOT = resolve('public'); // Simple p-limit implementation to avoid adding dependency function pLimit(concurrency: number) { const queue: (() => void)[] = []; let active = 0; const next = () => { active--; if (queue.length > 0) { queue.shift()!(); } }; const run = async (fn: () => Promise): Promise => { if (active >= concurrency) { await new Promise((resolve) => queue.push(resolve)); } active++; try { return await fn(); } finally { next(); } }; return run; } async function checkLinks() { const pages = [...source.getPages(), ...versionSource.getPages()]; console.log(`Total pages found: ${pages.length}`); const allFiles = await getFiles(); const filesToCheck = allFiles.filter((f) => !f.isVersioned); const allFilesAsTargets = allFiles; // Load redirects and add them to the scanned URLs let redirects: any[] = []; if (existsSync('redirects.json')) { redirects = JSON.parse(await readFile('redirects.json', 'utf-8')); } const scanned = { urls: new Map([ // Add /docs specifically as it's the home page and might be linked to ['/docs', { hashes: [] }], // Load redirects first ...redirects.map((r: any): [string, { hashes: string[] }] => [r.source, { hashes: [] }]), // Load files second, so they overwrite redirects with actual headings ...allFilesAsTargets.map((f): [string, { hashes: string[] }] => [f.url!, { hashes: getHeadings(f) }]), ]), fallbackUrls: [], }; writeFileSync('scanned-urls.json', JSON.stringify(Array.from(scanned.urls.keys()), null, 2)); console.log(`Manually populated URLs count: ${scanned.urls.size}`); console.time('validateFiles'); let linkErrors: any[] = []; let currentFile = ''; process.on('uncaughtException', (err) => { writeSync(1, `\n\n!!! CRITICAL ERROR in ${currentFile} !!!\n`); console.error(err); process.exit(1); }); for (const file of filesToCheck) { currentFile = file.path; writeSync(1, `Checking: ${file.path}\n`); try { const errors = await validateFiles([file], { scanned, baseUrl: '/docs', markdown: { components: { Card: { attributes: ['href'] }, }, }, checkRelativePaths: 'as-url', }); linkErrors.push(...errors); } catch (err: any) { console.error(`\nFAILED file: ${file.path}`); console.error('Error Details:', err); if (file.content) { console.error('First 15 lines of content:'); console.error(file.content.split('\n').slice(0, 15).join('\n')); } throw err; } } console.timeEnd('validateFiles'); printErrors(linkErrors, false); console.time('checkImages'); const imageErrors = await checkImages(filesToCheck); console.timeEnd('checkImages'); // Check for .md files const mdFiles = filesToCheck.filter(f => f.path.endsWith('.md')); if (mdFiles.length > 0) { console.error('\n❌ Error: Found .md files. All content files must use .mdx extension:'); mdFiles.forEach(f => console.error(` - ${f.path}`)); process.exit(1); } if (linkErrors.length === 0 && !imageErrors) { console.log('\n✅ All checks passed: No broken links or images found.'); } if (linkErrors.length > 0 || imageErrors) { process.exit(1); } } async function checkImages(files: (FileObject & { isVersioned?: boolean })[]): Promise { let hasErrors = false; const limit = pLimit(50); // Limit to 50 concurrent file checks // Flat list of all image checks const allChecks: Promise[] = []; for (const file of files) { allChecks.push(limit(async () => { const content = file.content; const imageRegex = /!\[.*?\]\((.*?)\)|(); if (data.toc && data.toc.length > 0) { data.toc.forEach((item: any) => headings.add(item.url.slice(1))); } if (data.structuredData?.headings) { data.structuredData.headings.forEach((h: any) => headings.add(h.id)); } // Fallback: parse content directly if (content) { const lines = content.split('\n'); let inCodeBlock = false; for (const line of lines) { if (line.trim().startsWith('```')) { inCodeBlock = !inCodeBlock; continue; } if (inCodeBlock) continue; const match = line.match(/^(#{1,6})\s+(.+)$/); if (match) { const title = match[2].trim(); const slug = title .toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/_/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-'); headings.add(slug); } } } return Array.from(headings); } async function getFiles() { const pages = [...source.getPages(), ...versionSource.getPages()]; const promises = pages.map( async (page: any): Promise => { const path = page.file?.path || page.absolutePath; return { path, content: await readFile(path, 'utf8'), // REMOVED /docs prefix here to ensure strict link checking against actual routes url: page.url.startsWith('/') ? page.url : '/' + page.url, data: page.data, isVersioned: path.includes('/v4.') || page.url.startsWith('v4.'), }; }, ); return Promise.all(promises); } await checkLinks(); } catch (err) { writeSync(1, `\n\n!!! TOP-LEVEL ERROR !!!\n`); console.error(err); process.exit(1); }