Initial check-mermaid.py — Mermaid block validator via mmdc in podman
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Syntax-check every ```mermaid block in repo markdown files.
|
||||
|
||||
Runs `mmdc` (Mermaid CLI) in a transient podman container; treats any
|
||||
non-zero exit as a syntax failure and reports the offending file:line.
|
||||
|
||||
Usage:
|
||||
check-mermaid.py # check everything under cwd
|
||||
check-mermaid.py docs/ # check one path
|
||||
check-mermaid.py --image foo # override container image
|
||||
check-mermaid.py --no-pull docs/ # skip upfront podman pull
|
||||
|
||||
Exit 0 if all blocks parse, 1 otherwise. Honors the standard CI
|
||||
convention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_IMAGE = "docker.io/minlag/mermaid-cli:latest"
|
||||
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "vendor"}
|
||||
|
||||
|
||||
def find_markdown(root: Path) -> list[Path]:
|
||||
out: list[Path] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
for fn in filenames:
|
||||
if fn.endswith(".md"):
|
||||
out.append(Path(dirpath) / fn)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def extract_blocks(md: Path) -> list[tuple[int, str]]:
|
||||
"""Return [(start_line_1indexed, body), ...] for every ```mermaid block."""
|
||||
lines = md.read_text().splitlines()
|
||||
out: list[tuple[int, str]] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if lines[i].strip() == "```mermaid":
|
||||
start = i + 1 # the line after the fence is line start+1 (1-indexed)
|
||||
body_lines: list[str] = []
|
||||
i += 1
|
||||
while i < len(lines) and not lines[i].startswith("```"):
|
||||
body_lines.append(lines[i])
|
||||
i += 1
|
||||
out.append((start + 1, "\n".join(body_lines) + "\n"))
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def check_block(image: str, body: str) -> tuple[bool, str]:
|
||||
"""Run mmdc on the body. Returns (ok, stderr_or_empty)."""
|
||||
# mmdc requires real files on a bind mount — using stdin/stdout
|
||||
# confuses puppeteer's file watching. Use a temp dir mounted into
|
||||
# the container. World-writable so the container's UID can write
|
||||
# the SVG output.
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
td_path = Path(td)
|
||||
td_path.chmod(0o777)
|
||||
(td_path / "in.mmd").write_text(body)
|
||||
r = subprocess.run(
|
||||
[
|
||||
"podman", "run", "--rm",
|
||||
"-v", f"{td}:/data:Z",
|
||||
image,
|
||||
"-i", "/data/in.mmd",
|
||||
"-o", "/data/out.svg",
|
||||
"--quiet",
|
||||
],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
return True, ""
|
||||
return False, (r.stderr or r.stdout).strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||
ap.add_argument("paths", nargs="*", default=["."],
|
||||
help="files or dirs to scan (default: current dir)")
|
||||
ap.add_argument("--image", default=DEFAULT_IMAGE,
|
||||
help=f"container image to use (default {DEFAULT_IMAGE})")
|
||||
ap.add_argument("--no-pull", action="store_true",
|
||||
help="skip the upfront `podman pull` step")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not shutil.which("podman"):
|
||||
print("podman not in PATH", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not args.no_pull:
|
||||
subprocess.run(["podman", "pull", "--quiet", args.image],
|
||||
capture_output=True, check=False)
|
||||
|
||||
# Collect targets
|
||||
targets: list[Path] = []
|
||||
for p_arg in args.paths:
|
||||
p = Path(p_arg)
|
||||
if p.is_dir():
|
||||
targets.extend(find_markdown(p))
|
||||
elif p.is_file():
|
||||
targets.append(p)
|
||||
targets = sorted(set(targets))
|
||||
|
||||
total_blocks = 0
|
||||
failures: list[tuple[Path, int, str]] = []
|
||||
|
||||
for md in targets:
|
||||
blocks = extract_blocks(md)
|
||||
if not blocks:
|
||||
continue
|
||||
for line, body in blocks:
|
||||
total_blocks += 1
|
||||
ok, err = check_block(args.image, body)
|
||||
marker = "\033[32m✓\033[0m" if ok else "\033[31m✗\033[0m"
|
||||
print(f" {marker} {md}:{line}")
|
||||
if not ok:
|
||||
failures.append((md, line, err))
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"\033[31m{len(failures)} of {total_blocks} block(s) FAILED:\033[0m\n")
|
||||
for md, line, err in failures:
|
||||
print(f"=== {md}:{line} ===")
|
||||
# Trim noisy puppeteer stack; keep the parse error
|
||||
for line_ in err.splitlines():
|
||||
if not line_.strip():
|
||||
continue
|
||||
print(f" {line_}")
|
||||
print()
|
||||
return 1
|
||||
print(f"\033[32m{total_blocks} block(s) OK\033[0m")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user