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:

Mermaid then Prism
  graph LR

  A --> B
Prism then Mermaid
  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:

  1. Prism runs only on snippets that are to be syntax highlighted; Mermaid rendering must be disabled on them.
  2. 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:

Expected Highlighting
  graph LR

  A --> B
Expected Rendering
  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:

Default Behavior
Tiny diagram
  graph LR

  A --> B
Expected Behavior
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) of language-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 the open attribute
      (so it is not currently not-open, i.e. it is visible)

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:

Default Rendering
  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
Expected Rendering
  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:

  • Add support of “LaTeX” in the form of MathJax
  • Adding LaTeX math support via Katex

It looks like the Mermaid developers are working on adding KaTeX support, via this PR:

  • Mermaid diagrams don’t render inside closed Details elements

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:

  • Execute code after initialize

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) of language-mermaid class with data-processed attribute set to true,
    (so they have already been processed by Mermaid)
  • that contain svg elements having the prefix mermaid 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 suffix Label
    (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 in Label, or
  • span elements with their space-separated class list containing Label .

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:

  • ZOOM!!!
  • Ability to zoom HTML diagram
  • Add zoom and pan to mermaid diagrams

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:

This was enough to get me started! My approach is similar to the one outlined in the comment, but instead of awaiting 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.

Default
  graph LR

  A1 ---> B ---> C
  A2 ---> B
Demo
  graph LR

  A1 ---> B ---> C
  A2 ---> B
Expected
  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.

  1. I always wrap my code within {% raw %}{% endraw %}, to disable Liquid rendering inside it. In particular, for Mermaid, this is necessary to use {{ Node }} syntax for hexagonal nodes.