😮 You looked at the source!

Dimitris Zorbas

Code spells, smells and sourcery

A Livebook Smart-Cell to Render Diagrams

I wrote my first Livebook smart-cell which renders diagrams from a textual description.

I recently discovered Kroki, an opensource tool which generates images from the text notation of popular diagramming languages. I then thought, Livebook makes such an excellent application companion for runnable documentation. What if we could easily embed architectural (or any kind) of diagrams without having to go through some complicated pipeline to convert the diagram definition into an image first?

Yes, we can!

An old Chinese proverb goes “Up-to-date runnable documentation with diagrams is worth a thousand Confluence pages”. Elixir’s standard documentation generator, ex_doc makes it trivial to include Livebook notebooks in your documentation and run them. It even supports Mermaid out of the box, you just need to wrap the source in mermaid backticks.

```mermaid
graph TD;
  A-->B;
  A-->C;
  B-->D;
  C-->D;
```

What if your diagrams are not part-fish? No problem, there’s kino_kroki.

You quickly can give it a go by following the link below:

Livebook badge

Note: Please mind that you need to be running Livebook > 0.6

To try it from an existing notebook, throw kino_kroki to the mix with:

Mix.install([:kino_kroki])

Then you’ll notice a new type of smart-cell titled diagram.

kino-kroki

An editor will appear where you can paste and edit the diagram source and when you evaluate the cell, the diagram will be rendered.

kino-kroki

Prior Art

Smart-cells are a powerful way to extend the functionality of Livebook. There’s a fantastic guide bundled with Livebook, to help you write your own smart-cell, which you can read here.

I took inspiration from github_graphql_smartcell which is somewhat similar to Kino.Kroki in the sense that they both include a source editor.

How it Works

As mentioned in the intro, this smart-cell is powered by Kroki. It’s API takes the diagram source as a URL encoded and compressed string and responds with an image.

The heart of the smart-cell is this tiny module:

defmodule Kino.Kroki do
  @moduledoc """
  A simple encoder for the online diagram renderer https://kroki.io/
  """

  @doc """
  Returns a `Kino.Markdown` image to render the diagram.

  ### Examples

      iex> Kino.Kroki.new(Kino.Kroki.Sample.get(:graphviz), :graphviz)
      %Kino.Markdown{
        content: "![svg](https://kroki.io/graphviz/svg/eJx9kM"
      }
  """
  @spec new(graph :: String.t(), type :: Kino.Kroki.Samples.type()) :: Kino.Markdown.t()
  def new(graph, type) do
    graph
    |> :zlib.compress()
    |> Base.url_encode64()
    |> then(&"https://kroki.io/#{type}/svg/#{&1}")
    |> then(&"![svg](#{&1})")
    |> Kino.Markdown.new()
  end
end

Compressing and encoding in Elixir and OTP as you can see, is particularly simple, compared to some other platforms (see examples). With the time left I contributed the Elixir example.

Making it Smart

So far, there’s nothing particularly dynamic about Kino.Kroki, it’s a regular Elixir module which can return a Markdown image.

To better understand the concept of a smart-cell, consider it a code template parameterized through UI interactions. At any point you can convert a smart-cell into a code cell for further modifications.

We’ll dissect each part of the module below:

defmodule Kino.KrokiSmartcell do
  use Kino.JS
  use Kino.JS.Live
  use Kino.SmartCell, name: "Diagram"

  # omitted
end

We’re defining a module for the smart-cell, which adds Kino.JS making the module asset-aware allowing us to inject JavaScript for our cell through the asset "main.js" macro as well as CSS through asset "main.css".

We also use Kino.JS.Live since we want the cell to be dynamic, listening to the update_type event then setting a diagram sample according to the selected type.

  • With the init/2 callback, that receives the argument and the “server” context, we initialise the type, setting it to “graphviz” and we also fetch and assign a sample textual definition of a GraphViz diagram. More details about the module returning samples will be available further below.
@default_type "graphviz"

@impl true
def init(_attrs, ctx) do
  ctx =
    ctx
    |> assign(type: @default_type)
    |> assign(diagram: Kino.Kroki.Samples.get(@default_type))

  {:ok, ctx}
end

With the handle_connect/1, which is invoked whenever a new client connects, we set up the initial state of the new client.

@impl true
def handle_connect(ctx) do
  {:ok, %{type: ctx.assigns.type, diagram: ctx.assigns.diagram}, ctx}
end
  • The handle_event/3 callback is responsible for handling any messages sent by the client. We fetch a sample diagram source for the selected type, update the context and broadcast an event which will be handled by JavaScript side of this cell and update the editor element.
@impl true
def handle_event("update_type", type, ctx) do
  ctx = assign(ctx, type: type)

  diagram = Kino.Kroki.Samples.get(type)

  ctx = update(ctx, :diagram, fn _ -> diagram end)
  broadcast_event(ctx, "update_type", %{type: ctx.assigns.type, diagram: diagram})

  {:noreply, ctx}
end

With the to_attrs/1 callback we compute the arguments for to_source/1. We set the type and diagram source.

@impl true
def to_attrs(ctx) do
  %{"type" => ctx.assigns.type, "diagram" => ctx.assigns.diagram}
end

We implement to_source/1 which returns the Elixir code for our cell. This is what will replace our smart-cell if we convert it to a “dumb” cell. We return the AST of the Elixir source which by calling Kino.Kroki.new/2 renders a markdown image generated by the Kroki server. Kino.SmartCell.quoted_to_string/1 converts the AST to a readable, formatted code string.

@impl true
def to_source(attrs) do
  quote do
    Kino.Kroki.new(unquote(attrs["diagram"]), unquote(attrs["type"]))
  end
  |> Kino.SmartCell.quoted_to_string()
