Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDRjtmAiNpo5mK5N3d7QwZ
44 lines
1.8 KiB
Python
Executable File
44 lines
1.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Check that every internal link and image of the built site points to an existing file.
|
|
|
|
Usage: tools/check_links.py [public] (exit status 1 when something is broken)
|
|
"""
|
|
import html
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from urllib.parse import unquote, urldefrag, urlparse
|
|
|
|
# minified pages drop the quotes around simple attribute values
|
|
ATTR = re.compile(r'\b(?:href|src)=(?:"([^"]*)"|\'([^\']*)\'|([^\s>"\']+))', re.I)
|
|
# code samples show markup, they do not link (escaped HTML in a <pre> would look like attributes)
|
|
PRE = re.compile(r'<pre\b.*?</pre>', re.I | re.S)
|
|
|
|
|
|
def main():
|
|
root = Path(sys.argv[1] if len(sys.argv) > 1 else "public").resolve()
|
|
broken, checked = {}, 0
|
|
for page in sorted(root.rglob("*.html")):
|
|
text = PRE.sub("", page.read_text(encoding="utf-8", errors="replace"))
|
|
for url in {a or b or c for a, b, c in ATTR.findall(text)}:
|
|
url = urldefrag(html.unescape(url))[0]
|
|
parsed = urlparse(url)
|
|
if not url or parsed.scheme or parsed.netloc:
|
|
continue # external link, mailto:, pure anchor
|
|
path = unquote(parsed.path)
|
|
target = (root / path.lstrip("/")) if path.startswith("/") else (page.parent / path)
|
|
target = target.resolve()
|
|
if target.is_dir():
|
|
target = target / "index.html"
|
|
checked += 1
|
|
if not target.is_file():
|
|
broken.setdefault(url, []).append(str(page.relative_to(root)))
|
|
print(f"{checked} internal references checked, {len(broken)} broken")
|
|
for url, pages in sorted(broken.items()):
|
|
print(f" {url} (in {pages[0]}{f' and {len(pages) - 1} more' if len(pages) > 1 else ''})")
|
|
sys.exit(1 if broken else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|