Adding Support for Inline DOT to Blahs

Published 2021‑11‑07 Last Modified 2022‑06‑06

One of the things I like about having my own homemade website is that I can do whatever I want with it. I suppose that's rather banal, but hey, it's my blog and I can say what I want on it.

Sometimes, what I want to say (not that I say many things on here...) is a diagram. In graduate school I made custom graphics using TikZ, which is amazing for making arbitrary production-quality graphics, but is hardly a casual tool. Another tool, more appropriate for casual usage, is Graphviz and the DOT language for laying out graphs. Since I mostly think about software these days, and many things in software are graphs, it would be nice to be able to have a clean, simple way to get a DOT graph into a blog post.

Update: I've switched this website to use React! I've discarded the workflow described below in favor of a pure-Javascript solution; see the update at the end of the post. The diagrams in this section don't work anymore!

So how do I do that? I store these posts as Markdown and render them using @ts-stack/markdown. I could store the DOT source somewhere, generate the images as static files as part of my build pipeline, then link to the images using the standard Markdown link syntax. But these diagrams would only be used in the particular post they appear in. Why require a sub-request for a static asset that would only appear on one page?

A neat feature of @ts-stack/markdown is that it lets you provide custom implementations of Renderer methods, one for each type of Markdown "cell". Even better, graphviz can output SVG, and SVG can be injected directly into HTML for display. So I can add a custom code method which takes a Markdown code block containing DOT source, renders the DOT source into SVG, and adds that SVG directly into the HTML document being rendered. Here's (most of) the code needed for that:

typescript
import { execSync } from "child_process"
import { Renderer } from "@ts-stack/markdown"

// Render DOT source using the dot command line tool, part of graphviz.
// You need graphviz installed for this to work! https://graphviz.org/download/
function dotToBuffer(input: string, format: "png" | "jpeg" | "svg"): Buffer {
  return execSync(`dot -T${format}`, {
    input: input,
  })
}

class BlahRenderer extends Renderer {
  code(code: string, lang?: string, escaped?: boolean): string {
    if (lang === "dot-rendered") {
      const svg = dotToBuffer(code, "svg").toString() // render the code in the block as SVG in a string

      // Inject the raw SVG from the Buffer directly into the rendered HTML
      // (along with some Tailwind styles).
      return `<p class="mx-auto overflow-auto">${svg}</p>`
    }

    // Handle normal code blocks - not quite a super() call, but roughly equivalent
    return this.highlightCode(code, lang, escaped)
  }
}

There's some other code to hook up the rendering to the actual web request, but there's nothing special there, just standard Express code. The end result is that a block with the custom language code dot-rendered gets rendered as inline SVG in the HTML output sent back to the browser. Here's an example:

dot
digraph {
    bgcolor="transparent"

    subgraph cluster_0 {
        label="Subgraph 1"
        A -> B
        A -> C
        C -> D[color=red, penwidth=3]
    }

    subgraph cluster_1 {
        label="Subgraph B"
        1 -> 2 -> 3 -> 4[color=blue]
        { rank="same"; 2, 3 }
    }
}

Since that worked, I did the same trick with PlantUML, which provides a domain-specific language for making various kinds of UML diagrams that it translates to DOT under the hood. Here's what that pipeline looks like:

plantuml
@startuml

Express -> "@ts-stack/markdown"
"@ts-stack/markdown" -> PlantUML
PlantUML -> DOT
DOT -> PlantUML
PlantUML -> "@ts-stack/markdown"
"@ts-stack/markdown" -> Express

@enduml

New Workflow Using React

As part of migrating this website to React, I wanted to make sure that the whole website could be rendered client-side. Though there have been some attempts to make Graphviz available via WASM, there doesn't seem to be anything like for PlantUML (unsurprisingly, since it's a Java library).

As a replacement, I went with Mermaid, which is a pure-Javascript library with some similar functionality to both Graphviz and PlantUML.

This is a simple React hook-based function component that defers rendering to Mermaid:

tsx
import React, { PropsWithChildren, useEffect, useState } from "react"
import mermaidAPI from "mermaid"

export function Mermaid({ children }: PropsWithChildren) {
  const [html, sethtml] = useState("")

  useEffect(() => {
    mermaidAPI.render("mermaid", String(children), sethtml)
  }, [children])

  return (
    <div
      dangerouslySetInnerHTML={{
        __html: html,
      }}
    />
  )
}
mermaid
sequenceDiagram
    participant Alice
    participant Bob
    Alice->>Bob: Hi Bob
    Bob->>Alice: Hi Alice