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/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/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..538912a7 --- /dev/null +++ b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py @@ -0,0 +1,636 @@ +"""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. 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 _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. + + 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 + """ + 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") + 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) + + 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") + + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_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
How it flows.
' +# --- 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_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_mermaid.py b/tests/ext/mermaid/test_mermaid.py new file mode 100644 index 00000000..f9fec157 --- /dev/null +++ b/tests/ext/mermaid/test_mermaid.py @@ -0,0 +1,387 @@ +"""Unit tests for the sphinx-gp-mermaid build-time inline-SVG extension.""" + +from __future__ import annotations + +import logging +import pathlib +import types +import typing as t + +import pytest +from docutils import nodes + +import sphinx_gp_mermaid as sgm + +# A stand-in for mmdc's real output: fixed ``my-svg`` id, responsive width with +# no height, an inline ``max-width``, an id-scoped style, and a marker ref. +_FAKE_MMDC_SVG = ( + '' + "" + '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.""" + + 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"]] == [ + "config-inited", + "builder-inited", + ] + + +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: hooks.setdefault(event, callback), + ) + sgm.setup(t.cast("t.Any", app)) + 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), + ) + 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"] 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}" + ) 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( 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"