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..5018e4fc 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 @@ -118,7 +119,7 @@ def run(self) -> list[nodes.Node]: svg_tag = svg_tag.replace("{svg_tag}' if target: @@ -142,6 +143,7 @@ def run(self) -> list[nodes.Node]: html = f"{img_html}" raw_node = nodes.raw("", html, format="html") + raw_node["zoom_filename"] = zoom_filename if "name" in self.options: self.add_name(raw_node) @@ -232,18 +234,21 @@ def _build_svg_styles(self, intrinsic_width: str, intrinsic_height: str) -> str: return f'style="{"; ".join(parts)}"' - def _resolve_target(self, svg_text: str) -> str: - """Return the href for the wrapper ```` tag, if any. + def _resolve_target(self, svg_text: str) -> tuple[str, str]: + """Return ``(href, zoom_filename)`` for the wrapper ```` tag. When ``:target:`` is given without a value (or as empty string), the raw SVG is written to ``_images/`` and linked so the user can open - the full diagram in a new browser tab for zooming. + the full diagram in a new browser tab for zooming. The href is + root-relative at this point; ``_resolve_zoom_links`` rewrites it + relative to each page at write time, once the builder knows the + page's final URI. """ if "target" not in self.options: - return "" + return "", "" target = (self.options["target"] or "").strip() if target: - return target + return target, "" # Auto-generate a standalone SVG file for zoom. qualname = self.arguments[0] @@ -258,7 +263,7 @@ 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}" + return f"_images/{filename}", filename def _build_wrapper_classes(self) -> list[str]: """Build CSS class list for the outer wrapper element.""" @@ -275,6 +280,22 @@ def _split_length(value: str) -> tuple[float, str]: return 0.0, value +def _resolve_zoom_links(app: "Sphinx", doctree: nodes.document, docname: str) -> None: + """Rewrite auto-generated zoom hrefs relative to the page being written.""" + builder = app.builder + if builder.format != "html": + return + page_uri = builder.get_target_uri(docname).split("#", 1)[0] + for node in doctree.findall(nodes.raw): + filename = node.get("zoom_filename") + if not filename: + continue + href = relative_uri(page_uri, f"_images/{filename}") + html = node.astext().replace(f'href="_images/{filename}"', f'href="{href}"') + node[0] = nodes.Text(html) + + def setup(app: "Sphinx") -> dict[str, Any]: app.add_directive("statemachine-diagram", StateMachineDiagram) + app.connect("doctree-resolved", _resolve_zoom_links) return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/tests/test_contrib_diagram.py b/tests/test_contrib_diagram.py index 0aaa4685..eb63df4a 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 @@ -1150,19 +1152,20 @@ def _make_directive(self, options=None, tmp_path=None): def test_no_target_option(self): directive = self._make_directive() - assert directive._resolve_target("") == "" + assert directive._resolve_target("") == ("", "") def test_explicit_target_url(self): directive = self._make_directive({"target": "https://example.com/diagram.svg"}) - assert directive._resolve_target("") == "https://example.com/diagram.svg" + assert directive._resolve_target("") == ("https://example.com/diagram.svg", "") def test_empty_target_generates_file(self, tmp_path): directive = self._make_directive({"target": ""}, tmp_path=tmp_path) svg_data = "" - result = directive._resolve_target(svg_data) + href, filename = directive._resolve_target(svg_data) - assert result.startswith("/_images/statemachine-") - assert result.endswith(".svg") + assert filename.startswith("statemachine-") + assert filename.endswith(".svg") + assert href == f"_images/{filename}" # Verify the file was written images_dir = tmp_path / "_images" @@ -1185,6 +1188,98 @@ def test_different_events_different_filename(self, tmp_path): assert d1._resolve_target("") != d2._resolve_target("") +@pytest.mark.parametrize("buildername", ["html", "dirhtml", "singlehtml"]) +@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.outdir / "index.html" + if buildername == "singlehtml" + else 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 TestResolveZoomLinks: + """Tests for the ``doctree-resolved`` handler that relativizes zoom hrefs.""" + + def _doctree(self, html): + doctree = nodes.document(None, None) + raw = nodes.raw("", html, format="html") + doctree += raw + return doctree, raw + + def _app(self, fmt="html", page_uri="guide/overview.html"): + app = mock.MagicMock() + app.builder.format = fmt + app.builder.get_target_uri.return_value = page_uri + return app + + def test_rewrites_href_relative_to_page(self): + from statemachine.contrib.diagram.sphinx_ext import _resolve_zoom_links + + doctree, raw = self._doctree('x') + raw["zoom_filename"] = "statemachine-abc.svg" + _resolve_zoom_links(self._app(), doctree, "guide/overview") + assert raw.astext() == 'x' + + def test_strips_fragment_from_singlehtml_page_uri(self): + from statemachine.contrib.diagram.sphinx_ext import _resolve_zoom_links + + doctree, raw = self._doctree('x') + raw["zoom_filename"] = "statemachine-abc.svg" + _resolve_zoom_links(self._app(page_uri="#document-index"), doctree, "index") + assert raw.astext() == 'x' + + def test_skips_raw_nodes_without_zoom_file(self): + from statemachine.contrib.diagram.sphinx_ext import _resolve_zoom_links + + doctree, raw = self._doctree('x') + _resolve_zoom_links(self._app(), doctree, "guide/overview") + assert raw.astext() == 'x' + + def test_skips_non_html_builders(self): + from statemachine.contrib.diagram.sphinx_ext import _resolve_zoom_links + + doctree, raw = self._doctree('x') + raw["zoom_filename"] = "statemachine-abc.svg" + _resolve_zoom_links(self._app(fmt="latex"), doctree, "guide/overview") + assert raw.astext() == 'x' + + class TestDirectiveRun: """Integration tests for StateMachineDiagram.run().""" @@ -1273,7 +1368,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"))