#!/usr/bin/env node const fs = require("node:fs/promises"); const path = require("node:path"); const readline = require("node:readline"); const { collectBundles } = require("./lib/content"); const CONTENT_DIR = path.resolve("content"); const IMAGE_EXTENSIONS = new Set([ ".apng", ".avif", ".bmp", ".gif", ".heic", ".heif", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp", ]); let promptInterface = null; process.stdout.on("error", handleStdoutError); main().then( () => { closePromptInterface(); process.exit(0); }, (error) => { closePromptInterface(); console.error(`❌ Vérification interrompue : ${error.message}`); process.exit(1); } ); /** * Parcourt tous les bundles et affiche uniquement ceux qui contiennent * au moins une image non référencée dans leur fichier index.md. */ async function main() { const bundles = await resolveBundlesToScan(); for (const bundle of bundles) { const unusedImages = await findUnusedBundleImages(bundle); if (unusedImages.length === 0) { continue; } console.log(`${toRelativePath(bundle.dir)}:`); for (const imagePath of unusedImages) { console.log(`- ${toRelativePath(imagePath)}`); } const action = await askBundleAction(); if (action === "1") { await appendUnusedImagesToMarkdown(bundle, unusedImages); console.log("✔ Images ajoutées au markdown."); continue; } await deleteUnusedImages(unusedImages); console.log("✔ Images non utilisées supprimées."); } await cleanupBundleMetadataFiles(bundles); } /** * Demande le mode de recherche puis retourne les bundles à parcourir. * @returns {Promise>} Bundles à analyser. */ async function resolveBundlesToScan() { const prompt = [ "Choisissez le mode de recherche:", "1) Dernier article écrit (défaut)", "2) Recherche globale", "Votre choix [1/2]: ", ].join("\n"); const answer = await askQuestion(prompt); if (answer === "" || answer === "1") { const latestBundle = await findLatestBundle(CONTENT_DIR); if (latestBundle) { return [latestBundle]; } return []; } if (answer === "2") { return collectBundles(CONTENT_DIR); } throw new Error('Choix invalide. Veuillez saisir "1" ou "2".'); } /** * Retourne le bundle le plus récent selon la date de modification du dossier. * @param {string} rootDir Racine du contenu. * @returns {Promise<{dir: string, indexPath: string}|null>} Bundle le plus récent. */ async function findLatestBundle(rootDir) { const bundles = await collectBundles(rootDir); let latestBundle = null; let latestTime = 0; for (const bundle of bundles) { const stat = await fs.stat(bundle.dir); if (stat.mtimeMs > latestTime) { latestBundle = bundle; latestTime = stat.mtimeMs; } } return latestBundle; } /** * Pose une question interactive et retourne la réponse saisie. * @param {string} query Question à afficher. * @returns {Promise} Réponse sans espaces superflus. */ function askQuestion(query) { return new Promise((resolve) => { const rl = getPromptInterface(); let resolved = false; const onClose = () => { if (resolved) { return; } resolved = true; resolve(""); }; rl.once("close", onClose); rl.question(query, (answer) => { if (resolved) { return; } resolved = true; rl.removeListener("close", onClose); resolve(answer.trim()); }); }); } /** * Retourne l'interface readline réutilisable pour toute l'exécution. * @returns {readline.Interface} Interface interactive. */ function getPromptInterface() { if (promptInterface !== null) { return promptInterface; } promptInterface = readline.createInterface({ input: process.stdin, output: process.stdout, }); return promptInterface; } /** * Ferme proprement l'interface readline si elle est ouverte. */ function closePromptInterface() { if (promptInterface === null) { return; } promptInterface.close(); promptInterface = null; } /** * Demande l'action à appliquer au bundle courant. * @returns {Promise<"1"|"2">} Action choisie. */ async function askBundleAction() { const prompt = [ "Action pour ce bundle:", "1) Ajouter les images au markdown", "2) Supprimer les images non utilisées", "Votre choix [1/2]: ", ].join("\n"); const answer = await askQuestion(prompt); if (answer === "1") { return "1"; } if (answer === "2") { return "2"; } throw new Error('Choix invalide. Veuillez saisir "1" ou "2".'); } /** * Ajoute les images manquantes à la section "Images complémentaires". * @param {{dir: string, indexPath: string}} bundle Bundle à modifier. * @param {string[]} unusedImages Chemins absolus des images à ajouter. */ async function appendUnusedImagesToMarkdown(bundle, unusedImages) { const articleText = await fs.readFile(bundle.indexPath, "utf8"); const imageLines = buildImageLines(bundle.dir, unusedImages); const updatedText = insertComplementaryImages(articleText, imageLines); await fs.writeFile(bundle.indexPath, updatedText, "utf8"); } /** * Supprime les images non utilisées du disque. * @param {string[]} imagePaths Chemins absolus des images à supprimer. */ async function deleteUnusedImages(imagePaths) { for (const imagePath of imagePaths) { await fs.unlink(imagePath); } } /** * Construit les lignes Markdown d'inclusion d'images. * @param {string} bundleDir Dossier du bundle. * @param {string[]} imagePaths Chemins absolus des images. * @returns {string[]} Lignes de type ![](images/...) */ function buildImageLines(bundleDir, imagePaths) { const lines = []; for (const imagePath of imagePaths) { const relativePath = toPosixPath(path.relative(bundleDir, imagePath)); lines.push(`![](${relativePath})`); } return lines; } /** * Insère les images dans la section "## Images complémentaires". * @param {string} articleText Contenu de index.md. * @param {string[]} imageLines Lignes Markdown des images à ajouter. * @returns {string} Nouveau contenu Markdown. */ function insertComplementaryImages(articleText, imageLines) { const headingRegex = /^##\s+Images complémentaires\s*$/m; const headingMatch = headingRegex.exec(articleText); const imageBlock = imageLines.join("\n\n"); if (headingMatch === null) { const trimmed = articleText.trimEnd(); return `${trimmed}\n\n## Images complémentaires\n\n${imageBlock}\n`; } const headingEnd = headingMatch.index + headingMatch[0].length; const afterHeading = articleText.slice(headingEnd); const nextHeadingRegex = /\n##\s+/; const nextHeadingMatch = nextHeadingRegex.exec(afterHeading); let sectionEnd = articleText.length; if (nextHeadingMatch !== null) { sectionEnd = headingEnd + nextHeadingMatch.index; } const sectionPrefix = articleText.slice(0, sectionEnd).trimEnd(); const sectionSuffix = articleText.slice(sectionEnd); return `${sectionPrefix}\n\n${imageBlock}\n${sectionSuffix}`; } /** * Retourne la liste des images d'un bundle absentes du texte de l'article. * @param {{dir: string, indexPath: string}} bundle Bundle à analyser. * @returns {Promise} Chemins absolus des images non utilisées. */ async function findUnusedBundleImages(bundle) { const imagesDir = path.join(bundle.dir, "images"); const hasImagesDir = await directoryExists(imagesDir); if (hasImagesDir === false) { return []; } const imageFiles = await collectImageFiles(imagesDir); if (imageFiles.length === 0) { return []; } imageFiles.sort((left, right) => left.localeCompare(right)); const articleText = await fs.readFile(bundle.indexPath, "utf8"); const searchableText = stripMarkdownCode(articleText); const unusedImages = []; for (const imagePath of imageFiles) { const relativePath = toPosixPath(path.relative(bundle.dir, imagePath)); const candidatePaths = buildCandidatePaths(relativePath); const isReferenced = hasAnyReference(searchableText, candidatePaths); if (isReferenced === false) { unusedImages.push(imagePath); } } return unusedImages; } /** * Supprime les fichiers .yaml orphelins dans data/ pour chaque bundle. * Un fichier est conservé si une image du bundle partage le même nom de base. * @param {Array<{dir: string}>} bundles Bundles à nettoyer. */ async function cleanupBundleMetadataFiles(bundles) { for (const bundle of bundles) { await cleanupMetadataFilesForBundle(bundle); } } /** * Nettoie les fichiers .yaml orphelins dans le dossier data/ d'un bundle. * @param {{dir: string}} bundle Bundle à nettoyer. */ async function cleanupMetadataFilesForBundle(bundle) { const imageBaseNames = await collectImageBaseNames(bundle.dir); const dataDir = path.join(bundle.dir, "data"); const hasDataDir = await directoryExists(dataDir); if (hasDataDir === false) { return; } const yamlFiles = await collectYamlFiles(dataDir); for (const yamlPath of yamlFiles) { const yamlBaseName = path.parse(yamlPath).name; if (imageBaseNames.has(yamlBaseName)) { continue; } await fs.unlink(yamlPath); } } /** * Retourne les noms de base des images du bundle (sans extension). * @param {string} bundleDir Dossier du bundle. * @returns {Promise>} Ensemble des noms de base. */ async function collectImageBaseNames(bundleDir) { const imageBaseNames = new Set(); const imagesDir = path.join(bundleDir, "images"); const hasImagesDir = await directoryExists(imagesDir); if (hasImagesDir === false) { return imageBaseNames; } const imageFiles = await collectImageFiles(imagesDir); for (const imagePath of imageFiles) { imageBaseNames.add(path.parse(imagePath).name); } return imageBaseNames; } /** * Indique si au moins une variante de chemin est trouvée dans le texte. * @param {string} articleText Contenu de index.md. * @param {string[]} candidatePaths Variantes à tester. * @returns {boolean} true si une référence est détectée. */ function hasAnyReference(articleText, candidatePaths) { for (const candidatePath of candidatePaths) { if (isPathReferenced(articleText, candidatePath)) { return true; } } return false; } /** * Supprime les zones de code Markdown pour éviter les faux positifs. * @param {string} text Texte brut du fichier Markdown. * @returns {string} Texte nettoyé des portions de code. */ function stripMarkdownCode(text) { const withoutFencedBackticks = text.replace(/```[\s\S]*?```/g, "\n"); const withoutFencedTildes = withoutFencedBackticks.replace(/~~~[\s\S]*?~~~/g, "\n"); return withoutFencedTildes.replace(/`[^`\n]*`/g, ""); } /** * Construit les variantes de chemin pour gérer les chemins encodés. * @param {string} relativePath Chemin relatif au bundle. * @returns {string[]} Variantes uniques du chemin. */ function buildCandidatePaths(relativePath) { const values = [relativePath]; const encoded = encodeURI(relativePath); if (encoded !== relativePath) { values.push(encoded); } return values; } /** * Vérifie si un chemin est référencé comme unité complète dans le texte. * @param {string} articleText Contenu de index.md. * @param {string} pathValue Chemin relatif à rechercher. * @returns {boolean} true si le chemin est présent. */ function isPathReferenced(articleText, pathValue) { const escapedPath = escapeRegExp(pathValue); const before = "(^|[\\s\"'`(=:/])"; const after = "($|[\\s\"'`)#?&,:;.!\\]}<>])"; const pattern = new RegExp(`${before}${escapedPath}${after}`, "u"); return pattern.test(articleText); } /** * Échappe une chaîne pour un usage littéral dans une expression régulière. * @param {string} value Valeur à échapper. * @returns {string} Valeur échappée. */ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Collecte récursivement les fichiers image d'un dossier. * @param {string} rootDir Dossier à parcourir. * @returns {Promise} Liste des fichiers image. */ async function collectImageFiles(rootDir) { const files = []; const entries = await fs.readdir(rootDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(rootDir, entry.name); if (entry.isDirectory()) { const nestedFiles = await collectImageFiles(fullPath); files.push(...nestedFiles); continue; } if (entry.isFile() === false) { continue; } const extension = path.extname(entry.name).toLowerCase(); if (IMAGE_EXTENSIONS.has(extension) === false) { continue; } files.push(fullPath); } return files; } /** * Collecte récursivement les fichiers .yaml d'un dossier. * @param {string} rootDir Dossier à parcourir. * @returns {Promise} Liste des chemins de fichiers .yaml. */ async function collectYamlFiles(rootDir) { const files = []; const entries = await fs.readdir(rootDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(rootDir, entry.name); if (entry.isDirectory()) { const nestedFiles = await collectYamlFiles(fullPath); files.push(...nestedFiles); continue; } if (entry.isFile() === false) { continue; } const extension = path.extname(entry.name).toLowerCase(); if (extension !== ".yaml") { continue; } files.push(fullPath); } return files; } /** * Vérifie l'existence d'un dossier. * @param {string} dirPath Dossier à vérifier. * @returns {Promise} true si le dossier existe. */ async function directoryExists(dirPath) { const entries = await fs.readdir(path.dirname(dirPath), { withFileTypes: true }); for (const entry of entries) { if (entry.name !== path.basename(dirPath)) { continue; } if (entry.isDirectory()) { return true; } return false; } return false; } /** * Convertit un chemin en représentation relative à la racine du dépôt. * @param {string} absolutePath Chemin absolu. * @returns {string} Chemin relatif normalisé en POSIX. */ function toRelativePath(absolutePath) { return toPosixPath(path.relative(process.cwd(), absolutePath)); } /** * Normalise les séparateurs de chemin au format POSIX. * @param {string} value Chemin à normaliser. * @returns {string} Chemin avec séparateurs "/". */ function toPosixPath(value) { return value.split(path.sep).join("/"); } /** * Interrompt proprement le script si la sortie standard est fermée. * @param {NodeJS.ErrnoException} error Erreur émise par stdout. */ function handleStdoutError(error) { if (error.code === "EPIPE") { process.exit(0); } throw error; }