1

Gestion et affichage des liens morts

This commit is contained in:
2025-11-11 17:27:52 +01:00
parent e533dc3fc1
commit 39dbd89397
13 changed files with 892 additions and 1356 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 })));
}
})();

View File

@@ -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();
}

View File

@@ -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;
});

View File

@@ -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;
});