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:
# 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:
---
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:
- 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:
---
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:
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 —> Stop</p>
</pre>
</figure>
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:
---
title: Testing mermaid in Astro
---
import Mermaid from "@components/markdown/Mermaid.astro";
<Mermaid title="Does it work?">
`` `mermaid
flowchart LR
Start --> Stop
`` `
</Mermaid>
⚠️ Please remove the extra space from triple backticks - dev.to has really bad code block support that doesn't allow nesting. Consider reading this article on my website.
My 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 --> 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 handycopy
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.
...
<figure class="expandable-diagram">
<figcaption>{title}</figcaption>
<div class="diagram-content">Loading diagram...</div>
<details>
<summary>Source</summary>
<slot />
</details>
</figure>
- 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:
...
<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:
- 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 loaded
document.addEventListener("DOMContentLoaded", async () => {
extractAndCleanMermaidCode();
mermaid.initialize({startOnLoad: true});
});
</script>
...
I hope this will help you out in marrying Astro with Mermaid.js.
Top comments (0)