Gestion et affichage des liens morts
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,102 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
const readline = require("readline");
|
||||
|
||||
const BASE_URL = "http://127.0.0.1:1313";
|
||||
const CONTENT_DIR = path.join(__dirname, "..", "content");
|
||||
const SITE_ROOT = path.resolve(__dirname, "..");
|
||||
const BAD_LINKS = [];
|
||||
|
||||
function isInternalLink(link) {
|
||||
return !link.includes("://") && !link.startsWith("mailto:") && !link.startsWith("tel:");
|
||||
}
|
||||
|
||||
function extractLinksFromLine(line) {
|
||||
const regex = /\]\(([^)"]+)\)/g;
|
||||
let match;
|
||||
const links = [];
|
||||
while ((match = regex.exec(line)) !== null) {
|
||||
links.push(match[1]);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function getBundleRelativeUrl(mdPath, link) {
|
||||
const bundleRoot = path.dirname(mdPath);
|
||||
let urlPath;
|
||||
|
||||
if (link.startsWith("/")) {
|
||||
urlPath = link;
|
||||
} else {
|
||||
const fullPath = path.resolve(bundleRoot, link);
|
||||
const relative = path.relative(CONTENT_DIR, fullPath);
|
||||
urlPath = "/" + relative.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
return urlPath;
|
||||
}
|
||||
|
||||
async function checkLink(file, lineNumber, link) {
|
||||
const relativeUrl = getBundleRelativeUrl(file, link);
|
||||
const fullUrl = `${BASE_URL}${relativeUrl}`;
|
||||
return new Promise((resolve) => {
|
||||
http.get(fullUrl, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
BAD_LINKS.push([path.relative(SITE_ROOT, file), link, lineNumber]);
|
||||
}
|
||||
res.resume();
|
||||
resolve();
|
||||
}).on("error", () => {
|
||||
BAD_LINKS.push([path.relative(SITE_ROOT, file), link, lineNumber]);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function processFile(filePath) {
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||
let lineNumber = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
lineNumber++;
|
||||
const links = extractLinksFromLine(line);
|
||||
for (const link of links) {
|
||||
if (isInternalLink(link)) {
|
||||
process.stdout.write(".");
|
||||
await checkLink(filePath, lineNumber, link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
let results = [];
|
||||
const list = fs.readdirSync(dir);
|
||||
list.forEach((file) => {
|
||||
file = path.resolve(dir, file);
|
||||
const stat = fs.statSync(file);
|
||||
if (stat && stat.isDirectory()) {
|
||||
results = results.concat(walk(file));
|
||||
} else if (file.endsWith(".md")) {
|
||||
results.push(file);
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const files = walk(CONTENT_DIR);
|
||||
console.log(`Analyzing ${files.length} Markdown files...`);
|
||||
for (const file of files) {
|
||||
await processFile(file);
|
||||
}
|
||||
|
||||
console.log("\n\n=== Broken Internal Links Report ===");
|
||||
if (BAD_LINKS.length === 0) {
|
||||
console.log("✅ No broken internal links found.");
|
||||
} else {
|
||||
console.table(BAD_LINKS.map(([f, u, l]) => ({ File: f + '#' + l, URL: u })));
|
||||
}
|
||||
})();
|
||||
@@ -1,450 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const SITE_ROOT = path.resolve(__dirname, "..");
|
||||
const CONFIG_PATH = path.join(__dirname, "config.json");
|
||||
|
||||
function loadConfig() {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Impossible de parser ${path.relative(SITE_ROOT, CONFIG_PATH)} (${error.message}).`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const externalConfig = {
|
||||
cacheDir: path.join(__dirname, "cache"),
|
||||
cacheFile: "external_links.yaml",
|
||||
...(config.externalLinks || {}),
|
||||
};
|
||||
|
||||
const CACHE_DIR = path.isAbsolute(externalConfig.cacheDir)
|
||||
? externalConfig.cacheDir
|
||||
: path.resolve(SITE_ROOT, externalConfig.cacheDir);
|
||||
const CACHE_PATH = path.isAbsolute(externalConfig.cacheFile)
|
||||
? externalConfig.cacheFile
|
||||
: path.join(CACHE_DIR, externalConfig.cacheFile);
|
||||
|
||||
function loadCache(cachePath) {
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return yaml.load(fs.readFileSync(cachePath, "utf8")) || {};
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de la lecture du cache YAML (${error.message}).`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckedDate(info) {
|
||||
if (info && typeof info.checked === "string") {
|
||||
const parsed = new Date(info.checked);
|
||||
if (!Number.isNaN(parsed.valueOf())) {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
}
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function getStatusCode(info) {
|
||||
if (info && typeof info.status === "number") {
|
||||
return info.status;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const frenchDateFormatter = new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function formatDisplayDate(isoString) {
|
||||
if (typeof isoString === "string") {
|
||||
const parsed = new Date(isoString);
|
||||
if (!Number.isNaN(parsed.valueOf())) {
|
||||
return frenchDateFormatter.format(parsed);
|
||||
}
|
||||
}
|
||||
return frenchDateFormatter.format(new Date());
|
||||
}
|
||||
|
||||
function getFilesForUrl(info) {
|
||||
if (!info) return [];
|
||||
if (Array.isArray(info.files) && info.files.length > 0) {
|
||||
return info.files;
|
||||
}
|
||||
if (Array.isArray(info.locations) && info.locations.length > 0) {
|
||||
return Array.from(new Set(info.locations.map((entry) => String(entry).split(":")[0])));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function splitFrontmatter(content) {
|
||||
if (!content.startsWith("---")) {
|
||||
return null;
|
||||
}
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const frontmatterText = match[1];
|
||||
let frontmatter = {};
|
||||
try {
|
||||
frontmatter = yaml.load(frontmatterText) || {};
|
||||
} catch (error) {
|
||||
console.error(`Frontmatter YAML invalide (${error.message}).`);
|
||||
return null;
|
||||
}
|
||||
const block = match[0];
|
||||
const body = content.slice(block.length);
|
||||
return { frontmatter, block, body };
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(value) {
|
||||
if (!value.endsWith("\n")) {
|
||||
return `${value}\n`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ensureBlankLineBeforeAppend(body) {
|
||||
if (body.endsWith("\n\n")) {
|
||||
return body;
|
||||
}
|
||||
if (body.endsWith("\n")) {
|
||||
return `${body}\n`;
|
||||
}
|
||||
return `${body}\n\n`;
|
||||
}
|
||||
|
||||
function markInterestingLink(filePath, url, info) {
|
||||
const original = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = splitFrontmatter(original);
|
||||
if (!parsed) {
|
||||
console.warn(`Frontmatter introuvable pour ${path.relative(SITE_ROOT, filePath)}, ignoré.`);
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
const { frontmatter } = parsed;
|
||||
let body = parsed.body;
|
||||
const checkedDate = getCheckedDate(info);
|
||||
const displayDate = formatDisplayDate(checkedDate);
|
||||
const httpCode = getStatusCode(info);
|
||||
let changed = false;
|
||||
|
||||
if (typeof frontmatter.title === "string" && !frontmatter.title.startsWith("[Lien mort]")) {
|
||||
frontmatter.title = `[Lien mort] ${frontmatter.title}`;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let statusEntries = [];
|
||||
if (Array.isArray(frontmatter.status)) {
|
||||
statusEntries = [...frontmatter.status];
|
||||
}
|
||||
|
||||
let statusEntry = statusEntries.find(
|
||||
(entry) => entry && typeof entry === "object" && entry.date === checkedDate
|
||||
);
|
||||
if (!statusEntry) {
|
||||
statusEntry = { date: checkedDate, http_code: httpCode };
|
||||
statusEntries.push(statusEntry);
|
||||
changed = true;
|
||||
} else if (statusEntry.http_code !== httpCode) {
|
||||
statusEntry.http_code = httpCode;
|
||||
changed = true;
|
||||
}
|
||||
frontmatter.status = statusEntries;
|
||||
|
||||
const noteLine = `> Lien inaccessible depuis le ${displayDate}`;
|
||||
const noteRegex = /(>\s*Lien inaccessible depuis le\s+)([^\n]+)/;
|
||||
const existing = body.match(noteRegex);
|
||||
if (existing) {
|
||||
const current = existing[2].trim();
|
||||
if (current !== displayDate) {
|
||||
body = body.replace(noteRegex, `> Lien inaccessible depuis le ${displayDate}`);
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
body = ensureBlankLineBeforeAppend(body);
|
||||
body += `${noteLine}\n`;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
const newFrontmatter = yaml.dump(frontmatter);
|
||||
const updatedContent = `---\n${newFrontmatter}---\n${body}`;
|
||||
if (updatedContent === original) {
|
||||
return { changed: false };
|
||||
}
|
||||
fs.writeFileSync(filePath, updatedContent, "utf8");
|
||||
return { changed: true };
|
||||
}
|
||||
|
||||
function collectDeadlinkMaxId(body) {
|
||||
let maxId = 0;
|
||||
const regex = /\[\^deadlink-(\d+)\]/g;
|
||||
let match;
|
||||
while ((match = regex.exec(body)) !== null) {
|
||||
const value = parseInt(match[1], 10);
|
||||
if (Number.isInteger(value) && value > maxId) {
|
||||
maxId = value;
|
||||
}
|
||||
}
|
||||
return maxId;
|
||||
}
|
||||
|
||||
function findExistingDeadlinkReference(line, url) {
|
||||
if (!line.includes(url)) return null;
|
||||
const escapedUrl = escapeRegExp(url);
|
||||
const markdownRegex = new RegExp(`\\[[^\\]]*\\]\\(${escapedUrl}\\)`);
|
||||
const angleRegex = new RegExp(`<${escapedUrl}>`);
|
||||
|
||||
let referenceId = null;
|
||||
|
||||
const searchers = [
|
||||
{ regex: markdownRegex },
|
||||
{ regex: angleRegex },
|
||||
];
|
||||
|
||||
for (const { regex } of searchers) {
|
||||
const match = regex.exec(line);
|
||||
if (!match) continue;
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
const tail = line.slice(end);
|
||||
const footnoteMatch = tail.match(/^([\s)*_~`]*?)\[\^deadlink-(\d+)\]/);
|
||||
if (footnoteMatch) {
|
||||
referenceId = `deadlink-${footnoteMatch[2]}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return referenceId;
|
||||
}
|
||||
|
||||
function insertDeadlinkReference(line, url, nextId) {
|
||||
const escapedUrl = escapeRegExp(url);
|
||||
const markdownRegex = new RegExp(`\\[[^\\]]*\\]\\(${escapedUrl}\\)`);
|
||||
const angleRegex = new RegExp(`<${escapedUrl}>`);
|
||||
|
||||
const footnoteRef = `[^deadlink-${nextId}]`;
|
||||
|
||||
const markdownMatch = markdownRegex.exec(line);
|
||||
if (markdownMatch) {
|
||||
const end = markdownMatch.index + markdownMatch[0].length;
|
||||
let insertPos = end;
|
||||
while (insertPos < line.length && /[*_]/.test(line[insertPos])) {
|
||||
insertPos += 1;
|
||||
}
|
||||
return line.slice(0, insertPos) + ' ' + footnoteRef + line.slice(insertPos);
|
||||
}
|
||||
|
||||
const angleMatch = angleRegex.exec(line);
|
||||
if (angleMatch) {
|
||||
const end = angleMatch.index + angleMatch[0].length;
|
||||
return line.slice(0, end) + footnoteRef + line.slice(end);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function upsertFootnoteDefinition(body, footnoteId, isoDate) {
|
||||
const displayDate = formatDisplayDate(isoDate);
|
||||
const desired = `Lien inaccessible depuis le ${displayDate}`;
|
||||
const definitionRegex = new RegExp(`^\\[\\^${footnoteId}\\]:\\s*(.+)$`, "m");
|
||||
const match = definitionRegex.exec(body);
|
||||
if (match) {
|
||||
if (match[1].trim() !== desired) {
|
||||
return {
|
||||
body: body.replace(definitionRegex, `[^${footnoteId}]: ${desired}`),
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
return { body, changed: false };
|
||||
}
|
||||
let updated = ensureTrailingNewline(body);
|
||||
updated = ensureBlankLineBeforeAppend(updated);
|
||||
updated += `[^${footnoteId}]: ${desired}\n`;
|
||||
return { body: updated, changed: true };
|
||||
}
|
||||
|
||||
function markMarkdownLink(filePath, url, info) {
|
||||
const original = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = splitFrontmatter(original);
|
||||
const hasFrontmatter = Boolean(parsed);
|
||||
const block = parsed?.block ?? "";
|
||||
const bodyOriginal = parsed ? parsed.body : original;
|
||||
|
||||
const lines = bodyOriginal.split("\n");
|
||||
let inFence = false;
|
||||
let fenceChar = null;
|
||||
let referenceId = null;
|
||||
let changed = false;
|
||||
let maxId = collectDeadlinkMaxId(bodyOriginal);
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
|
||||
const trimmed = line.trimStart();
|
||||
const fenceMatch = trimmed.match(/^([`~]{3,})/);
|
||||
if (fenceMatch) {
|
||||
const currentFenceChar = fenceMatch[1][0];
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceChar = currentFenceChar;
|
||||
continue;
|
||||
}
|
||||
if (fenceChar === currentFenceChar) {
|
||||
inFence = false;
|
||||
fenceChar = null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.includes(url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingRef = findExistingDeadlinkReference(line, url);
|
||||
if (existingRef) {
|
||||
referenceId = existingRef;
|
||||
break;
|
||||
}
|
||||
|
||||
const nextId = maxId + 1;
|
||||
const updatedLine = insertDeadlinkReference(line, url, nextId);
|
||||
if (updatedLine) {
|
||||
lines[i] = updatedLine;
|
||||
referenceId = `deadlink-${nextId}`;
|
||||
maxId = nextId;
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!referenceId) {
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
let body = lines.join("\n");
|
||||
const { body: updatedBody, changed: definitionChanged } = upsertFootnoteDefinition(
|
||||
body,
|
||||
referenceId,
|
||||
getCheckedDate(info)
|
||||
);
|
||||
|
||||
body = updatedBody;
|
||||
if (definitionChanged) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
const updatedContent = hasFrontmatter ? `${block}${body}` : body;
|
||||
if (updatedContent === original) {
|
||||
return { changed: false };
|
||||
}
|
||||
fs.writeFileSync(filePath, updatedContent, "utf8");
|
||||
return { changed: true };
|
||||
}
|
||||
|
||||
function processFile(absolutePath, url, info) {
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
console.warn(`Fichier introuvable: ${absolutePath}`);
|
||||
return { changed: false };
|
||||
}
|
||||
const relative = path.relative(SITE_ROOT, absolutePath);
|
||||
if (relative.startsWith("content/interets/liens-interessants/")) {
|
||||
return markInterestingLink(absolutePath, url, info);
|
||||
}
|
||||
if (path.extname(relative).toLowerCase() === ".md") {
|
||||
return markMarkdownLink(absolutePath, url, info);
|
||||
}
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(CACHE_PATH)) {
|
||||
console.error("Cache introuvable. Exécutez d'abord tools/check_external_links.js.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cache = loadCache(CACHE_PATH);
|
||||
const entries = Object.entries(cache).filter(
|
||||
([, info]) => info && info.manually_killed === true
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log("Aucun lien marqué comme mort manuellement dans le cache.");
|
||||
return;
|
||||
}
|
||||
|
||||
let updates = 0;
|
||||
let warnings = 0;
|
||||
|
||||
for (const [url, info] of entries) {
|
||||
const files = getFilesForUrl(info);
|
||||
if (files.length === 0) {
|
||||
console.warn(`Aucun fichier associé à ${url}.`);
|
||||
warnings += 1;
|
||||
continue;
|
||||
}
|
||||
for (const relativePath of files) {
|
||||
const absolutePath = path.isAbsolute(relativePath)
|
||||
? relativePath
|
||||
: path.resolve(SITE_ROOT, relativePath);
|
||||
try {
|
||||
const { changed } = processFile(absolutePath, url, info);
|
||||
if (changed) {
|
||||
updates += 1;
|
||||
console.log(
|
||||
`✅ ${path.relative(SITE_ROOT, absolutePath)} mis à jour pour ${url}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
warnings += 1;
|
||||
console.error(
|
||||
`Erreur lors du traitement de ${path.relative(SITE_ROOT, absolutePath)} (${error.message}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates === 0) {
|
||||
console.log("Aucune modification nécessaire.");
|
||||
} else {
|
||||
console.log(`${updates} fichier(s) mis à jour.`);
|
||||
}
|
||||
|
||||
if (warnings > 0) {
|
||||
console.warn(`${warnings} fichier(s) n'ont pas pu être traités complètement.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
const SITE_ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
const steps = [
|
||||
{ label: "check_internal_links", script: path.join(__dirname, "check_internal_links.js") },
|
||||
{ label: "check_external_links", script: path.join(__dirname, "check_external_links.js") },
|
||||
{ label: "update_external_links", script: path.join(__dirname, "update_external_links.js") },
|
||||
{ label: "mark_dead_links", script: path.join(__dirname, "mark_dead_links.js") },
|
||||
];
|
||||
|
||||
function runStep({ label, script }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("node", [script], {
|
||||
cwd: SITE_ROOT,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (typeof code === "number" && code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const reason =
|
||||
typeof code === "number"
|
||||
? `code ${code}`
|
||||
: signal
|
||||
? `signal ${signal}`
|
||||
: "unknown reason";
|
||||
reject(new Error(`Étape "${label}" terminée avec ${reason}`));
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
reject(new Error(`Impossible d'exécuter "${label}": ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const step of steps) {
|
||||
const relative = path.relative(SITE_ROOT, step.script);
|
||||
console.log(`\n➡️ Exécution de ${relative}...`);
|
||||
await runStep(step);
|
||||
}
|
||||
console.log("\n✅ Workflow des liens terminé.");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`\n❌ Échec du workflow: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const util = require("util");
|
||||
const yaml = require("js-yaml");
|
||||
const readline = require("readline");
|
||||
const { execFile } = require("child_process");
|
||||
|
||||
const execFileAsync = util.promisify(execFile);
|
||||
|
||||
const SITE_ROOT = path.resolve(__dirname, "..");
|
||||
const CONFIG_PATH = path.join(__dirname, "config.json");
|
||||
|
||||
let config = {};
|
||||
if (fs.existsSync(CONFIG_PATH)) {
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Impossible de parser ${path.relative(
|
||||
SITE_ROOT,
|
||||
CONFIG_PATH
|
||||
)}. Valeurs par défaut utilisées. (${error.message})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const externalConfig = {
|
||||
cacheDir: path.join(__dirname, "cache"),
|
||||
cacheFile: "external_links.yaml",
|
||||
...(config.externalLinks || {}),
|
||||
};
|
||||
|
||||
const CACHE_DIR = path.isAbsolute(externalConfig.cacheDir)
|
||||
? externalConfig.cacheDir
|
||||
: path.resolve(SITE_ROOT, externalConfig.cacheDir);
|
||||
const CACHE_PATH = path.isAbsolute(externalConfig.cacheFile)
|
||||
? externalConfig.cacheFile
|
||||
: path.join(CACHE_DIR, externalConfig.cacheFile);
|
||||
|
||||
function ensureDirectoryExists(targetFile) {
|
||||
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
|
||||
}
|
||||
|
||||
function loadCache() {
|
||||
if (!fs.existsSync(CACHE_PATH)) return {};
|
||||
try {
|
||||
return yaml.load(fs.readFileSync(CACHE_PATH, "utf8")) || {};
|
||||
} catch (e) {
|
||||
console.error("Erreur de lecture du cache YAML:", e.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveCache(cache) {
|
||||
ensureDirectoryExists(CACHE_PATH);
|
||||
fs.writeFileSync(CACHE_PATH, yaml.dump(cache), "utf8");
|
||||
}
|
||||
|
||||
function promptFactory() {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const question = (q) =>
|
||||
new Promise((resolve) => rl.question(q, (ans) => resolve(ans.trim())));
|
||||
return {
|
||||
async ask(q) {
|
||||
return await question(q);
|
||||
},
|
||||
close() {
|
||||
rl.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureCheckRanIfNeeded() {
|
||||
if (fs.existsSync(CACHE_PATH)) return;
|
||||
console.log(
|
||||
"Cache introuvable. Exécution préalable de tools/check_external_links.js..."
|
||||
);
|
||||
await execFileAsync("node", [path.join(__dirname, "check_external_links.js")], {
|
||||
cwd: SITE_ROOT,
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function listBrokenUrls(cache) {
|
||||
const result = [];
|
||||
for (const [url, info] of Object.entries(cache)) {
|
||||
const status = info && typeof info.status === "number" ? info.status : null;
|
||||
const killed = info && info.manually_killed === true;
|
||||
const validated = info && info.manually_validated === true;
|
||||
if (killed) continue; // on ne traite plus ces URL
|
||||
if (validated) continue; // déjà validé manuellement
|
||||
if (status !== null && (status >= 400 || status === 0)) {
|
||||
result.push({ url, info });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getFilesForUrl(info) {
|
||||
let files = [];
|
||||
if (Array.isArray(info?.files) && info.files.length > 0) {
|
||||
files = info.files;
|
||||
} else if (Array.isArray(info?.locations) && info.locations.length > 0) {
|
||||
files = Array.from(new Set(info.locations.map((s) => String(s).split(":")[0])));
|
||||
}
|
||||
return files.map((p) => path.resolve(SITE_ROOT, p));
|
||||
}
|
||||
|
||||
function replaceInFile(filePath, from, to) {
|
||||
if (!fs.existsSync(filePath)) return { changed: false };
|
||||
const original = fs.readFileSync(filePath, "utf8");
|
||||
if (!original.includes(from)) return { changed: false };
|
||||
const updated = original.split(from).join(to);
|
||||
if (updated !== original) {
|
||||
fs.writeFileSync(filePath, updated, "utf8");
|
||||
return { changed: true };
|
||||
}
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureCheckRanIfNeeded();
|
||||
let cache = loadCache();
|
||||
|
||||
const broken = listBrokenUrls(cache);
|
||||
if (broken.length === 0) {
|
||||
console.log("Aucun lien en erreur (>= 400) à traiter.");
|
||||
return;
|
||||
}
|
||||
|
||||
const p = promptFactory();
|
||||
try {
|
||||
for (const { url, info } of broken) {
|
||||
const statusLabel = typeof info.status === "number" ? String(info.status) : "inconnu";
|
||||
const locations = Array.isArray(info.locations) ? info.locations : [];
|
||||
const files = Array.isArray(info.files) ? info.files : Array.from(new Set(locations.map((s) => String(s).split(":")[0])));
|
||||
console.log("\nURL: ", url);
|
||||
console.log("Statut: ", statusLabel);
|
||||
if (locations.length > 0) {
|
||||
console.log("Emplacements:");
|
||||
for (const loc of locations) console.log(" - ", loc);
|
||||
} else if (files.length > 0) {
|
||||
console.log("Emplacements:");
|
||||
for (const f of files) console.log(" - ", `${f}:?`);
|
||||
} else {
|
||||
console.log("Fichiers: (aucun chemin enregistré)");
|
||||
}
|
||||
|
||||
const choice = (
|
||||
await p.ask(
|
||||
"Action ? [i]gnorer, [c]onfirmer, [r]emplacer, [m]ort, [q]uitter (défaut: i) : "
|
||||
)
|
||||
).toLowerCase() || "i";
|
||||
|
||||
if (choice === "q") {
|
||||
console.log("Arrêt demandé.");
|
||||
break;
|
||||
}
|
||||
|
||||
if (choice === "i") {
|
||||
// Ignorer
|
||||
continue;
|
||||
}
|
||||
|
||||
if (choice === "c") {
|
||||
const nowIso = new Date().toISOString();
|
||||
cache[url] = {
|
||||
...(cache[url] || {}),
|
||||
manually_validated: true,
|
||||
manually_killed: cache[url]?.manually_killed === true,
|
||||
status: 200,
|
||||
errorType: null,
|
||||
method: "MANUAL",
|
||||
checked: nowIso,
|
||||
};
|
||||
saveCache(cache);
|
||||
console.log("Marqué comme validé manuellement.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (choice === "m") {
|
||||
cache[url] = {
|
||||
...(cache[url] || {}),
|
||||
manually_killed: true,
|
||||
manually_validated: cache[url]?.manually_validated === true,
|
||||
status: cache[url]?.status ?? null,
|
||||
errorType: cache[url]?.errorType ?? null,
|
||||
method: cache[url]?.method ?? null,
|
||||
};
|
||||
saveCache(cache);
|
||||
console.log("Marqué comme mort (plus jamais retesté).");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (choice === "r") {
|
||||
if (!(Array.isArray(files) && files.length > 0)) {
|
||||
console.log(
|
||||
"Impossible de remplacer: aucun fichier enregistré pour cet URL. Relancez d'abord tools/check_external_links.js."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const newUrl = await p.ask("Nouvel URL: ");
|
||||
if (!newUrl || !newUrl.includes("://")) {
|
||||
console.log("URL invalide, opération annulée.");
|
||||
continue;
|
||||
}
|
||||
// Remplacements dans les fichiers listés
|
||||
let changedFiles = 0;
|
||||
for (const rel of files) {
|
||||
const abs = path.resolve(SITE_ROOT, rel);
|
||||
const { changed } = replaceInFile(abs, url, newUrl);
|
||||
if (changed) changedFiles++;
|
||||
}
|
||||
console.log(`Remplacements effectués dans ${changedFiles} fichier(s).`);
|
||||
|
||||
// Mettre à jour la base: déplacer l'entrée vers la nouvelle clé
|
||||
const oldEntry = cache[url] || {};
|
||||
const newEntryExisting = cache[newUrl] || {};
|
||||
cache[newUrl] = {
|
||||
...newEntryExisting,
|
||||
files: Array.isArray(oldEntry.files) ? [...oldEntry.files] : files,
|
||||
locations: Array.isArray(oldEntry.locations)
|
||||
? [...oldEntry.locations]
|
||||
: Array.isArray(oldEntry.files)
|
||||
? oldEntry.files.map((f) => `${f}:?`)
|
||||
: Array.isArray(locations)
|
||||
? [...locations]
|
||||
: [],
|
||||
manually_validated: false,
|
||||
manually_killed: false,
|
||||
status: null,
|
||||
errorType: null,
|
||||
method: newEntryExisting.method || null,
|
||||
checked: null,
|
||||
};
|
||||
delete cache[url];
|
||||
saveCache(cache);
|
||||
console.log("Base mise à jour pour le nouvel URL.");
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log("Choix non reconnu. Ignoré.");
|
||||
}
|
||||
} finally {
|
||||
p.close();
|
||||
}
|
||||
|
||||
console.log("\nTerminé. Vous pouvez relancer 'node tools/check_external_links.js' pour mettre à jour les statuts.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Erreur:", err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user