Markdown is a rather simplistic markup language. As such, it is less powerful, but also easier to learn than e.g. Asciidoc or reStructuredText. We picked it because most developers and architects are already familiar with it.

Our documentation site contains diverse content, such as glossaries, tutorials and SDK manuals. Those can mostly be expressed using plain text. But architecture documentation especially relies heavily on diagrams and charts to illustrate concepts.

One option to include such content would be as images that are created with external tools. However, in many circumstances, we can also describe the figures as text, e.g. PlantUML or Graphviz.

Ideally, a developer would write the following code in a Markdown file:

Our system's runtime architecture looks as follows:

{% plantuml %}
frame "Microsoft Azure" {
    artifact artifact1 [
        Azure Container Registry
    ]
    cloud cloud1 [
        Azure Kubernetes Service
    ]
    database database1 [
        Component 1 database
        (Azure Database for PostgreSQL server)
    ]
    database database2 [
        Component 2 database
        (Azure Database for PostgreSQL server)
    ]
    database database3 [
        Analytical database per FSP
        (TimescaleDB on Azure Database for PostgreSQL server)
    ]
    component component1 [
        Data analytics processing engine
        (Apache Spark, managed via Azure Databricks)
    ]

    artifact1 --> cloud1
    cloud1 --> database1
    cloud1 --> database2
    database1 --> component1
    component1 --> database3
}
{% endplantuml %}

PlantUML can render the above textual description into this SVG:

Rendering of the above PlantUML code
Rendering of the above PlantUML code

Jekyll doesn’t have built-in support of PlantUML, though, so we have to implement a plugin. As I have described in a previous post, a plugin is a Ruby file that we can drop into the _plugins folder.

For implementation, I took inspiration from the existing PlantUML plugin, which I adapted for our purposes.

The full code of our custom plugin is below (don’t worry, we’ll go through it in detail):

require 'digest'
require 'fileutils'
require 'tmpdir'
require 'base64'
require 'ostruct'

module Jekyll
  class PlantumlBlock < Liquid::Block
    def cache
      @@cache ||= Jekyll::Cache.new("PlantUML")
    end

    def plantuml(params)
      config = Jekyll.configuration({})["plantuml"]

      skinparams = config["skinparams"].map{|k, v| "-S#{k}=#{v}"}.join(" ")

      system("plantuml #{skinparams} #{params}") or raise "PlantUML error"
    end

    def render(context)
      content = super
      key = Digest::MD5.hexdigest(content)

      rendered = cache.getset(key) do
        Dir.mktmpdir do |dir|
          file = "#{dir}/diagram.uml"
          File.open(file, 'w') do |f|
            f.write("@startuml\n")
            f.write(content)
            f.write("\n@enduml")
          end

          plantuml("-tsvg #{file}")
          plantuml("-tpng #{file}")

          OpenStruct.new(
            svg: File.read("#{dir}/diagram.svg"),
            png: File.read("#{dir}/diagram.png")
          )
        end
      end

      svg_data = Base64.strict_encode64(rendered.svg)
      png_data = Base64.strict_encode64(rendered.png)

      "<picture><source srcset='data:image/svg+xml;base64,#{svg_data}' type='image/svg+xml'><img src='data:image/png;base64,#{png_data}'></picture>"
    end
  end
end

Liquid::Template.register_tag('plantuml', Jekyll::PlantumlBlock)

Let’s take a closer look.

A custom Liquid block is a class that extends, perhaps unsurprisingly, Liquid::Block. Whenever a pair of {% plantuml %} and {% endplantuml %} is encountered, Jekyll will call the render method for us.

Ignoring the caching for now, the general flow of render is:

  1. extract the PlantUML code (content = super);
  2. write it to a temporary file (Dir.mktempdir, File.open and f.write);
  3. run the plantuml binary (via the plantuml helper method and system) twice, once for SVG and once for PNG;
  4. bundle the result in an OpenStruct for caching purposes;
  5. encode the images as Base64 (Base64.strict_encode64); and finally
  6. generate an HTML string that references the images not as files, but with data URIs.

The advantage of this approach is that we don’t have to generate auxiliary files, but obtain self-contained HTML.

For caching, we utilize Jekyll’s built-in caching library. It provides us with a key-value store: the key is the hash of the PlantUML code (hexdigest), and the value is the struct from step 4. When regenerating the site, Jekyll will not rebuild any diagrams that haven’t changed.

The plantuml helper method also reads additional configuration from Jekyll’s _config.yml, e.g. to select an appropriate PlantUML theme.

Finally, the plugin registers the custom block with register_tag.

A similar approach works for Graphviz, where we use the dot binary instead of plantuml.

Conclusion

Jekyll plugins provide a lightweight way to include other asset types, such as diagrams, in statically-generated sites. This reduces page load times compared to client-side rendering, e.g. with Mermaid, and allows us to use a broad range of figure types.