Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support latest es module mermaid, add support for ELK diagrams #151

Merged
merged 2 commits into from
Sep 12, 2024
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["pypy3.8", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12-dev"]
python-version: ["pypy3.9", "3.9", "3.10", "3.11", "3.12"]
os: [windows-latest, macos-latest, ubuntu-latest]

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
Expand Down
40 changes: 33 additions & 7 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ Directive options

A preview after adding ``:zoom:`` option only to the first diagram example above:

``:config:``: JSON to pass through to the `mermaid configuration <https://mermaid.js.org/config/configuration.html>`_

``:title:``: Title to pass through to the `mermaid configuration <https://mermaid.js.org/config/configuration.html>`_


Config values
-------------
Expand All @@ -141,22 +145,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 +247,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"],
)
165 changes: 114 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,59 @@
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 all_mermaids = document.querySelectorAll(".mermaid");
const mermaids_to_add_zoom = {d3_node_count} === -1 ? all_mermaids.length : {d3_node_count};
const mermaids_processed = document.querySelectorAll(".mermaid[data-processed='true']");
if(mermaids_to_add_zoom > 0) {{
var svgs = d3.selectAll("{d3_selector}");
if(all_mermaids.length !== mermaids_processed.length) {{
// try again in a sec, wait for mermaids to load
setTimeout(load, 200);
return;
}} else if(svgs.size() !== mermaids_to_add_zoom) {{
// try again in a sec, wait for mermaids to load
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 +114,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 +156,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 +176,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 +187,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,70 +478,76 @@ 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", d3_node_count=-1, 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 = ""
count = 0
for mermaid_node in mermaid_nodes:
if "zoom_id" in mermaid_node:
_zoom_id = mermaid_node["zoom_id"]
if _d3_selector == "":
_d3_selector += f".mermaid#{_zoom_id} svg"
else:
_d3_selector += f", .mermaid#{_zoom_id} svg"
count += 1
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, d3_node_count=count, 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 +569,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