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:
- You need to install a headless browser on your machine to be able to build your site
- It might prevent your site from building on CI/CD (like Cloudflare Pages)
- It might slow down your site’s build time significantly
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:
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:
Nothing special here:
- 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. - 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 aDOMContentLoaded
event listener for the mermaid renderer. - The mermaid renderer looks through the entire page for
<pre>
elements with themermaid
class. Including it here will ensure that the diagram code will be processed by mermaid. In my case I also need to add thenot-prose
class to remove some conflicts with my markdown styling. - 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:
And the results is:
This is where inspecting the page source comes in handy. This way we can see what Astro rendered before mermaid tried to process it:
There are several issues here:
- Our code is wrapped in
<p>
tag, confusing the hell out of mermaid - The double dash
--
has been replaced with an em dash—
which is not what mermaid expects - The
>
character has been replaced with>
which messes thing up even more
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:
- Pass the code as a string to the component - deal with manually adding
\n
to simulate new lines as HTML doesn’t support multiline arguments. - Load the diagrams as separate files using the
import
statement - don’t have everything in one place. - Go the crazy route and pass a code block to the component 🤪
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:
This blog uses Expressive Code] to render the code blocks, and therefore the page’s source code will look like this:
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:
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!
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.
- The whole component is wrapped in a
figure
element with aexpandable-diagram
class. This way we can easily find all instances of the component using CSS selectors. - The
div.diagram-content
element is where the diagram will be rendered. - The source buggon needs to be clicked by the user to reveal the code block.
- 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:
A lot is happening here, so let’s break it down:
- To prevent mermaid from processing the diagrams instantly, we need to postpone it’s initialization.
- We define the
extractMermaidCode
function to keep things somewhat organized. - 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. - Once we’re in our component, we can easily find the
copy
button as there’s only one. - Extracting the code is relatively simple.
- Of course there’s one more catch. The
copy
button contains adata-code
attribute with the new lines replaces withU+007f
character. We need to replace it with\n
for mermaid to understand it. - Now that we have the code, we can create a new
pre
element withmermaid
class. This is what the mermaid library will look for to render the diagram from. - We can replace the existing diagram content (
Loading diagram...
) with the newly createdpre
element. - We register our own
DOMContentLoaded
event listener that will allow us to run code only once the page is fully loaded. - 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:
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:
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:
...<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 loadeddocument.addEventListener("DOMContentLoaded", async () => { extractAndCleanMermaidCode(); mermaid.initialize({startOnLoad: true});});</script>...
I hope this will help you out in marrying Astro with Mermaid.js.