mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 23:32:55 +08:00
186 lines
5.5 KiB
Python
186 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate repository documentation structure and local references."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DOCS = ROOT / "docs"
|
|
ALLOWED_DOCS_ROOT_FILES = {"README.md", "README_CN.md"}
|
|
IGNORED_DIRECTORIES = {
|
|
".git",
|
|
".mypy_cache",
|
|
".pytest_cache",
|
|
".ruff_cache",
|
|
".test_deps",
|
|
"__pycache__",
|
|
"data",
|
|
"logs",
|
|
"node_modules",
|
|
}
|
|
|
|
MARKDOWN_LINK_RE = re.compile(r"(!?)\[[^\]]*\]\(([^)]+)\)")
|
|
HTML_LINK_RE = re.compile(
|
|
r"<(a|img|script|link)\b[^>]*?\b(?:href|src)\s*=\s*([\"'])(.*?)\2",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
|
|
WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
|
|
|
|
def iter_document_files() -> list[Path]:
|
|
files: list[Path] = []
|
|
for path in ROOT.rglob("*"):
|
|
if not path.is_file() or path.suffix.lower() not in {".md", ".html"}:
|
|
continue
|
|
if any(part in IGNORED_DIRECTORIES for part in path.relative_to(ROOT).parts):
|
|
continue
|
|
files.append(path)
|
|
return sorted(files)
|
|
|
|
|
|
def strip_fenced_blocks(text: str) -> str:
|
|
output: list[str] = []
|
|
active_marker: str | None = None
|
|
active_length = 0
|
|
|
|
for line in text.splitlines():
|
|
match = FENCE_RE.match(line)
|
|
if match:
|
|
fence = match.group(1)
|
|
marker = fence[0]
|
|
if active_marker is None:
|
|
active_marker = marker
|
|
active_length = len(fence)
|
|
elif marker == active_marker and len(fence) >= active_length:
|
|
active_marker = None
|
|
active_length = 0
|
|
continue
|
|
if active_marker is None:
|
|
output.append(line)
|
|
|
|
return "\n".join(output)
|
|
|
|
|
|
def has_balanced_fences(text: str) -> bool:
|
|
active_marker: str | None = None
|
|
active_length = 0
|
|
|
|
for line in text.splitlines():
|
|
match = FENCE_RE.match(line)
|
|
if not match:
|
|
continue
|
|
fence = match.group(1)
|
|
marker = fence[0]
|
|
if active_marker is None:
|
|
active_marker = marker
|
|
active_length = len(fence)
|
|
elif marker == active_marker and len(fence) >= active_length:
|
|
active_marker = None
|
|
active_length = 0
|
|
|
|
return active_marker is None
|
|
|
|
|
|
def clean_markdown_target(raw_target: str) -> str:
|
|
target = raw_target.strip()
|
|
if target.startswith("<") and ">" in target:
|
|
return target[1 : target.index(">")]
|
|
return target.split(maxsplit=1)[0]
|
|
|
|
|
|
def resolve_local_target(source: Path, raw_target: str) -> Path | None:
|
|
target = clean_markdown_target(raw_target)
|
|
if not target or target.startswith(("#", "/", "\\")):
|
|
return None
|
|
if WINDOWS_ABSOLUTE_RE.match(target):
|
|
return None
|
|
|
|
parsed = urlsplit(target)
|
|
if parsed.scheme or parsed.netloc:
|
|
return None
|
|
|
|
path_text = unquote(parsed.path)
|
|
if not path_text:
|
|
return None
|
|
return (source.parent / path_text).resolve()
|
|
|
|
|
|
def validate() -> list[str]:
|
|
errors: list[str] = []
|
|
referenced_assets: set[Path] = set()
|
|
|
|
unexpected_root_files = sorted(
|
|
path.name
|
|
for path in DOCS.iterdir()
|
|
if path.is_file() and path.name not in ALLOWED_DOCS_ROOT_FILES
|
|
)
|
|
if unexpected_root_files:
|
|
errors.append(
|
|
"docs/ root contains uncategorized files: "
|
|
+ ", ".join(unexpected_root_files)
|
|
)
|
|
|
|
for source in iter_document_files():
|
|
relative_source = source.relative_to(ROOT)
|
|
try:
|
|
text = source.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
errors.append(f"{relative_source}: invalid UTF-8 ({exc})")
|
|
continue
|
|
|
|
if source.suffix.lower() == ".md" and not has_balanced_fences(text):
|
|
errors.append(f"{relative_source}: unbalanced fenced code block")
|
|
|
|
searchable_text = strip_fenced_blocks(text) if source.suffix.lower() == ".md" else text
|
|
references: list[tuple[str, bool]] = []
|
|
references.extend(
|
|
(match.group(2), bool(match.group(1)))
|
|
for match in MARKDOWN_LINK_RE.finditer(searchable_text)
|
|
)
|
|
references.extend(
|
|
(match.group(3), match.group(1).lower() == "img")
|
|
for match in HTML_LINK_RE.finditer(searchable_text)
|
|
)
|
|
|
|
for raw_target, is_asset in references:
|
|
resolved = resolve_local_target(source, raw_target)
|
|
if resolved is None:
|
|
continue
|
|
if not resolved.exists():
|
|
errors.append(f"{relative_source}: missing local target {raw_target}")
|
|
continue
|
|
if is_asset or (DOCS / "screenshots").resolve() in resolved.parents:
|
|
referenced_assets.add(resolved)
|
|
|
|
screenshots = DOCS / "screenshots"
|
|
if screenshots.exists():
|
|
for asset in sorted(path.resolve() for path in screenshots.rglob("*") if path.is_file()):
|
|
if asset not in referenced_assets:
|
|
errors.append(
|
|
f"{asset.relative_to(ROOT)}: screenshot asset is not referenced"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
errors = validate()
|
|
if errors:
|
|
print("Documentation validation failed:")
|
|
for error in errors:
|
|
print(f"- {error}")
|
|
return 1
|
|
|
|
print("Documentation structure, links, code fences, and assets are valid.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|