Adding Support for Inline DOT to Blahs

Published 2021‑11‑07

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.

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 }
    }
}
%3 cluster_0 Subgraph 1 cluster_1 Subgraph B A A B B A->B C C A->C D D C->D 1 1 2 2 1->2 3 3 2->3 4 4 3->4