2"""Local markdown link checker across every Markdown file the current commit carries."""
4from __future__
import annotations
10from pathlib
import Path
12sys.path.insert(0, str(Path(__file__).resolve().parent))
13from repo_files
import enumerate_repository_files
16LINK_PATTERN = re.compile(
r"!\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)|\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
21 @brief Parse command-line arguments.
22 @return Value returned by `parse_args()`.
24 parser = argparse.ArgumentParser(
26 "Check local markdown links across every repository Markdown file.\n"
27 "HTTP(S), mailto, and in-page anchor links are skipped."
29 formatter_class=argparse.RawTextHelpFormatter,
32 " python3 tests/tooling/check_markdown_links.py\n"
33 " python3 tests/tooling/check_markdown_links.py --repo-root .\n"
34 " python3 tests/tooling/check_markdown_links.py --no-readme"
40 default=Path(__file__).resolve().parents[2],
41 help=
"Repository root directory (default: parent of this script).",
46 help=
"Skip README.md in scan set.",
48 return parser.parse_args()
51SKIP_DIRS = {
".git",
"docs_build",
"obj",
"bin",
"stubs",
"runs",
"studies",
"__pycache__",
".pytest_cache"}
56 @brief List tracked plus non-ignored untracked Markdown files, so the scan set is
57 reproducible for a given commit regardless of local build or scratch output.
58 @param[in] repo_root Repository root directory.
59 @return Sorted Markdown paths, or None when git enumeration is unavailable.
61 return enumerate_repository_files(repo_root,
".md")
66 @brief Yield every repository Markdown file the current commit would carry.
67 @param[in] repo_root Repository root directory.
68 @param[in] include_readme Whether the top-level README participates in the scan.
69 @return Generator of Markdown paths.
72 if candidates
is None:
74 candidates = sorted(repo_root.rglob(
"*.md"))
75 for markdown
in candidates:
76 if not markdown.is_file():
78 parts = markdown.relative_to(repo_root).parts
79 if SKIP_DIRS.intersection(parts):
81 if not include_readme
and markdown == repo_root /
"README.md":
88 @brief Perform should skip link.
89 @param[in] target Argument passed to `should_skip_link()`.
90 @return Value returned by `should_skip_link()`.
92 lower = target.lower()
94 lower.startswith(
"http://")
95 or lower.startswith(
"https://")
96 or lower.startswith(
"mailto:")
97 or lower.startswith(
"#")
103 @brief Remove Markdown angle brackets, fragments, and query strings before resolving a link target.
104 @param[in] raw_target Argument passed to `normalize_target()`.
105 @return Value returned by `normalize_target()`.
107 cleaned = raw_target.strip().strip(
"<>").split(
"#", 1)[0].split(
"?", 1)[0]
113 @brief Entry point for this script.
114 @return Value returned by `main()`.
117 repo_root = args.repo_root.resolve()
121 for md_file
in scanned:
122 if not md_file.is_file():
123 failures.append((str(md_file),
"<file>",
"Markdown file not found"))
125 content = md_file.read_text(encoding=
"utf-8", errors=
"replace")
126 for match
in LINK_PATTERN.findall(content):
127 target = match[0]
or match[1]
133 resolved = (md_file.parent / normalized).resolve()
135 exists = resolved.exists()
136 except OSError
as exc:
137 failures.append((str(md_file.relative_to(repo_root)), target, f
"{resolved} ({exc})"))
140 failures.append((str(md_file.relative_to(repo_root)), target, str(resolved)))
143 print(
"Broken markdown links detected:")
144 for src, target, resolved
in failures:
145 print(f
" - {src}: '{target}' -> missing '{resolved}'")
148 print(f
"Markdown link check passed for {len(scanned)} repository Markdown files.")
152if __name__ ==
"__main__":
bool should_skip_link(str target)
Perform should skip link.
argparse.Namespace parse_args()
Parse command-line arguments.
iter_markdown_files(Path repo_root, bool include_readme)
Yield every repository Markdown file the current commit would carry.
str normalize_target(str raw_target)
Remove Markdown angle brackets, fragments, and query strings before resolving a link target.
int main()
Entry point for this script.
list[Path]|None git_markdown(Path repo_root)
List tracked plus non-ignored untracked Markdown files, so the scan set is reproducible for a given c...
Head of a generic C-style linked list.