#!/usr/bin/env python3 """Find broken relative links and heading anchors in Markdown files. Usage: python3 scripts/check_md_links.py [PATH ...] [--no-anchors] [--exclude GLOB ...] PATH Markdown files or directories. Defaults to the current directory. --no-anchors Only check that linked files exist; skip "#heading" fragments. --exclude Glob matched against the relative path. Can be repeated. What it checks: [text](relative/path.md), ![alt](img.png), [text](#anchor), [text](other.md#anchor) and reference definitions like [id]: path.md Links starting with "/" are resolved from the current directory (repo root). What it skips: http(s)://, mailto:, tel: and other URL schemes (no network calls are made), anything inside fenced code blocks or inline code spans. Anchor matching approximates GitHub's heading slugs (lowercase, punctuation removed, spaces to hyphens, -1/-2 suffixes for repeats) and also accepts id="..." and name="..." attributes in HTML. Headings built from unusual Markdown may slug differently; use --no-anchors if that causes false alarms. Requirements: Python 3.8+, no packages, no environment variables. Exit codes: 0 no broken links 1 one or more broken links 2 usage error (path not found) """ import argparse import fnmatch import os import re import sys from urllib.parse import unquote SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", "vendor"} INLINE_LINK = re.compile(r"!?\[(?:[^\[\]]|\[[^\]]*\])*\]\(\s*(<[^>]*>|[^)\s]+)(?:\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*\)") REF_DEF = re.compile(r"^\s{0,3}\[[^\]]+\]:\s*(<[^>]*>|\S+)") HEADING = re.compile(r"^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$") HTML_ID = re.compile(r"""\b(?:id|name)\s*=\s*["']([^"']+)["']""") SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:") FENCE = re.compile(r"^\s{0,3}(```|~~~)") INLINE_CODE = re.compile(r"`+[^`]*`+") def slugify(text): text = re.sub(r"<[^>]+>", "", text) text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) text = text.replace("`", "").strip().lower() text = re.sub(r"[^\w\- ]", "", text) return text.replace(" ", "-") def scan(path): """Return (links, anchors) for one Markdown file. links = [(line, target)].""" links, anchors, counts = [], set(), {} in_fence = None with open(path, encoding="utf-8", errors="replace") as handle: for number, raw in enumerate(handle, start=1): fence = FENCE.match(raw) if fence: marker = fence.group(1) if in_fence is None: in_fence = marker elif in_fence == marker: in_fence = None continue if in_fence: continue line = INLINE_CODE.sub("", raw) heading = HEADING.match(line) if heading: base = slugify(heading.group(2)) n = counts.get(base, 0) anchors.add(base if n == 0 else "%s-%d" % (base, n)) counts[base] = n + 1 anchors.update(HTML_ID.findall(line)) for match in INLINE_LINK.finditer(line): links.append((number, match.group(1))) ref = REF_DEF.match(line) if ref: links.append((number, ref.group(1))) return links, anchors def collect(paths, excludes): files = [] for root in paths: if os.path.isfile(root): candidates = [root] elif os.path.isdir(root): candidates = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS) candidates.extend(os.path.join(dirpath, f) for f in sorted(filenames)) else: print("error: path not found: %s" % root, file=sys.stderr) sys.exit(2) for path in candidates: if not path.lower().endswith((".md", ".markdown")): continue rel = os.path.relpath(path).replace(os.sep, "/") if not any(fnmatch.fnmatch(rel, p) for p in excludes): files.append(rel) return files def main(): parser = argparse.ArgumentParser(description="Check relative Markdown links.") parser.add_argument("paths", nargs="*", default=["."]) parser.add_argument("--no-anchors", action="store_true") parser.add_argument("--exclude", action="append", default=[], metavar="GLOB") args = parser.parse_args() files = collect(args.paths, args.exclude) cache = {} def anchors_for(path): if path not in cache: cache[path] = scan(path)[1] return cache[path] broken = 0 for path in files: links, anchors = scan(path) cache[path] = anchors for line, target in links: target = target.strip("<>") if not target or SCHEME.match(target): continue file_part, _, fragment = target.partition("#") file_part = unquote(file_part.split("?", 1)[0]) if file_part: if file_part.startswith("/"): resolved = os.path.normpath(file_part.lstrip("/")) else: resolved = os.path.normpath(os.path.join(os.path.dirname(path), file_part)) if not os.path.exists(resolved): print("%s:%d: missing file %s" % (path, line, target)) broken += 1 continue else: resolved = path if fragment and not args.no_anchors and resolved.lower().endswith((".md", ".markdown")) and os.path.isfile(resolved): if unquote(fragment).lower() not in {a.lower() for a in anchors_for(resolved)}: print("%s:%d: missing anchor #%s in %s" % (path, line, fragment, resolved)) broken += 1 print("checked %d file(s), %d broken link(s)" % (len(files), broken)) return 1 if broken else 0 if __name__ == "__main__": sys.exit(main())