Files
copilotkit__copilotkit/docs/scripts/check-broken-links.js

352 lines
9.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Script to check for broken internal links in the documentation
* This helps identify broken links before they reach users
*/
const fs = require('fs');
const path = require('path');
// Configuration
const DOCS_DIR = 'content/docs';
const COMPONENTS_DIR = 'components';
const EXCLUDE_PATTERNS = ['**/node_modules/**', '**/dist/**', '**/build/**'];
const NEXT_CONFIG_PATH = 'next.config.mjs';
/**
* Parse redirects from next.config.mjs by executing it
*/
async function parseRedirects() {
try {
// Import the next.config.mjs module
const configModule = await import('../next.config.mjs');
const config = configModule.default;
// Execute the redirects function to get all redirects (including auto-generated)
if (config.redirects && typeof config.redirects === 'function') {
const redirects = await config.redirects();
return redirects;
}
} catch (error) {
console.warn('Warning: Could not load redirects from next.config.mjs:', error.message);
}
return [];
}
/**
* Extract all markdown links and JSX href attributes from a file
*/
function extractLinks(filePath, content) {
const links = [];
// Match markdown links [text](url)
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let match;
while ((match = markdownLinkRegex.exec(content)) !== null) {
const [, text, url] = match;
// Skip external links
if (url.startsWith('http') || url.startsWith('mailto:') || url.startsWith('tel:')) {
continue;
}
// Skip anchor links
if (url.startsWith('#')) {
continue;
}
// Remove anchors from internal links
const cleanUrl = url.split('#')[0];
if (!cleanUrl) continue; // Skip if it was only an anchor
links.push({
text: text.trim(),
url: cleanUrl.trim(),
file: filePath,
line: content.substring(0, match.index).split('\n').length
});
}
// Also extract JSX href attributes: href="..." or href='...'
const jsxHrefRegex = /href=["']([^"']+)["']/g;
while ((match = jsxHrefRegex.exec(content)) !== null) {
const [fullMatch, url] = match;
// Skip external links
if (url.startsWith('http') || url.startsWith('mailto:') || url.startsWith('tel:')) {
continue;
}
// Skip anchor links
if (url.startsWith('#')) {
continue;
}
// Remove anchors from internal links
const cleanUrl = url.split('#')[0];
if (!cleanUrl) continue; // Skip if it was only an anchor
links.push({
text: `<${fullMatch}>`,
url: cleanUrl.trim(),
file: filePath,
line: content.substring(0, match.index).split('\n').length
});
}
return links;
}
/**
* Normalize file path to URL path
* Handles Fumadocs routing conventions:
* - Removes route groups like (root), (other)
* - Removes /integrations/ prefix
* - Converts index.mdx to parent folder
*/
function filePathToUrl(relativePath) {
let parts = relativePath.replace(/\.mdx$/, '').split('/');
// Remove route groups (folders wrapped in parentheses)
parts = parts.filter(part => !part.match(/^\([^)]+\)$/));
// Remove 'integrations' prefix if present
if (parts[0] === 'integrations') {
parts.shift();
}
// Handle index files - remove 'index' from the end
if (parts[parts.length - 1] === 'index') {
parts.pop();
}
// Join and ensure we have a clean path
const url = parts.join('/');
return url || '/'; // Root if empty
}
/**
* Check if a link is valid
*/
function isValidLink(url, allPages, sourceFile = null, redirects = []) {
// Handle absolute links (starting with /)
if (url.startsWith('/')) {
// Remove leading slash and normalize
const normalizedUrl = url.slice(1);
// Remove trailing slash
const cleanUrl = normalizedUrl.replace(/\/$/, '');
// Check if there's a redirect for this URL
const redirect = redirects.find(r => {
// Match exact path or with wildcards
const sourcePath = r.source.replace(/:\w+\*/g, '.*');
const regex = new RegExp(`^${sourcePath}$`);
return regex.test(url);
});
// If there's a redirect, validate the destination instead
if (redirect) {
const destUrl = redirect.destination.slice(1).replace(/\/$/, ''); // Remove leading / and trailing /
return allPages.some(page => {
const pageUrl = page.url.replace(/\/$/, '');
return pageUrl === destUrl;
});
}
// Check if page exists
return allPages.some(page => {
const pageUrl = page.url.replace(/\/$/, '');
return pageUrl === cleanUrl || pageUrl === normalizedUrl;
});
}
// Handle relative links (anything not starting with /)
// This includes: ./foo, ../foo, and just foo
if (sourceFile) {
// Get the directory of the source file relative to DOCS_DIR
const relativePath = path.relative(DOCS_DIR, sourceFile);
const sourceDir = path.dirname(relativePath);
// Resolve the relative link
const resolvedPath = path.join(sourceDir, url);
// Normalize the path (removes ./ and ../)
const normalizedPath = path.normalize(resolvedPath);
// Convert to URL format (strip route groups, handle index files, etc.)
const resolvedUrl = filePathToUrl(normalizedPath);
// Check if this resolved path exists in allPages
return allPages.some(page => {
const pageUrl = page.url.replace(/\/$/, '');
const checkUrl = resolvedUrl.replace(/\/$/, '');
return pageUrl === checkUrl || pageUrl === resolvedUrl;
});
}
// Fallback: no source file provided, can't resolve relative links
return false;
}
/**
* Recursively find all files with given extensions in a directory
*/
function findFiles(dir, extensions) {
const files = [];
try {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
// Recursively search subdirectories
files.push(...findFiles(fullPath, extensions));
} else if (extensions.some(ext => item.endsWith(ext))) {
files.push(fullPath);
}
}
} catch (error) {
console.error(`Error reading directory ${dir}:`, error.message);
}
return files;
}
/**
* Recursively find all .mdx files in a directory
*/
function findMdxFiles(dir) {
return findFiles(dir, ['.mdx']);
}
/**
* Find all .tsx and .jsx component files
*/
function findComponentFiles(dir) {
return findFiles(dir, ['.tsx', '.jsx']);
}
/**
* Get all available pages with their URL mappings
*/
function getAllPages() {
const pages = [];
try {
// Walk through the docs directory and find all .mdx files
const files = findMdxFiles(DOCS_DIR);
files.forEach(file => {
const relativePath = path.relative(DOCS_DIR, file);
const url = filePathToUrl(relativePath);
pages.push({
url: url,
file: file
});
});
} catch (error) {
console.error('Error reading pages:', error);
}
return pages;
}
/**
* Main function
*/
async function main() {
console.log('🔍 Checking for broken links...\n');
const redirects = await parseRedirects();
console.log(`🔀 Found ${redirects.length} redirects in next.config.mjs\n`);
const allPages = getAllPages();
const allLinks = [];
const brokenLinks = [];
// Find all .mdx files
const mdxFiles = findMdxFiles(DOCS_DIR);
// Find all component files (.tsx, .jsx)
const componentFiles = findComponentFiles(COMPONENTS_DIR);
console.log(`📁 Found ${mdxFiles.length} documentation files`);
console.log(`🧩 Found ${componentFiles.length} component files`);
console.log(`📄 Found ${allPages.length} pages\n`);
// Extract links from documentation files
mdxFiles.forEach(file => {
try {
const content = fs.readFileSync(file, 'utf8');
const links = extractLinks(file, content);
allLinks.push(...links);
} catch (error) {
console.error(`Error reading ${file}:`, error.message);
}
});
// Extract links from component files
componentFiles.forEach(file => {
try {
const content = fs.readFileSync(file, 'utf8');
const links = extractLinks(file, content);
allLinks.push(...links);
} catch (error) {
console.error(`Error reading ${file}:`, error.message);
}
});
console.log(`🔗 Found ${allLinks.length} internal links\n`);
// Check each link
allLinks.forEach(link => {
if (!isValidLink(link.url, allPages, link.file, redirects)) {
brokenLinks.push(link);
}
});
// Report results
if (brokenLinks.length === 0) {
console.log('✅ No broken links found!');
return 0; // Success
} else {
console.log(`❌ Found ${brokenLinks.length} broken links:\n`);
brokenLinks.forEach(link => {
console.log(` 📄 ${link.file}:${link.line}`);
console.log(` Link: [${link.text}](${link.url})`);
console.log('');
});
console.log('💡 Suggestions:');
console.log(' - Check if the file exists');
console.log(' - Verify the path is correct');
console.log(' - Consider adding redirects in middleware.ts');
console.log(' - Update the link to point to the correct page');
return 1; // Error - broken links found
}
}
// Run the script
if (require.main === module) {
main()
.then(exitCode => {
process.exit(exitCode || 0);
})
.catch(error => {
console.error(error);
process.exit(1);
});
}
module.exports = { extractLinks, isValidLink, getAllPages, filePathToUrl, parseRedirects };