Why D2?
D2 is a modern diagram-as-code tool. I wanted diagrams in my blog posts that:
- Live in version control as source code (not binary images)
- Render consistently
- Auto-update during development
Now, I already have Mermaid diagrams so what gives? I really like the visual style and especially the layout of D2 diagrams. They’re not the best in all cases, but adding D2 support means I can pick and choose between that and Mermaid. Hugo doesn’t support D2 out of the box, so I built a custom tool.
The solution
Claude and I wrote a Go program (blog.go) that:
- Finds all
.d2files incontent/ - Renders them to
.svgfiles in the same location - Watches for changes and re-renders automatically
- Integrates with Hugo’s dev server
The SVG files are git-ignored. Only the .d2 source files get committed.
Here’s how the whole flow works:
The same diagram, but wider to show detail. Try right-clicking to see the SVG or to save as an image. Pretty cool!
How it works
Initial render
When you run task (or go run blog.go --serve), the tool:
| |
The renderFile function builds a command like:
| |
File watching
In development mode, the tool uses fsnotify to watch for changes:
| |
The 300ms debounce prevents excessive re-renders when your editor saves multiple times.
Config integration
D2 rendering options come from config.toml:
| |
If you change config, the tool re-renders all diagrams.
Using it in posts
Just create a .d2 file in your page bundle. For example, the diagram above comes from this source:
| |
Reference it with the Hugo shortcode.
Having the matching highlight tags is optional but it does give you a spot to add a title for the resulting <figure> HTML element.
| |
The width parameter is optional. Use it to control diagram size. Hugo picks up the generated SVG and includes it in the page.
Why this approach?
To be honest, writing a Go program seemed like overkill. But after trying and failing with a pure Hugo approach, and then tearing my hair out with flaky shell scripts, it started to feel more reasonable ;-)
So yeah, I did consider using Hugo pipes or external processors, but this was simpler:
- Single Go binary, no extra dependencies
- Works with any Hugo theme
- Config lives in
config.toml(one place for all settings) - File watching is fast and reliable
- Easy to extend if I want more preprocessing later. This one is interesting because for D2 only, it does still feel a bit like overkill. But at least now I have an approach for the next whacko file format I want to support!
The whole script is ~400 lines of Go. Worth it for declarative diagrams in my blog.