Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ dev = [
"doc8",
"esbonio",
"ruff",
"sphinxcontrib-mermaid>=2.0.2",
"ty",
]
docs = [
Expand Down
113 changes: 111 additions & 2 deletions src/atsphinx/typst/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from importlib import metadata
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from docutils import nodes
from rst2typst.writer import TypstTranslator as BaseTypstTranslator
Expand All @@ -37,7 +37,6 @@ def _typst_local_package_fullname(name: str, version: str | None = None) -> str:
version = metadata.version(name)
return f"@local/{name}:{version}"


class TypstTranslator(SphinxTranslator, BaseTypstTranslator):
"""Custom translator that has converter from dotctree to Typst syntax."""

Expand All @@ -54,6 +53,7 @@ class TypstTranslator(SphinxTranslator, BaseTypstTranslator):
"desc_parameterlist",
"desc_returns",
"desc_sig_punctuation",
"mermaid", # Optional mermaid support
]

def __init__(self, document: nodes.document, builder: Builder) -> None:
Expand Down Expand Up @@ -170,6 +170,115 @@ def _escape(txt: str) -> str:
def depart_index(self, node: addnodes.index):
pass


def mermaid_render_to_svg(self, code: str, mermaid_options: Any, mermaid_cmd:str) -> tuple[Any | None, Any | None]:
"""Render a Mermaid diagram to SVG using ``sphinxcontrib.mermaid``.

Temporarily patches the builder's ``mermaid_cmd``, ``imagedir``, and
``imgpath`` attributes so that :func:`sphinxcontrib.mermaid.render_mm`
writes output files into the builder's ``_images_dir``. All patched
attributes are restored in a ``finally`` block regardless of success or
failure.

If ``mermaid_cmd`` is a relative path it is resolved against the
project's ``confdir`` first, then ``confdir/_static``, falling back to
the ``confdir``-relative path when neither location exists on disk.

Args:
code: Raw Mermaid diagram source to render.
mermaid_options: Options dict forwarded verbatim to
:func:`~sphinxcontrib.mermaid.render_mm`.
mermaid_cmd: Path to the Mermaid CLI executable. Relative paths
are resolved as described above before being applied.

Returns:
A ``(relfn, outfn)`` tuple as returned by
:func:`~sphinxcontrib.mermaid.render_mm`, where *relfn* is the
relative path to the generated SVG file and *outfn* is the
absolute path. Both elements are ``None`` when rendering fails.

Raises:
ImportError: If ``sphinxcontrib-mermaid`` is not installed.
"""
from sphinxcontrib.mermaid import render_mm # may throw ImportError

builder: Builder = self.builder

confdir = Path(builder.app.confdir)
# Temporarily set mermaid_cmd to absolute path if it is relative path
if isinstance(mermaid_cmd, str) and not Path(mermaid_cmd).is_absolute():
mermaid_cmd_path = Path(mermaid_cmd)
confdir_cmd = confdir / mermaid_cmd_path
static_cmd = confdir / "_static" / mermaid_cmd_path

if confdir_cmd.exists():
builder.config.mermaid_cmd = str(confdir_cmd)
elif static_cmd.exists():
builder.config.mermaid_cmd = str(static_cmd)
else:
builder.config.mermaid_cmd = str(confdir_cmd)

# Temporarily set imagedir using builder's _images_dir so render_mm creates files there
original_imagedir = getattr(builder, 'imagedir', None)
original_imgpath = getattr(builder, 'imgpath', None)
images_dir_name = builder._images_dir.name
builder.imagedir = images_dir_name
builder.imgpath = images_dir_name

try:
relfn, outfn = render_mm(self, code, mermaid_options, _fmt='svg', prefix='mermaid')
except Exception:
logger.exception("Mermaid code block failed to render")
relfn, outfn = None, None
finally:
Comment thread
illilillillili marked this conversation as resolved.
builder.config.mermaid_cmd = mermaid_cmd
# Restore original values
if original_imagedir is not None:
builder.imagedir = original_imagedir
else:
delattr(builder, 'imagedir')
if original_imgpath is not None:
builder.imgpath = original_imgpath
else:
delattr(builder, 'imgpath')
return relfn, outfn



def visit_mermaid(self, node):
"""Handle mermaid node - render to SVG using sphinxcontrib.mermaid."""
try:
code = node['code']
options = node.get('options', {})
mermaid_cmd = self.builder.config.mermaid_cmd


relative_filename, output_filename = self.mermaid_render_to_svg(code, options, mermaid_cmd)

