th.
Two people facing an art exhibit of a difficult to follow diagram connecting various blocks of text
Photo by charlesdeluvio on Unsplash.

Displaying Mermaid charts in Gatsby

24 Aug 2023 • 13 min read

I like diagrams. I find they do a great job explaining my thought processes and giving a better understanding to what are often abstract systems. The problem I have with most diagrams (and diagramming tools) is that they often have a short shelf-life. Diagrams are typically stored as flat files (e.g. a .jpg) and when the diagram becomes out of date, it becomes a chore to find the original file to update it. Diagramming tooling is often gated by logins and licenses. Because the files aren't text, it's also hard to describe differences between an original diagram and an updated diagram with a tool like Git.

In February 2022, GitHub added support for diagrams with Mermaid, a markdown-inspired diagramming and charting tool. GitHub's support of Mermaid introduced me to the tool, and I was thrilled when I discovered it. Mermaid solves the problems I had with diagramming tools, and because it uses text to describe a diagram, I can track changes with git. My coworkers can tell you that for a few months afterwards, I was constantly making Mermaid diagrams (maybe simply because I could).

I've wanted to start using diagrams in my writing on my blog, but I needed to first add support to it with my build tooling (Gatsby). However, I found that the existing tooling was a bit lacking and didn't suit my needs. I decided to roll up my sleeves and build support for it with the features I was looking for. In this article, I'll detail exactly how to build a Gatsby plugin that transforms Mermaid syntax like this:

flowchart TD
  a(Write mermaid)
  b(Generate a diagram)
  c(Profit)

  a --> b --> c

To this:

Write mermaid
Generate a diagram
Profit

gatsby-remark-mermaid

I first went looking for existing options and found gatsby-remark-mermaid. As I determined if this was the plugin I wanted to use, I learned a few things:

  • Mermaid needs to run in a browser to render the layout for a diagram. gatsby-remark-mermaid requires a path to an instance of chromium (managed by the user, not by the plugin) and launches chromium in headless mode, lays out the chart, and returns the svg (or takes a screenshot to make an image/pdf). I originally thought this was a dealbreaker as I wanted a pure-CLI approach–though the solution I arrived at does something similar!
  • The plugin is a thin wrapper around remark-mermaidjs, and because of the way the plugin is implemented, it doesn't support caching. I found this unnacceptable because rendering charts is slow–I wanted to render them once and then use a cached value in subsequent renders.
  • remark-mermaidjs doesn't look specifically for the mermaid header, but instead renders the entire page and grabs the mermaid charts. I wanted something a little more precise.

For these reasons, I decided to go looking for other solutions.

mermaid-cli

My goal was to find a command-line-based tool to render mermaid code to svg, which would enable me to integrate it as part of the Gatsby build process. I found mermaid-cli, a command line tool by the same folks who maintain mermaid.js. Instead of taking in a chromimum path, mermaid-cli utilizes puppeteer. Puppeteer downloads a chromium binary and uses that to do headless rendering, instead of using the local chromium installation.

I started playing around with mermaid-cli and initially tried the node style approach:

import {run} from '@mermaid-js/mermaid-cli';

await run('input.mmd', 'output.svg');

I couldn't seem to get this approach to work–it kept complaining about the file path. I moved on to the local node module approach:

yarn add @mermaid-js/mermaid-cli

./node_modules/.bin/mmdc -i input.mmd -o output.svg

This approach worked and I was able to successfully convert a mermaid file to svg. 🎉

Creating a local plugin

Now that I had succesfully used mermaid-cli, I wanted to integrate it with my build and development process with a Gatsby plugin. Gatsby makes local plugin development really easy–you essentially just need to follow the plugin guidelines and then your plugin "just works". I decided to follow the Gatsby naming conventions and name my plugin gatsby-remark-mermaid-to-svg. Then I followed the right directory structure:

/thetrevorharmon.com
└── gatsby-config.ts
└── /src
└── /plugins
    └── /gatsby-remark-mermaid-to-svg
        └── package.json
        └── index.mjs

By following this directory structure, I can add the plugin to my config file with just the name and Gatsby will automatically pick up the local plugin and...plug it in 😏...to my build process!

You'll notice that I chose to make a local plugin instead of a community plugin. I don't want the burden of maintaining a plugin for the community when I only need it for my blog. However, if you're passionate about this, please take this work and make it into a community plugin!

Now that I have the correct directory structure in place, we can start by creating a basic function that wraps mermaid-cli:

import fs from 'fs';
import {execSync} from 'child_process';
import {v4 as uuid} from 'uuid';

function generateSVGFromMermaid(mermaidText) {
  // 1
  const tempFilename = `${uuid()}.mmd`;

  // 2
  fs.writeFileSync(`/tmp/${tempFilename}`, mermaidText);

  // 3
  const command = [
    `./node_modules/.bin/mmdc`,
    `-i /tmp/${tempFilename}`,
    `-o /tmp/${tempFilename}.svg`,
  ].join(' ');

  execSync(command);

  // 4
  const svg = fs.readFileSync(`/tmp/${tempFilename}.svg`).toString();

  // 5
  fs.unlinkSync(`/tmp/${tempFilename}`);
  fs.unlinkSync(`/tmp/${tempFilename}.svg`);

  return svg;
}

