Adding Mermaid diagrams to Astro MDX
Tags: astro, webdev, markdown, mermaid
Published: 2024-07-05 21:17:34

Adding [Mermaid diagrams](https://mermaid.js.org/) 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](https://www.npmjs.com/package/mermaid) 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:

```bash
# 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:

```html
---
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:

```md
---
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6lv7cmzkrv4ff3s7f912.png)

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

```html
<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:
- 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 `&gt;` 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:

```md
---
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](https://xkonti.tech/blog/astro-mermaid-mdx).

[My blog](https://xkonti.tech/blog/astro-mermaid-mdx) uses [Expressive Code](https://expressive-code.com/)] to render the code blocks, and therefore the page's source code will look like this:

```html
<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:

```html
<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.

```html
...
<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:

```html
...
<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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v0037mocngkrmgxl3hbi.png)

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:

![Screenshot of the final version of the component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a03fcbbgn5y9hl6sgtpz.png)

## 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:

```html
...
<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.