97e91a83f3
Ruff / Ruff (push) Waiting to run
Test / Core Tests (push) Waiting to run
Test / Offline Coverage Tests (Python 3.10) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.11) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.12) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.13) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.9) (push) Waiting to run
Test / Full Coverage (Python 3.11) (push) Waiting to run
Test / Core Provider Tests (OpenAI) (push) Blocked by required conditions
Test / Core Provider Tests (Anthropic) (push) Blocked by required conditions
Test / Core Provider Tests (Google) (push) Blocked by required conditions
Test / Core Provider Tests (Other) (push) Blocked by required conditions
Test / Anthropic Tests (push) Blocked by required conditions
Test / Gemini Tests (push) Blocked by required conditions
Test / Google GenAI Tests (push) Blocked by required conditions
Test / Vertex AI Tests (push) Blocked by required conditions
Test / OpenAI Tests (push) Blocked by required conditions
Test / Writer Tests (push) Blocked by required conditions
Test / Auto Client Tests (push) Blocked by required conditions
ty / type-check (push) Waiting to run
195 lines
5.3 KiB
Python
Executable File
195 lines
5.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Check for broken internal links in documentation files.
|
|
|
|
Finds:
|
|
- Broken internal links (missing target files)
|
|
- Broken anchor links
|
|
- Orphaned pages (no incoming links)
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
def find_markdown_files(docs_dir: Path) -> list[Path]:
|
|
"""Find all markdown files in the docs directory."""
|
|
return list(docs_dir.rglob("*.md"))
|
|
|
|
|
|
def extract_links(content: str, file_path: Path) -> list[tuple[str, int]]: # noqa: ARG001
|
|
"""
|
|
Extract internal markdown links from content.
|
|
|
|
Returns:
|
|
List of (link_target, line_number) tuples
|
|
"""
|
|
links = []
|
|
|
|
# Match markdown links: [text](url)
|
|
for match in re.finditer(r"\[([^\]]+)\]\(([^)]+)\)", content):
|
|
link_text = match.group(1)
|
|
link_url = match.group(2)
|
|
line_num = content[: match.start()].count("\n") + 1
|
|
|
|
# Skip external links
|
|
if link_url.startswith(("http://", "https://", "mailto:", "#")):
|
|
continue
|
|
|
|
links.append((link_url, line_num))
|
|
|
|
return links
|
|
|
|
|
|
def resolve_link(link_url: str, source_file: Path, docs_dir: Path) -> tuple[bool, str]: # noqa: ARG001
|
|
"""
|
|
Resolve a relative link and check if target exists.
|
|
|
|
Returns:
|
|
(exists, resolved_path)
|
|
"""
|
|
# Split anchor if present
|
|
if "#" in link_url:
|
|
link_path, anchor = link_url.split("#", 1)
|
|
else:
|
|
link_path = link_url
|
|
anchor = None
|
|
|
|
# Resolve relative path
|
|
source_dir = source_file.parent
|
|
target_path = (source_dir / link_path).resolve()
|
|
|
|
# Check if file exists
|
|
exists = target_path.exists()
|
|
|
|
return exists, str(target_path)
|
|
|
|
|
|
def check_file(file_path: Path, docs_dir: Path) -> dict[str, list[tuple[str, int]]]:
|
|
"""Check all links in a file."""
|
|
issues = {}
|
|
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8")
|
|
links = extract_links(content, file_path)
|
|
|
|
broken_links = []
|
|
for link_url, line_num in links:
|
|
exists, resolved_path = resolve_link(link_url, file_path, docs_dir)
|
|
if not exists:
|
|
broken_links.append((link_url, line_num))
|
|
|
|
if broken_links:
|
|
issues["broken_links"] = broken_links
|
|
|
|
return issues
|
|
except Exception as e:
|
|
return {"error": [(str(e), 0)]}
|
|
|
|
|
|
def find_orphaned_pages(files: list[Path], docs_dir: Path) -> set[Path]:
|
|
"""Find pages with no incoming links."""
|
|
all_files = set(files)
|
|
referenced_files = set()
|
|
|
|
for file_path in files:
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8")
|
|
links = extract_links(content, file_path)
|
|
|
|
for link_url, _ in links:
|
|
exists, resolved_path = resolve_link(link_url, file_path, docs_dir)
|
|
if exists:
|
|
referenced_files.add(Path(resolved_path))
|
|
except Exception:
|
|
pass
|
|
|
|
# Files that are not referenced (orphaned)
|
|
orphaned = all_files - referenced_files
|
|
|
|
# Remove index pages and special files from orphaned list
|
|
orphaned = {
|
|
f
|
|
for f in orphaned
|
|
if not any(
|
|
part in str(f)
|
|
for part in ["index.md", "AGENT.md", "repository-overview.md"]
|
|
)
|
|
}
|
|
|
|
return orphaned
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Check for broken internal links in documentation"
|
|
)
|
|
parser.add_argument(
|
|
"--docs-dir",
|
|
type=Path,
|
|
default=Path("docs"),
|
|
help="Directory containing documentation files (default: docs)",
|
|
)
|
|
parser.add_argument(
|
|
"--summary",
|
|
action="store_true",
|
|
help="Show only summary statistics",
|
|
)
|
|
parser.add_argument(
|
|
"--file",
|
|
type=Path,
|
|
help="Check a single file instead of all files",
|
|
)
|
|
parser.add_argument(
|
|
"--find-orphans",
|
|
action="store_true",
|
|
help="Find orphaned pages with no incoming links",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.file:
|
|
files = [args.file]
|
|
else:
|
|
files = find_markdown_files(args.docs_dir)
|
|
|
|
all_issues = {}
|
|
total_broken = 0
|
|
|
|
for file_path in files:
|
|
issues = check_file(file_path, args.docs_dir)
|
|
if issues:
|
|
all_issues[str(file_path)] = issues
|
|
if "broken_links" in issues:
|
|
total_broken += len(issues["broken_links"])
|
|
|
|
if args.summary:
|
|
print("Summary Statistics:")
|
|
print("=" * 60)
|
|
print(f" Files with broken links: {len(all_issues)}")
|
|
print(f" Total broken links: {total_broken}")
|
|
else:
|
|
# Detailed report
|
|
for file_path, issues in sorted(all_issues.items()):
|
|
if "broken_links" in issues:
|
|
print(f"\n{file_path}:")
|
|
for link_url, line_num in issues["broken_links"]:
|
|
print(f" Line {line_num}: {link_url}")
|
|
|
|
if args.find_orphans:
|
|
orphaned = find_orphaned_pages(files, args.docs_dir)
|
|
if orphaned:
|
|
print("\n" + "=" * 60)
|
|
print("Orphaned Pages (no incoming links):")
|
|
print("=" * 60)
|
|
for file_path in sorted(orphaned):
|
|
print(f" {file_path}")
|
|
print(f"\nTotal orphaned pages: {len(orphaned)}")
|
|
|
|
print(f"\nTotal files checked: {len(files)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|