Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/mermaid-lint.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +40 to +41

- name: Render Mermaid diagrams
run: python scripts/render_mermaid.py
3 changes: 3 additions & 0 deletions scripts/puppeteer-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
}
135 changes: 135 additions & 0 deletions scripts/render_mermaid.py
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +4 to +6

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"
Comment on lines +37 to +38


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
Comment on lines +70 to +71


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
Comment on lines +88 to +92
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
Comment on lines +112 to +115
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:]))
Loading