1
Files
2025/tools/check_unused_images.js
2026-03-05 11:55:41 +01:00

537 lines
14 KiB
JavaScript

#!/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<Array<{dir: string, indexPath: string}>>} 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<string>} 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<string[]>} 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<Set<string>>} 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<string[]>} 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<string[]>} 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<boolean>} 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;
}