if relative_filename and output_filename:
# render_mm created file directly in _images_dir
# No need to register for copying - file is already in final location
img_node = nodes.image()
img_node['uri'] = relative_filename # Already points to images_dir/filename.svg
img_node['alt'] = node.get('alt', 'Mermaid diagram')
if 'align' in node:
img_node['align'] = node['align']
# Use parent class to render (skip our visit_image path processing)
super(TypstTranslator, self).visit_image(img_node)
super(TypstTranslator, self).depart_image(img_node)
except ImportError:
logger.warning("sphinxcontrib.mermaid not installed. Skipping mermaid diagram.")
except nodes.SkipNode:
raise
except Exception as e:
logger.warning(f"Mermaid rendering failed: {e}. Skipping diagram.")
Comment thread
illilillillili marked this conversation as resolved.

raise nodes.SkipNode

def depart_mermaid(self, node):
"""Empty, unused visitor method for mermaid blocks"""
pass

def visit_start_of_file(self, node: addnodes.start_of_file):
# NOTE: Implement this when rendering anything as the "start of file."
pass
Expand Down
65 changes: 65 additions & 0 deletions tests/test_writers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# TODO: Write docstrings after.
# ruff: noqa: D101, D102, D106, D107
from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from docutils import nodes

from atsphinx.typst import builders as t

if TYPE_CHECKING:
from pytest_mock import MockFixture
from sphinx.testing.util import SphinxTestApp


class Test_TypstTranslator:
class Test_visit_mermaid:
@pytest.mark.sphinx("typst", testroot="with-mermaid-nodep")
def test__no_sphinxcontrib_mermaid(self, app: SphinxTestApp):
"""Build succeeds when sphinxcontrib.mermaid is not installed.

The ``with-mermaid-nodep`` testroot does not load ``sphinxcontrib.mermaid``
so the ``.. mermaid::`` directive is unknown and produces no node - the
translator's ``visit_mermaid`` is never reached, but the build must still
complete and emit a ``.typ`` file.
"""
app.build()
out = app.outdir / "index.typ"
assert out.exists()

@pytest.mark.sphinx("typst", testroot="with-mermaid", freshenv=True)
def test__render_produces_image(self, app: SphinxTestApp, mocker: MockFixture):
"""When render_mm succeeds the SVG is embedded as an image directive."""
import atsphinx.typst.writer as w

fake_relfn = "_images/mermaid-abc123.svg"
fake_outfn = "/tmp/mermaid-abc123.svg"
mocker.patch.object(
w.TypstTranslator,
"mermaid_render_to_svg",
return_value=(fake_relfn, fake_outfn),
)
app.build()
out = app.outdir / "index.typ"
assert out.exists()
content = out.read_text()
# The SVG path should appear in the generated Typst source
assert "mermaid-abc123.svg" in content

@pytest.mark.sphinx("typst", testroot="with-mermaid", freshenv=True)
def test__render_failure_is_skipped(self, app: SphinxTestApp, mocker: MockFixture):
"""A render error is caught, logged as a warning, and build still succeeds."""
import atsphinx.typst.writer as w

mocker.patch.object(
w.TypstTranslator,
"mermaid_render_to_svg",
side_effect=RuntimeError("mmdc crashed"),
)
mock_logger = mocker.patch.object(w, "logger")
app.build()
out = app.outdir / "index.typ"
assert out.exists()
mock_logger.warning.assert_called_once()
15 changes: 15 additions & 0 deletions tests/testdocs/test-with-mermaid-nodep/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@

# noqa: D100

extensions = [
"atsphinx.typst",
]

typst_documents = [
{
"entrypoint": "index",
"filename": "index",
"theme": "manual",
"title": "Test documentation",
}
]
9 changes: 9 additions & 0 deletions tests/testdocs/test-with-mermaid-nodep/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Test doc for atsphinx-typst
===========================

.. mermaid::

%%{init: {'theme':'base'}}%%
block
a
b
16 changes: 16 additions & 0 deletions tests/testdocs/test-with-mermaid/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

# noqa: D100

extensions = [
"sphinxcontrib.mermaid",
"atsphinx.typst",
]

typst_documents = [
{
"entrypoint": "index",
"filename": "index",
"theme": "manual",
"title": "Test documentation",
}
]
9 changes: 9 additions & 0 deletions tests/testdocs/test-with-mermaid/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Test doc for atsphinx-typst
===========================

.. mermaid::

%%{init: {'theme':'base'}}%%
block
a
b
Loading