Graphviz plugin for Jekyll


Problem statement

I’m rather fond of graphviz for my everyday graphing needs.

But I’m also tired of re-using the clunky old Makefile:

all: $(shell find . -iname \*.dot |sed 's/dot$\/svg/')

watch:
	onfilechange \*.dot 'make'

.SUFFIXES: .dot .svg

.dot.svg:
	dot -Tsvg -o $@ $<

Because in order to have a reasonable refresh-after-save when developing, I have to switch to the assets dir, and run the make watch. Not the end of the world, but doesn’t spark joy either.

Desired end-state

Therefore, I would like to have a Jekyll plugin1. One that parses a Liquid block and spits out the SVG as inline img tag (with an optional title).

Example:

{% graphviz_captioned some title %}
digraph G {
  node [shape=box]
  edge [color = "#aaaaaa"]
  overlap = "false"
  nodesep = 1

  sda2 [label="sda2", color="#ffd44f"]
  sdb2 [label="sdb2", color="#ffd44f"]

  md1 [label="mdraid mirror (md1)", color="#ffd44f"]

  sda2, sdb2 -> md1
}
{% endgraphviz_captioned %}

should produce:

some title some title

and errors should be clearly visible (in page).

And while one could make a case that I’m suffering from NIH, I think the few lines of Ruby don’t justify the additional dependency.

Not to mention that the support for the image captions (see the example above) isn’t there.

Solution

The solution is straightforward, one file in _plugins directory does the trick:

# frozen_string_literal: true

=begin
Allows to use the following in templates:

{% graphviz some title %}
digraph G {
  node [shape=box]
  edge [color = "#aaaaaa"]
  overlap = "false"
  nodesep = 1

  sda2 [label="sda2", color="#ffd44f"]
  sdb2 [label="sdb2", color="#ffd44f"]

  md1 [label="mdraid mirror (md1)", color="#ffd44f"]

  sda2, sdb2 -> md1
}
{% endgraphviz %}

Which will generate the graph using `dot` command,
and output the result as `img` tag with proper title.

Alternatively, you can leave out the title out,
in which case it will be set to `inlined graphviz graph`.

And if you use `graphviz_captioned` block instead of `graphviz`,
the title will also end up as a caption below the image.
=end

require "jekyll"
require 'open3'
require 'cgi'
require 'base64'
require 'digest/sha2'
require 'fileutils'
require 'tempfile'

$LOAD_PATH.unshift(File.dirname(__FILE__))

module Jekyll
  class RenderGraphvizBlock < Liquid::Block
    CACHE = '_gvcache'

    def initialize(tag_name, text, tokens)
      super
      @caption = !!(tag_name =~ /captioned/)
      @title = text && text.strip
    end

    def gen_svg(input)
      FileUtils.mkdir_p(CACHE)
      sha = Digest::SHA256.hexdigest(input)
      cache = File.join(CACHE, sha)

      if FileTest.exists?(cache)
        output = File.read(cache)
        return [output, nil]
      end

      output, status = Open3.capture2e("dot -Tsvg", stdin_data: input, binmode: true)
      output.force_encoding 'UTF-8'
      if status.success?
        # cache it
        t = Tempfile.new(cache)
        t.write(output)
        t.close
        File.rename(t.path, cache)

        [output, nil]
      else
        [nil, output]
      end
    end

    def render(context)
      text = super
      output, error = gen_svg(text.strip)
      if error
        "<pre><code>" + CGI.escapeHTML(error) + "</code></pre>"
      else
        title = @title || "inlined graphviz graph"
        b64 = Base64.strict_encode64(output)
        out = []
        out << "<p>"
        out << "<img alt=\"#{CGI.escapeHTML(title)}\" "
        out << "src=\"data:image/svg+xml;base64,#{b64}\" />"
        if @caption
          out << "\n<em>#{CGI.escapeHTML(title)}</em>"
        end
        out << "</p>"
        out.join
      end
    end
  end
end

Liquid::Template.register_tag('graphviz', Jekyll::RenderGraphvizBlock)
Liquid::Template.register_tag('graphviz_captioned', Jekyll::RenderGraphvizBlock)

Closing words

To eat my own dog food, I’ve converted the last post to use this plugin.

Works as expected. Yay.

  1. The same way I have a plugin for the stl viewer, but I haven’t talked about that at all, have I?