/``.
+
+ >>> _chrome_glob("linux")
+ '*/chrome-linux64/chrome'
+ >>> _chrome_glob("win32")
+ '*/chrome-win64/chrome.exe'
+ >>> _chrome_glob("darwin").startswith("*/chrome-mac-")
+ True
+ """
+ if platform.startswith("win"):
+ return "*/chrome-win64/chrome.exe"
+ if platform == "darwin":
+ return (
+ "*/chrome-mac-*/Google Chrome for Testing.app"
+ "/Contents/MacOS/Google Chrome for Testing"
+ )
+ return "*/chrome-linux64/chrome"
+
+
+def _discover_chrome() -> str | None:
+ """Return a Chrome installed by ``puppeteer browsers install``, if any.
+
+ Puppeteer's automatic resolution can miss the cached browser (its cache dir
+ is computed relative to the install location); pointing ``executablePath`` at
+ the discovered binary sidesteps that.
+ """
+ cache = pathlib.Path.home() / ".cache" / "puppeteer" / "chrome"
+ if not cache.is_dir():
+ return None
+ candidates = sorted(cache.glob(_chrome_glob(sys.platform)))
+ return str(candidates[-1]) if candidates else None
+
+
+def _resolve_mmdc(app: Sphinx) -> list[str] | None:
+ """Locate the ``mmdc`` executable: config, then docs-local, then ``PATH``."""
+ configured = app.config.mermaid_inline_cmd
+ if configured:
+ found = shutil.which(configured)
+ if found:
+ return [found]
+ path = pathlib.Path(configured)
+ if path.exists():
+ return [str(path)]
+ local = pathlib.Path(app.confdir) / "node_modules" / ".bin" / "mmdc"
+ if local.exists():
+ return [str(local)]
+ found = shutil.which("mmdc")
+ return [found] if found else None
+
+
+def _puppeteer_config_file(app: Sphinx, tmpdir: pathlib.Path) -> pathlib.Path:
+ """Write a puppeteer config (``--no-sandbox`` + resolved Chrome) and return it.
+
+ An explicit ``mermaid_inline_puppeteer_config`` wins; otherwise a minimal
+ config is generated, adding ``executablePath`` from
+ ``PUPPETEER_EXECUTABLE_PATH`` or a discovered cached Chrome.
+ """
+ configured = app.config.mermaid_inline_puppeteer_config
+ if configured:
+ return pathlib.Path(configured)
+ data: dict[str, t.Any] = {
+ "args": ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
+ }
+ executable = os.environ.get("PUPPETEER_EXECUTABLE_PATH") or _discover_chrome()
+ if executable:
+ data["executablePath"] = executable
+ out = tmpdir / "puppeteer.json"
+ out.write_text(json.dumps(data), encoding="utf-8")
+ return out
+
+
+def _mermaid_config(theme: str) -> dict[str, t.Any]:
+ """Return the mermaid ``-c`` config: base theme + furo palette + themeCSS.
+
+ ``block.padding: 0`` tiles block-beta panes contiguously, like a tmux
+ window; other diagram types ignore the ``block`` key.
+
+ >>> cfg = _mermaid_config("light")
+ >>> cfg["theme"], "themeVariables" in cfg, cfg["block"]["padding"]
+ ('base', True, 0)
+ """
+ return {
+ "theme": "base",
+ "block": {"padding": 0},
+ "themeVariables": _PALETTES[theme],
+ "themeCSS": _theme_css(theme),
+ }
+
+
+def _render(app: Sphinx, source: str, config_json: str) -> str:
+ """Render ``source`` to an SVG string via ``mmdc`` using ``config_json``."""
+ mmdc = _resolve_mmdc(app)
+ if mmdc is None:
+ msg = (
+ "mmdc (@mermaid-js/mermaid-cli) not found; install it in the docs "
+ "toolchain or set the mermaid_inline_cmd config value"
+ )
+ raise MermaidRendererMissing(msg)
+ with tempfile.TemporaryDirectory(prefix="mermaid-inline-") as td:
+ tmpdir = pathlib.Path(td)
+ in_file = tmpdir / "diagram.mmd"
+ out_file = tmpdir / "diagram.svg"
+ config_file = tmpdir / "config.json"
+ in_file.write_text(source, encoding="utf-8")
+ config_file.write_text(config_json, encoding="utf-8")
+ argv = [
+ *mmdc,
+ "-i",
+ str(in_file),
+ "-o",
+ str(out_file),
+ "-b",
+ "transparent",
+ "-c",
+ str(config_file),
+ "-p",
+ str(_puppeteer_config_file(app, tmpdir)),
+ ]
+ try:
+ subprocess.run(
+ argv,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=180,
+ )
+ except FileNotFoundError as exc: # mmdc vanished between resolve and run
+ raise MermaidRendererMissing(str(exc)) from exc
+ except subprocess.SubprocessError as exc:
+ stderr = getattr(exc, "stderr", "") or ""
+ msg = f"mmdc failed: {exc}\n{stderr}"
+ raise MermaidRenderError(msg) from exc
+ if not out_file.is_file():
+ msg = "mmdc produced no SVG output"
+ raise MermaidRenderError(msg)
+ return out_file.read_text(encoding="utf-8")
+
+
+def _render_cached(app: Sphinx, source: str, theme: str) -> str:
+ """Return a rendered SVG, reading/writing a content-hashed on-disk cache.
+
+ The cache lives outside ``_build`` (under the confdir) so it survives the
+ ``rm -rf docs/_build`` that precedes a full build.
+ """
+ config_json = json.dumps(_mermaid_config(theme), sort_keys=True)
+ digest = _diagram_digest(source, theme, extra=config_json)
+ cache_dir = pathlib.Path(app.confdir) / "_mermaid_cache"
+ cache_file = cache_dir / f"{digest}.svg"
+ if cache_file.is_file():
+ return cache_file.read_text(encoding="utf-8")
+ svg = _render(app, source, config_json)
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ cache_file.write_text(svg, encoding="utf-8")
+ return svg
+
+
+def _warn_render_failure(node: nodes.Node, exc: MermaidError) -> None:
+ """Emit a single build warning when rendering is unavailable or fails."""
+ global _render_warned
+ if _render_warned:
+ return
+ _render_warned = True
+ logger.warning(
+ "mermaid render unavailable; emitting diagram source as text: %s",
+ exc,
+ location=node,
+ )
+
+
+def html_visit_mermaid_inline(self: HTML5Translator, node: mermaid_inline) -> None:
+ """Render the diagram and append dual light/dark inline SVG, then skip."""
+ source: str = node["mermaid_source"]
+ app = self.builder.app
+ try:
+ light = _render_cached(app, source, _THEME_LIGHT)
+ dark = _render_cached(app, source, _THEME_DARK)
+ except MermaidError as exc:
+ _warn_render_failure(node, exc)
+ self.body.append(
+ '' + html.escape(source) + " ",
+ )
+ raise nodes.SkipNode from None
+
+ digest = _diagram_digest(source, "")
+ light = _normalize_svg(light, svg_id=_svg_element_id(digest, _THEME_LIGHT))
+ dark = _normalize_svg(dark, svg_id=_svg_element_id(digest, _THEME_DARK))
+
+ caption: str = node.get("caption", "")
+ alt = node.get("alt", "") or caption
+ aria = f' aria-label="{html.escape(alt, quote=True)}"' if alt else ""
+ ids: list[str] = node.get("ids", [])
+ fig_id = f' id="{ids[0]}"' if ids else ""
+
+ parts = [
+ f'',
+ f'{light}
',
+ (
+ '{dark}
'
+ ),
+ ]
+ if caption:
+ parts.append(f"{html.escape(caption)} ")
+ parts.append(" ")
+ self.body.append("".join(parts))
+ raise nodes.SkipNode
+
+
+def _depart_mermaid_inline(self: HTML5Translator, node: mermaid_inline) -> None:
+ """No-op; :func:`html_visit_mermaid_inline` raises ``SkipNode``."""
+
+
+def setup(app: Sphinx) -> dict[str, t.Any]:
+ """Register the directive, node, config values, and stylesheet."""
+ app.add_node(
+ mermaid_inline,
+ html=(html_visit_mermaid_inline, _depart_mermaid_inline),
+ )
+ app.add_directive("mermaid", MermaidDirective)
+ app.add_config_value("mermaid_inline_cmd", "", "env")
+ app.add_config_value("mermaid_inline_puppeteer_config", "", "env")
+ app.add_css_file("css/gp-diagram.css")
+ return {
+ "version": "0.1.0",
+ "parallel_read_safe": True,
+ "parallel_write_safe": True,
+ }
diff --git a/docs/_ext/tmux_layout.py b/docs/_ext/tmux_layout.py
new file mode 100644
index 0000000000..4ae13c7bba
--- /dev/null
+++ b/docs/_ext/tmux_layout.py
@@ -0,0 +1,366 @@
+"""Render tmux window layouts as inline SVG that looks like a terminal.
+
+A ``tmux-layout`` directive declares a screen size and a tmux layout; the panes
+tile the screen with no gaps, exactly the way tmux splits a window. Each pane's
+shell commands are drawn top-left in a fixed-width font and syntax-highlighted
+with Pygments, so the diagram reads like the real terminal. This is the
+spiritual successor to the old ``aafig`` ASCII-art boxes, but tmux-aware.
+
+Authoring — panes are separated by a ``---`` line, each holding that pane's
+commands::
+
+ :::{tmux-layout}
+ :size: 64x18
+ :layout: even-vertical
+
+ echo 'did you know'
+ echo 'you can inline'
+ ---
+ echo 'single commands'
+ ---
+ echo 'for panes'
+ :::
+
+``:layout:`` is one of tmux's arrangements: ``even-vertical`` (default),
+``even-horizontal``, ``main-vertical``, ``main-horizontal``, ``tiled``.
+
+Output is a single inline ```` whose colours are CSS custom properties
+(``gp-tmux-layout.css``), so it paints with the page, adapts to light/dark via
+``body[data-theme]`` with no JavaScript and no second render, and rides
+gp-sphinx SPA swaps as live DOM — without the headless-browser dependency the
+mermaid pipeline needs.
+"""
+
+from __future__ import annotations
+
+import html
+import typing as t
+
+from docutils import nodes
+from pygments.lexers.shell import BashLexer
+from pygments.token import STANDARD_TYPES
+from sphinx.util import logging
+from sphinx.util.docutils import SphinxDirective
+
+if t.TYPE_CHECKING:
+ from sphinx.application import Sphinx
+ from sphinx.writers.html5 import HTML5Translator
+
+logger = logging.getLogger(__name__)
+
+#: Pixels per tmux character cell (column width, row height). The viewBox is in
+#: these pixels, so at the SVG's intrinsic size one unit is one CSS pixel and
+#: the text renders at the page's code font size, like a real code block.
+_CW = 8.0
+_CH = 17.0
+#: Padding (px) between a pane edge and its text.
+_PAD = 6.0
+#: Approx. font ascent (px) for placing the first baseline below the padding.
+_FONT = 12.0
+#: Baseline-to-baseline distance (px) for stacked command lines.
+_LINE_H = 17.0
+#: A simple shell prompt prefixed to each command line. The ``gp`` class is
+#: Pygments' Generic.Prompt, so it takes the theme's prompt colour (like the
+#: ``$`` in the site's console blocks).
+_PROMPT = '❯ ' # noqa: RUF001
+
+_LAYOUTS = (
+ "even-vertical",
+ "even-horizontal",
+ "main-vertical",
+ "main-horizontal",
+ "tiled",
+)
+_LEXER = BashLexer()
+
+
+class TmuxLayoutError(ValueError):
+ """A tmux-layout directive could not be rendered."""
+
+
+class Rect(t.NamedTuple):
+ """A pane rectangle in character-cell coordinates."""
+
+ x: float
+ y: float
+ w: float
+ h: float
+
+
+def _even(n: int, w: float, h: float, *, vertical: bool) -> list[Rect]:
+ """Split the screen into ``n`` equal panes, filling it completely.
+
+ >>> [round(r.h, 2) for r in _even(2, 80, 24, vertical=True)]
+ [12.0, 12.0]
+ >>> [round(r.w, 2) for r in _even(2, 80, 24, vertical=False)]
+ [40.0, 40.0]
+ """
+ rects = []
+ for i in range(n):
+ if vertical:
+ y0, y1 = i * h / n, (i + 1) * h / n
+ rects.append(Rect(0.0, y0, w, y1 - y0))
+ else:
+ x0, x1 = i * w / n, (i + 1) * w / n
+ rects.append(Rect(x0, 0.0, x1 - x0, h))
+ return rects
+
+
+def _main(
+ n: int, w: float, h: float, *, vertical: bool, ratio: float = 0.5
+) -> list[Rect]:
+ """One main pane plus the rest split evenly beside/below it.
+
+ >>> r = _main(3, 80, 24, vertical=True)
+ >>> (round(r[0].w), round(r[1].x), len(r))
+ (40, 40, 3)
+ """
+ if n == 1:
+ return [Rect(0.0, 0.0, w, h)]
+ if vertical:
+ mw = w * ratio
+ rects = [Rect(0.0, 0.0, mw, h)]
+ rects += [
+ Rect(mw + r.x, r.y, r.w, r.h)
+ for r in _even(n - 1, w - mw, h, vertical=True)
+ ]
+ else:
+ mh = h * ratio
+ rects = [Rect(0.0, 0.0, w, mh)]
+ rects += [
+ Rect(r.x, mh + r.y, r.w, r.h)
+ for r in _even(n - 1, w, h - mh, vertical=False)
+ ]
+ return rects
+
+
+def _tiled(n: int, w: float, h: float) -> list[Rect]:
+ """Tile ``n`` panes into a grid that fills the screen (tmux ``tiled``).
+
+ >>> len(_tiled(4, 80, 24)), round(_tiled(4, 80, 24)[0].w)
+ (4, 40)
+ """
+ rows = cols = 1
+ while rows * cols < n:
+ rows += 1
+ if rows * cols < n:
+ cols += 1
+ rects = []
+ for i in range(n):
+ r, c = divmod(i, cols)
+ in_row = cols if r < rows - 1 else n - cols * (rows - 1)
+ x0, x1 = c * w / in_row, (c + 1) * w / in_row
+ y0, y1 = r * h / rows, (r + 1) * h / rows
+ rects.append(Rect(x0, y0, x1 - x0, y1 - y0))
+ return rects
+
+
+def arrange(layout: str, n: int, w: int, h: int) -> list[Rect]:
+ """Return ``n`` pane rectangles filling a ``w`` x ``h`` screen.
+
+ >>> [round(r.h) for r in arrange("even-vertical", 3, 80, 24)]
+ [8, 8, 8]
+ >>> len(arrange("tiled", 4, 80, 24))
+ 4
+ """
+ if n < 1:
+ msg = "a tmux layout needs at least one pane"
+ raise TmuxLayoutError(msg)
+ if n == 1:
+ return [Rect(0.0, 0.0, float(w), float(h))]
+ if layout == "even-vertical":
+ return _even(n, w, h, vertical=True)
+ if layout == "even-horizontal":
+ return _even(n, w, h, vertical=False)
+ if layout == "main-vertical":
+ return _main(n, w, h, vertical=True)
+ if layout == "main-horizontal":
+ return _main(n, w, h, vertical=False)
+ if layout == "tiled":
+ return _tiled(n, w, h)
+ msg = f"unknown layout {layout!r}; expected one of {', '.join(_LAYOUTS)}"
+ raise TmuxLayoutError(msg)
+
+
+def _highlight(line: str) -> str:
+ """Return a shell command as Pygments-classed ````s.
+
+ The command name (a shell builtin like ``echo``/``pwd``) is left in the
+ default text colour, like a freshly typed command; strings, comments, and
+ the like keep their Pygments classes.
+
+ >>> 'class="nb"' in _highlight("echo hello")
+ False
+ >>> 'class="s1"' in _highlight("echo 'hi'")
+ True
+ >>> "'hi'" in _highlight("echo 'hi'")
+ True
+ """
+ spans = []
+ for token, value in _LEXER.get_tokens(line):
+ text = value.rstrip("\n")
+ if not text:
+ continue
+ css = STANDARD_TYPES.get(token, "")
+ if css == "nb": # command builtins (echo, pwd) keep the default colour
+ css = ""
+ escaped = html.escape(text)
+ spans.append(f'{escaped} ' if css else escaped)
+ return "".join(spans)
+
+
+def _parse_size(size: str) -> tuple[int, int]:
+ """Parse a ``WxH`` screen size.
+
+ >>> _parse_size("64x18")
+ (64, 18)
+ """
+ try:
+ w_str, h_str = size.lower().split("x", 1)
+ return int(w_str), int(h_str)
+ except ValueError as exc:
+ msg = f"invalid size {size!r}; expected WxH (e.g. 80x24)"
+ raise TmuxLayoutError(msg) from exc
+
+
+def render_layout(panes: list[list[str]], layout: str, size: tuple[int, int]) -> str:
+ """Render panes (each a list of command lines) to an inline ````.
+
+ >>> svg = render_layout([["echo a"], ["echo b"]], "even-vertical", (40, 12))
+ >>> svg.startswith("',
+ ]
+ # Pane fills first; the window border and single dividers go on top so a
+ # shared edge is one line, not two abutting pane strokes.
+ parts.extend(
+ f' '
+ for rect in rects
+ )
+ for rect in rects:
+ right, bottom = rect.x + rect.w, rect.y + rect.h
+ if right < w: # internal vertical divider
+ parts.append(
+ f' ',
+ )
+ if bottom < h: # internal horizontal divider
+ parts.append(
+ f' ',
+ )
+ parts.append(
+ f' ',
+ )
+ # Pygments colours come from furo's own `.highlight` rules (theme-matched,
+ # light/dark) via `fill: currentColor`; a ignores its background.
+ parts.append('')
+ for rect, commands in zip(rects, panes, strict=True):
+ tx = rect.x * _CW + _PAD
+ baseline = rect.y * _CH + _PAD + _FONT
+ for i, line in enumerate(commands):
+ body = _highlight(line)
+ if not body:
+ continue
+ ty = baseline + i * _LINE_H
+ parts.append(
+ f'{_PROMPT}{body} ',
+ )
+ parts.append(" ")
+ return "".join(parts)
+
+
+def _split_panes(content: list[str]) -> list[list[str]]:
+ """Split directive content on ``---`` lines into per-pane command lists.
+
+ >>> _split_panes(["echo a", "echo b", "---", "echo c"])
+ [['echo a', 'echo b'], ['echo c']]
+ """
+ panes: list[list[str]] = [[]]
+ for line in content:
+ if line.strip() == "---":
+ panes.append([])
+ else:
+ panes[-1].append(line.rstrip())
+ return [
+ [line for line in pane if line.strip()]
+ for pane in panes
+ if any(p.strip() for p in pane)
+ ]
+
+
+class tmux_layout(nodes.General, nodes.Element):
+ """Doctree node carrying a rendered tmux-layout SVG."""
+
+
+class TmuxLayoutDirective(SphinxDirective):
+ """Render a declared tmux layout as an inline, terminal-styled SVG."""
+
+ has_content = True
+ required_arguments = 0
+ optional_arguments = 0
+ option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = {
+ "size": lambda x: x,
+ "layout": lambda x: x,
+ "caption": lambda x: x,
+ }
+
+ def run(self) -> list[nodes.Node]:
+ """Return a :class:`tmux_layout` node with the rendered SVG."""
+ panes = _split_panes(list(self.content))
+ if not panes:
+ warning = self.state.document.reporter.warning(
+ "tmux-layout directive has no panes",
+ line=self.lineno,
+ )
+ return [warning]
+ layout = self.options.get("layout", "even-vertical")
+ try:
+ svg = render_layout(
+ panes, layout, _parse_size(self.options.get("size", "80x24"))
+ )
+ except TmuxLayoutError as exc:
+ logger.warning("invalid tmux layout: %s", exc, location=self.get_location())
+ return [
+ nodes.literal_block("\n".join(self.content), "\n".join(self.content))
+ ]
+ node = tmux_layout()
+ node["svg"] = svg
+ node["caption"] = self.options.get("caption", "")
+ return [node]
+
+
+def html_visit_tmux_layout(self: HTML5Translator, node: tmux_layout) -> None:
+ """Append the inline SVG (wrapped in a figure) and skip the node."""
+ caption = node.get("caption", "")
+ parts = [f'{node["svg"]}']
+ if caption:
+ parts.append(f"{html.escape(caption)} ")
+ parts.append(" ")
+ self.body.append("".join(parts))
+ raise nodes.SkipNode
+
+
+def _depart_tmux_layout(self: HTML5Translator, node: tmux_layout) -> None:
+ """No-op; :func:`html_visit_tmux_layout` raises ``SkipNode``."""
+
+
+def setup(app: Sphinx) -> dict[str, t.Any]:
+ """Register the directive, node, and stylesheet."""
+ app.add_node(tmux_layout, html=(html_visit_tmux_layout, _depart_tmux_layout))
+ app.add_directive("tmux-layout", TmuxLayoutDirective)
+ app.add_css_file("css/gp-tmux-layout.css")
+ return {
+ "version": "0.1.0",
+ "parallel_read_safe": True,
+ "parallel_write_safe": True,
+ }
diff --git a/docs/_static/css/gp-diagram.css b/docs/_static/css/gp-diagram.css
new file mode 100644
index 0000000000..c032e4332a
--- /dev/null
+++ b/docs/_static/css/gp-diagram.css
@@ -0,0 +1,72 @@
+/*
+ * Build-time mermaid diagrams (docs/_ext/mermaid_inline.py).
+ *
+ * Each diagram is inlined twice — a light and a dark SVG — and this stylesheet
+ * shows exactly one. Mermaid bakes literal colours into an id-scoped,
+ * !important "
+ 'a '
+ ' '
+ " "
+)
+
+
+class NormalizeCase(t.NamedTuple):
+ """A ``_normalize_svg`` scenario and its expected substrings."""
+
+ test_id: str
+ raw_svg: str
+ svg_id: str
+ expect_contains: tuple[str, ...]
+ expect_absent: tuple[str, ...]
+
+
+_NORMALIZE_CASES: list[NormalizeCase] = [
+ NormalizeCase(
+ test_id="width-and-height-from-viewbox",
+ raw_svg=(
+ ' '
+ ),
+ svg_id="mermaid-deadbeef-light",
+ expect_contains=(
+ 'width="200"',
+ 'height="80"',
+ 'id="mermaid-deadbeef-light"',
+ "#mermaid-deadbeef-light{",
+ ),
+ expect_absent=("my-svg", "max-width", 'width="100%"'),
+ ),
+ NormalizeCase(
+ test_id="marker-references-rewritten",
+ raw_svg=(
+ ''
+ ' '
+ ),
+ svg_id="mermaid-abc-dark",
+ expect_contains=("url(#mermaid-abc-dark_end)", 'width="10"', 'height="10"'),
+ expect_absent=("my-svg",),
+ ),
+ NormalizeCase(
+ test_id="decimal-viewbox-dimensions",
+ raw_svg=(
+ ' '
+ ),
+ svg_id="mermaid-x-light",
+ expect_contains=('width="1141.25"', 'height="94"'),
+ expect_absent=("max-width",),
+ ),
+ NormalizeCase(
+ test_id="block-negative-viewbox-origin",
+ raw_svg=(
+ ''
+ ' '
+ ),
+ svg_id="mermaid-blk-light",
+ expect_contains=('width="148"', 'height="194"'),
+ expect_absent=('width="100%"', 'width="10"'),
+ ),
+]
+
+
+@pytest.mark.parametrize(
+ "case",
+ _NORMALIZE_CASES,
+ ids=[c.test_id for c in _NORMALIZE_CASES],
+)
+def test_normalize_svg(case: NormalizeCase) -> None:
+ """``_normalize_svg`` rewrites the id, sets size, and drops ``max-width``."""
+ out = mi._normalize_svg(case.raw_svg, svg_id=case.svg_id)
+ for needle in case.expect_contains:
+ assert needle in out, f"{case.test_id}: expected {needle!r}"
+ for needle in case.expect_absent:
+ assert needle not in out, f"{case.test_id}: unexpected {needle!r}"
+
+
+class DigestCase(t.NamedTuple):
+ """Two ``_diagram_digest`` inputs and whether their hashes should match."""
+
+ test_id: str
+ a_source: str
+ a_theme: str
+ b_source: str
+ b_theme: str
+ expect_equal: bool
+
+
+_DIGEST_CASES: list[DigestCase] = [
+ DigestCase(
+ "identical-inputs-match",
+ "flowchart LR a-->b",
+ "default",
+ "flowchart LR a-->b",
+ "default",
+ True,
+ ),
+ DigestCase(
+ "theme-differs",
+ "flowchart LR a-->b",
+ "default",
+ "flowchart LR a-->b",
+ "dark",
+ False,
+ ),
+ DigestCase(
+ "source-differs",
+ "flowchart LR a-->b",
+ "default",
+ "flowchart LR a-->c",
+ "default",
+ False,
+ ),
+]
+
+
+@pytest.mark.parametrize(
+ "case",
+ _DIGEST_CASES,
+ ids=[c.test_id for c in _DIGEST_CASES],
+)
+def test_diagram_digest(case: DigestCase) -> None:
+ """``_diagram_digest`` is stable per input and varies by theme and source."""
+ a = mi._diagram_digest(case.a_source, case.a_theme)
+ b = mi._diagram_digest(case.b_source, case.b_theme)
+ assert (a == b) is case.expect_equal
+ assert len(a) == 40
+
+
+def test_svg_element_id_is_themed_and_unique() -> None:
+ """``_svg_element_id`` yields distinct, theme-suffixed ids per variant."""
+ digest = mi._diagram_digest("flowchart LR a-->b", "")
+ light = mi._svg_element_id(digest, "light")
+ dark = mi._svg_element_id(digest, "dark")
+ assert light != dark
+ assert light.startswith("mermaid-")
+ assert light.endswith("-light")
+
+
+class PaletteCase(t.NamedTuple):
+ """A theme name whose furo palette must define the flowchart variables."""
+
+ test_id: str
+ theme: str
+
+
+_PALETTE_CASES: list[PaletteCase] = [
+ PaletteCase(test_id=f"{theme}-palette", theme=theme)
+ for theme in (mi._THEME_LIGHT, mi._THEME_DARK)
+]
+
+_REQUIRED_PALETTE_KEYS = (
+ "primaryColor",
+ "primaryBorderColor",
+ "primaryTextColor",
+ "lineColor",
+ "textColor",
+)
+
+
+@pytest.mark.parametrize(
+ "case",
+ _PALETTE_CASES,
+ ids=[c.test_id for c in _PALETTE_CASES],
+)
+def test_palette_defines_flowchart_colors(case: PaletteCase) -> None:
+ """Each theme palette defines the mermaid colour variables as hex values."""
+ palette = mi._PALETTES[case.theme]
+ for key in _REQUIRED_PALETTE_KEYS:
+ assert key in palette, f"{case.test_id}: missing {key}"
+ assert palette[key].startswith("#"), f"{case.test_id}: {key} not hex"
+
+
+def _make_translator(tmp_path: Path) -> types.SimpleNamespace:
+ """Return a minimal stand-in for the HTML translator the visitor needs."""
+ config = types.SimpleNamespace(
+ mermaid_inline_cmd="",
+ mermaid_inline_puppeteer_config="",
+ )
+ app = types.SimpleNamespace(confdir=str(tmp_path), config=config)
+ builder = types.SimpleNamespace(app=app)
+ return types.SimpleNamespace(builder=builder, body=[])
+
+
+def test_visitor_emits_dual_themed_svg(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ """The visitor inlines one light and one dark SVG with shared geometry."""
+ seen: list[str] = []
+
+ def fake_render_cached(app: object, source: str, theme: str) -> str:
+ seen.append(theme)
+ return _FAKE_MMDC_SVG
+
+ monkeypatch.setattr(mi, "_render_cached", fake_render_cached)
+ translator = _make_translator(tmp_path)
+ node = mi.mermaid_inline()
+ node["mermaid_source"] = "flowchart LR a-->b"
+ node["caption"] = "How it flows"
+ node["alt"] = ""
+
+ with pytest.raises(nodes.SkipNode):
+ mi.html_visit_mermaid_inline(t.cast("t.Any", translator), node)
+
+ html = "".join(translator.body)
+ assert seen == [mi._THEME_LIGHT, mi._THEME_DARK]
+ assert html.count("gp-diagram--light") == 1
+ assert html.count("gp-diagram--dark") == 1
+ assert "How it flows " in html
+ assert "my-svg" not in html
+ # Both variants normalized to identical geometry -> shift-free toggle.
+ assert html.count('viewBox="0 0 200 80"') == 2
+
+
+def test_visitor_falls_back_when_renderer_missing(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """A missing renderer degrades to a text fallback and warns once."""
+ monkeypatch.setattr(mi, "_render_warned", False)
+
+ def boom(app: object, source: str, theme: str) -> str:
+ msg = "no mmdc"
+ raise mi.MermaidRendererMissing(msg)
+
+ monkeypatch.setattr(mi, "_render_cached", boom)
+ translator = _make_translator(tmp_path)
+ node = mi.mermaid_inline()
+ node["mermaid_source"] = "flowchart LR a-->b"
+ node["caption"] = ""
+ node["alt"] = ""
+
+ with caplog.at_level(logging.WARNING), pytest.raises(nodes.SkipNode):
+ mi.html_visit_mermaid_inline(t.cast("t.Any", translator), node)
+
+ html = "".join(translator.body)
+ assert 'class="gp-diagram-fallback"' in html
+ assert "flowchart LR a-->b" in html
+ warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
+ assert any("mermaid render unavailable" in r.getMessage() for r in warnings)
+
+
+def test_render_cache_is_idempotent(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ """``_render_cached`` invokes mmdc once, then serves from the disk cache."""
+ calls = {"n": 0}
+
+ def fake_render(app: object, source: str, config_json: str) -> str:
+ calls["n"] += 1
+ return _FAKE_MMDC_SVG
+
+ monkeypatch.setattr(mi, "_render", fake_render)
+ config = types.SimpleNamespace(
+ mermaid_inline_cmd="",
+ mermaid_inline_puppeteer_config="",
+ )
+ app = types.SimpleNamespace(confdir=str(tmp_path), config=config)
+
+ first = mi._render_cached(
+ t.cast("t.Any", app), "flowchart LR a-->b", mi._THEME_LIGHT
+ )
+ second = mi._render_cached(
+ t.cast("t.Any", app), "flowchart LR a-->b", mi._THEME_LIGHT
+ )
+
+ assert first == second == _FAKE_MMDC_SVG
+ assert calls["n"] == 1
+ assert list((tmp_path / "_mermaid_cache").glob("*.svg"))
+
+
+def test_setup_registers_components() -> None:
+ """``setup`` registers the node, directive, config values, css, and is safe."""
+ recorded: dict[str, list[t.Any]] = {
+ "nodes": [],
+ "directives": [],
+ "config": [],
+ "css": [],
+ }
+
+ def add_node(node: object, **kwargs: object) -> None:
+ recorded["nodes"].append(node)
+
+ def add_directive(name: str, cls: object) -> None:
+ recorded["directives"].append((name, cls))
+
+ def add_config_value(name: str, default: object, rebuild: object) -> None:
+ recorded["config"].append(name)
+
+ def add_css_file(name: str, **kwargs: object) -> None:
+ recorded["css"].append(name)
+
+ app = types.SimpleNamespace(
+ add_node=add_node,
+ add_directive=add_directive,
+ add_config_value=add_config_value,
+ add_css_file=add_css_file,
+ )
+ meta = mi.setup(t.cast("t.Any", app))
+
+ assert meta["parallel_read_safe"] is True
+ assert meta["parallel_write_safe"] is True
+ assert mi.mermaid_inline in recorded["nodes"]
+ assert ("mermaid", mi.MermaidDirective) in recorded["directives"]
+ assert "mermaid_inline_cmd" in recorded["config"]
+ assert "mermaid_inline_puppeteer_config" in recorded["config"]
+ assert "css/gp-diagram.css" in recorded["css"]
diff --git a/tests/test_docs_tmux_layout.py b/tests/test_docs_tmux_layout.py
new file mode 100644
index 0000000000..d787eae874
--- /dev/null
+++ b/tests/test_docs_tmux_layout.py
@@ -0,0 +1,202 @@
+"""Tests for the tmux-layout directive (``docs/_ext/tmux_layout.py``)."""
+
+from __future__ import annotations
+
+import importlib.util
+import types
+import typing as t
+from pathlib import Path
+
+import pytest
+
+_EXT_PATH = Path(__file__).resolve().parent.parent / "docs" / "_ext" / "tmux_layout.py"
+_spec = importlib.util.spec_from_file_location("tmux_layout", _EXT_PATH)
+assert _spec is not None
+assert _spec.loader is not None
+tl = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(tl)
+
+
+class ArrangeCase(t.NamedTuple):
+ """A named layout with a pane count and the geometry it should produce."""
+
+ test_id: str
+ layout: str
+ n: int
+ expect_count: int
+
+
+_ARRANGE_CASES: list[ArrangeCase] = [
+ ArrangeCase("even-vertical-3", "even-vertical", 3, 3),
+ ArrangeCase("even-horizontal-2", "even-horizontal", 2, 2),
+ ArrangeCase("main-vertical-3", "main-vertical", 3, 3),
+ ArrangeCase("main-horizontal-3", "main-horizontal", 3, 3),
+ ArrangeCase("tiled-4", "tiled", 4, 4),
+ ArrangeCase("tiled-3-spans-last-row", "tiled", 3, 3),
+ ArrangeCase("single-pane", "even-vertical", 1, 1),
+]
+
+
+@pytest.mark.parametrize(
+ "case",
+ _ARRANGE_CASES,
+ ids=[c.test_id for c in _ARRANGE_CASES],
+)
+def test_arrange_fills_screen(case: ArrangeCase) -> None:
+ """Each arrangement returns the right panes and tiles the screen exactly."""
+ width, height = 80, 24
+ rects = tl.arrange(case.layout, case.n, width, height)
+ assert len(rects) == case.expect_count
+ # Non-overlapping panes that fill the screen sum to its area.
+ area = sum(r.w * r.h for r in rects)
+ assert area == pytest.approx(width * height)
+ # Every pane stays within the screen.
+ for r in rects:
+ assert r.x >= -1e-6
+ assert r.y >= -1e-6
+ assert r.x + r.w <= width + 1e-6
+ assert r.y + r.h <= height + 1e-6
+
+
+def test_arrange_unknown_layout_raises() -> None:
+ """An unknown layout name is a clear error."""
+ with pytest.raises(tl.TmuxLayoutError, match="unknown layout"):
+ tl.arrange("spiral", 2, 80, 24)
+
+
+class HighlightCase(t.NamedTuple):
+ """A shell command and the Pygments class its first token should carry."""
+
+ test_id: str
+ command: str
+ expect_class: str
+
+
+_HIGHLIGHT_CASES: list[HighlightCase] = [
+ HighlightCase("single-quoted-string", "echo 'hi there'", 'class="s1"'),
+ HighlightCase("comment", "# a note", 'class="c1"'),
+]
+
+
+@pytest.mark.parametrize(
+ "case",
+ _HIGHLIGHT_CASES,
+ ids=[c.test_id for c in _HIGHLIGHT_CASES],
+)
+def test_highlight_emits_pygments_classes(case: HighlightCase) -> None:
+ """``_highlight`` wraps shell tokens in Pygments-classed tspans."""
+ out = tl._highlight(case.command)
+ assert case.expect_class in out
+ assert " None:
+ """A command builtin (echo/pwd) is left in the default colour, no nb class."""
+ assert 'class="nb"' not in tl._highlight("echo hello")
+ assert 'class="nb"' not in tl._highlight("pwd")
+
+
+def test_highlight_escapes_markup() -> None:
+ """Angle brackets in a command are HTML-escaped, not emitted as tags."""
+ out = tl._highlight("echo ")
+ assert " " not in out
+ assert "<a>" in out
+
+
+class SplitCase(t.NamedTuple):
+ """Directive content lines and the per-pane command lists they yield."""
+
+ test_id: str
+ content: list[str]
+ expect: list[list[str]]
+
+
+_SPLIT_CASES: list[SplitCase] = [
+ SplitCase("two-panes", ["echo a", "---", "echo b"], [["echo a"], ["echo b"]]),
+ SplitCase(
+ "multiline-pane",
+ ["echo a", "echo b", "---", "echo c"],
+ [["echo a", "echo b"], ["echo c"]],
+ ),
+ SplitCase(
+ "blank-lines-dropped", ["echo a", "", "---", "echo b"], [["echo a"], ["echo b"]]
+ ),
+]
+
+
+@pytest.mark.parametrize(
+ "case",
+ _SPLIT_CASES,
+ ids=[c.test_id for c in _SPLIT_CASES],
+)
+def test_split_panes(case: SplitCase) -> None:
+ """``_split_panes`` splits on ``---`` and keeps each pane's commands."""
+ assert tl._split_panes(case.content) == case.expect
+
+
+class SizeCase(t.NamedTuple):
+ """A ``:size:`` string and the (cols, rows) it parses to."""
+
+ test_id: str
+ size: str
+ expect: tuple[int, int]
+
+
+_SIZE_CASES: list[SizeCase] = [
+ SizeCase("plain", "64x18", (64, 18)),
+ SizeCase("uppercase-x", "80X24", (80, 24)),
+]
+
+
+@pytest.mark.parametrize(
+ "case",
+ _SIZE_CASES,
+ ids=[c.test_id for c in _SIZE_CASES],
+)
+def test_parse_size(case: SizeCase) -> None:
+ """``_parse_size`` reads a ``WxH`` screen size."""
+ assert tl._parse_size(case.size) == case.expect
+
+
+def test_parse_size_invalid_raises() -> None:
+ """A malformed size is a clear error."""
+ with pytest.raises(tl.TmuxLayoutError, match="invalid size"):
+ tl._parse_size("not-a-size")
+
+
+def test_render_layout_structure() -> None:
+ """The rendered SVG has one window, one rect per pane, and highlighting."""
+ svg = tl.render_layout([["echo a"], ["echo b"]], "even-vertical", (40, 12))
+ assert svg.startswith("")
+
+
+def test_setup_registers_components() -> None:
+ """``setup`` registers the node, directive, css, and is parallel-safe."""
+ recorded: dict[str, list[t.Any]] = {"nodes": [], "directives": [], "css": []}
+
+ def add_node(node: object, **kwargs: object) -> None:
+ recorded["nodes"].append(node)
+
+ def add_directive(name: str, cls: object) -> None:
+ recorded["directives"].append((name, cls))
+
+ def add_css_file(name: str, **kwargs: object) -> None:
+ recorded["css"].append(name)
+
+ app = types.SimpleNamespace(
+ add_node=add_node,
+ add_directive=add_directive,
+ add_css_file=add_css_file,
+ )
+ meta = tl.setup(t.cast("t.Any", app))
+
+ assert meta["parallel_read_safe"] is True
+ assert meta["parallel_write_safe"] is True
+ assert tl.tmux_layout in recorded["nodes"]
+ assert ("tmux-layout", tl.TmuxLayoutDirective) in recorded["directives"]
+ assert "css/gp-tmux-layout.css" in recorded["css"]