DEV Community

Xkonti
Xkonti

Posted on • Originally published at xkonti.tech

Adding Mermaid diagrams to Astro MDX

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

---
title: Testing mermaid in Astro
---

import Mermaid from "@components/markdown/Mermaid.astro";

<Mermaid title="Does it work?">
flowchart LR
    Start --> Stop
</Mermaid>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

---
title: Testing mermaid in Astro
---

import Mermaid from "@components/markdown/Mermaid.astro";

<Mermaid title="Does it work?">
`` `mermaid
flowchart LR
    Start --> Stop
`` `
</Mermaid>
Enter fullscreen mode Exit fullscreen mode

⚠ïļ 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 --&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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

...
<figure class="expandable-diagram">
  <figcaption>{title}</figcaption>

  <div class="diagram-content">Loading diagram...</div>

  <details>
    <summary>Source</summary>
    <slot />
  </details>
</figure>
Enter fullscreen mode Exit fullscreen mode
  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:

...
<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>
...
Enter fullscreen mode Exit fullscreen mode

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:

Screenshot of the final version of the component

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>
...
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)