Diagrams-as-code

Documenting your design or code is an important requisite for any software developer, and diagrams are a necessary part of that. As coders we code, but being forced to reach for a visual sketch tool makes many of us whine.

That’s where the diagrams-as-code ecosystem comes in. D2, or Declarative Diagramming, is one such tool; Mermaid is another popular one. The idea is simple: you don’t sketch from scratch, you describe the diagram in code.

The diagrams on this blog are now rendered this way. This post is about how I wired D2 into the build, the design choice behind it, and a couple of gotchas I hit along the way.

Implementation

This blog is built by a POSIX sh script that pipes Markdown through pandoc to produce HTML. Pandoc is excellent at Markdown, but it has no idea what a D2 diagram is: a fenced block tagged d2 is just another code block, syntax-highlighted and left as-is. The goal, then, was to turn those code blocks into real diagrams at build time.

Two ways to do it

Before writing any code I considered two approaches:

The first option is simpler, but the diagram source then lives apart from the prose it belongs to. There’s a manual step that’s easy to forget, and the two drift over time. The whole point of diagrams-as-code is that the diagram is text, versioned next to the article. So I went with the second approach: the D2 source lives in the Markdown, and the build renders it.

The natural place to hook in is a pandoc filter. Pandoc ships with a Lua interpreter, so a filter needs no additional language runtime — and the only new dependency is the d2 binary itself.

The filter

The filter lives at templates/d2.lua — written in Lua (thanks, deepseek!). Its core is a CodeBlock handler that only touches blocks whose class is d2:

local function d2_block(cb)
  if not cb.classes:includes("d2") then
    return nil
  end
  ...
end

For each such block it writes the source to a temporary file, shells out to the d2 CLI, and reads the SVG back:

local ok, res = pcall(pandoc.pipe, "d2", {
  "--no-xml-tag",
  "--theme", LIGHT_THEME,
  "--dark-theme", DARK_THEME,
  input, output,
}, "")

A few deliberate choices in there:

The block is then replaced with an inline <figure>:

return pandoc.RawBlock("html", '<figure class="d2-diagram">' .. svg .. "</figure>")

The SVG is also annotated with role="img", an aria-label, and a <title> taken from an optional alt attribute, so the diagram is at least nominally accessible.

Wiring it into the build

build.sh gained three small things: a LUA_FILTER variable pointing at the filter, a preflight check that d2 is on PATH, and a --lua-filter flag on both pandoc invocations — one for post bodies and one for the homepage:

html_body=$(echo "$body" | pandoc --from markdown --to html5 \
  --syntax-highlighting=pygments --lua-filter="$LUA_FILTER")

A little CSS keeps the diagrams in check: max-width, auto height, and centring, plus a style for the error fallback.

The whole pipeline

Which, drawn as a diagram, looks like this:

diagramauthormarkdownpandocd2html writespost bodyfenced d2 blockSVGpublic/*/index.html

That diagram is itself a fenced d2 block in this article’s Markdown, rendered by the very filter it describes.

Writing a diagram

Using it is just a fenced code block tagged d2:

```d2
client -> server: request
server -> db: query
```

Optionally you can attach an alt label:

```d2 {alt="Request flow"}
client -> server: request
```

The alt text becomes the SVG’s accessible label. You can see it in the wild in the Reflex post.

Gotchas

--format svg isn’t a thing

When I first wrote the filter I passed --format svg, assuming D2 would take an explicit output format. It didn’t, and the build failed with a spectacularly unhelpful message:

D2 render failed: Error running d2 (error code 1): <no output>

The diagram was fine. D2 has no --format flag — the output format is determined by the output filename extension (.svg, in our case), or by --stdout-format when writing to stdout. The svg value wasn’t the problem; the flag was.

The <no output> part is a second trap. pandoc.pipe reports a failed command’s stderr, but D2 prints usage errors to stdout, which pandoc discards when the command exits non-zero. So any D2 invocation error surfaces as a blank <no output> until you know to look. Dropping --format and letting the .svg extension do the work was the whole fix.

Limitations

A couple of things I haven’t solved yet:

Wrapping up

D2 has turned out to be a pleasant fit for a text-based, shell-driven workflow: the diagram is just more Markdown, the build takes care of the rest, and the output adapts to light and dark mode for free. If you’re on a static site and want diagrams without leaving your editor, a pandoc filter like this is a small, self-contained way to get there.