mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
352 lines
9.4 KiB
JavaScript
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 };
|