From faabb91e62712cdd6b16512654a3f21def2882e1 Mon Sep 17 00:00:00 2001 From: claude-timemachine Date: Tue, 26 May 2026 14:07:49 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20check-mermaid.py=20=E2=80=94=20Mermai?= =?UTF-8?q?d=20block=20validator=20via=20mmdc=20in=20podman?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- check-mermaid.py | 146 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 check-mermaid.py diff --git a/check-mermaid.py b/check-mermaid.py new file mode 100644 index 0000000..31b9fa6 --- /dev/null +++ b/check-mermaid.py @@ -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())