diff options
Diffstat (limited to '')
| -rw-r--r-- | tools/link_check.py | 108 |
1 files changed, 108 insertions, 0 deletions
diff --git a/tools/link_check.py b/tools/link_check.py new file mode 100644 index 0000000..a0d9e27 --- /dev/null +++ b/tools/link_check.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Very small internal link checker. + +Checks: +- href="/path" and src="/path" for local files +- Only checks local paths (starting with / or relative), skips http(s), mailto, xmpp, onion, etc. + +Scans both *.html and *.php files. PHP blocks are scanned as raw text — the +href/src regex doesn't care about surrounding PHP syntax. + +Usage: + python tools/link_check.py +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + +SKIP_DIRS = {".git", ".agents", ".claude", "tools", "pub", "docs", + "admin", "api", "locales", "partials"} +SKIP_FILES = { + "test.html", + "test_jp.html", + "test_zh.html", + "startpage/test.html", +} + +RE_URL = re.compile(r"\b(?:href|src)=(['\"])(.*?)\1", re.I) +# Match URLs that look like dynamic PHP output: anything containing <?...?>. +RE_DYNAMIC = re.compile(r"<\?.*?\?>", re.S) + + +def is_external(u: str) -> bool: + u = u.strip() + return ( + u.startswith("http://") + or u.startswith("https://") + or u.startswith("mailto:") + or u.startswith("xmpp:") + or u.startswith("signal:") + or u.startswith("data:") + or u.startswith("javascript:") + or u.startswith("gopher:") + or u.startswith("gemini:") + or u.startswith("#") + or u.startswith("//") + or u.endswith(".onion/") + or ".onion" in u + ) + + +def normalize(p: Path, url: str) -> Path | None: + url = url.split("#", 1)[0].split("?", 1)[0].strip() + if not url: + return None + if is_external(url): + return None + + if url.startswith("/"): + return (ROOT / url.lstrip("/")).resolve() + + # relative + return (p.parent / url).resolve() + + +def main() -> int: + missing = [] + files: list[Path] = [] + for ext in ("*.html", "*.php"): + files.extend(ROOT.rglob(ext)) + for src_file in files: + if any(part in SKIP_DIRS for part in src_file.parts): + continue + rel = src_file.relative_to(ROOT).as_posix() + if rel in SKIP_FILES: + continue + text = src_file.read_text(encoding="utf-8", errors="ignore") + for m in RE_URL.finditer(text): + url = m.group(2) + # Skip URLs that contain PHP expressions; they're computed at runtime. + if RE_DYNAMIC.search(url): + continue + target = normalize(src_file, url) + if not target: + continue + # if it points to a directory, allow index.html or index.php + if target.is_dir(): + if (target / "index.html").exists() or (target / "index.php").exists(): + continue + if not target.exists(): + missing.append((str(src_file.relative_to(ROOT)), url)) + + if missing: + print("Missing local links:") + for src, url in missing: + print(f"- {src}: {url}") + return 1 + + print("OK: no missing local href/src found") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