end

The JavaScript source of Kino.Kroki is:

  asset "main.js" do
    """
    export function init(ctx, payload) {
      ctx.importCSS("main.css");
      ctx.importCSS("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap");

      root.innerHTML = `
        <div class="app">
          <form>
            <div class="container">
              <div class="row header">
                <div class="inline-field">
                  <label class="inline-input-label label">Diagram type</label>
                  <select class="input" name="type"/>
                    <option value="blockdiag">BlockDiag</option>
                    <!-- rest of the options omitted -->
                  </select>
                </div>

                <div class="logo">
                  <span>Powered by: </span>
                  <a href="https://kroki.io">
                    <img alt="kroki" src="https://kroki.io/assets/logo.svg"/>
                  </a>
                </div>
              </div>

              <div class="row">
                <div class="field grow">
                  <label class="input-label">Diagram Source</label>
                  <textarea
                    id="diagram-source"
                    name="diagram"
                    class="input textarea code"
                    placeholder=""
                    rows="25">#{Kino.Kroki.Samples.get(@default_type)}</textarea>
                </div>
              </div>
            </container>
          </form>
        </div>
      `;

      const typeEl = ctx.root.querySelector(`[name="type"]`);
      const diagramEl = ctx.root.querySelector(`#diagram-source`);

      typeEl.addEventListener("change", (event) => {
        ctx.pushEvent("update_type", event.target.value);
      });

      ctx.handleEvent("update_type", (event) => {
        typeEl.value = event.type;
        diagramEl.value = event.diagram;
      });

      ctx.handleSync(() => {
        // Synchronously invokes change listeners
        document.activeElement &&
          document.activeElement.dispatchEvent(new Event("change"));
      });
    }
    """
  end

We use the asset/2 macro to define the necessary JS code inline, but there’s also the option to set a path to load assets with:

use Kino.JS, assets_path: "lib/assets/kino_kroki"

We need to export an init function for our JavaScript module. The first parameter ctx is the client-side context which provides a variety of useful functions such as importCSS which we use to load CSS from a URL.

We initialise the root element of the smart-cell with root.innerHTML and set up event handlers. With ctx.pushEvent we push a client-side change to the cell server and with ctx.handleEvent we apply changes from the server.

typeEl.addEventListener("change", (event) => {
  ctx.pushEvent("update_type", event.target.value);
});

ctx.handleEvent("update_type", (event) => {
  typeEl.value = event.type;
  diagramEl.value = event.diagram;
});

ctx.handleSync(() => {
  // Synchronously invokes change listeners
  document.activeElement &&
    document.activeElement.dispatchEvent(new Event("change"));
});

The complete source of Kino.Kroki can be found here.

Finishing Touches

We need to make sure Livebook knows about this new smart-cell to list it as one of the available options.

In the mix.exs of Kino.Kroki there is:

def application do
  [
    mod: {Kino.Kroki.Application, []}
  ]
end

and in the application module:

defmodule Kino.Kroki.Application do
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    # 👇 This tells Kino we implemented a smart-cell
    Kino.SmartCell.register(Kino.KrokiSmartcell)

    Supervisor.start_link([], strategy: :one_for_one, name: KinoDB.Supervisor)
  end
end

Diagram Samples

Kino.Kroki is the last module of the smart-cell. It reads and parses a text file containing samples for the diagram types Kroki can generate images from. Let’s take it apart to see if there are any useful patterns in there (hint: there are).

The text file containing the samples has the following format:

<--- sample:blockdiag --->
blockdiag {
  Kroki -> generates -> "Block diagrams";
  Kroki -> is -> "very easy!";

  Kroki [color = "greenyellow"];
  "Block diagrams" [color = "pink"];
  "very easy!" [color = "orange"];
}
<--- sample:wavedrom --->
{ signal: [
  { name: "clk",         wave: "p.....|..." },
  { name: "Data",        wave: "x.345x|=.x", data: ["head", "body", "tail", "data"] },
  { name: "Request",     wave: "0.1..0|1.0" },
  {},
  { name: "Acknowledge", wave: "1.....|01." }
]}

We start by assigning the location of the file to a module attribute:

defmodule Kino.Kroki.Samples do
  @samples_file :code.priv_dir(:kino_kroki)
                |> Path.join("samples.txt")

Then with:

@external_resource @samples_file

We instruct the compiler to recompile Kiko.Kroki.Samples each time there’s a change to the samples file.

We then parse the file into a map where the keys are diagram types and the values are samples.

@samples_separator ~r/<--- sample:(?<type>.+) --->\n/m
@samples @samples_file
         |> File.read!()
         |> String.split(@samples_separator,
           include_captures: true,
           trim: true
         )
         |> Enum.chunk_every(2)
         |> Enum.map(fn
           [separator, diagram] ->
             {String.to_atom(Regex.named_captures(@samples_separator, separator)["type"]),
              String.trim(diagram)}
         end)
         |> Map.new()

We also define a type type, which is a union of all the supported diagram types.

@type type :: unquote(Enum.reduce(Map.keys(@samples), &{:|, [], [&1, &2]}))

This is rendered nicely in the docs and helps the user of this library provide a valid argument when used programmatically.

Outro

There’s a lot of ground to cover with the recent extensibility improvements the amazing developers, community and sponsors a bringing to Livebook. I recommend checking out the docs for:

If you are seeking inspiration for your next notebook or smart-cell, check out notes.club which is an Elixir app to help you discover notebooks contributed by the community.

  • Coming soom to a notebook near you “an open_api_spex smart-cell”, stay tuned!

Spotted a Mistake?

Please contact me on twitter, or in the comments, or submit a PR for corrections.