Skip to content

Commit

Permalink
Support latest es module mermaid, add support for ELK diagrams
Browse files Browse the repository at this point in the history
  • Loading branch information
timkpaine committed Sep 11, 2024
1 parent 8cbe93b commit d09a95d
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 71 deletions.
36 changes: 29 additions & 7 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -141,22 +141,40 @@ Config values
The output format for Mermaid when building HTML files. This must be either ``'raw'``
``'png'`` or ``'svg'``; the default is ``'raw'``. ``mermaid-cli`` is required if it's not ``raw``

``mermaid_use_local``

Optional path to a local installation of ``mermaid.esm.min.mjs``. By default, we will pull from jsdelivr.

``mermaid_version``

The version of mermaid that will be used to parse ``raw`` output in HTML files. This should match a version available on https://unpkg.com/browse/mermaid/. The default is ``"10.2.0"``. If you need a newer version, you'll need to add the custom initialization. See below.
The version of mermaid that will be used to parse ``raw`` output in HTML files. This should match a version available on https://unpkg.com/browse/mermaid/. The default is ``"11.2.0"``.

If it's set to ``""``, the lib won't be automatically included from the CDN service and you'll need to add it as a local
file in ``html_js_files``. For instance, if you download the lib to `_static/js/mermaid.js`, in ``conf.py``::
``mermaid_init_js``

Mermaid initialization code. Default to ``"mermaid.initialize({startOnLoad:false});"``.

html_js_files = [
'js/mermaid.js',
]
.. versionchanged:: 0.7
The init code doesn't include the `<script>` tag anymore. It's automatically added at build time.

``mermaid_elk_use_local``

Optional path to a local installation of ``mermaid-layout-elk.esm.min.mjs``. By default, we will pull from jsdelivr.

``mermaid_include_elk``

The version of mermaid ELK renderer that will be used. The default is ``"0.1.4"``. Leave blank to disable ELK layout.

``d3_use_local``

Optional path to a local installation of ``d3.min.js``. By default, we will pull from jsdelivr.

``d3_version``

The version of d3 that will be used to provide zoom functionality on mermaid graphs. The default is ``"7.9.0"``.

``mermaid_init_js``

Mermaid initialization code. Default to ``"mermaid.initialize({startOnLoad:true});"``.
Mermaid initialization code. Default to ``"mermaid.initialize({startOnLoad:false});"``.

