Skip to content

Adding Mermaid diagrams to Astro MDX

Posted on: July 5, 2024

Adding Mermaid diagrams to Astro pages has a surprising number of challenges. Most of the solutions out there rely on using headless browsers to render the diagrams. This approach has a few drawbacks:

The reason for the popularity of this approach is that Mermaid.js relies on browser APIs to lay the diagrams out. Astro doesn’t have access to these APIs, so it can’t render the diagrams directly.

Fortunately, there’s another option: rendering the diagrams on the client side. Definitely not ideal, as suddenly our pages won’t be fully pre-rendered, but in case only some of your pages have diagrams, it’s still a viable option. Especially that it doesn’t require any additional setup.

High level overview

The idea is to use the official mermaid package directly and let it render the diagrams on the client side. In my case I want to have a diagram as a separate component to add some additional functionality, like the ability to display the diagram’s source code. This decision has one side effect: The component won’t work in pure Markdown files. It will only work in MDX files.

To make it work in pure Markdown files, one would need to create a rehype/remark plugin, but I didn’t feel like it was worth the effort - I use MDX files for everything as it provides more functionality.

Building the component

First we need to install the mermaid package:

Terminal window
# npm
npm install mermaid
# pnpm
pnpm add mermaid

Now let’s create the component. It will be an Astro component as we don’t need any additional framework functionality for this. Let’s call it Mermaid.astro - I placed in in stc/components/markdown folder:

Mermaid.astro
---
export interface Props {
title?: string;
}
const { title = "" } = Astro.props;
---
<script>
import mermaid from "mermaid";
</script>
<figure>
<figcaption>{title}</figcaption>
<pre class="mermaid not-prose">
<slot />
</pre>
</figure>

Nothing special here:

  1. We make the component accept a title prop so that we can display a nice title - relying on mermaid’s built-in titles itn’t optimal as the title will show up in various sizes depending on the diagram’s size.
  2. We add a script that will import the mermaid package on the client side. It’s worth noting that Astro will include that script only once on the page no matter how many times we use the component. Simply including the mermaid will register a DOMContentLoaded event listener for the mermaid renderer.
  3. The mermaid renderer looks through the entire page for <pre> elements with the mermaid class. Including it here will ensure that the diagram code will be processed by mermaid. In my case I also need to add the not-prose class to remove some conflicts with my markdown styling.
  4. The <slot /> element will be replaced with the mermaid code wrapped by this component.

Now let’s try to use it in an MDX file:

mermaid-text.mdx
---
title: Testing mermaid in Astro
---
import Mermaid from "@components/markdown/Mermaid.astro";
<Mermaid title="Does it work?">
flowchart LR
Start --> Stop
</Mermaid>

And the results is:

Screenshot showing mermaid complaining about a syntax error

This is where inspecting the page source comes in handy. This way we can see what Astro rendered before mermaid tried to process it:

<figure>
<figcaption>Does it work?</figcaption>
<pre class="mermaid not-prose">
<p>flowchart LR
Start —&gt; Stop</p>
</pre>
</figure>

There are several issues here:

What could have caused this? Markdown.

When the MDX page is rendered, all that is not explicitly an MDX element, is processed by markdown. This includes everything wrapped in the <Mermaid> component. Markdown saw some text - it marked it as a paragraph, escaped the scary characters (>), and then prettyfied it by consolidating the dashes.

Solving the issue

There are several ways to solve this issue:

Of course I went for the last one. It might sound like a great idea, but depending on the way your setup renders the code blocks, it might be a bit of a pain to deal with. Let’s try it:

mermaid-text.mdx
---
title: Testing mermaid in Astro
---
import Mermaid from "@components/markdown/Mermaid.astro";
<Mermaid title="Does it work?">
```mermaid
flowchart LR
Start --> Stop
```
</Mermaid>

This blog uses Expressive Code] to render the code blocks, and therefore the page’s source code will look like this:

<figure>
<figcaption>Does it work?</figcaption>
<pre class="mermaid not-prose">
<div class="expressive-code">
<figure class="frame">
<figcaption class="header"></figcaption>
<pre data-language="mermaid">
<code>
<div class="ec-line">
<div class="code">
<span style="--0:#B392F0;--1:#24292E">flowchart LR</span>
</div>
</div>
<div class="ec-line">
<div class="code">
<span class="indent">
<span style="--0:#B392F0;--1:#24292E"> </span>
</span>
<span style="--0:#B392F0;--1:#24292E">Start --&gt; Stop</span>
</div>
</div>
</code>
</pre>
<div class="copy">
<button
title="Copy to clipboard"
data-copied="Copied!"
data-code="flowchart LR Start --> Stop"
>
<div></div>
</button>
</div>
</figure>
</div>
</pre>
</figure>

Wow. This added a bit more markup to the page… but what’s that? A copy button? How does that work? Take a look at it’s markup:

<button
title="Copy to clipboard"
data-copied="Copied!"
data-code="flowchart LR Start --> Stop"
>
<div></div>
</button>

That’s the whole source code of our diagram in a pleasant HTML argument string. It’s easy to extract it and give it to mermaid on the client side. Let’s modify our Mermaid.astro component to do exactly that!

No copy button?

If you’re not using Expressive Code and your code blocks don’t have the handy copy button, I included an alternative code snipped at the end of the article.

