diff --git a/.github/workflows/mermaid-lint.yml b/.github/workflows/mermaid-lint.yml new file mode 100644 index 0000000..f7907f7 --- /dev/null +++ b/.github/workflows/mermaid-lint.yml @@ -0,0 +1,44 @@ +name: Mermaid Render Lint + +# Isolated from the main docs build: mermaid-cli (mmdc) pulls in a headless +# Chromium dependency, so this render check runs on its own only when docs or +# READMEs change. It renders every ```mermaid``` block and fails fast on +# invalid syntax that the text-only label linter cannot catch (see issue #214). + +on: + pull_request: + paths: + - "README*.md" + - "DESIGN.md" + - "docs/**/*.md" + - "scripts/render_mermaid.py" + - "scripts/puppeteer-config.json" + - ".github/workflows/mermaid-lint.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + render-lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "20" + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.10" + + - name: Install mermaid-cli + run: npm install -g @mermaid-js/mermaid-cli + + - name: Render Mermaid diagrams + run: python scripts/render_mermaid.py diff --git a/scripts/puppeteer-config.json b/scripts/puppeteer-config.json new file mode 100644 index 0000000..2274c80 --- /dev/null +++ b/scripts/puppeteer-config.json @@ -0,0 +1,3 @@ +{ + "args": ["--no-sandbox", "--disable-setuid-sandbox"] +} diff --git a/scripts/render_mermaid.py b/scripts/render_mermaid.py new file mode 100755 index 0000000..44cf91d --- /dev/null +++ b/scripts/render_mermaid.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Render-lint Mermaid diagrams embedded in Markdown to catch syntax errors. + +Unlike ``lint_mermaid.py`` (label-hygiene only), this script actually renders +every ```mermaid``` fenced block with the Mermaid CLI (``mmdc``). Invalid +syntax that a text linter cannot detect will fail the render, and therefore CI. + +Because ``mmdc`` pulls in a headless-Chromium dependency, this check is kept in +an isolated workflow rather than the main docs build. + +Usage:: + + python scripts/render_mermaid.py [FILE ...] + +With no arguments, the default target globs are scanned. Explicit file +arguments (e.g. the changed files on a PR) are used as-is when provided. + +Exit code 0 when every diagram renders, 1 when any diagram fails to render or +when ``mmdc`` is unavailable. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# Files/globs to scan, relative to the repository root. +TARGET_GLOBS = ("README*.md", "DESIGN.md", "docs/**/*.md") + +FENCE_START = "```mermaid" +FENCE_END = "```" + +REPO_ROOT = Path(__file__).resolve().parent.parent +# Puppeteer config so Chromium runs in sandboxed CI containers. +PUPPETEER_CONFIG = REPO_ROOT / "scripts" / "puppeteer-config.json" + + +def _iter_default_targets() -> list[Path]: + seen: set[Path] = set() + files: list[Path] = [] + for pattern in TARGET_GLOBS: + for path in sorted(REPO_ROOT.glob(pattern)): + if path.is_file() and path not in seen: + seen.add(path) + files.append(path) + return files + + +def _extract_blocks(path: Path) -> list[tuple[int, str]]: + """Return (start_line, source) for each Mermaid block in *path*.""" + blocks: list[tuple[int, str]] = [] + in_block = False + start_line = 0 + buffer: list[str] = [] + for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + stripped = raw.strip() + if not in_block: + if stripped.startswith(FENCE_START): + in_block = True + start_line = lineno + buffer = [] + continue + if stripped == FENCE_END: + in_block = False + blocks.append((start_line, "\n".join(buffer))) + continue + buffer.append(raw) + return blocks + + +def _render(source: str, workdir: Path) -> tuple[bool, str]: + """Render one Mermaid *source*; return (ok, message).""" + src = workdir / "diagram.mmd" + out = workdir / "diagram.svg" + src.write_text(source + "\n", encoding="utf-8") + cmd = [ + "mmdc", + "--input", + str(src), + "--output", + str(out), + "--quiet", + ] + if PUPPETEER_CONFIG.is_file(): + cmd += ["--puppeteerConfigFile", str(PUPPETEER_CONFIG)] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip() + return False, detail + return True, "" + + +def main(argv: list[str]) -> int: + if shutil.which("mmdc") is None: + print( + "error: 'mmdc' (mermaid-cli) not found on PATH. " + "Install with: npm install -g @mermaid-js/mermaid-cli", + file=sys.stderr, + ) + return 1 + + if argv: + targets = [Path(a) for a in argv if Path(a).is_file()] + else: + targets = _iter_default_targets() + + failures: list[str] = [] + rendered = 0 + for path in targets: + for start_line, source in _extract_blocks(path): + if not source.strip(): + continue + with tempfile.TemporaryDirectory() as tmp: + ok, detail = _render(source, Path(tmp)) + if ok: + rendered += 1 + else: + failures.append(f"{path}:{start_line}: Mermaid render failed\n{detail}") + + if failures: + print("Mermaid render lint FAILED:\n", file=sys.stderr) + for failure in failures: + print(failure, file=sys.stderr) + print("", file=sys.stderr) + return 1 + + print(f"Mermaid render lint OK: {rendered} diagram(s) rendered cleanly.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))