This function:

  1. Generates a new temporary filename with the uuid() function. This ensures that I won't ever have naming collisions. Notice the .mmd file extension–that's the file extension for a standalone mermaid document.
  2. mermaidText describes the markup that defines a diagram–I need to write that to a local, temporary file.
  3. I assemble and execute a command against the mmdc binary. This dumps the result of the file into a .svg file.
  4. The results of the svg file are read into a string.
  5. Finally, we clean up the temporary files before returning the svg markup.

With this function that can take in mermaid text and returns a svg, we need to write the plugin function to connect this to Gatsby. The function will crawl a markdown document and when it encounters a mermaid code block, render the block to an svg. Here's the plugin function:

import {visit} from 'unist-util-visit';

export function gatsbyRemarkMermaidToSvg({markdownAST}) {
  // 1
  visit(markdownAST, 'code', (node) => {
    if (node == null) {
      return;
    }

    // 2
    if (node.lang === 'mermaid') {
      try {
        const svg = generateSVGFromMermaid(node.value);

        // 3
        node.type = 'html';
        node.lang = undefined;
        node.value = `<div className="Mermaid">${svg}</div>`;
      } catch {
        // 4
        console.error(
          'Could not convert mermaid to svg with value:',
          node.value,
        );
      }
    }

    // 5
    if (node.lang === 'mermaid-code') {
      node.lang = 'mermaid';
      return;
    }
  });

  return markdownAST;
}

Some details about this code:

  1. As I mentioned before, this parses mermaid diagrams within the context of markdown. I suppose that you could also define a parser for JSX, but this plugin will only work with markdown. Gatsby exposes a markdown as an abstract syntax tree called markdownAST, and we can use the visit function from unist-util-visit to walk the tree. We are specifically looking for code nodes.
  2. If we encounter a node with the mermaid language, we've found what we're looking for.
  3. After generating the svg, we re-assign the type to html. This tells Gatsby to now render this node as html instead of as a code block (which also prevents other plugins from treating it like a code block). We also assign a new value to it that includes the newly-minted svg.
  4. If we encounter an error somewhere along the way, we log it and don't change the type or value. This way we can still get a code block with syntax highlighting if the svg renderer fails.
  5. For cases where I want to show mermaid markup (instead of rendering an svg), I've added this extra check that will rewrite mermaid-code as just mermaid–then plugins like prismjs can provide syntax highlighting.

Adding extensibility

Now that we have a working plugin, let's add a little extensibility. Right now, we are stuck with the base mermaid theme and font. We also can't customize anything about the markup. Let's add a few options to our function:

