Nettoyage des images
This commit is contained in:
536
tools/check_unused_images.js
Normal file
536
tools/check_unused_images.js
Normal file
@@ -0,0 +1,536 @@
|
||||
#!/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 
|
||||
*/
|
||||
function buildImageLines(bundleDir, imagePaths) {
|
||||
const lines = [];
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
const relativePath = toPosixPath(path.relative(bundleDir, imagePath));
|
||||
lines.push(``);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user