Initial check-mermaid.py — Mermaid block validator via mmdc in podman

This commit is contained in:
2026-05-26 14:07:49 +00:00
parent ab5871b3b1
commit faabb91e62
+146
View File
@@ -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())