// 1
const defaultOptions = {  wrapperClassName: 'Mermaid',  shouldRemoveDefaultStyling: true,  backgroundColor: 'transparent',  mermaidOptions: {    theme: 'base',    flowchart: {      useMaxWidth: true,    },    themeVariables: {      fontFamily: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif',    },  },};
// 2
export function gatsbyRemarkMermaidToSvg({markdownAST}, pluginOptions) {
  // 3
  const options = Object.assign({}, defaultOptions, pluginOptions);
  visit(markdownAST, 'code', (node) => {
    if (node && node.lang === 'mermaid') {
      try {
        const svg = generateSVGFromMermaid(node.value, options);

        node.type = 'html';
        node.lang = undefined;
        // 4
        node.value = `<div class=${options.wrapperClassName}>${svg}</div>`;      } catch {
        console.error(
          'Could not convert mermaid to svg with value:',
          node.value,
        );
      }
    }
  });

  return markdownAST;
}

I made the following changes:

  1. As with any sensible plugin, I'm defining default options that can be overriden in the Gatsby config file. You'll see how each of these are used in the code.
  2. The pluginOptions in the function is the object that is passed to the options key in a Gatsby config file.
  3. I take the options passed in and combine them with the defaults (as a fallback) so we always have a complete config object.
  4. The wrapperClassName option is the classname of the div that wraps the svg—in this case, it defaults to Mermaid.

Now that we've added plugin options, we can pass them through to our svg generator function:

function generateSVGFromMermaid(mermaidText, options) {  const tempFilename = `${uuidv4()}.mmd`;

  // 1
  fs.writeFileSync(    `/tmp/${tempFilename}.json`,    JSON.stringify(options.mermaidOptions, undefined, 2),  );
  fs.writeFileSync(`/tmp/${tempFilename}`, mermaidText);

  const command = [
    `./node_modules/.bin/mmdc`,
    `-i /tmp/${tempFilename}`,
    `-o /tmp/${tempFilename}.svg`,
    // 2
    `--backgroundColor ${options.backgroundColor}`,    `--configFile /tmp/${tempFilename}.json`,  ].join(' ');

  // Convert the mermaid text to an SVG using mermaid-cli
  execSync(command);

  // Read the SVG file into a string
  const svgString = fs.readFileSync(`/tmp/${tempFilename}.svg`).toString();

  // Clean up the temp files
  fs.unlinkSync(`/tmp/${tempFilename}`);
  fs.unlinkSync(`/tmp/${tempFilename}.svg`);
  fs.unlinkSync(`/tmp/${tempFilename}.json`);

  // 3
  if (options.shouldRemoveDefaultStyling) {    const svgStringWithoutStyle = svgString.replace(      /(<style>[\s\S]+<\/style>)/,      '',    );    return svgStringWithoutStyle;  }
  return svgString;
}

Some details of this code:

  1. mermaid-cli requires us to pass in the options as a path to a file, so we stringify and save the mermaid options to a config file.
  2. In addition to the mermaid configuration options, mermaid-cli gives us an option for the background color of the rendered webpage (which I've set to transparent).
  3. I find the default mermaid styling to be a bit...lacking. Additionally, none of the themes match the theme of my blog. Unfortunately, mermaid-cli doesn't give us an option to opt out of the theme and always embeds the styles as part of the svg. I added a simple regex replace that removes anything between <style> tags so that I can provide my own styles without a bunch of !important statements everywhere.

Caching the generated svgs

Once I had this initial implementation working and my mermaid charts were rendered, I thought I was done. However, as I started to write an article that contained a few diagrams, I found that the live preview performed poorly. I started to notice how slow the update cycle was and working on the article felt sluggish. I figured that caching was the solution to my problem, and fortunately Gatsby makes interacting with the cache very straightforward–it's one of the arguments that is passed to a plugin!

In order to cache a mermaid block, I needed a way to create a unique id for each mermaid block, as there is no "default" unique id for a markdown code block. The solution I arrived at was to hash the content of the block and use the hash as the cache key. If the diagram remains the same, the hash stays the same and is fetched from the cache. If it changes, the hash changes and a chart is generated and then cached.

The downside of this approach is that it creates orphaned svgs in the cache (since I don't keep a running list of all of the mermaid blocks). I think this is an acceptable tradeoff since clearing the cache is easy and fairly quick.

Here's the logical flow I wanted to implement:

Yes
No
Enter Mermaid node
Create hash of
markup to use
as a key
Is there
a value in
the cache?
Generate
a new svg
Use the value
in the cache
Update the
node value
with the svg
Save the svg
to the cache

In order to implement this logical flow into our plugin, we first need a helper function to generate our cache key:

function getNodeKey(node, markdownNode) {
  const contentHash = crypto
    .createHash('sha256')
    .update(node.value)
    .digest('hex');
  return `${markdownNode.frontmatter.slug}__Mermaid__${contentHash}`;
}

I'm using the node.js crypto module to perform the hash. The two arguments are named similarly but are distinct–the node describes the node of the markdown syntax tree that we walk, whereas markdownNode describes the Mdx object generated by gatsby-plugin-mdx. I don't love that they are so similarly named, but markdownNode is named by Gatsby and I figured it made sense to go with their naming conventions.

Now that we have a helper function, our plugin can change to support caching:

export async function gatsbyRemarkMermaidToSvg(
  {markdownAST, markdownNode, cache},  pluginOptions,
) {
  const options = Object.assign({}, defaultOptions, pluginOptions);

  visit(markdownAST, 'code', async (node) => {
    if (node == null) {
      return;
    }

    if (node.lang === 'mermaid-code') {
      node.lang = 'mermaid';
      return;
    }

    if (node.lang === 'mermaid') {
      // 1      const nodeKey = getNodeKey(node, markdownNode);      // 2      const cachedValue = await cache.get(nodeKey);      let svg = node.value;      // 3      if (cachedValue) {        svg = JSON.parse(cachedValue).svg;      } else {        try {          svg = generateSVGFromMermaid(node.value, options);        } catch {          console.error(            'Could not convert mermaid to svg with value:',            node.value,          );        }      }      node.type = 'html';      node.lang = undefined;      node.value = `<div class=${options.wrapperClassName}>${svg}</div>`;      // 4      if (svg && svg !== node.value) {        await cache.set(nodeKey, JSON.stringify({svg}));      }    }
  });

  return markdownAST;
}

As mentioned before, Gatsby makes interacting with the cache really easy. In this updated code, we:

  1. Get a cache key using the helper from earlier
  2. Look to see if a cached svg exists for this node
  3. Either assign the cached value or generate a new value
  4. Save the value into the cache to use later

With this addition, our mermaid charts are now cached! 💾

Make chart
Save to cache

Conclusion

I found making a local plugin in Gatsby to be very straightforward–the fact you can simply conform to a directory structure and then be off to the races is :chefs-kiss:. If you've made it this far and you're looking for a simple copy+paste, feel free to check out the plugin code on GitHub.

Reply to this post on Twitter
© 2024 Trevor Harmon