Preparing the component

First, let’s rework the component’s HTML markup. We’ll wrap it in a figure element and place the code block indside a details element. This way we can hide the code block by default and show it only when the user clicks on the Source button.

Mermaid.astro
...
<figure class="expandable-diagram">
<figcaption>{title}</figcaption>
<div class="diagram-content">Loading diagram...</div>
<details>
<summary>Source</summary>
<slot />
</details>
</figure>
  1. The whole component is wrapped in a figure element with a expandable-diagram class. This way we can easily find all instances of the component using CSS selectors.
  2. The div.diagram-content element is where the diagram will be rendered.
  3. The source buggon needs to be clicked by the user to reveal the code block.
  4. The slot element will be replaced with the code block rendered by Expressive Code.

Extracting the source code

Now let’s rewrite our script to extract the code from the copy button and place it in the .diagram-content element:

Mermaid.astro
...
<script>
import mermaid from "mermaid";
// Postpone mermaid initialization
mermaid.initialize({ startOnLoad: false });
function extractMermaidCode() {
// Find all mermaid components
const mermaidElements = document.querySelectorAll("figure.expandable-diagram");
mermaidElements.forEach((element) => {
// Find the `copy` button for each component
const copyButton = element.querySelector(".copy button");
// Extract the code from the `data-code` attribute
let code = copyButton.dataset.code;
// Replace the U+007f character with `\n` to simulate new lines
code = code.replace(/\u007F/g, "\n");
// Construct the `pre` element for the diagram code
const preElement = document.createElement("pre");
preElement.className = "mermaid not-prose";
preElement.innerHTML = code;
// Find the diagram content container and override it's content
const diagramContainer = element.querySelector(".diagram-content");
diagramContainer.replaceChild(preElement, diagramContainer.firstChild);
});
}
// Wait for the DOM to be fully loaded
document.addEventListener("DOMContentLoaded", async () => {
extractMermaidCode();
mermaid.initialize({ startOnLoad: true });
});
</script>
...

A lot is happening here, so let’s break it down:

  1. To prevent mermaid from processing the diagrams instantly, we need to postpone it’s initialization.
  2. We define the extractMermaidCode function to keep things somewhat organized.
  3. The script will be executed only once per page, so we need to find all instances of our Mermaid component. This way we can process them all at once.
  4. Once we’re in our component, we can easily find the copy button as there’s only one.
  5. Extracting the code is relatively simple.
  6. Of course there’s one more catch. The copy button contains a data-code attribute with the new lines replaces with U+007f character. We need to replace it with \n for mermaid to understand it.
  7. Now that we have the code, we can create a new pre element with mermaid class. This is what the mermaid library will look for to render the diagram from.
  8. We can replace the existing diagram content (Loading diagram...) with the newly created pre element.
  9. We register our own DOMContentLoaded event listener that will allow us to run code only once the page is fully loaded.
  10. Finally, we call our extractMermaidCode function to prep the HTML for mermaid and render the diagrams.

Phew! What was a lot of code, but it’s not the worst. Let’s save it and refgresh the page:

Screenshot showing the diagram displaying properly

That’s much better! The only thing left is to modify it a bit to make it look better. This is after a light dressing up with Tailwind to fit this blog’s theme:

What do you think?
Loading diagram...
Source
graph TD
A{{Does it suck?}} -->|Yes| B[It could be much worse]
A -->|No| C[I'm glad you like it!]
B --> D[[Let me know in the comments!]]
C --> D

In case you’re not using Expressive Code

If you’re not using Expressive Code and your code blocks don’t have the copy button, there’s always a different way. I know it sounds crazy, but you could try to go over all the spans rendered by the code block and collect the characters from there. After some fiddling with ChatGPT, here’s an example of this approach in action that worked for well me:

Mermaid.astro
...
<script>
import mermaid from "mermaid";
mermaid.initialize({ startOnLoad: false });
function extractAndCleanMermaidCode() {
const mermaidElements = document.querySelectorAll("figure.expandable-diagram");
mermaidElements.forEach((element) => {
// Find the code element within the complex structure
const codeElement = element.querySelector(
'pre[data-language="mermaid"] code'
);
if (!codeElement) return;
// Extract the text content from each line div
const codeLines = codeElement.querySelectorAll(".ec-line .code");
let cleanedCode = Array.from(codeLines)
.map((line) => line.textContent.trim())
.join("\n");
// Remove any leading/trailing whitespace
cleanedCode = cleanedCode.trim();
// Create a new pre element with just the cleaned code
const newPreElement = document.createElement("pre");
newPreElement.className = "mermaid not-prose";
newPreElement.textContent = cleanedCode;
// Find the diagram content container
const diagramContentContainer = element.querySelector(".diagram-content");
// Replace existing diagram content child with the new pre element
diagramContentContainer.replaceChild(newPreElement, diagramContentContainer.firstChild);
});
}
// Wait for the DOM to be fully loaded
document.addEventListener("DOMContentLoaded", async () => {
extractAndCleanMermaidCode();
mermaid.initialize({startOnLoad: true});
});
</script>
...

I hope this will help you out in marrying Astro with Mermaid.js.

👍 4 likes on dev.to 👍 be the first one to comment on dev.to