From cef4d2b8d01296e5b52ec9ac8604b054ef7ad163 Mon Sep 17 00:00:00 2001 From: Odilon Hugonnot Date: Sun, 25 Oct 2026 10:23:41 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20ajoute=20un=20contr=C3=B4le=20statique?= =?UTF-8?q?=20des=20liens=20et=20images=20(#345)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/check_links.py résout les liens .md et les images comme Docusaurus (dossier traduit d'abord, puis repli sur docs/) et les classe par catégorie : antislash, casse, cible absente, image absente. Environ 0,5 s, sans build. Exposé via `npm run test:links`. --fail-on permet de ne faire échouer que certaines catégories, pour bloquer les régressions au fur et à mesure des corrections. Ajoute aussi .nvmrc (Node 20, comme le Dockerfile). --- .nvmrc | 1 + README.md | 2 + package.json | 3 +- scripts/check_links.py | 144 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 .nvmrc create mode 100644 scripts/check_links.py diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..209e3ef4 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/README.md b/README.md index 49fdb839..0689605e 100755 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Pour construire le wiki en local avant de modifier la version actuelle du wiki : - pour la version dev ```npm run start``` - pour build ```npm run build``` - puis ```npm run serve``` +- Vérifier les liens et images (sans build, Python 3.9+) ```npm run test:links``` + - détail fichier:ligne ```npm run test:links -- -v```, une seule langue ```npm run test:links -- --lang fr``` ------------- diff --git a/package.json b/package.json index 402aee1c..f07ebe32 100755 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids" + "write-heading-ids": "docusaurus write-heading-ids", + "test:links": "python3 -I scripts/check_links.py" }, "dependencies": { "@docusaurus/core": "^2.4.3", diff --git a/scripts/check_links.py b/scripts/check_links.py new file mode 100644 index 00000000..bfe7efe5 --- /dev/null +++ b/scripts/check_links.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Vérifie les liens .md et les images des docs, sans build (quelques secondes). + +Reproduit la résolution de Docusaurus : un lien est cherché d'abord dans le +dossier localisé (i18n//.../current), puis dans docs/ (repli). +Sortie : un compte par catégorie et par langue ; code retour 1 s'il reste des erreurs. + + python3 scripts/check_links.py # résumé + python3 scripts/check_links.py -v # détail de chaque lien + python3 scripts/check_links.py --lang fr # une seule langue +""" +import argparse +import os +import re +import sys +from collections import Counter, defaultdict + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DOCS = os.path.join(ROOT, "docs") +STATIC = os.path.join(ROOT, "static") +LOCALES = {"en": DOCS} +for lang in ("fr", "ru", "de"): + for cand in (lang, lang.upper()): + p = os.path.join(ROOT, "i18n", cand, "docusaurus-plugin-content-docs", "current") + if os.path.isdir(p): + LOCALES[lang] = p + break + +LINK = re.compile(r"(!?)\[[^\]]*\]\(\s*]|\([^()]*\))+)>?(?:\s+\"[^\"]*\")?\s*\)") +FENCE = re.compile(r"^(```|~~~)") +EXTERNAL = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//|#)", re.I) + + +def exists(path): + return os.path.exists(path) + + +def exists_nocase(path): + """Vrai si le chemin existe en ignorant la casse (détecte Jobs vs jobs).""" + cur = os.path.abspath(os.sep) + for part in os.path.normpath(path).split(os.sep)[1:]: + try: + names = os.listdir(cur) + except OSError: + return False + match = next((n for n in names if n.lower() == part.lower()), None) + if match is None: + return False + cur = os.path.join(cur, match) + return True + + +def candidates(target, src_file, locale_root): + """Chemins possibles pour un lien : relatif au fichier, ou depuis la racine docs.""" + t = target.split("#")[0].split("?")[0] + bases = [] + if t.startswith("/"): + bases = [t.lstrip("/")] + else: + rel = os.path.relpath(os.path.dirname(src_file), locale_root) + bases = [os.path.normpath(os.path.join(rel, t))] + out = [] + for b in bases: + for root in dict.fromkeys([locale_root, DOCS]): + out.append(os.path.join(root, b)) + return out + + +def check_file(path, locale_root, errors): + in_fence = False + with open(path, encoding="utf8", errors="replace") as f: + for n, line in enumerate(f, 1): + if FENCE.match(line.strip()): + in_fence = not in_fence + if in_fence: + continue + for bang, target in LINK.findall(line): + if EXTERNAL.match(target): + continue + kind = None + if "\\" in target: + kind = "antislash" + target_n = target.replace("\\", "/") + else: + target_n = target + is_image = bool(bang) or target_n.lower().endswith( + (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg") + ) + if is_image: + cands = candidates(target_n, path, locale_root) + cands += [os.path.join(ROOT, target_n.lstrip("/")), + os.path.join(STATIC, target_n.lstrip("/").removeprefix("static/"))] + else: + if not target_n.split("#")[0].endswith((".md", ".mdx")) and "." in os.path.basename(target_n.split("#")[0]): + continue # autre type de fichier (pdf, zip...) : hors périmètre + cands = candidates(target_n, path, locale_root) + if any(exists(c) for c in cands) and kind is None: + continue + if kind is None: + kind = "casse" if any(exists_nocase(c) for c in cands) else ( + "image-absente" if is_image else "cible-absente") + elif not any(exists(c) for c in cands): + kind = "antislash" if any(exists_nocase(c) for c in cands) else "antislash+absente" + errors.append((kind, os.path.relpath(path, ROOT), n, target)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("-v", "--verbose", action="store_true") + ap.add_argument("--lang", choices=sorted(LOCALES)) + ap.add_argument("--fail-on", default="all", + help="catégories qui font échouer, séparées par des virgules (défaut : toutes)") + args = ap.parse_args() + + total = 0 + summary = defaultdict(Counter) + detail = [] + for lang, root in LOCALES.items(): + if args.lang and lang != args.lang: + continue + errs = [] + for d, _, fs in os.walk(root): + for f in fs: + if f.endswith((".md", ".mdx")): + check_file(os.path.join(d, f), root, errs) + for kind, *_ in errs: + summary[lang][kind] += 1 + detail += [(lang, *e) for e in errs] + total += len(errs) + + for lang, c in summary.items(): + print(f"[{lang}] {sum(c.values()):5d} liens en erreur : " + ", ".join(f"{k}={v}" for k, v in c.most_common())) + if args.verbose: + for lang, kind, f, n, t in detail: + print(f"{kind:18s} {f}:{n} {t}") + if total == 0: + print("OK : aucun lien en erreur") + wanted = None if args.fail_on == "all" else set(args.fail_on.split(",")) + bad = [d for d in detail if wanted is None or d[1] in wanted] + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main())