* ci: harden contributor checks * ci: pin setup-uv action release * ci: split workflow checks * docs: clarify required checks
110 lines
3.5 KiB
Python
110 lines
3.5 KiB
Python
"""Validate contributor-facing Markdown and use-case documentation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
SKIP_DIRS = {".git", "node_modules", ".venv", ".uv-cache"}
|
|
USE_CASE_TABLES = {
|
|
Path("README.md"): "## Use Cases",
|
|
Path("use-cases/README.md"): "## Use Cases",
|
|
}
|
|
PRIMARY_LINK_RE = re.compile(
|
|
r"^\[(?:Code|Plugin|Live Demo|Learn more)\]\(([^)]+)\)",
|
|
flags=re.M,
|
|
)
|
|
|
|
|
|
def _markdown_files() -> list[Path]:
|
|
return sorted(
|
|
path
|
|
for path in Path(".").rglob("*.md")
|
|
if not any(part in SKIP_DIRS for part in path.parts)
|
|
)
|
|
|
|
|
|
def _check_active_relative_links() -> list[str]:
|
|
missing: list[str] = []
|
|
root = Path.cwd().resolve()
|
|
for path in _markdown_files():
|
|
active = re.sub(r"<!--.*?-->", "", path.read_text(), flags=re.S)
|
|
for raw in re.findall(r"\[[^\]]*\]\(([^)]+)\)", active):
|
|
link = raw.split("#", 1)[0]
|
|
if not link or link.startswith(("http://", "https://", "mailto:")):
|
|
continue
|
|
|
|
target = (path.parent / link).resolve()
|
|
try:
|
|
target.relative_to(root)
|
|
except ValueError:
|
|
missing.append(f"{path}: {raw} -> outside repository")
|
|
continue
|
|
|
|
if not target.exists():
|
|
missing.append(f"{path}: {raw} -> missing")
|
|
return missing
|
|
|
|
|
|
def _check_use_case_banner_links() -> tuple[list[str], list[str]]:
|
|
failures: list[str] = []
|
|
warnings: list[str] = []
|
|
|
|
for path, heading in USE_CASE_TABLES.items():
|
|
text = path.read_text()
|
|
start = text.find(heading)
|
|
if start == -1:
|
|
failures.append(f"{path}: missing {heading}")
|
|
continue
|
|
|
|
table_start = text.find("<table>", start)
|
|
table_end = text.find("</table>", table_start)
|
|
if table_start == -1 or table_end == -1:
|
|
failures.append(f"{path}: missing use-case table")
|
|
continue
|
|
|
|
table = text[table_start:table_end]
|
|
cells = re.findall(r"<td[^>]*>(.*?)</td>", table, flags=re.S)
|
|
for index, cell in enumerate(cells, start=1):
|
|
title_match = re.search(r"####\s+(.+)", cell)
|
|
title = title_match.group(1).strip() if title_match else f"use case {index}"
|
|
banner_match = re.search(r"\[!\[[^\]]*\]\([^)]+\)\]\(([^)]+)\)", cell)
|
|
primary_match = PRIMARY_LINK_RE.search(cell)
|
|
|
|
if not banner_match and primary_match:
|
|
warnings.append(f"{path}: {title}: primary link has no linked banner")
|
|
elif banner_match and not primary_match:
|
|
failures.append(f"{path}: {title}: missing primary link")
|
|
elif (
|
|
banner_match
|
|
and primary_match
|
|
and banner_match.group(1) != primary_match.group(1)
|
|
):
|
|
failures.append(
|
|
f"{path}: {title}: banner link {banner_match.group(1)} "
|
|
f"does not match primary link {primary_match.group(1)}"
|
|
)
|
|
|
|
return failures, warnings
|
|
|
|
|
|
def main() -> int:
|
|
failures = _check_active_relative_links()
|
|
use_case_failures, warnings = _check_use_case_banner_links()
|
|
failures.extend(use_case_failures)
|
|
|
|
if warnings:
|
|
print("\n".join(f"warning: {warning}" for warning in warnings))
|
|
|
|
if failures:
|
|
print("\n".join(failures))
|
|
return 1
|
|
|
|
print("Documentation checks passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|