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:
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
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.
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. 🎉
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:
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.mermaidText
describes the markup that defines a diagram–I need to write that to a local, temporary file.mmdc
binary. This dumps the result of the file into a .svg
file.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:
markdownAST
, and we can use the visit
function from unist-util-visit to walk the tree. We are specifically looking for code nodes.mermaid
language, we've found what we're looking for.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.mermaid-code
as just mermaid
–then plugins like prismjs can provide syntax highlighting.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:
pluginOptions
in the function is the object that is passed to the options
key in a Gatsby config file.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:
transparent
).<style>
tags so that I can provide my own styles without a bunch of !important
statements everywhere.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:
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:
With this addition, our mermaid charts are now cached! 💾
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.
Get my posts in your feed of choice. Opt-out any time.
If you enjoyed this article, you might enjoy one of these: