Détection des liens internes morts et blocage du déploiement
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
DEST_USER="root"
|
DEST_USER="root"
|
||||||
DEST_HOST="server-main.home.arpa"
|
DEST_HOST="server-main.home.arpa"
|
||||||
@@ -8,6 +10,9 @@ DEST_DIR="/var/lib/www/richard-dern.fr/"
|
|||||||
HUGO_ENV="production"
|
HUGO_ENV="production"
|
||||||
TARGET_OWNER="caddy:caddy"
|
TARGET_OWNER="caddy:caddy"
|
||||||
|
|
||||||
|
echo "==> Vérification des liens internes"
|
||||||
|
node "$SCRIPT_DIR/tools/check_internal_links.js"
|
||||||
|
|
||||||
echo "==> Génération du site Hugo pour l'environnement $HUGO_ENV (avec nettoyage de destination)"
|
echo "==> Génération du site Hugo pour l'environnement $HUGO_ENV (avec nettoyage de destination)"
|
||||||
hugo --environment "$HUGO_ENV" --cleanDestinationDir
|
hugo --environment "$HUGO_ENV" --cleanDestinationDir
|
||||||
|
|
||||||
|
|||||||
324
tools/check_internal_links.js
Normal file
324
tools/check_internal_links.js
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { sanitizeUrlCandidate } = require("./lib/markdown_links");
|
||||||
|
|
||||||
|
const SITE_ROOT = path.resolve(__dirname, "..");
|
||||||
|
const CONTENT_DIR = path.join(SITE_ROOT, "content");
|
||||||
|
const TARGET_EXTENSIONS = new Set([".md", ".markdown", ".mdx", ".yaml", ".yml"]);
|
||||||
|
const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]);
|
||||||
|
const INTERNAL_LINK_REGEX = /\/[^\s"'`<>\\\[\]{}|]+/g;
|
||||||
|
const VALID_PREFIX_REGEX = /[\s"'`([<{=:]/;
|
||||||
|
const PATH_KEY_REGEX = /^\s*(?:"path"|'path'|path)\s*:/i;
|
||||||
|
|
||||||
|
function toPosix(value) {
|
||||||
|
return value.split(path.sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeToSite(filePath) {
|
||||||
|
return toPosix(path.relative(SITE_ROOT, filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTargetFile(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return TARGET_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkdownFile(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return MARKDOWN_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYamlFile(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return ext === ".yaml" || ext === ".yml";
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectContentEntries(rootDir) {
|
||||||
|
const files = [];
|
||||||
|
const directories = new Set(["/"]);
|
||||||
|
|
||||||
|
function walk(currentDir) {
|
||||||
|
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(currentDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const relative = path.relative(rootDir, fullPath);
|
||||||
|
const normalized = relative ? `/${toPosix(relative)}` : "/";
|
||||||
|
directories.add(normalized);
|
||||||
|
walk(fullPath);
|
||||||
|
} else if (entry.isFile() && isTargetFile(fullPath)) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(rootDir);
|
||||||
|
return { files, directories };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeInternalLink(raw) {
|
||||||
|
const candidate = sanitizeUrlCandidate(raw);
|
||||||
|
if (!candidate) return null;
|
||||||
|
if (!candidate.startsWith("/")) return null;
|
||||||
|
if (candidate.startsWith("//")) return null;
|
||||||
|
if (candidate.includes("://")) return null;
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInternalLink(link) {
|
||||||
|
if (typeof link !== "string" || !link.startsWith("/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let normalized = link.split("?")[0];
|
||||||
|
normalized = normalized.split("#")[0];
|
||||||
|
normalized = normalized.replace(/\/+/g, "/");
|
||||||
|
normalized = normalized.replace(/\/+$/, "");
|
||||||
|
if (!normalized) {
|
||||||
|
normalized = "/";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedDirForLink(link) {
|
||||||
|
if (link === "/") {
|
||||||
|
return CONTENT_DIR;
|
||||||
|
}
|
||||||
|
const relative = link.slice(1);
|
||||||
|
const segments = relative.split("/").filter(Boolean);
|
||||||
|
return path.join(CONTENT_DIR, ...segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countRepeatedChar(text, startIndex, char) {
|
||||||
|
let count = 0;
|
||||||
|
while (text[startIndex + count] === char) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatchingPair(text, startIndex, openChar, closeChar) {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = startIndex; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (ch === "\\") {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === openChar) {
|
||||||
|
depth++;
|
||||||
|
} else if (ch === closeChar) {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMarkdownLinksFromLine(line) {
|
||||||
|
const results = [];
|
||||||
|
let inlineFence = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
|
||||||
|
if (ch === "`") {
|
||||||
|
const runLength = countRepeatedChar(line, i, "`");
|
||||||
|
if (!inlineFence) {
|
||||||
|
inlineFence = runLength;
|
||||||
|
} else if (inlineFence === runLength) {
|
||||||
|
inlineFence = null;
|
||||||
|
}
|
||||||
|
i += runLength - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inlineFence) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch !== "[") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBracket = findMatchingPair(line, i, "[", "]");
|
||||||
|
if (closeBracket === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pointer = closeBracket + 1;
|
||||||
|
while (pointer < line.length && /\s/.test(line[pointer])) {
|
||||||
|
pointer++;
|
||||||
|
}
|
||||||
|
if (pointer >= line.length || line[pointer] !== "(") {
|
||||||
|
i = closeBracket;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeParen = findMatchingPair(line, pointer, "(", ")");
|
||||||
|
if (closeParen === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = line.slice(pointer + 1, closeParen);
|
||||||
|
results.push({ destination });
|
||||||
|
i = closeParen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInternalLinks(filePath) {
|
||||||
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const entries = [];
|
||||||
|
const skipPathKey = isYamlFile(filePath);
|
||||||
|
const treatAsMarkdown = isMarkdownFile(filePath);
|
||||||
|
let fenceDelimiter = null;
|
||||||
|
let inFrontMatter = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < lines.length; index++) {
|
||||||
|
const line = lines[index];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (treatAsMarkdown) {
|
||||||
|
if (index === 0 && trimmed === "---") {
|
||||||
|
inFrontMatter = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFrontMatter) {
|
||||||
|
if (trimmed === "---") {
|
||||||
|
inFrontMatter = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fenceMatch = trimmed.match(/^(```+|~~~+)/);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const delimiterChar = fenceMatch[1][0];
|
||||||
|
if (!fenceDelimiter) {
|
||||||
|
fenceDelimiter = delimiterChar;
|
||||||
|
} else if (delimiterChar === fenceDelimiter) {
|
||||||
|
fenceDelimiter = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fenceDelimiter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownLinks = extractMarkdownLinksFromLine(line);
|
||||||
|
for (const { destination } of markdownLinks) {
|
||||||
|
const sanitized = sanitizeInternalLink(destination);
|
||||||
|
if (!sanitized) continue;
|
||||||
|
const normalized = normalizeInternalLink(sanitized);
|
||||||
|
if (!normalized || normalized === "//") continue;
|
||||||
|
entries.push({ link: normalized, line: index + 1 });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipPathKey && PATH_KEY_REGEX.test(line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of line.matchAll(INTERNAL_LINK_REGEX)) {
|
||||||
|
const raw = match[0];
|
||||||
|
const startIndex = match.index ?? line.indexOf(raw);
|
||||||
|
if (startIndex > 0) {
|
||||||
|
const prevChar = line[startIndex - 1];
|
||||||
|
if (!VALID_PREFIX_REGEX.test(prevChar)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sanitized = sanitizeInternalLink(raw);
|
||||||
|
if (!sanitized) continue;
|
||||||
|
const normalized = normalizeInternalLink(sanitized);
|
||||||
|
if (!normalized || normalized === "//") continue;
|
||||||
|
entries.push({ link: normalized, line: index + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMissingLink(missingMap, link, filePath, line) {
|
||||||
|
let entry = missingMap.get(link);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {
|
||||||
|
expectedPath: expectedDirForLink(link),
|
||||||
|
references: [],
|
||||||
|
referenceKeys: new Set(),
|
||||||
|
};
|
||||||
|
missingMap.set(link, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceKey = `${filePath}:${line}`;
|
||||||
|
if (entry.referenceKeys.has(referenceKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.referenceKeys.add(referenceKey);
|
||||||
|
entry.references.push({
|
||||||
|
file: relativeToSite(filePath),
|
||||||
|
line,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!fs.existsSync(CONTENT_DIR)) {
|
||||||
|
console.error(`Le dossier content est introuvable (${CONTENT_DIR}).`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { files, directories } = collectContentEntries(CONTENT_DIR);
|
||||||
|
const missingLinks = new Map();
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = extractInternalLinks(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Impossible de lire ${relativeToSite(filePath)} (${error.message}).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { link, line } of entries) {
|
||||||
|
if (directories.has(link)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addMissingLink(missingLinks, link, filePath, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingLinks.size === 0) {
|
||||||
|
console.log("Tous les liens internes pointent vers un dossier existant.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Liens internes cassés détectés: ${missingLinks.size}`);
|
||||||
|
const sorted = Array.from(missingLinks.entries()).sort((a, b) => a[0].localeCompare(b[0], "fr"));
|
||||||
|
|
||||||
|
for (const [link, data] of sorted) {
|
||||||
|
const expectedRelative = relativeToSite(data.expectedPath);
|
||||||
|
console.error(`- ${link} (attendu: ${expectedRelative})`);
|
||||||
|
for (const reference of data.references) {
|
||||||
|
console.error(` • ${reference.file}:${reference.line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur lors de la vérification des liens internes: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user