.. versionchanged:: 0.7
The init code doesn't include the `<script>` tag anymore. It's automatically added at build time.
Expand Down Expand Up @@ -225,6 +243,10 @@ Then in your `.md` documents include a code block as in reStructuredTexts::
Alice->John: Hello John, how are you?
```

For GitHub cross-support, you can omit the curly braces and configure myst to use the `mermaid` code block as a myst directive. For example, in `conf.py`::

myst_fence_as_directive = ["mermaid"]

Building PDFs on readthedocs.io
-----------------------------------

Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,6 @@ def remove_block(text, token, margin=0):
platforms="any",
packages=find_packages(),
include_package_data=True,
install_requires=["sphinx", "pyyaml"],
namespace_packages=["sphinxcontrib"],
)
162 changes: 111 additions & 51 deletions sphinxcontrib/mermaid.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import posixpath
import re
from hashlib import sha1
from json import loads
from subprocess import PIPE, Popen
from tempfile import _get_default_tempdir
import uuid
Expand All @@ -25,19 +26,58 @@
from docutils import nodes
from docutils.parsers.rst import Directive, directives
from docutils.statemachine import ViewList
from packaging.version import Version
from sphinx.application import Sphinx
from sphinx.locale import _
from sphinx.util import logging
from sphinx.util.i18n import search_image_for_language
from sphinx.util.osutil import ensuredir
from yaml import dump

from .autoclassdiag import class_diagram
from .exceptions import MermaidError

logger = logging.getLogger(__name__)

mapname_re = re.compile(r'<map id="(.*?)"')
_MERMAID_INIT_JS_DEFAULT = "mermaid.initialize({startOnLoad:false});"
_MERMAID_RUN_NO_D3_ZOOM = """
import mermaid from "{_mermaid_js_url}";
window.addEventListener("load", () => mermaid.run());
"""

_MERMAID_RUN_D3_ZOOM = """
import mermaid from "{_mermaid_js_url}";
const load = async () => {{
await mermaid.run();
const mermaids = document.querySelectorAll(".mermaid");
const mermaids_processed = document.querySelectorAll(".mermaid[data-processed='true']");
if(mermaids.length !== 0) {{
var svgs = d3.selectAll("{_d3_selector}");
if(mermaids.length !== mermaids_processed.length) {{
// try again in a sec
setTimeout(load, 200);
return;
}} else if(svgs.size() !== mermaids.length) {{
// try again in a sec
setTimeout(load, 200);
return;
}} else {{
svgs.each(function() {{
var svg = d3.select(this);
svg.html("<g class='wrapper'>" + svg.html() + "</g>");
var inner = svg.select("g");
var zoom = d3.zoom().on("zoom", function(event) {{
inner.attr("transform", event.transform);
}});
svg.call(zoom);
}});
}}
}}
}};
window.addEventListener("load", load);
"""

class mermaid(nodes.General, nodes.Inline, nodes.Element):
pass
Expand Down Expand Up @@ -73,10 +113,14 @@ class Mermaid(Directive):
optional_arguments = 1
final_argument_whitespace = False
option_spec = {
# Sphinx directives
"alt": directives.unchanged,
"align": align_spec,
"caption": directives.unchanged,
"zoom": directives.unchanged,
# Mermaid directives
"config": directives.unchanged,
"title": directives.unchanged,
}

def get_mm_code(self):
Expand Down Expand Up @@ -111,7 +155,7 @@ def get_mm_code(self):
mmcode = "\n".join(self.content)
return mmcode

def run(self):
def run(self, **kwargs):
mmcode = self.get_mm_code()
# mmcode is a list, so it's a system message, not content to be included in the
# document.
Expand All @@ -131,6 +175,7 @@ def run(self):
node = mermaid()
node["code"] = mmcode
node["options"] = {}
# Sphinx directives
if "alt" in self.options:
node["alt"] = self.options["alt"]
if "align" in self.options:
Expand All @@ -141,6 +186,18 @@ def run(self):
node["zoom"] = True
node["zoom_id"] = f"id-{uuid.uuid4()}"

# Mermaid directives
mm_config = "---"
if "config" in self.options:
mm_config += "\n"
mm_config += dump({"config": loads(self.options['config'])})
if "title" in self.options:
mm_config += "\n"
mm_config += f"title: {self.options['title']}"
mm_config += "\n---\n"
if mm_config != "---\n---\n":
node["code"] = mm_config + node["code"]

caption = self.options.get("caption")
if caption:
node = figure_wrapper(self, node, caption)
Expand Down Expand Up @@ -420,41 +477,57 @@ def install_js(
return

# Add required JavaScript
if not app.config.mermaid_version:
_mermaid_js_url = None # assume it is local
if app.config.mermaid_use_local:
_mermaid_js_url = app.config.mermaid_use_local
elif app.config.mermaid_version == "latest":
_mermaid_js_url = "https://unpkg.com/mermaid/dist/mermaid.min.js"
_mermaid_js_url = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs"
elif Version(app.config.mermaid_version) > Version("10.2.0"):
_mermaid_js_url = f"https://cdn.jsdelivr.net/npm/mermaid@{app.config.mermaid_version}/dist/mermaid.esm.min.mjs"
elif app.config.mermaid_version:
raise MermaidError("Requires mermaid js version 10.3.0 or later")

app.add_js_file(_mermaid_js_url, priority=app.config.mermaid_js_priority, type="module")

if app.config.mermaid_elk_use_local:
_mermaid_elk_js_url = app.config.mermaid_elk_use_local
elif app.config.mermaid_include_elk == "latest":
_mermaid_elk_js_url = "https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs"
elif app.config.mermaid_include_elk:
_mermaid_elk_js_url = f"https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk@{app.config.mermaid_include_elk}/dist/mermaid-layout-elk.esm.min.mjs"
else:
_mermaid_js_url = f"https://unpkg.com/mermaid@{app.config.mermaid_version}/dist/mermaid.min.js"
if _mermaid_js_url:
app.add_js_file(_mermaid_js_url, priority=app.config.mermaid_js_priority)
_mermaid_elk_js_url = None
if _mermaid_elk_js_url:
app.add_js_file(_mermaid_elk_js_url, priority=app.config.mermaid_js_priority, type="module")

if app.config.mermaid_init_js == _MERMAID_INIT_JS_DEFAULT:
# Update if esm is used and no custom init-js is provided
if _mermaid_elk_js_url:
# Add registration of ELK layouts
app.config.mermaid_init_js = f'import mermaid from "{_mermaid_js_url}";import elkLayouts from "{_mermaid_elk_js_url}";mermaid.registerLayoutLoaders(elkLayouts);{app.config.mermaid_init_js}';
else:
app.config.mermaid_init_js = f'import mermaid from "{_mermaid_js_url}";{app.config.mermaid_init_js}';

if app.config.mermaid_init_js:
# If mermaid is local the init-call must be placed after `html_js_files` which has a priority of 800.
priority = (
app.config.mermaid_init_js_priority if _mermaid_js_url is not None else 801
)
app.add_js_file(None, body=app.config.mermaid_init_js, priority=priority)

app.add_js_file(None, body=app.config.mermaid_init_js, priority=priority, type="module")

_wrote_mermaid_run = False
if app.config.mermaid_output_format == "raw":
if app.config.d3_use_local:
_d3_js_url = app.config.d3_use_local
elif app.config.d3_version == "latest":
_d3_js_url = "https://cdn.jsdelivr.net/npm/d3/dist/d3.min.js"
elif app.config.d3_version:
_d3_js_url = f"https://cdn.jsdelivr.net/npm/d3@{app.config.d3_version}/dist/d3.min.js"
app.add_js_file(_d3_js_url, priority=app.config.mermaid_js_priority)

if app.config.mermaid_d3_zoom:
_d3_js_url = "https://unpkg.com/d3/dist/d3.min.js"
_d3_js_script = """
window.addEventListener("load", function () {
var svgs = d3.selectAll(".mermaid svg");
svgs.each(function() {
var svg = d3.select(this);
svg.html("<g>" + svg.html() + "</g>");
var inner = svg.select("g");
var zoom = d3.zoom().on("zoom", function(event) {
inner.attr("transform", event.transform);
});
svg.call(zoom);
});
});
"""
app.add_js_file(_d3_js_url, priority=app.config.mermaid_js_priority)
app.add_js_file(None, body=_d3_js_script, priority=app.config.mermaid_js_priority)
_d3_js_script = _MERMAID_RUN_D3_ZOOM.format(_d3_selector=".mermaid svg", _mermaid_js_url=_mermaid_js_url)
app.add_js_file(None, body=_d3_js_script, priority=app.config.mermaid_js_priority, type="module")
_wrote_mermaid_run = True
elif doctree:
mermaid_nodes = doctree.findall(mermaid)
_d3_selector = ""
Expand All @@ -466,24 +539,12 @@ def install_js(
else:
_d3_selector += f", .mermaid#{_zoom_id} svg"
if _d3_selector != "":
_d3_js_url = "https://unpkg.com/d3/dist/d3.min.js"
_d3_js_script = f"""
window.addEventListener("load", function () {{
var svgs = d3.selectAll("{_d3_selector}");
svgs.each(function() {{
var svg = d3.select(this);
svg.html("<g>" + svg.html() + "</g>");
var inner = svg.select("g");
var zoom = d3.zoom().on("zoom", function(event) {{
inner.attr("transform", event.transform);
}});
svg.call(zoom);
}});
}});
"""
app.add_js_file(_d3_js_url, priority=app.config.mermaid_js_priority)
app.add_js_file(None, body=_d3_js_script, priority=app.config.mermaid_js_priority)
_d3_js_script = _MERMAID_RUN_D3_ZOOM.format(_d3_selector=_d3_selector, _mermaid_js_url=_mermaid_js_url)
app.add_js_file(None, body=_d3_js_script, priority=app.config.mermaid_js_priority, type="module")
_wrote_mermaid_run = True

if not _wrote_mermaid_run and _mermaid_js_url:
app.add_js_file(None, body=_MERMAID_RUN_NO_D3_ZOOM.format(_mermaid_js_url=_mermaid_js_url), priority=app.config.mermaid_js_priority, type="module")

def setup(app):
app.add_node(
Expand All @@ -505,16 +566,15 @@ def setup(app):
app.add_config_value("mermaid_verbose", False, "html")
app.add_config_value("mermaid_sequence_config", False, "html")

# Starting in version 10, mermaid is an "ESM only" package
# thus it requires a different initialization code not yet supported.
# So the current latest version supported is this
# Discussion: https://github.com/mermaid-js/mermaid/discussions/4148
app.add_config_value("mermaid_version", "10.2.0", "html")
app.add_config_value("mermaid_use_local", "", "html")
app.add_config_value("mermaid_version", "11.2.0", "html")
app.add_config_value("mermaid_elk_use_local", "", "html")
app.add_config_value("mermaid_include_elk", "0.1.4", "html")
app.add_config_value("mermaid_js_priority", 500, "html")
app.add_config_value("mermaid_init_js_priority", 500, "html")
app.add_config_value(
"mermaid_init_js", "mermaid.initialize({startOnLoad:true});", "html"
)
app.add_config_value("mermaid_init_js", _MERMAID_INIT_JS_DEFAULT, "html")
app.add_config_value("d3_use_local", "", "html")
app.add_config_value("d3_version", "7.9.0", "html")
app.add_config_value("mermaid_d3_zoom", False, "html")
app.connect("html-page-context", install_js)

Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

@pytest.fixture(scope='session')
def rootdir():
return path(__file__).parent.abspath() / 'roots'
return path(__file__).parent.abspath() / 'roots'
Loading

0 comments on commit d09a95d

Please sign in to comment.