From 5813850c4e355c50ff7203e025f3dc2f4cf7170b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 1 Jul 2026 18:43:55 -0500 Subject: [PATCH 1/5] mermaid(feat[scaffold]): Add sphinx-gp-mermaid workspace package why: Build-time mermaid rendering is proven in tmuxp's docs but lives in its docs/_ext, unavailable to other gp-sphinx consumers. Scaffold the workspace package so the renderer can move in and be reused. what: - packages/sphinx-gp-mermaid: pyproject (sphinx>=8.1, hatchling), README, py.typed, skeleton setup() returning parallel-safe metadata - Root pyproject: uv source, dev group, isort first-party, pytest testpath entries - package_tools: smoke_sphinx_gp_mermaid wheel smoke runner - package_reference: assign the ux cluster - docs: landing stub + legacy extensions/ redirect - tests: add to the publishable-package set --- docs/_ext/package_reference.py | 1 + docs/packages/sphinx-gp-mermaid/index.md | 6 ++ docs/redirects.txt | 1 + packages/sphinx-gp-mermaid/README.md | 62 +++++++++++++++++++ packages/sphinx-gp-mermaid/pyproject.toml | 40 ++++++++++++ .../src/sphinx_gp_mermaid/__init__.py | 57 +++++++++++++++++ .../src/sphinx_gp_mermaid/py.typed | 0 pyproject.toml | 4 ++ scripts/ci/package_tools.py | 19 ++++++ tests/test_package_reference.py | 1 + uv.lock | 15 +++++ 11 files changed, 206 insertions(+) create mode 100644 docs/packages/sphinx-gp-mermaid/index.md create mode 100644 packages/sphinx-gp-mermaid/README.md create mode 100644 packages/sphinx-gp-mermaid/pyproject.toml create mode 100644 packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py create mode 100644 packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/py.typed diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 64a7464c..5d8b06b0 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -201,6 +201,7 @@ class PackageDocsRecord: "sphinx-fonts": "tokens", "sphinx-ux-badges": "ux", "sphinx-ux-autodoc-layout": "ux", + "sphinx-gp-mermaid": "ux", "sphinx-vite-builder": "build-seo", "sphinx-gp-opengraph": "build-seo", "sphinx-gp-sitemap": "build-seo", diff --git a/docs/packages/sphinx-gp-mermaid/index.md b/docs/packages/sphinx-gp-mermaid/index.md new file mode 100644 index 00000000..7467f39c --- /dev/null +++ b/docs/packages/sphinx-gp-mermaid/index.md @@ -0,0 +1,6 @@ +(sphinx-gp-mermaid)= + +# sphinx-gp-mermaid + +```{package-landing} sphinx-gp-mermaid +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index ac574907..a65d799e 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -1,6 +1,7 @@ extensions/sphinx-gp-opengraph packages/sphinx-gp-opengraph extensions/sphinx-gp-sitemap packages/sphinx-gp-sitemap extensions/sphinx-gp-llms packages/sphinx-gp-llms +extensions/sphinx-gp-mermaid packages/sphinx-gp-mermaid extensions/gp-sphinx packages/gp-sphinx extensions/index packages/index extensions/sphinx-autodoc-argparse packages/sphinx-autodoc-argparse diff --git a/packages/sphinx-gp-mermaid/README.md b/packages/sphinx-gp-mermaid/README.md new file mode 100644 index 00000000..d33eb09f --- /dev/null +++ b/packages/sphinx-gp-mermaid/README.md @@ -0,0 +1,62 @@ +# sphinx-gp-mermaid + +Build-time Mermaid diagrams for Sphinx. Fenced `mermaid` blocks render to +inline `` during the build via `mmdc` +([@mermaid-js/mermaid-cli](https://github.com/mermaid-js/mermaid-cli)): +no client-side mermaid runtime, no asynchronous pop-in, no layout shift. + +Each diagram is rendered twice — a light and a dark variant — and both are +inlined, toggled by CSS on `body[data-theme]`. Diagrams paint with the page, +follow the theme toggle without JavaScript, and survive SPA navigation as +live DOM. + +## Install + +```console +$ pip install sphinx-gp-mermaid +``` + +## Usage + +Enable the extension in `conf.py`: + +```python +extensions = ["sphinx_gp_mermaid"] +``` + +With gp-sphinx, opt in via `merge_sphinx_config`: + +```python +config = merge_sphinx_config( + extra_extensions=["sphinx_gp_mermaid"], +) +``` + +Author diagrams as MyST fences: + +````markdown +:::{mermaid} +:caption: How it flows. + +flowchart LR + a --> b +::: +```` + +## Renderer toolchain + +Rendering shells out to `mmdc`, resolved from the `mermaid_cmd` config value, +then `/node_modules/.bin/mmdc`, then `PATH`. Install it in the docs +toolchain: + +```console +$ pnpm add -D @mermaid-js/mermaid-cli +``` + +When the renderer is unavailable the build still succeeds: diagrams degrade +to their source text with a single warning. + +## Documentation + +See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-gp-mermaid/) +for directive options, theming, caching, and CI setup. diff --git a/packages/sphinx-gp-mermaid/pyproject.toml b/packages/sphinx-gp-mermaid/pyproject.toml new file mode 100644 index 00000000..ae2349bf --- /dev/null +++ b/packages/sphinx-gp-mermaid/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-gp-mermaid" +version = "0.0.1a31" +description = "Build-time Mermaid diagrams for Sphinx — dual light/dark inline SVG rendered via mermaid-cli" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "mermaid", "diagrams", "documentation", "svg"] +dependencies = [ + "sphinx>=8.1", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_gp_mermaid"] diff --git a/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py new file mode 100644 index 00000000..406926f7 --- /dev/null +++ b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py @@ -0,0 +1,57 @@ +"""Build-time mermaid rendering for Sphinx, producing inline SVG. + +Renders fenced ``mermaid`` blocks to inline ```` at build time via +``mmdc`` (`@mermaid-js/mermaid-cli`_), so diagrams paint with the page: there +is no client-side mermaid runtime, no asynchronous pop-in, and no layout +shift. Each diagram is rendered twice — a light and a dark variant — and both +are inlined, toggled by CSS on ``body[data-theme]``. + +Examples +-------- +>>> from sphinx_gp_mermaid import setup +>>> callable(setup) +True + +.. _`@mermaid-js/mermaid-cli`: https://github.com/mermaid-js/mermaid-cli +""" + +from __future__ import annotations + +import logging +import typing as t + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.util.typing import ExtensionMetadata + +_EXTENSION_VERSION = "0.0.1a31" + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +__all__ = ["setup"] + + +def setup(app: Sphinx) -> ExtensionMetadata: + """Register the mermaid directive, node, config values, and stylesheet. + + Parameters + ---------- + app : Sphinx + Sphinx application instance. + + Returns + ------- + ExtensionMetadata + Extension metadata with version and parallel-build flags. + + Examples + -------- + >>> from sphinx_gp_mermaid import setup + >>> callable(setup) + True + """ + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/py.typed b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index 0fe91e3c..e902d810 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ sphinx-ux-autodoc-layout = { workspace = true } sphinx-gp-opengraph = { workspace = true } sphinx-gp-sitemap = { workspace = true } sphinx-gp-llms = { workspace = true } +sphinx-gp-mermaid = { workspace = true } gp-furo-theme = { workspace = true } gp-sphinx = { workspace = true } sphinx-vite-builder = { workspace = true } @@ -53,6 +54,7 @@ dev = [ "sphinx-gp-opengraph", "sphinx-gp-sitemap", "sphinx-gp-llms", + "sphinx-gp-mermaid", "gp-furo-theme", "sphinx-vite-builder", # Docs @@ -182,6 +184,7 @@ known-first-party = [ "sphinx_gp_opengraph", "sphinx_gp_sitemap", "sphinx_gp_llms", + "sphinx_gp_mermaid", ] combine-as-imports = true required-imports = [ @@ -244,6 +247,7 @@ testpaths = [ "packages/sphinx-gp-opengraph/src", "packages/sphinx-gp-sitemap/src", "packages/sphinx-gp-llms/src", + "packages/sphinx-gp-mermaid/src", "packages/sphinx-vite-builder/src", ] filterwarnings = [ diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index 8260f283..6fb18936 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -733,6 +733,24 @@ def smoke_sphinx_gp_llms(dist_dir: pathlib.Path, version: str) -> None: ) +def smoke_sphinx_gp_mermaid(dist_dir: pathlib.Path, version: str) -> None: + """Verify the sphinx-gp-mermaid extension installs and imports cleanly.""" + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv( + python_path, + *_workspace_wheel_requirements(dist_dir), + ) + _run_python( + python_path, + ( + "import sphinx_gp_mermaid; " + "from sphinx_gp_mermaid import setup; " + "assert callable(setup)" + ), + ) + + def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: """Verify the autodoc-fastmcp extension installs and imports cleanly.""" with tempfile.TemporaryDirectory() as tmp: @@ -863,6 +881,7 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: "sphinx-gp-opengraph": smoke_sphinx_gp_opengraph, "sphinx-gp-sitemap": smoke_sphinx_gp_sitemap, "sphinx-gp-llms": smoke_sphinx_gp_llms, + "sphinx-gp-mermaid": smoke_sphinx_gp_mermaid, "gp-sphinx": smoke_gp_sphinx, "sphinx-autodoc-argparse": smoke_sphinx_autodoc_argparse, "sphinx-autodoc-api-style": smoke_sphinx_autodoc_api_style, diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 622cb9fc..8823d597 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -23,6 +23,7 @@ def test_workspace_packages_lists_publishable_packages() -> None: "sphinx-gp-opengraph", "sphinx-gp-sitemap", "sphinx-gp-llms", + "sphinx-gp-mermaid", "gp-sphinx", "sphinx-autodoc-argparse", "sphinx-autodoc-api-style", diff --git a/uv.lock b/uv.lock index 5370f17a..49b14f91 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,7 @@ members = [ "sphinx-autodoc-typehints-gp", "sphinx-fonts", "sphinx-gp-llms", + "sphinx-gp-mermaid", "sphinx-gp-opengraph", "sphinx-gp-sitemap", "sphinx-gp-theme", @@ -535,6 +536,7 @@ dev = [ { name = "sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx" }, { name = "sphinx-gp-llms" }, + { name = "sphinx-gp-mermaid" }, { name = "sphinx-gp-opengraph" }, { name = "sphinx-gp-sitemap" }, { name = "sphinx-ux-autodoc-layout" }, @@ -575,6 +577,7 @@ dev = [ { name = "sphinx-autodoc-pytest-fixtures", editable = "packages/sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx", editable = "packages/sphinx-autodoc-sphinx" }, { name = "sphinx-gp-llms", editable = "packages/sphinx-gp-llms" }, + { name = "sphinx-gp-mermaid", editable = "packages/sphinx-gp-mermaid" }, { name = "sphinx-gp-opengraph", editable = "packages/sphinx-gp-opengraph" }, { name = "sphinx-gp-sitemap", editable = "packages/sphinx-gp-sitemap" }, { name = "sphinx-ux-autodoc-layout", editable = "packages/sphinx-ux-autodoc-layout" }, @@ -1828,6 +1831,18 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] +[[package]] +name = "sphinx-gp-mermaid" +version = "0.0.1a31" +source = { editable = "packages/sphinx-gp-mermaid" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] + [[package]] name = "sphinx-gp-opengraph" version = "0.0.1a31" From 1bbf968004c35dd968e49f86e231408d9166c28c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 1 Jul 2026 18:48:36 -0500 Subject: [PATCH 2/5] mermaid(feat[port]): Port tmuxp's mermaid_inline renderer why: The renderer is proven in tmuxp production docs; moving it into the workspace package makes it installable by every gp-sphinx consumer. what: - Full pipeline: mermaid directive -> mermaid_inline node -> HTML write-phase visitor -> mmdc subprocess -> dual light/dark inline SVG - Content-hash SVG cache under /_mermaid_cache, keyed on render version, theme, full mermaid config JSON, and source; cache key inputs unchanged from the tmuxp version so existing caches hit - mmdc resolution (config -> confdir node_modules -> PATH), puppeteer config generation with cached-Chrome discovery, graceful degradation to escaped source when the renderer is missing - Config values: mermaid_cmd, mermaid_puppeteer_config - CSS classes join the workspace namespace: gp-sphinx-diagram, gp-sphinx-diagram__variant--theme-{light,dark}, gp-sphinx-diagram__fallback; stylesheet ships in the package's _static and registers at builder-inited - Unit tests: SVG normalization, digest, palettes, visitor, fallback, cache idempotency, setup registration, static-path idempotence --- .../src/sphinx_gp_mermaid/__init__.py | 523 +++++++++++++++++- .../_static/css/sphinx_gp_mermaid.css | 71 +++ tests/ext/mermaid/__init__.py | 1 + tests/ext/mermaid/test_mermaid.py | 369 ++++++++++++ 4 files changed, 959 insertions(+), 5 deletions(-) create mode 100644 packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/_static/css/sphinx_gp_mermaid.css create mode 100644 tests/ext/mermaid/__init__.py create mode 100644 tests/ext/mermaid/test_mermaid.py diff --git a/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py index 406926f7..57874ee5 100644 --- a/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py +++ b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py @@ -3,8 +3,26 @@ Renders fenced ``mermaid`` blocks to inline ```` at build time via ``mmdc`` (`@mermaid-js/mermaid-cli`_), so diagrams paint with the page: there is no client-side mermaid runtime, no asynchronous pop-in, and no layout -shift. Each diagram is rendered twice — a light and a dark variant — and both -are inlined, toggled by CSS on ``body[data-theme]``. +shift. The finished SVG ships as regular page DOM, so it also rides through +gp-sphinx's SPA navigation with zero re-initialisation. + +Each diagram is rendered twice — a light and a dark variant — and both are +inlined, toggled by CSS on ``body[data-theme]`` (see +``_static/css/sphinx_gp_mermaid.css``). Mermaid bakes literal colours into an +id-scoped, ``!important`` ``
'
+            + 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'
', + ( + '' + ), + ( + '' + ), + ] + 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) -> ExtensionMetadata: - """Register the mermaid directive, node, config values, and stylesheet. + """Register the directive, node, config values, and stylesheet. Parameters ---------- @@ -50,6 +546,23 @@ def setup(app: Sphinx) -> ExtensionMetadata: >>> callable(setup) True """ + app.add_node( + mermaid_inline, + html=(html_visit_mermaid_inline, _depart_mermaid_inline), + ) + app.add_directive("mermaid", MermaidDirective) + app.add_config_value("mermaid_cmd", "", "env") + app.add_config_value("mermaid_puppeteer_config", "", "env") + + _static_dir = str(pathlib.Path(__file__).parent / "_static") + + def _add_static_path(app: Sphinx) -> None: + if _static_dir not in app.config.html_static_path: + app.config.html_static_path.append(_static_dir) + + app.connect("builder-inited", _add_static_path) + app.add_css_file("css/sphinx_gp_mermaid.css") + return { "version": _EXTENSION_VERSION, "parallel_read_safe": True, diff --git a/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/_static/css/sphinx_gp_mermaid.css b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/_static/css/sphinx_gp_mermaid.css new file mode 100644 index 00000000..b2b32234 --- /dev/null +++ b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/_static/css/sphinx_gp_mermaid.css @@ -0,0 +1,71 @@ +/* + * Build-time mermaid diagrams (sphinx_gp_mermaid). + * + * 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 = sgm._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( + test_id="identical-inputs-match", + a_source="flowchart LR a-->b", + a_theme="default", + b_source="flowchart LR a-->b", + b_theme="default", + expect_equal=True, + ), + DigestCase( + test_id="theme-differs", + a_source="flowchart LR a-->b", + a_theme="default", + b_source="flowchart LR a-->b", + b_theme="dark", + expect_equal=False, + ), + DigestCase( + test_id="source-differs", + a_source="flowchart LR a-->b", + a_theme="default", + b_source="flowchart LR a-->c", + b_theme="default", + expect_equal=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 = sgm._diagram_digest(case.a_source, case.a_theme) + b = sgm._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 = sgm._diagram_digest("flowchart LR a-->b", "") + light = sgm._svg_element_id(digest, "light") + dark = sgm._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 (sgm._THEME_LIGHT, sgm._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 = sgm._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: pathlib.Path) -> types.SimpleNamespace: + """Return a minimal stand-in for the HTML translator the visitor needs.""" + config = types.SimpleNamespace( + mermaid_cmd="", + mermaid_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: pathlib.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(sgm, "_render_cached", fake_render_cached) + translator = _make_translator(tmp_path) + node = sgm.mermaid_inline() + node["mermaid_source"] = "flowchart LR a-->b" + node["caption"] = "How it flows" + node["alt"] = "" + + with pytest.raises(nodes.SkipNode): + sgm.html_visit_mermaid_inline(t.cast("t.Any", translator), node) + + html = "".join(translator.body) + assert seen == [sgm._THEME_LIGHT, sgm._THEME_DARK] + assert html.count("gp-sphinx-diagram__variant--theme-light") == 1 + assert html.count("gp-sphinx-diagram__variant--theme-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: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A missing renderer degrades to a text fallback and warns once.""" + monkeypatch.setattr(sgm, "_render_warned", False) + + def boom(app: object, source: str, theme: str) -> str: + msg = "no mmdc" + raise sgm.MermaidRendererMissing(msg) + + monkeypatch.setattr(sgm, "_render_cached", boom) + translator = _make_translator(tmp_path) + node = sgm.mermaid_inline() + node["mermaid_source"] = "flowchart LR a-->b" + node["caption"] = "" + node["alt"] = "" + + with caplog.at_level(logging.WARNING), pytest.raises(nodes.SkipNode): + sgm.html_visit_mermaid_inline(t.cast("t.Any", translator), node) + + html = "".join(translator.body) + assert 'class="gp-sphinx-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: pathlib.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(sgm, "_render", fake_render) + config = types.SimpleNamespace( + mermaid_cmd="", + mermaid_puppeteer_config="", + ) + app = types.SimpleNamespace(confdir=str(tmp_path), config=config) + + first = sgm._render_cached( + t.cast("t.Any", app), + "flowchart LR a-->b", + sgm._THEME_LIGHT, + ) + second = sgm._render_cached( + t.cast("t.Any", app), + "flowchart LR a-->b", + sgm._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 hooks.""" + recorded: dict[str, list[t.Any]] = { + "nodes": [], + "directives": [], + "config": [], + "css": [], + "connect": [], + } + + 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) + + def connect(event: str, callback: t.Callable[..., object]) -> int: + recorded["connect"].append((event, callback)) + return 0 + + app = types.SimpleNamespace( + add_node=add_node, + add_directive=add_directive, + add_config_value=add_config_value, + add_css_file=add_css_file, + connect=connect, + ) + meta = sgm.setup(t.cast("t.Any", app)) + + assert meta["parallel_read_safe"] is True + assert meta["parallel_write_safe"] is True + assert sgm.mermaid_inline in recorded["nodes"] + assert ("mermaid", sgm.MermaidDirective) in recorded["directives"] + assert "mermaid_cmd" in recorded["config"] + assert "mermaid_puppeteer_config" in recorded["config"] + assert "css/sphinx_gp_mermaid.css" in recorded["css"] + assert [event for event, _ in recorded["connect"]] == ["builder-inited"] + + +def test_static_path_hook_is_idempotent() -> None: + """The ``builder-inited`` hook appends the package static dir exactly once.""" + connected: list[t.Callable[[t.Any], None]] = [] + + app = types.SimpleNamespace( + add_node=lambda *a, **kw: None, + add_directive=lambda *a, **kw: None, + add_config_value=lambda *a, **kw: None, + add_css_file=lambda *a, **kw: None, + connect=lambda event, callback: connected.append(callback), + ) + sgm.setup(t.cast("t.Any", app)) + assert len(connected) == 1 + + static_paths: list[str] = [] + fake_app = types.SimpleNamespace( + config=types.SimpleNamespace(html_static_path=static_paths), + ) + connected[0](fake_app) + connected[0](fake_app) + + expected = str(pathlib.Path(sgm.__file__).parent / "_static") + assert static_paths == [expected] From 9416da10e2145978a498c10a6a9b664c1a0451c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 1 Jul 2026 18:54:04 -0500 Subject: [PATCH 3/5] mermaid(feat[fallback]): Non-HTML output and per-builder warn guard why: The HTML-only visitor left text/latex/man/texinfo builds to crash on an unhandled node, and the module-global warn-once flag contradicted the extension's parallel_write_safe declaration. what: - text/man/latex/texinfo visitors emit an "[diagram: ]" stand-in (alt falling back to caption), following sphinx.ext.graphviz - Warn-once memo moves to a builder attribute (imgmath's pattern), so each writer process warns at most once and fresh builds start clean - setup() excludes _mermaid_cache from source discovery at config-inited - _render treats OSError (e.g. a resolved but non-executable command) as MermaidRendererMissing instead of crashing the build - Tests: fallback-text cases, all four visitors, per-builder memo, PermissionError degradation, and a text-builder integration scenario --- .../src/sphinx_gp_mermaid/__init__.py | 84 ++++++- tests/ext/mermaid/test_fallbacks.py | 228 ++++++++++++++++++ tests/ext/mermaid/test_mermaid.py | 36 ++- 3 files changed, 330 insertions(+), 18 deletions(-) create mode 100644 tests/ext/mermaid/test_fallbacks.py diff --git a/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py index 57874ee5..538912a7 100644 --- a/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py +++ b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py @@ -55,8 +55,14 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx + from sphinx.builders import Builder + from sphinx.config import Config from sphinx.util.typing import ExtensionMetadata from sphinx.writers.html5 import HTML5Translator + from sphinx.writers.latex import LaTeXTranslator + from sphinx.writers.manpage import ManualPageTranslator + from sphinx.writers.texinfo import TexinfoTranslator + from sphinx.writers.text import TextTranslator __all__ = [ "MermaidDirective", @@ -170,8 +176,10 @@ def _theme_css(theme: str) -> str: # ``viewBox="-5 -97 148 194"``). _VIEWBOX_RE = re.compile(r']*?\bviewBox="-?[\d.]+ -?[\d.]+ ([\d.]+) ([\d.]+)"') -#: Guards the "renderer missing/failed" warning so it fires once, not per node. -_render_warned = False +#: Builder attribute guarding the "renderer missing/failed" warning so it +#: fires once per writer process, not per node (parallel-write safe, unlike a +#: module global). +_WARNED_ATTR = "_sphinx_gp_mermaid_warned" class MermaidError(Exception): @@ -435,7 +443,7 @@ def _render(app: Sphinx, source: str, config_json: str) -> str: text=True, timeout=180, ) - except FileNotFoundError as exc: # mmdc vanished between resolve and run + except OSError as exc: # mmdc vanished after resolve, or isn't executable raise MermaidRendererMissing(str(exc)) from exc except subprocess.SubprocessError as exc: stderr = getattr(exc, "stderr", "") or "" @@ -465,12 +473,15 @@ def _render_cached(app: Sphinx, source: str, theme: str) -> str: 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: +def _warn_render_failure(builder: Builder, node: nodes.Node, exc: MermaidError) -> None: + """Emit a single build warning when rendering is unavailable or fails. + + The once-only memo lives on the builder (imgmath's pattern), so parallel + writer processes each warn at most once and a fresh build starts clean. + """ + if getattr(builder, _WARNED_ATTR, False): return - _render_warned = True + setattr(builder, _WARNED_ATTR, True) logger.warning( "mermaid render unavailable; emitting diagram source as text: %s", exc, @@ -486,7 +497,7 @@ def html_visit_mermaid_inline(self: HTML5Translator, node: mermaid_inline) -> No light = _render_cached(app, source, _THEME_LIGHT) dark = _render_cached(app, source, _THEME_DARK) except MermaidError as exc: - _warn_render_failure(node, exc) + _warn_render_failure(self.builder, node, exc) self.body.append( '
'
             + html.escape(source)
@@ -527,6 +538,50 @@ def _depart_mermaid_inline(self: HTML5Translator, node: mermaid_inline) -> None:
     """No-op; :func:`html_visit_mermaid_inline` raises ``SkipNode``."""
 
 
+def _diagram_fallback_text(node: mermaid_inline) -> str:
+    """Return the alt-text stand-in non-HTML builders emit for a diagram.
+
+    >>> node = mermaid_inline()
+    >>> node["alt"] = "session holds windows"
+    >>> _diagram_fallback_text(node)
+    '[diagram: session holds windows]'
+    >>> _diagram_fallback_text(mermaid_inline())
+    '[diagram]'
+    """
+    alt: str = node.get("alt", "") or node.get("caption", "")
+    return f"[diagram: {alt}]" if alt else "[diagram]"
+
+
+def text_visit_mermaid_inline(self: TextTranslator, node: mermaid_inline) -> None:
+    """Emit the alt-text stand-in for the text builder, then skip."""
+    self.add_text(_diagram_fallback_text(node))
+    raise nodes.SkipNode
+
+
+def man_visit_mermaid_inline(
+    self: ManualPageTranslator,
+    node: mermaid_inline,
+) -> None:
+    """Emit the alt-text stand-in for the man builder, then skip."""
+    self.body.append(_diagram_fallback_text(node))
+    raise nodes.SkipNode
+
+
+def latex_visit_mermaid_inline(self: LaTeXTranslator, node: mermaid_inline) -> None:
+    """Emit the escaped alt-text stand-in for the LaTeX builder, then skip."""
+    self.body.append(self.encode(_diagram_fallback_text(node)))
+    raise nodes.SkipNode
+
+
+def texinfo_visit_mermaid_inline(
+    self: TexinfoTranslator,
+    node: mermaid_inline,
+) -> None:
+    """Emit the escaped alt-text stand-in for the texinfo builder, then skip."""
+    self.body.append(self.escape(_diagram_fallback_text(node)))
+    raise nodes.SkipNode
+
+
 def setup(app: Sphinx) -> ExtensionMetadata:
     """Register the directive, node, config values, and stylesheet.
 
@@ -549,6 +604,10 @@ def setup(app: Sphinx) -> ExtensionMetadata:
     app.add_node(
         mermaid_inline,
         html=(html_visit_mermaid_inline, _depart_mermaid_inline),
+        text=(text_visit_mermaid_inline, None),
+        man=(man_visit_mermaid_inline, None),
+        latex=(latex_visit_mermaid_inline, None),
+        texinfo=(texinfo_visit_mermaid_inline, None),
     )
     app.add_directive("mermaid", MermaidDirective)
     app.add_config_value("mermaid_cmd", "", "env")
@@ -560,6 +619,13 @@ def _add_static_path(app: Sphinx) -> None:
         if _static_dir not in app.config.html_static_path:
             app.config.html_static_path.append(_static_dir)
 
+    def _exclude_cache_dir(app: Sphinx, config: Config) -> None:
+        # The render cache lives under the confdir (outside _build) so it
+        # survives clean builds; keep Sphinx from treating it as sources.
+        if "_mermaid_cache" not in config.exclude_patterns:
+            config.exclude_patterns.append("_mermaid_cache")
+
+    app.connect("config-inited", _exclude_cache_dir)
     app.connect("builder-inited", _add_static_path)
     app.add_css_file("css/sphinx_gp_mermaid.css")
 
diff --git a/tests/ext/mermaid/test_fallbacks.py b/tests/ext/mermaid/test_fallbacks.py
new file mode 100644
index 00000000..30419d2c
--- /dev/null
+++ b/tests/ext/mermaid/test_fallbacks.py
@@ -0,0 +1,228 @@
+"""Tests for sphinx-gp-mermaid's non-HTML fallbacks and failure handling."""
+
+from __future__ import annotations
+
+import logging
+import subprocess
+import textwrap
+import types
+import typing as t
+
+import pytest
+from docutils import nodes
+
+import sphinx_gp_mermaid as sgm
+from tests._sphinx_scenarios import (
+    ScenarioFile,
+    SharedSphinxResult,
+    SphinxScenario,
+    build_shared_sphinx_result,
+    read_output,
+)
+
+
+class FallbackTextCase(t.NamedTuple):
+    """Node attributes and the expected alt-text stand-in."""
+
+    test_id: str
+    alt: str
+    caption: str
+    expected: str
+
+
+_FALLBACK_TEXT_CASES: list[FallbackTextCase] = [
+    FallbackTextCase(
+        test_id="alt-wins",
+        alt="session holds windows",
+        caption="ignored",
+        expected="[diagram: session holds windows]",
+    ),
+    FallbackTextCase(
+        test_id="caption-fallback",
+        alt="",
+        caption="how it flows",
+        expected="[diagram: how it flows]",
+    ),
+    FallbackTextCase(
+        test_id="no-text",
+        alt="",
+        caption="",
+        expected="[diagram]",
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "case",
+    _FALLBACK_TEXT_CASES,
+    ids=[c.test_id for c in _FALLBACK_TEXT_CASES],
+)
+def test_diagram_fallback_text(case: FallbackTextCase) -> None:
+    """``_diagram_fallback_text`` prefers alt, falls back to caption."""
+    node = sgm.mermaid_inline()
+    node["alt"] = case.alt
+    node["caption"] = case.caption
+    assert sgm._diagram_fallback_text(node) == case.expected
+
+
+def _make_text_translator() -> types.SimpleNamespace:
+    """Return a text-translator stand-in collecting ``add_text`` calls."""
+    ns = types.SimpleNamespace(texts=[])
+    ns.add_text = ns.texts.append
+    return ns
+
+
+class VisitorCase(t.NamedTuple):
+    """A non-HTML visitor with its translator stand-in and output reader."""
+
+    test_id: str
+    visit: t.Callable[[t.Any, sgm.mermaid_inline], None]
+    make_translator: t.Callable[[], types.SimpleNamespace]
+    get_output: t.Callable[[types.SimpleNamespace], str]
+    expected: str
+
+
+_VISITOR_CASES: list[VisitorCase] = [
+    VisitorCase(
+        test_id="text-builder",
+        visit=sgm.text_visit_mermaid_inline,
+        make_translator=_make_text_translator,
+        get_output=lambda ns: "".join(ns.texts),
+        expected="[diagram: how it flows]",
+    ),
+    VisitorCase(
+        test_id="man-builder",
+        visit=sgm.man_visit_mermaid_inline,
+        make_translator=lambda: types.SimpleNamespace(body=[]),
+        get_output=lambda ns: "".join(ns.body),
+        expected="[diagram: how it flows]",
+    ),
+    VisitorCase(
+        test_id="latex-builder-escapes",
+        visit=sgm.latex_visit_mermaid_inline,
+        make_translator=lambda: types.SimpleNamespace(
+            body=[],
+            encode=lambda s: f"{s}",
+        ),
+        get_output=lambda ns: "".join(ns.body),
+        expected="[diagram: how it flows]",
+    ),
+    VisitorCase(
+        test_id="texinfo-builder-escapes",
+        visit=sgm.texinfo_visit_mermaid_inline,
+        make_translator=lambda: types.SimpleNamespace(
+            body=[],
+            escape=lambda s: f"{s}",
+        ),
+        get_output=lambda ns: "".join(ns.body),
+        expected="[diagram: how it flows]",
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "case",
+    _VISITOR_CASES,
+    ids=[c.test_id for c in _VISITOR_CASES],
+)
+def test_non_html_visitors_emit_alt_stand_in(case: VisitorCase) -> None:
+    """Each non-HTML visitor emits the alt-text stand-in and skips the node."""
+    node = sgm.mermaid_inline()
+    node["alt"] = ""
+    node["caption"] = "how it flows"
+    translator = case.make_translator()
+
+    with pytest.raises(nodes.SkipNode):
+        case.visit(t.cast("t.Any", translator), node)
+
+    assert case.get_output(translator) == case.expected
+
+
+def test_warn_render_failure_memoizes_per_builder(
+    caplog: pytest.LogCaptureFixture,
+) -> None:
+    """The render-failure warning fires once per builder, not per node."""
+    node = sgm.mermaid_inline()
+    exc = sgm.MermaidRendererMissing("no mmdc")
+    first_builder = types.SimpleNamespace()
+    second_builder = types.SimpleNamespace()
+
+    with caplog.at_level(logging.WARNING):
+        sgm._warn_render_failure(t.cast("t.Any", first_builder), node, exc)
+        sgm._warn_render_failure(t.cast("t.Any", first_builder), node, exc)
+        sgm._warn_render_failure(t.cast("t.Any", second_builder), node, exc)
+
+    warnings = [
+        r
+        for r in caplog.records
+        if r.levelno == logging.WARNING
+        and "mermaid render unavailable" in r.getMessage()
+    ]
+    assert len(warnings) == 2
+
+
+def test_render_treats_permission_error_as_renderer_missing(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: t.Any,
+) -> None:
+    """A resolved but non-executable mmdc degrades instead of crashing."""
+    monkeypatch.setattr(sgm, "_resolve_mmdc", lambda app: [str(tmp_path / "mmdc")])
+
+    def raise_permission_error(
+        *args: object,
+        **kwargs: object,
+    ) -> t.NoReturn:
+        raise PermissionError(13, "Permission denied")
+
+    monkeypatch.setattr(subprocess, "run", raise_permission_error)
+    config = types.SimpleNamespace(mermaid_cmd="", mermaid_puppeteer_config="")
+    app = types.SimpleNamespace(confdir=str(tmp_path), config=config)
+
+    with pytest.raises(sgm.MermaidRendererMissing):
+        sgm._render(t.cast("t.Any", app), "flowchart LR a-->b", "{}")
+
+
+_TEXT_CONF = textwrap.dedent(
+    """\
+    extensions = ["myst_parser", "sphinx_gp_mermaid"]
+    """
+)
+
+_TEXT_INDEX = textwrap.dedent(
+    """\
+    # Demo
+
+    ```{mermaid}
+    :alt: session holds windows
+
+    flowchart TD
+        a --> b
+    ```
+    """
+)
+
+
+@pytest.fixture(scope="module")
+def mermaid_text_build(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> SharedSphinxResult:
+    """Build a minimal MyST project with the text builder."""
+    cache_root = tmp_path_factory.mktemp("mermaid-text")
+    scenario = SphinxScenario(
+        files=(
+            ScenarioFile("conf.py", _TEXT_CONF),
+            ScenarioFile("index.md", _TEXT_INDEX),
+        ),
+        buildername="text",
+    )
+    return build_shared_sphinx_result(cache_root, scenario)
+
+
+@pytest.mark.integration
+def test_text_builder_emits_alt_stand_in(
+    mermaid_text_build: SharedSphinxResult,
+) -> None:
+    """A text build renders the diagram as its alt-text stand-in, not a crash."""
+    output = read_output(mermaid_text_build, "index.txt")
+    assert "[diagram: session holds windows]" in output
+    assert "flowchart" not in output
diff --git a/tests/ext/mermaid/test_mermaid.py b/tests/ext/mermaid/test_mermaid.py
index 0092bbea..f9fec157 100644
--- a/tests/ext/mermaid/test_mermaid.py
+++ b/tests/ext/mermaid/test_mermaid.py
@@ -242,7 +242,6 @@ def test_visitor_falls_back_when_renderer_missing(
     caplog: pytest.LogCaptureFixture,
 ) -> None:
     """A missing renderer degrades to a text fallback and warns once."""
-    monkeypatch.setattr(sgm, "_render_warned", False)
 
     def boom(app: object, source: str, theme: str) -> str:
         msg = "no mmdc"
@@ -341,29 +340,48 @@ def connect(event: str, callback: t.Callable[..., object]) -> int:
     assert "mermaid_cmd" in recorded["config"]
     assert "mermaid_puppeteer_config" in recorded["config"]
     assert "css/sphinx_gp_mermaid.css" in recorded["css"]
-    assert [event for event, _ in recorded["connect"]] == ["builder-inited"]
+    assert [event for event, _ in recorded["connect"]] == [
+        "config-inited",
+        "builder-inited",
+    ]
 
 
-def test_static_path_hook_is_idempotent() -> None:
-    """The ``builder-inited`` hook appends the package static dir exactly once."""
-    connected: list[t.Callable[[t.Any], None]] = []
+def _connected_hooks() -> dict[str, t.Callable[..., None]]:
+    """Run ``setup()`` against a fake app and return its connected hooks."""
+    hooks: dict[str, t.Callable[..., None]] = {}
 
     app = types.SimpleNamespace(
         add_node=lambda *a, **kw: None,
         add_directive=lambda *a, **kw: None,
         add_config_value=lambda *a, **kw: None,
         add_css_file=lambda *a, **kw: None,
-        connect=lambda event, callback: connected.append(callback),
+        connect=lambda event, callback: hooks.setdefault(event, callback),
     )
     sgm.setup(t.cast("t.Any", app))
-    assert len(connected) == 1
+    return hooks
+
+
+def test_static_path_hook_is_idempotent() -> None:
+    """The ``builder-inited`` hook appends the package static dir exactly once."""
+    hooks = _connected_hooks()
 
     static_paths: list[str] = []
     fake_app = types.SimpleNamespace(
         config=types.SimpleNamespace(html_static_path=static_paths),
     )
-    connected[0](fake_app)
-    connected[0](fake_app)
+    hooks["builder-inited"](fake_app)
+    hooks["builder-inited"](fake_app)
 
     expected = str(pathlib.Path(sgm.__file__).parent / "_static")
     assert static_paths == [expected]
+
+
+def test_cache_dir_exclusion_hook_is_idempotent() -> None:
+    """The ``config-inited`` hook excludes the cache dir exactly once."""
+    hooks = _connected_hooks()
+
+    config = types.SimpleNamespace(exclude_patterns=["_build"])
+    hooks["config-inited"](types.SimpleNamespace(), config)
+    hooks["config-inited"](types.SimpleNamespace(), config)
+
+    assert config.exclude_patterns == ["_build", "_mermaid_cache"]

From 270b4f480fe7b54688e07d8659d1debe8826f5bc Mon Sep 17 00:00:00 2001
From: Tony Narlock 
Date: Wed, 1 Jul 2026 18:58:26 -0500
Subject: [PATCH 4/5] mermaid(test[integration]): Stub-mmdc build test and
 token palette sync

why: The unit suite never exercises the fence -> directive -> subprocess
-> dual-figure HTML pipeline, and the palette hexes are hand copies of
gp-furo-tokens values that could drift silently.

what:
- Full HTML build over a stub mmdc (a python script honouring the
  -i/-o/-c contract) that bakes themeVariables.primaryColor into the
  SVG, proving both theme configs cross the subprocess boundary
- Asserts both fence spellings render, SVG normalization, option
  passthrough (caption/alt/name), packaged CSS shipping, cache
  exclusion and population, and a stable figure-markup snapshot
- Palette tripwire: scrapes light.ts/dark.ts custom properties and
  checks every non-font themeVariable against its token (black/white
  keywords normalized), plus a coverage guard for new palette keys
---
 .../__snapshots__/test_integration.ambr       |   4 +
 tests/ext/mermaid/test_integration.py         | 204 ++++++++++++++++++
 tests/ext/mermaid/test_palette_sync.py        | 129 +++++++++++
 3 files changed, 337 insertions(+)
 create mode 100644 tests/ext/mermaid/__snapshots__/test_integration.ambr
 create mode 100644 tests/ext/mermaid/test_integration.py
 create mode 100644 tests/ext/mermaid/test_palette_sync.py

diff --git a/tests/ext/mermaid/__snapshots__/test_integration.ambr b/tests/ext/mermaid/__snapshots__/test_integration.ambr
new file mode 100644
index 00000000..cbf6f8ec
--- /dev/null
+++ b/tests/ext/mermaid/__snapshots__/test_integration.ambr
@@ -0,0 +1,4 @@
+# serializer version: 1
+# name: test_figure_markup_snapshot
+  '
How it flows.
' +# --- diff --git a/tests/ext/mermaid/test_integration.py b/tests/ext/mermaid/test_integration.py new file mode 100644 index 00000000..5a01684a --- /dev/null +++ b/tests/ext/mermaid/test_integration.py @@ -0,0 +1,204 @@ +"""Integration tests: full Sphinx HTML build with a stubbed mmdc renderer. + +The scenario ships a ``fake_mmdc.py`` stand-in for the real +``@mermaid-js/mermaid-cli`` binary: it honours the ``-i``/``-o``/``-c`` +contract and bakes the config's ``themeVariables.primaryColor`` into the SVG, +so assertions can prove that both the light and the dark render config +actually reached the subprocess — without node, puppeteer, or Chrome. +""" + +from __future__ import annotations + +import re +import textwrap +import typing as t + +import pytest + +from tests._sphinx_scenarios import ( + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +pytestmark = pytest.mark.integration + +_FAKE_MMDC = textwrap.dedent( + '''\ + #!/usr/bin/env python3 + """Fake mmdc: writes a canned SVG shaped like real mermaid-cli output.""" + + from __future__ import annotations + + import json + import pathlib + import sys + + + def main() -> None: + """Write the canned SVG to the ``-o`` path, filled per ``-c`` config.""" + args = sys.argv[1:] + opts = { + flag: args[index + 1] + for index, flag in enumerate(args) + if flag in {"-i", "-o", "-c", "-p", "-b"} + } + config = json.loads(pathlib.Path(opts["-c"]).read_text(encoding="utf-8")) + fill = config["themeVariables"]["primaryColor"] + svg = ( + '' + "" + 'a' + '' + "" + ) + pathlib.Path(opts["-o"]).write_text(svg, encoding="utf-8") + + + if __name__ == "__main__": + main() + ''' +) + +# The exec bit is load-bearing: _resolve_mmdc tries shutil.which() on the +# configured path, and the scenario harness writes files without it. conf.py +# is the only scenario-owned code that runs before rendering, so it applies +# the chmod. +_CONF_PY = textwrap.dedent( + """\ + import pathlib + + extensions = ["myst_parser", "sphinx_gp_mermaid"] + html_theme = "basic" + myst_enable_extensions = ["colon_fence"] + myst_fence_as_directive = ["mermaid"] + + _stub = pathlib.Path(__file__).parent / "fake_mmdc.py" + _stub.chmod(0o755) + mermaid_cmd = str(_stub) + """ +) + +_INDEX_MD = textwrap.dedent( + """\ + # Demo + + ```mermaid + flowchart LR + a --> b + ``` + + :::{mermaid} + :caption: How it flows. + :alt: a to b + :name: flow-diagram + + flowchart TD + a --> b + ::: + """ +) + + +@pytest.fixture(scope="module") +def mermaid_html_build( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a MyST project rendering two diagrams through the stub mmdc.""" + cache_root = tmp_path_factory.mktemp("mermaid-html") + scenario = SphinxScenario( + files=( + ScenarioFile("conf.py", _CONF_PY), + ScenarioFile("index.md", _INDEX_MD), + ScenarioFile("fake_mmdc.py", _FAKE_MMDC), + ), + ) + return build_shared_sphinx_result(cache_root, scenario) + + +@pytest.fixture(scope="module") +def mermaid_html(mermaid_html_build: SharedSphinxResult) -> str: + """Return the built ``index.html`` contents.""" + return read_output(mermaid_html_build, "index.html") + + +def test_build_does_not_degrade( + mermaid_html_build: SharedSphinxResult, + mermaid_html: str, +) -> None: + """The stub renderer satisfies both renders; nothing falls back or warns. + + Cross-app registration noise ("already registered") is expected when many + Sphinx apps share a process, so assert on the degradation signals only. + """ + assert "mermaid render unavailable" not in mermaid_html_build.warnings + assert "gp-sphinx-diagram__fallback" not in mermaid_html + + +def test_both_fence_spellings_render_figures(mermaid_html: str) -> None: + """The plain ```mermaid fence and :::{mermaid} both produce figures.""" + assert mermaid_html.count('
None: + """Each diagram inlines a light and a dark SVG from separate renders.""" + assert mermaid_html.count("gp-sphinx-diagram__variant--theme-light") == 2 + assert mermaid_html.count("gp-sphinx-diagram__variant--theme-dark") == 2 + # The stub bakes themeVariables.primaryColor into the fill, proving the + # light and dark configs each flowed through the subprocess boundary. + assert mermaid_html.count("fill:#f8f9fb;") == 2 + assert mermaid_html.count("fill:#1a1c1e;") == 2 + + +def test_svgs_are_normalized(mermaid_html: str) -> None: + """Ids are rewritten, size is explicit, and max-width is stripped.""" + assert "my-svg" not in mermaid_html + assert len(re.findall(r'id="mermaid-[0-9a-f]{12}-light"', mermaid_html)) == 2 + assert len(re.findall(r'id="mermaid-[0-9a-f]{12}-dark"', mermaid_html)) == 2 + assert mermaid_html.count('width="200" height="80"') == 4 + assert "max-width" not in mermaid_html + + +def test_caption_alt_and_name_flow_through(mermaid_html: str) -> None: + """Directive options surface as figcaption, aria-label, and figure id.""" + assert "
How it flows.
" in mermaid_html + assert 'aria-label="a to b"' in mermaid_html + assert 'id="flow-diagram"' in mermaid_html + assert mermaid_html.count('aria-hidden="true"') == 2 + + +def test_stylesheet_ships_with_the_package( + mermaid_html_build: SharedSphinxResult, + mermaid_html: str, +) -> None: + """The packaged CSS is linked in the page and copied into _static.""" + assert "css/sphinx_gp_mermaid.css" in mermaid_html + css = mermaid_html_build.outdir / "_static" / "css" / "sphinx_gp_mermaid.css" + assert css.is_file() + + +def test_cache_dir_is_excluded_and_populated( + mermaid_html_build: SharedSphinxResult, +) -> None: + """The confdir cache holds one SVG per (diagram, theme) and is excluded.""" + assert "_mermaid_cache" in mermaid_html_build.app.config.exclude_patterns + cache = mermaid_html_build.srcdir / "_mermaid_cache" + assert len(list(cache.glob("*.svg"))) == 4 + + +def test_figure_markup_snapshot( + mermaid_html: str, + snapshot_html_fragment: t.Callable[..., None], +) -> None: + """The named figure's full markup is stable across runs.""" + match = re.search( + r'
.*?
', + mermaid_html, + flags=re.DOTALL, + ) + assert match is not None + snapshot_html_fragment(match.group(0)) diff --git a/tests/ext/mermaid/test_palette_sync.py b/tests/ext/mermaid/test_palette_sync.py new file mode 100644 index 00000000..5e5638bf --- /dev/null +++ b/tests/ext/mermaid/test_palette_sync.py @@ -0,0 +1,129 @@ +"""Tripwire keeping the mermaid palettes in sync with gp-furo-tokens. + +Mermaid bakes literal colours into rendered SVGs, so the extension carries +hex copies of the gp-furo token values — CSS custom properties cannot reach +inside ``mmdc``. These tests scrape ``light.ts``/``dark.ts`` at test time so +a token retune fails here instead of drifting silently. +""" + +from __future__ import annotations + +import pathlib +import re +import typing as t + +import pytest + +import sphinx_gp_mermaid as sgm + +_TOKENS_SRC = pathlib.Path(__file__).parents[3] / "packages" / "gp-furo-tokens" / "src" + +_TOKEN_RE = re.compile(r'"(--[\w-]+)":\s*"([^"]+)"') + +#: CSS colour keywords the token files use where the palette stores hex. +_KEYWORD_HEX = { + "black": "#000000", + "white": "#ffffff", +} + + +def _load_tokens(filename: str) -> dict[str, str]: + """Return custom-property values scraped from a gp-furo-tokens TS file.""" + contents = (_TOKENS_SRC / filename).read_text(encoding="utf-8") + return { + name: _KEYWORD_HEX.get(value.lower(), value).lower() + for name, value in _TOKEN_RE.findall(contents) + } + + +class PaletteSyncCase(t.NamedTuple): + """One mermaid themeVariable and the gp-furo token it must equal.""" + + test_id: str + palette_key: str + token_name: str + + +_PALETTE_SYNC_CASES: list[PaletteSyncCase] = [ + PaletteSyncCase( + test_id="primary-color", + palette_key="primaryColor", + token_name="--color-background-secondary", + ), + PaletteSyncCase( + test_id="primary-border-color", + palette_key="primaryBorderColor", + token_name="--color-brand-primary", + ), + PaletteSyncCase( + test_id="primary-text-color", + palette_key="primaryTextColor", + token_name="--color-foreground-primary", + ), + PaletteSyncCase( + test_id="line-color", + palette_key="lineColor", + token_name="--color-foreground-muted", + ), + PaletteSyncCase( + test_id="text-color", + palette_key="textColor", + token_name="--color-foreground-primary", + ), + PaletteSyncCase( + test_id="background", + palette_key="background", + token_name="--color-background-primary", + ), + PaletteSyncCase( + test_id="edge-label-background", + palette_key="edgeLabelBackground", + token_name="--color-background-secondary", + ), + PaletteSyncCase( + test_id="secondary-color", + palette_key="secondaryColor", + token_name="--color-background-primary", + ), + PaletteSyncCase( + test_id="tertiary-color", + palette_key="tertiaryColor", + token_name="--color-background-secondary", + ), +] + +_THEME_TOKEN_FILES = { + sgm._THEME_LIGHT: "light.ts", + sgm._THEME_DARK: "dark.ts", +} + + +@pytest.mark.parametrize( + "case", + _PALETTE_SYNC_CASES, + ids=[c.test_id for c in _PALETTE_SYNC_CASES], +) +@pytest.mark.parametrize("theme", [sgm._THEME_LIGHT, sgm._THEME_DARK]) +def test_palette_matches_gp_furo_tokens(theme: str, case: PaletteSyncCase) -> None: + """Every colour in ``_PALETTES`` equals its gp-furo token value.""" + tokens = _load_tokens(_THEME_TOKEN_FILES[theme]) + assert case.token_name in tokens, ( + f"{case.token_name} not found in {_THEME_TOKEN_FILES[theme]}; " + "the token was renamed or the scrape regex no longer matches" + ) + palette_value = sgm._PALETTES[theme][case.palette_key].lower() + assert palette_value == tokens[case.token_name], ( + f"{theme} palette {case.palette_key}={palette_value} out of sync with " + f"{case.token_name}={tokens[case.token_name]}; update _PALETTES and " + "bump _RENDER_VERSION" + ) + + +def test_every_palette_colour_is_covered() -> None: + """Each non-font palette key has a sync case, so new keys can't drift.""" + covered = {case.palette_key for case in _PALETTE_SYNC_CASES} + for theme, palette in sgm._PALETTES.items(): + colour_keys = {key for key in palette if key != "fontFamily"} + assert colour_keys == covered, ( + f"{theme}: uncovered keys {colour_keys - covered}" + ) From e12a0ca0cddd985fda43392dc6f5897930b7a959 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 1 Jul 2026 19:00:30 -0500 Subject: [PATCH 5/5] config(feat[mermaid]): Route plain mermaid fences when extension active why: myst-parser snapshots myst_* config at its own config-inited, so sphinx_gp_mermaid cannot reliably register its fence routing from setup(); conf level is the deterministic place, and consumers should not need boilerplate for it. what: - merge_sphinx_config() sets myst_fence_as_directive = ["mermaid"] whenever sphinx_gp_mermaid lands in the final extension list - Explicit myst_fence_as_directive overrides still win (overrides apply last) - Tests: routing on with the extension, absent without it, override wins; docstring doctest documents the behavior --- packages/gp-sphinx/src/gp_sphinx/config.py | 17 +++++++++++ tests/test_config.py | 33 ++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index f57478cb..ffe66730 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -437,6 +437,17 @@ def merge_sphinx_config( >>> "sphinx_design" in conf["extensions"] False + Loading sphinx_gp_mermaid also routes plain ``mermaid`` fences to it: + + >>> conf = merge_sphinx_config( + ... project="test", + ... version="1.0", + ... copyright="2026", + ... extra_extensions=["sphinx_gp_mermaid"], + ... ) + >>> conf["myst_fence_as_directive"] + ['mermaid'] + Auto-computed values from source_repository and docs_url: >>> conf = merge_sphinx_config( @@ -601,6 +612,12 @@ def merge_sphinx_config( if vite_root_setting is not None: conf["sphinx_vite_builder_root"] = vite_root_setting + # Route plain ```mermaid fences to sphinx_gp_mermaid's directive. This + # must happen at conf level: myst-parser snapshots myst_* config at its + # own config-inited, so an extension-level mutation lands too late. + if "sphinx_gp_mermaid" in ext_list: + conf["myst_fence_as_directive"] = ["mermaid"] + # Apply overrides last (can override auto-computed values) conf.update(overrides) diff --git a/tests/test_config.py b/tests/test_config.py index 7071055d..7805ec7b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -216,6 +216,39 @@ def test_merge_sphinx_config_default_myst_config() -> None: assert result["myst_enable_extensions"] == DEFAULT_MYST_EXTENSIONS +def test_merge_sphinx_config_mermaid_fence_routing() -> None: + """Loading sphinx_gp_mermaid routes plain mermaid fences to it.""" + result = merge_sphinx_config( + project="test", + version="1.0", + copyright="2026", + extra_extensions=["sphinx_gp_mermaid"], + ) + assert result["myst_fence_as_directive"] == ["mermaid"] + + +def test_merge_sphinx_config_no_fence_routing_without_mermaid() -> None: + """Without the mermaid extension, no fence routing is configured.""" + result = merge_sphinx_config( + project="test", + version="1.0", + copyright="2026", + ) + assert "myst_fence_as_directive" not in result + + +def test_merge_sphinx_config_mermaid_fence_override_wins() -> None: + """An explicit myst_fence_as_directive override replaces the default.""" + result = merge_sphinx_config( + project="test", + version="1.0", + copyright="2026", + extra_extensions=["sphinx_gp_mermaid"], + myst_fence_as_directive=["mermaid", "dot"], + ) + assert result["myst_fence_as_directive"] == ["mermaid", "dot"] + + def test_merge_sphinx_config_default_fonts() -> None: """Default font configuration is present.""" result = merge_sphinx_config(