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