#!/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())