537 lines
14 KiB
JavaScript
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 
|
|
*/
|
|
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;
|
|
}
|