#!/usr/bin/env python3 """校验 vrsub 文档中的相对 Markdown 链接是否都指向真实文件。 用法:uv run python scripts/check_doc_links.py(在 vrsub 目录下执行) 职责:扫描 README.md、AGENTS.md 与 docs/*.md 中的所有相对链接 (含 #锚点),确认目标文件存在、锚点能在目标文件中找到对应标题。 退出码非 0 表示存在断链,供 CI 或提交前检查使用。 """ from __future__ import annotations import re import sys from pathlib import Path # Markdown 链接语法 [文本](目标),目标不含协议头即为仓库内相对链接 LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)") # GitHub 风格锚点生成:小写、去反引号与标点、空格转连字符(此处按中文文档习惯保留中文) _ANCHOR_STRIP = re.compile(r"[^\w\u4e00-\u9fff\s-]") def slug(title: str) -> str: """把 Markdown 标题转成锚点 id,规则与 GitHub/Gitea 一致。""" text = title.strip().lower() text = _ANCHOR_STRIP.sub("", text) return text.replace(" ", "-") def collect_anchors(path: Path) -> set[str]: """收集文件中全部标题的锚点,用于校验 #片段。""" anchors: set[str] = set() for line in path.read_text(encoding="utf-8").splitlines(): if line.startswith("#"): anchors.add(slug(line.lstrip("#").strip())) return anchors def main() -> int: root = Path(__file__).resolve().parent.parent files = [root / "README.md", root / "AGENTS.md", *sorted((root / "docs").glob("*.md"))] problems: list[str] = [] for md in files: if not md.is_file(): problems.append(f"缺少文档:{md.relative_to(root)}") continue for target in LINK_RE.findall(md.read_text(encoding="utf-8")): if target.startswith(("http://", "https://", "mailto:")): continue path_part, _, anchor = target.partition("#") resolved = (md.parent / path_part).resolve() if path_part else md.resolve() # 目录链接(如 ./docs/)视为可达,只要求路径存在 if path_part and not resolved.exists(): problems.append(f"{md.relative_to(root)} -> 文件不存在:{target}") continue if anchor and resolved.suffix == ".md" and anchor not in collect_anchors(resolved): problems.append(f"{md.relative_to(root)} -> 锚点不存在:{target}") if problems: print("\n".join(problems)) print(f"\n共 {len(problems)} 处断链") return 1 print(f"检查 {len(files)} 个文档,全部链接与锚点可达") return 0 if __name__ == "__main__": sys.exit(main())