From 9209684c04016a0eb22fde7934a7616ad3b76002 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Tue, 11 Nov 2025 22:59:04 +0100 Subject: [PATCH] =?UTF-8?q?D=C3=A9tection=20des=20liens=20internes=20morts?= =?UTF-8?q?=20et=20blocage=20du=20d=C3=A9ploiement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy.sh | 5 + tools/check_internal_links.js | 324 ++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 tools/check_internal_links.js diff --git a/deploy.sh b/deploy.sh index 68dc0c06..31e3fd25 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,6 +1,8 @@ #!/bin/bash set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # Configuration DEST_USER="root" DEST_HOST="server-main.home.arpa" @@ -8,6 +10,9 @@ DEST_DIR="/var/lib/www/richard-dern.fr/" HUGO_ENV="production" 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)" hugo --environment "$HUGO_ENV" --cleanDestinationDir diff --git a/tools/check_internal_links.js b/tools/check_internal_links.js new file mode 100644 index 00000000..7c5d7ad6 --- /dev/null +++ b/tools/check_internal_links.js @@ -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); + } +}