#!/usr/bin/env node import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** * Crawler pour détecter les liens cassés dans la documentation Veza * Génère un inventaire des slugs et détecte les liens cassés */ // Configuration const DOCS_DIR = path.join(__dirname, '..', 'docs'); const META_DIR = path.join(__dirname, '..', 'meta'); const OUTPUT_CSV = path.join(META_DIR, 'broken-links.csv'); const SLUG_INVENTORY = path.join(META_DIR, 'slug-inventory.json'); // Patterns pour détecter les liens const LINK_PATTERNS = [ // Liens internes Docusaurus /\[([^\]]+)\]\(\/docs\/([^)]+)\)/g, // Liens avec ancres /\[([^\]]+)\]\(\/docs\/([^#)]+)(#[^)]+)?\)/g, // Liens relatifs /\[([^\]]+)\]\(([^)]+\.mdx?)(#[^)]+)?\)/g, // Liens avec placeholders /\[([^\]]+)\]\(\/docs\/([^{}]+)\{([^}]+)\}([^)]+)?\)/g, ]; // Inventaire des slugs existants const slugInventory = new Map(); const brokenLinks = []; /** * Extrait tous les slugs des fichiers MDX */ function extractSlugs() { console.log('🔍 Extraction des slugs...'); function scanDirectory(dir) { const items = fs.readdirSync(dir, { withFileTypes: true }); for (const item of items) { const fullPath = path.join(dir, item.name); if (item.isDirectory()) { scanDirectory(fullPath); } else if (item.isFile() && (item.name.endsWith('.mdx') || item.name.endsWith('.md'))) { const relativePath = path.relative(DOCS_DIR, fullPath); const slug = relativePath .replace(/\.mdx?$/, '') .replace(/\\/g, '/'); // Ajouter différentes variantes du slug slugInventory.set(slug, { file: relativePath, fullPath, exists: true }); // Ajouter la version avec index if (item.name === 'index.mdx' || item.name === 'index.md') { const parentSlug = path.dirname(slug); if (parentSlug !== '.') { slugInventory.set(parentSlug, { file: relativePath, fullPath, exists: true }); } } } } } scanDirectory(DOCS_DIR); console.log(`✅ ${slugInventory.size} slugs extraits`); } /** * Analyse un fichier pour détecter les liens cassés */ function analyzeFile(filePath) { const content = fs.readFileSync(filePath, 'utf8'); const relativePath = path.relative(DOCS_DIR, filePath); // Diviser le contenu en lignes pour détecter les blocs de code const lines = content.split('\n'); let inCodeBlock = false; let codeBlockType = ''; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Détecter le début d'un bloc de code if (line.trim().startsWith('```')) { if (!inCodeBlock) { inCodeBlock = true; codeBlockType = line.trim().substring(3); } else { inCodeBlock = false; codeBlockType = ''; } continue; } // Ignorer les lignes dans des blocs de code if (inCodeBlock) { continue; } // Analyser les liens dans cette ligne for (const pattern of LINK_PATTERNS) { let match; while ((match = pattern.exec(line)) !== null) { const [fullMatch, linkText, targetPath, anchor] = match; // Nettoyer le chemin cible let cleanPath = targetPath; if (cleanPath.startsWith('/docs/')) { cleanPath = cleanPath.substring(6); // Enlever /docs/ } // Vérifier si le lien est cassé const isBroken = !isValidLink(cleanPath, anchor); if (isBroken) { brokenLinks.push({ sourceFile: relativePath, linkText: linkText.trim(), targetPath: cleanPath, anchor: anchor || '', fullMatch, line: i + 1, type: detectLinkType(fullMatch) }); } } } } } /** * Vérifie si un lien est valide */ function isValidLink(targetPath, anchor = '') { // Nettoyer le chemin let cleanPath = targetPath; // Enlever les placeholders temporairement cleanPath = cleanPath.replace(/\{[^}]+\}/g, 'PLACEHOLDER'); // Vérifier les variantes possibles const variants = [ cleanPath, cleanPath + '/index', cleanPath.replace(/\/$/, ''), cleanPath.replace(/\/$/, '') + '/index' ]; for (const variant of variants) { if (slugInventory.has(variant)) { return true; } } // Vérifier les liens externes if (targetPath.startsWith('http') || targetPath.startsWith('mailto:')) { return true; } return false; } /** * Détecte le type de lien */ function detectLinkType(fullMatch) { if (fullMatch.includes('{')) return 'placeholder'; if (fullMatch.includes('http')) return 'external'; if (fullMatch.includes('/docs/')) return 'internal'; return 'relative'; } /** * Obtient le numéro de ligne d'une correspondance */ function getLineNumber(content, match) { const lines = content.substring(0, content.indexOf(match)).split('\n'); return lines.length; } /** * Scanne tous les fichiers pour les liens cassés */ function scanBrokenLinks() { console.log('🔍 Scan des liens cassés...'); function scanDirectory(dir) { const items = fs.readdirSync(dir, { withFileTypes: true }); for (const item of items) { const fullPath = path.join(dir, item.name); if (item.isDirectory()) { scanDirectory(fullPath); } else if (item.isFile() && (item.name.endsWith('.mdx') || item.name.endsWith('.md'))) { analyzeFile(fullPath); } } } scanDirectory(DOCS_DIR); console.log(`❌ ${brokenLinks.length} liens cassés détectés`); } /** * Génère le CSV des liens cassés */ function generateCSV() { console.log('📊 Génération du CSV...'); const csvHeader = 'Source File,Link Text,Target Path,Anchor,Type,Line,Full Match\n'; const csvRows = brokenLinks.map(link => `"${link.sourceFile}","${link.linkText}","${link.targetPath}","${link.anchor}","${link.type}",${link.line},"${link.fullMatch.replace(/"/g, '""')}"` ).join('\n'); const csvContent = csvHeader + csvRows; fs.writeFileSync(OUTPUT_CSV, csvContent, 'utf8'); console.log(`✅ CSV généré: ${OUTPUT_CSV}`); } /** * Génère l'inventaire des slugs */ function generateSlugInventory() { console.log('📋 Génération de l\'inventaire des slugs...'); const inventory = { generated: new Date().toISOString(), totalSlugs: slugInventory.size, slugs: Object.fromEntries(slugInventory) }; fs.writeFileSync(SLUG_INVENTORY, JSON.stringify(inventory, null, 2), 'utf8'); console.log(`✅ Inventaire généré: ${SLUG_INVENTORY}`); } /** * Génère un rapport de synthèse */ function generateReport() { const report = { timestamp: new Date().toISOString(), totalSlugs: slugInventory.size, brokenLinks: brokenLinks.length, brokenLinksByType: brokenLinks.reduce((acc, link) => { acc[link.type] = (acc[link.type] || 0) + 1; return acc; }, {}), brokenLinksByFile: brokenLinks.reduce((acc, link) => { acc[link.sourceFile] = (acc[link.sourceFile] || 0) + 1; return acc; }, {}) }; console.log('\n📊 RAPPORT DE SYNTHÈSE'); console.log('===================='); console.log(`Total slugs: ${report.totalSlugs}`); console.log(`Liens cassés: ${report.brokenLinks}`); console.log('\nPar type:'); Object.entries(report.brokenLinksByType).forEach(([type, count]) => { console.log(` ${type}: ${count}`); }); console.log('\nPar fichier:'); Object.entries(report.brokenLinksByFile).forEach(([file, count]) => { console.log(` ${file}: ${count}`); }); } // Exécution principale async function main() { console.log('🚀 Démarrage du crawler Veza Docs'); console.log('=================================='); // Créer le répertoire meta s'il n'existe pas if (!fs.existsSync(META_DIR)) { fs.mkdirSync(META_DIR, { recursive: true }); } try { extractSlugs(); scanBrokenLinks(); generateSlugInventory(); generateCSV(); generateReport(); console.log('\n✅ Crawler terminé avec succès'); process.exit(0); } catch (error) { console.error('❌ Erreur lors du crawl:', error); process.exit(1); } } main();