I recently learned about Mermaid,
while I was searching for libraries to render some diagrams
in my previous blog post.
And I instantly fell in love with Mermaid,
and its Markdown-style syntax for creating diagrams.
I neither had to worry about the HTML <canvas>
or <svg>
syntax, nor the JS/CSS for the diagrams.
Mermaid just works, right out of the box, and beautifully so!
However, since I was already using Prism for syntax highlighting, and MathJax for rendering math on my blog, I quickly ran into a few issues in getting all of the to play nice with each other. Given how popular each of these libraries is, I was hoping to find some answers on StackOverflow or or their GitHub repos, but unfortunately I didn’t any solutions that worked for me. Since I spent a few hours tweaking various configs and finally got everything to work nicely together, in less than 100 lines of changes, I thought I’d document my journey in this blog post.
tl;dr for the impatient
<html>
<head>
...
<!-- Disable Prism on snippets with `.no-highlight` -->
<script data-reject-selector=".no-highlight *" src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/filter-highlight-all/prism-filter-highlight-all.min.js">
</script>
</head>
<body>
<script>
/* Enable Mermaid only on `.language-mermaid` snippets
with `.no-highlight` that are not inside collapsed `<details markdown='1'> */
const mmdCodeSelector =
"pre.no-highlight code.language-mermaid:not(details:not([open]) .language-mermaid)";
/* Selector for node and edge labels for MathJax typesetting */
const mmdLabelSelector =
"code.language-mermaid[data-processed='true'] svg[id^='mermaid'] span[class$='Label'], \
code.language-mermaid[data-processed='true'] svg[id^='mermaid'] span[class*='Label ']";
function SVGPanZoomResize (panZoom) {
panZoom.resize().fit().center();
}
/* Mermaid callback for MathJax typesetting and SVG Pan Zoom */
function mmdCallback (svgId) {
MathJax.typeset(document.getElementById(svgId).querySelectorAll(mmdLabelSelector));
svg.style.maxWidth = 'unset';
const oldHeight = svg.getBoundingClientRect().height;
const panZoom = svgPanZoom(svg, { controlIconsEnabled: true });
svg.style.height = oldHeight + 'px';
SVGPanZoomResize(panZoom);
}
document.addEventListener('DOMContentLoaded', function () {
mermaid.initialize({ startOnLoad: false });
mermaid.run({ querySelector: mmdCodeSelector, postRenderCallback: mmdCallback });
/* Resize, fit and center diagrams on window resize */
window.addEventListener("resize", function () {
Array.prototype.forEach.call(
document.querySelectorAll('svg > g.svg-pan-zoom_viewport'),
g => {
const svg = g.parentElement;
const panZoom = svgPanZoom(svg);
SVGPanZoomResize(panZoom);
const vh = g.getBoundingClientRect().height
, vw = g.getBoundingClientRect().width;
svg.style.height = (svg.getBoundingClientRect().width * vh / vw) + 'px';
SVGPanZoomResize(panZoom);
}
);
});
/* Render Mermaid diagrams inside collapsed `<details markdown='1'> only when they are opened */
Array.prototype.forEach.call(
document.getElementsByTagName('details'),
d => d.addEventListener(
"toggle",
(e) => mermaid.run({
nodes: d.querySelectorAll(mmdCodeSelector),
postRenderCallback: mmdCallback
})
))
});
...
</script>
</body>
</html>
Suppressing Prism
The first issue I had to tackle was turning off the syntax highlighting from Prism, because Mermaid didn’t seem to like syntax-highlighted Mermaid code. I show an example in the right-most column below:
graph LR
A --> B
graph LR
A --> B
In the left-most column, I show result when Mermaid successfully renders a diagram but then Prism picks it up again for syntax highlighting. It became clear that Prism and Mermaid must run mutually exclusively:
- Prism runs only on snippets that are to be syntax highlighted; Mermaid rendering must be disabled on them.
- Mermaid runs only on snippets that are to be rendered as diagrams; Prism syntax highlighting must be disabled on them.
Here are the expected results when neither interferes with the other:
graph LR
A --> B
graph LR
A --> B
I took some inspiration from this old (2019) PR, in particular from the following snippet:
<script>
/*
Custom code from Emanote to selectively skip class="language-mermaid"
*/
(function() {
var elements = document.querySelectorAll('pre > code[class*="language"]:not(.language-mermaid)');
for (var element of elements) {
Prism.highlightElement(element, false);
}
})();
</script>
Prism now provides a plugin
for easily filtering elements to selectively syntax highlight,
but essentially the authors chose to disable Prism on any <code>
snippet that has language-mermaid
class.
That solves the conflict with Mermaid, but also closes the door to syntax highlighting Mermaid snippets!
I wanted to implement something a bit more flexible.
I started using a new class no-highlight
to suppress Prism syntax highlighting selectively.
Using the filterHighlightAll
plugin, it’s a simple one-liner:
<script data-reject-selector=".no-highlight *"
src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/filter-highlight-all/prism-filter-highlight-all.min.js">
</script>
The data-reject-selector
attribute set to .no-highlight *
instructs Prism to suppress syntax highlighting within subtrees under nodes with the no-highlight
class.
So now we have handled the Prism side of the issue —
we can use this class on our snippets to stop Prism from interfering with Mermaid rendering.
Adding a class is also super easy in Markdown:1
```mermaid {% raw %}
graph LR
A --> B
{% endraw %} ```
{: .no-highlight }
The final piece of the puzzle is to turn off Mermaid rendering
on snippets that are to be syntax highlighted — those that don’t have the .no-highlight
class.
We achieve this with the following event listener that initializes Mermaid
on DOMContentLoaded
:
document.addEventListener('DOMContentLoaded', function () {
mermaid.initialize({ startOnLoad: false });
mermaid.run({ querySelector: 'pre.no-highlight code.language-mermaid' });
});
We turn off the automatic rendering on load by setting startOnLoad
to false
,
and manually invoke mermaid.run
with a querySelector
that filters out snippets with .no-highlight
class.
Rendering Inside <details>
The next issue on my plate was debugging invisible Mermaid diagrams inside <details>
This was only onbserved on Firefox (my default web browser on PC) though;
maybe Chrome has some special sauce that somehow mitigates this issue.
On Firefox, I saw no errors on the console,
and DOM inspection revealed that diagrams were indeed being generated!
But, they were super, ultra, tiny!
Here’s an example:
Tiny diagram
graph LR
A --> B
Good diagram
graph LR
A --> B
This issue has been observed on other collapsible elements (tabs, sections etc.), and has been reported across several repos:
- mermaid.init() on hidden items is not displaying labels
- Chart does not render if parent element is not displayed
- Mermaid rendering but not appearing within Content Tabs
- Mermaid diagrams don’t render inside closed Details elements
- Mermaid charts don’t render in hidden elements (tabs, collapsible sections)
However, I was unable to find a satisfactory solution in these reports.
I think the only working solution I found
was in a comment
on one of the bug reports.
I didn’t like the idea of re-initializing Mermaid every time though.
Instead, I chose to use the mermaid.run
API to render a collapsed element only when it’s expanded.
Extending the DOMContentLoaded
listener from the previous section,
we now have:
const mmdCodeSelector =
"pre.no-highlight code.language-mermaid:not(details:not([open]) .language-mermaid)";
document.addEventListener('DOMContentLoaded', function () {
mermaid.initialize({ startOnLoad: false });
mermaid.run({ querySelector: mmdCodeSelector });
Array.prototype.forEach.call(
document.getElementsByTagName('details'),
d => d.addEventListener(
"toggle",
(e) => mermaid.run({ nodes: d.querySelectorAll(mmdCodeSelector) })
)
)
});
The mmdCodeSelector
identifies code
elements to be rendered,
which are currently visible (not within a collapsed details
).
In line 6, we locate all such code
elements under body
,
and perform the first round of rendering right after DOM initialization.
In line 9, we defer the rendering of the collapsed code
elements,
until a toggle
event is triggered on an an ancestor details
element.
Upon receiving this trigger, we use our selector again
to locate and render newly visible code
elements
only under the toggled details
element.
This only happens once per details
element —
a collapsed rendered diagram is not re-rendered when opened again.
The querySelector
is now significantly longer and complex,
with nested CSS :not
pseudoselectors.
So let’s go over it carefully.
The following graph outlines the DOM hierarchy that the selector searches for:
graph LR
classDef ghost fill:#E5FFAA,stroke:#A6F100
classDef tag fill:#FFE9EA,stroke:#FF787E
root(root):::ghost
subgraph pre
direction TB
A{{pre}}:::tag
B(.no-highlight)
A --- B
end
subgraph code
direction TB
C{{code}}:::tag
D(.language-mermaid)
E(:not)
C --- D --- E
end
subgraph details
direction TB
F{{details}}:::tag
G(:not)
H("[open]")
F --- G --o H
end
style details fill:#ECECEC,stroke:#787878,stroke-dasharray:4 4
root -..-> details -..-> pre -..-> code
E ---o details
In a nutshell, the selector identifies:
code
element(s) oflanguage-mermaid
class,
(so they are expected to have Mermaid content in it)- that are under some
pre
element of.no-highlight
class,
(so they are expected to be rendered, not syntax highlighted) - that does
:not
have:- a
details
ancestor that does:not
currently have theopen
attribute
(so it is not currently not-open, i.e. it is visible)
- a
Rendering MathJax
The next issue is a bit of a niche one, but I was surprised to see it reported on Mermaid repo before — Mermaid does not render math, or more specifically LaTeX, inside labels. For example:
graph LR
classDef invisible fill:black,stroke:black
A($x = y$)
B($y = z$)
X( ):::invisible
C($x = z$)
A --- X
B --- X
X -- $\mathrm{\ transitivity\ }$ --> C
graph LR
classDef invisible fill:black,stroke:black
A($x = y$)
B($y = z$)
X( ):::invisible
C($x = z$)
A --- X
B --- X
X -- $\mathrm{\ transitivity\ }$ --> C
The two most popular libraries for rendering LaTeX on the web are KaTeX and MathJax. And neither works out of the box with Mermaid. I came across the following bug reports on their repo:
It looks like the Mermaid developers are working on adding KaTeX support, via this PR:
But from what I understood from the changes,
I think they are adding the rendering logic to Mermaid,
and trying to avoid external dependencies.
I the mean time, I could come up with a pretty easy fix,
using Mermaid’s secret postRenderCallback
option.
I didn’t find anything in the Mermaid 10.x documentation regarding this function,
but a few searches for “mermaidjs callback support” took me to this bug report:
where a kind stranger showed an example
usage for postRenderCallback
.
The idea is to invoke MathJax typesetting on each node and edge label,
after Mermaid is done rendering a diagram.
And since this is to be done after Mermaid rendering,
we don’t even need to weaken our securityLevel
.
To achieve this, once again, we extend our DOMContentLoaded
listener:
const mmdCodeSelector =
"pre.no-highlight code.language-mermaid:not(details:not([open]) .language-mermaid)";
const mmdLabelSelector =
"code.language-mermaid[data-processed='true'] svg[id^='mermaid'] span[class$='Label'], \
code.language-mermaid[data-processed='true'] svg[id^='mermaid'] span[class*='Label ']";
function mmdCallback (svgId) {
MathJax.typeset(document.getElementById(svgId).querySelectorAll(mmdLabelSelector));
}
document.addEventListener('DOMContentLoaded', function () {
mermaid.initialize({ startOnLoad: false });
mermaid.run({
querySelector: mmdCodeSelector,
postRenderCallback: mmdCallback,
});
Array.prototype.forEach.call(
document.getElementsByTagName('details'),
d => d.addEventListener(
"toggle",
(e) => mermaid.run({
nodes: d.querySelectorAll(mmdCodeSelector),
postRenderCallback: mmdCallback,
})
)
)
});
Other than the callback stuff, which is self-explanatory,
one interesting bit here is the mmdLabelSelector
,
which finds:
code
element(s) oflanguage-mermaid
class withdata-processed
attribute set totrue
,
(so they have already been processed by Mermaid)- that contain
svg
elements having the prefixmermaid
in their id,
(just to be extra sure that we are entering a Mermaid-rendered svg) - that contain
span
elements of a class with the suffixLabel
(so it is some label, typically NodeLabel or EdgeLabel, in a Mermaid diagram)
The selector looks long and ugly because
Selecting elements with a particular class-name suffix is a bit hacky
because of limitations of the CSS specification.
Essentially, under the appropriate code
and svg
elements, we look for
span
elements with their space-separated class list ending inLabel
, orspan
elements with their space-separated class list containingLabel
.
Pan & Zoom Support
This last section is more of an enhancement, rather than an issue. A reader who is on a mobile device might have already noticed that some of the diagrams on this page appear too small on their device. So, I wanted to add touch-based pan and zoom support to my SVGs. This is already available in Mermaid’s live editor, so I was hoping to find some config option to enable it in diagrams outside of the live editor as well. Unfortunately, I did not. My searches led me to the following bug reports:
All three of these are open bug reports with no assigned PR yet, so maybe we won’t have native pan and zoom support in Mermaid any time soon ☹ However, I was able to find the following:
- a comment on one of the bug reports, where a kind stranger shares a working example using the svg-pan-zoom library
- this PR, in which the Mermaid devs added pan and zoom to the live editor
This was enough to get me started!
My approach is similar to the one outlined in the comment,
but instead of await
ing on Mermaid to finish render
,
and then adding svgPanZoom
to the rendered SVG,
I decided to do it in the callback function:
function SVGPanZoomResize (panZoom) {
panZoom.resize().fit().center();
}
function mmdCallback (svgId) {
MathJax.typeset(document.getElementById(svgId).querySelectorAll(mmdLabelSelector));
svg.style.maxWidth = 'unset';
const oldHeight = svg.getBoundingClientRect().height;
const panZoom = svgPanZoom(svg, { controlIconsEnabled: true });
svg.style.height = oldHeight + 'px';
SVGPanZoomResize(panZoom);
}
Other than adding svgPanZoom
to our rendered svg
,
there is one other interesting bit here.
The svg-pan-zoom library has a known issue
that breaks the SVG’s height – it clips the height to 150px.
To fix this, I save the old height (after Mermaid’s rendering) in line 9,
and in line 11 set this as the new height after adding svgPanZoom
.
Finally, I resize, fit and center the image in line 12, since the height is changed.
So far so good! The svg-pan-zoom library is quite small and takes care of basic pan and zoom. However, the diagrams are no longer responsive — resizing a page, doesn’t automatically resize the diagrams if needed. Thankfully, the library authors have considered this use case, and have provided a demo that shows how to achieve this. However, the approach in this demo doesn’t adjust the SVG container’s height.
I compare the default behavior, the demo approach,
and my expected behavior below.
I have added black borders to the svg
container element,
so we can observe its dimensions compared to the dimensions of the diagram.
graph LR
A1 ---> B ---> C
A2 ---> B
graph LR
A1 ---> B ---> C
A2 ---> B
graph LR
A1 ---> B ---> C
A2 ---> B
On resizing the page, specifically on shrinking the page width, we observe that:
- the diagram in the left column isn’t resized, and overflows out of the column
- the diagram in the center column is resized,
but its container
svg
’s height isn’t - the diagram and its container
svg
’s height in the right column are resized, thus leaving no additional padding around it
To observe this issue on a mobile device, try loading this page in landscape mode and then rotate your phone to portrait mode to shrink the page width.
Fortunately, this issue is pretty easy to fix. I list my window resize event listener below:
window.addEventListener("resize", function () {
Array.prototype.forEach.call(
document.querySelectorAll('svg > g.svg-pan-zoom_viewport'),
g => {
const svg = g.parentElement;
const panZoom = svgPanZoom(svg);
SVGPanZoomResize(panZoom);
const vh = g.getBoundingClientRect().height
, vw = g.getBoundingClientRect().width;
svg.style.height = (svg.getBoundingClientRect().width * vh / vw) + 'px';
SVGPanZoomResize(panZoom);
}
);
});
Line 7 is what the library authors suggest in their demo.
In lines 8 and 9, I grab the viewport’s (i.e., the actual diagram’s) dimensions,
and in line 10, I scale the container svg
’s height proportionately.
Finally, in line 11, I resize, fit and center the diagram
inside the adjusted container.