docs: add anchor gate and folded references toc

This commit is contained in:
tukuaiai
2026-05-03 21:22:10 +08:00
parent 53f050f341
commit b5043f6216
9 changed files with 156 additions and 38 deletions
+1 -1
View File
@@ -4,4 +4,4 @@
当前已有:
- `check-local-links.py`:仓库内 Markdown 相对链接检查脚本。
- `check-local-links.py`:仓库内 Markdown 相对链接与锚点检查脚本。
+98 -16
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Check local Markdown links that should resolve inside this repository."""
"""Check local Markdown links and anchors that should resolve in this repository."""
from __future__ import annotations
@@ -21,7 +21,6 @@ LINK_PATTERNS = [
re.compile(r"\b(?:href|src)=[\"']([^\"']+)[\"']"),
]
EXTERNAL_PREFIXES = (
"#",
"http://",
"https://",
"wss://",
@@ -32,6 +31,72 @@ EXTERNAL_PREFIXES = (
)
def github_slug(title: str) -> str:
"""Approximate GitHub Markdown heading anchors for local validation."""
title = re.sub(r"<[^>]+>", "", title.strip().lower())
title = re.sub(r"[`*_~]", "", title)
title = re.sub(r"[^\w\u4e00-\u9fff\- ]+", "", title)
title = re.sub(r"\s+", "-", title).strip("-")
return title
def strip_fenced_code(text: str) -> str:
"""Remove fenced code blocks before scanning inline links."""
lines: list[str] = []
in_fence = False
fence_marker = ""
for line in text.splitlines():
stripped = line.lstrip()
if stripped.startswith(("```", "~~~")):
marker = stripped[:3]
if not in_fence:
in_fence = True
fence_marker = marker
elif marker == fence_marker:
in_fence = False
fence_marker = ""
lines.append("")
continue
lines.append("" if in_fence else line)
return "\n".join(lines)
def markdown_anchors(path: Path) -> set[str]:
text = path.read_text(encoding="utf-8", errors="ignore")
anchors = set(re.findall(r"<a\s+id=[\"']([^\"']+)[\"']", text))
used: dict[str, int] = {}
in_fence = False
fence_marker = ""
for line in text.splitlines():
stripped = line.lstrip()
if stripped.startswith(("```", "~~~")):
marker = stripped[:3]
if not in_fence:
in_fence = True
fence_marker = marker
elif marker == fence_marker:
in_fence = False
fence_marker = ""
continue
if in_fence:
continue
heading = re.match(r"^(#{1,6})\s+(.+?)\s*#*\s*$", line)
if not heading:
continue
slug = github_slug(heading.group(2))
if not slug:
continue
count = used.get(slug, 0)
used[slug] = count + 1
anchors.add(slug if count == 0 else f"{slug}-{count}")
return anchors
def should_skip(path: Path) -> bool:
rel = path.relative_to(ROOT)
if any(part in SKIP_PARTS for part in rel.parts):
@@ -39,17 +104,23 @@ def should_skip(path: Path) -> bool:
return any(rel == prefix or prefix in rel.parents for prefix in SKIP_PREFIXES)
def normalize_target(raw: str) -> str:
def normalize_target(raw: str) -> tuple[str, str]:
raw = raw.strip()
if not raw or raw.startswith(EXTERNAL_PREFIXES):
return ""
return "", ""
target = raw.split()[0] if " " in raw and not raw.startswith("<") else raw
target = target.strip("<>").split("#", 1)[0]
return urllib.parse.unquote(target)
target = target.strip("<>")
if "#" in target:
path_part, anchor = target.split("#", 1)
else:
path_part, anchor = target, ""
return urllib.parse.unquote(path_part), urllib.parse.unquote(anchor)
def main() -> int:
missing: list[tuple[Path, str, Path]] = []
missing_links: list[tuple[Path, str, Path]] = []
missing_anchors: list[tuple[Path, str, Path, str]] = []
anchor_cache: dict[Path, set[str]] = {}
checked_files = 0
for markdown_file in sorted(ROOT.rglob("*.md")):
@@ -57,31 +128,42 @@ def main() -> int:
continue
checked_files += 1
text = markdown_file.read_text(encoding="utf-8", errors="ignore")
scan_text = strip_fenced_code(text)
for pattern in LINK_PATTERNS:
for match in pattern.finditer(text):
for match in pattern.finditer(scan_text):
raw = match.group(1)
target = normalize_target(raw)
if not target or target.startswith(("/", "\\")):
target, anchor = normalize_target(raw)
if target.startswith(("/", "\\")):
continue
destination = (markdown_file.parent / target).resolve()
destination = (markdown_file.parent / target).resolve() if target else markdown_file.resolve()
try:
destination.relative_to(ROOT)
except ValueError:
continue
if not destination.exists():
missing.append((markdown_file.relative_to(ROOT), raw, markdown_file.parent / target))
missing_links.append((markdown_file.relative_to(ROOT), raw, markdown_file.parent / target))
continue
if anchor and destination.suffix.lower() == ".md":
if destination not in anchor_cache:
anchor_cache[destination] = markdown_anchors(destination)
if anchor not in anchor_cache[destination]:
missing_anchors.append((markdown_file.relative_to(ROOT), raw, destination.relative_to(ROOT), anchor))
if missing:
if missing_links or missing_anchors:
print("MISSING_LINKS")
for source, raw, resolved in missing:
for source, raw, resolved in missing_links:
print(f"{source} -> {raw} => {resolved.relative_to(ROOT)}")
print(f"TOTAL={len(missing)}")
print("MISSING_ANCHORS")
for source, raw, destination, anchor in missing_anchors:
print(f"{source} -> {raw} => {destination}#{anchor}")
print(f"TOTAL_LINKS={len(missing_links)}")
print(f"TOTAL_ANCHORS={len(missing_anchors)}")
return 1
print(f"OK local links checked: {checked_files} files")
print(f"OK local links and anchors checked: {checked_files} files")
return 0