diff --git a/docs/images/readme_trafficlightmachine.png b/docs/images/readme_trafficlightmachine.png
index f5179c05..2f65f411 100644
Binary files a/docs/images/readme_trafficlightmachine.png and b/docs/images/readme_trafficlightmachine.png differ
diff --git a/statemachine/contrib/diagram/sphinx_ext.py b/statemachine/contrib/diagram/sphinx_ext.py
index 84ab50f3..8313b554 100644
--- a/statemachine/contrib/diagram/sphinx_ext.py
+++ b/statemachine/contrib/diagram/sphinx_ext.py
@@ -24,6 +24,7 @@
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.util.docutils import SphinxDirective
+from sphinx.util.osutil import relative_uri
if TYPE_CHECKING:
from sphinx.application import Sphinx
@@ -258,7 +259,8 @@ def _resolve_target(self, svg_text: str) -> str:
with open(outpath, "w", encoding="utf-8") as f:
f.write(svg_text)
- return f"/_images/{filename}"
+ page_uri = self.env.app.builder.get_target_uri(self.env.docname)
+ return relative_uri(page_uri, f"_images/{filename}")
def _build_wrapper_classes(self) -> list[str]:
"""Build CSS class list for the outer wrapper element."""
diff --git a/tests/test_contrib_diagram.py b/tests/test_contrib_diagram.py
index 0aaa4685..ad9fceb3 100644
--- a/tests/test_contrib_diagram.py
+++ b/tests/test_contrib_diagram.py
@@ -1,10 +1,12 @@
import re
from contextlib import contextmanager
from unittest import mock
+from urllib.parse import urljoin
from xml.etree import ElementTree
import pytest
from docutils import nodes
+from sphinx.testing.util import SphinxTestApp
from statemachine.contrib.diagram import DotGraphMachine
from statemachine.contrib.diagram import main
from statemachine.contrib.diagram import quickchart_write_svg
@@ -1146,6 +1148,8 @@ def _make_directive(self, options=None, tmp_path=None):
if tmp_path is not None:
directive.state = mock.MagicMock()
directive.state.document.settings.env.app.outdir = str(tmp_path)
+ directive.env.docname = "index"
+ directive.env.app.builder.get_target_uri.return_value = "index.html"
return directive
def test_no_target_option(self):
@@ -1161,7 +1165,7 @@ def test_empty_target_generates_file(self, tmp_path):
svg_data = ""
result = directive._resolve_target(svg_data)
- assert result.startswith("/_images/statemachine-")
+ assert result.startswith("_images/statemachine-")
assert result.endswith(".svg")
# Verify the file was written
@@ -1185,6 +1189,47 @@ def test_different_events_different_filename(self, tmp_path):
assert d1._resolve_target("") != d2._resolve_target("")
+@pytest.mark.parametrize("buildername", ["html", "dirhtml"])
+@pytest.mark.parametrize("docname", ["index", "guide/overview", "guide/index"])
+def test_sphinx_zoom_link_resolves_under_documentation_prefix(
+ tmp_path, request, buildername, docname
+):
+ srcdir = tmp_path / "src"
+ srcdir.mkdir()
+ (srcdir / "conf.py").write_text(
+ 'extensions = ["statemachine.contrib.diagram.sphinx_ext"]\n', encoding="utf-8"
+ )
+ document = srcdir / f"{docname}.rst"
+ document.parent.mkdir(parents=True, exist_ok=True)
+ document.write_text(
+ "Diagram\n=======\n\n"
+ ".. statemachine-diagram:: tests.examples.traffic_light_machine.TrafficLightMachine\n"
+ " :target:\n",
+ encoding="utf-8",
+ )
+ if docname != "index":
+ (srcdir / "index.rst").write_text(
+ f"Index\n=====\n\n.. toctree::\n\n {docname}\n", encoding="utf-8"
+ )
+ app = SphinxTestApp(
+ buildername=buildername, srcdir=srcdir, builddir=tmp_path / "build", freshenv=True
+ )
+ request.addfinalizer(app.cleanup)
+ app.build()
+ assert app.statuscode == 0
+ assert app.warning.getvalue() == ""
+ page = app.builder.get_outfilename(docname)
+ with open(page, encoding="utf-8") as f:
+ match = re.search(r'href="([^"]*statemachine-[^"]+\.svg)"', f.read())
+ assert match is not None
+ href = match.group(1)
+ filename = href.rsplit("/", 1)[-1]
+ prefix = "https://example.com/project/en/stable/"
+ page_url = urljoin(prefix, app.builder.get_target_uri(docname))
+ assert urljoin(page_url, href) == f"{prefix}_images/{filename}"
+ assert (app.outdir / "_images" / filename).is_file()
+
+
class TestDirectiveRun:
"""Integration tests for StateMachineDiagram.run()."""
@@ -1199,6 +1244,8 @@ def _make_directive(self, tmp_path, options=None):
directive.state_machine = mock.MagicMock()
directive.state = mock.MagicMock()
directive.state.document.settings.env.app.outdir = str(tmp_path)
+ directive.env.docname = "index"
+ directive.env.app.builder.get_target_uri.return_value = "index.html"
directive.content_offset = 0
return directive
@@ -1273,7 +1320,7 @@ def test_render_with_empty_target(self, tmp_path):
"""Empty target auto-generates a zoom SVG file."""
_, result = self._run(tmp_path, options={"target": ""})
- assert 'href="/_images/statemachine-' in result[0].astext()
+ assert 'href="_images/statemachine-' in result[0].astext()
images_dir = tmp_path / "_images"
assert any(images_dir.glob("statemachine-*.svg"))