elixir

Livebook for Elixir: Just What the Docs Ordered

Adam Lancaster

Adam Lancaster on

Livebook for Elixir: Just What the Docs Ordered

While initially conceived as a tool for data exploration (much like Jupyter for Python), Livebook has deservedly become a sensation in the Elixir community.

It has been fantastic to see all the wonderful ways teams are leveraging Livebook for a range of different use cases. We have seen Livebooks being used to:

  • Create interactive documentation for libraries.
  • Build onboarding material and guides.
  • Audit and explore potential dependencies in your app.

Livebooks have also been used as the default REPL interface for project development.

In this post, we'll show how you can easily create interactive documentation with Livebook and outline some top tips for using Livebook. We will assume you have installed Livebook, following the guidance in their README.

But First: What is Livebook for Elixir?

Livebooks are supercharged markdown files where you can add sections of arbitrary executable Elixir code. They are inspired by similar notebooks for other languages (like Python's Jupyter), but Livebooks leverage LiveView and other BEAM goodies, so they are even better.

Livebook files get their own .livemd extension, and (somewhat confusingly) we create and run those Livebook markdown files using a Phoenix application also called Livebook.

That phoenix app runs in the browser and enables a whole host of interactive features like collaboration, as we will see.

The expectation is that you will install the Livebook repo locally and start a Livebook server from somewhere on your machine where there are Livebooks (the files).

The Livebook app will show you the working directory of where you started the Livebook server, so you can select any given Livebook to run from there.

Let's now look at a library to document.

Livebook Docs: A Single Source of Truth in Elixir

Our library is going to accept various inputs and rate them according to the following chonk chart:

alt chart showing cats of various sizes

It will output the chonk rating accordingly. First, let's create the library.

mix new chonk_o_meter && cd chonk_o_meter

Let's add ex_doc to our deps in our mix.exs:

defp deps do
  [
    {:ex_doc, ">= 0.0.0", runtime: false, only: [:docs, :dev]},
  ]
end

Now, download the chonk chart image above and put it into the root of the library in a directory called images so we can refer to it in our README. In the README.md, let's put a title and a short explanation of what the library aims to do:

# Chonk 'O' Meter
 
Chonk O Meter is a state-of-the-art size estimator. It will rate the size of anything according to the following chart:
 
![alt chart showing cats of various sizes](./images/chonk.jpg)

So far, so good. Now we will open the module and write our moduledoc. But here's the thing: what we really want to do is just copy what we already wrote for the README.

Having one source of truth for that information is super valuable as the library develops because having to update the documentation in multiple places is a recipe for errors!

It's good to repeat the same information in different formats because different people will come to the library via different paths.

Some may see the repo first (and therefore the README), whereas others may see the library on Hex first — and so only see the moduledoc.

You may think that it's easy enough to copy and paste, but once we add our Livebook into the mix, there will be three places we need to update docs when something changes!

Instead, let's be a bit smarter. We will section off a part of the README and read that section into the moduledoc at compile time. Sandwich the introduction to the library between two markdown comments in the README:

<!-- README START -->
 
.... Library introduction here.
 
<!-- README END -->

Now, we can write the introduction as if it were a moduledoc in between those two comments, like so:

<!-- README START -->
 
Chonk O Meter is a state-of-the-art size estimator. It will rate the size of anything according to the following chart:
 
![alt chart showing cats of various sizes](./images/chonk.jpg)
 
<!-- README END -->

In the main module ChonkOMeter, we can do this:

defmodule ChonkOMeter do
  @moduledoc File.read!(Path.expand("./README.md"))
             |> String.split("<!-- README START -->")
             |> Enum.at(1)
             |> String.split("<!-- README END -->")
             |> List.first()
end

This will extract the guide sandwiched between the markdown comments and set it as the moduledoc. Now we only need to change the README to update both!

We can generate the documentation and view it locally to verify that this works as expected. If you run mix docs, a doc folder will appear with an index.html file. We can open doc/index.html to view our documentation in the browser.

Adding an Image to the Moduledoc

If you navigate to the module's documentation, you will notice that the image is missing. Hex allows you to point to assets in your docs as long as they are included inside the doc directory (generated when you create docs with the mix docs command).

Usually, that command overwrites the whole doc folder. So, to ensure that our pictures are always copied there, we can use an alias in our mix.exs file. We will turn the mix docs command into one that runs mix docs and then copies all images inside the /images directory into a doc/images directory.

defmodule ChonkOMeter.MixProject do
  use Mix.Project
 
  def project do
    [
      ...
      aliases: aliases(),
      ...
    ]
  end
 
  defp aliases() do
    [docs: ["docs", &copy_pictures/1]]
  end
 
  defp copy_pictures(_) do
    File.cp_r(Path.expand("./images/"), Path.expand("./doc/images/"))
  end
end

If you run mix docs now, you will see that the images/ directory gets copied over to the doc folder. Open the doc/index.html file and you should now see the chonk chart appear!

Doctests in Elixir's Livebook

It's important to note that in writing our moduledoc in this way, we don't lose any of the usual capabilities ex_docs give us.

Anything you can normally do in a doctest, you can still do here. To demonstrate that, let's add a doctest to our README. First, we'll need a function to test:

defmodule ChonkOMeter do
  @moduledoc File.read!(Path.expand("./README.md"))
             |> String.split("<!-- README START -->")
             |> Enum.at(1)
             |> String.split("<!-- README END -->")
 
  @doc """
  Returns the Chonk rating for a given number of story points.
  """
  def story_points(points) when is_integer(points) and points >= 0 and points < 3 do
    "A Fine Boi"
  end
 
  def story_points(points) when is_integer(points) and points >= 3 and points < 5 do
    "He Chomnk"
  end
 
  def story_points(points) when is_integer(points) and points >= 5 and points < 8 do
    "A Heckin' Chonker"
  end
 
  def story_points(points) when is_integer(points) and points >= 8 and points < 10 do
    "H E F T Y C H O N K"
  end
 
  def story_points(points) when is_integer(points) and points >= 10 and points < 15 do
    "Mega Chonk"
  end
 
  def story_points(points) when is_integer(points) and points >= 15 do
    "Oh Lawd He Comin'"
  end
end

Now remove the boilerplate in our test file, so it looks like this:

defmodule ChonkOMeterTest do
  use ExUnit.Case
  doctest ChonkOMeter
end

Run the tests to ensure there are none for now:

mix test

Finally, in our README, we can add the usual doctest syntax:

<!-- README START -->
 
Chonk O Meter is a state-of-the-art size estimator. It will rate the size of anything according to the following chart:
 
![alt chart showing cats of various sizes](./images/chonk.jpg)
 
For example:
 
    iex> ChonkOMeter.story_points(10)
    "Mega Chonk"
 
<!-- README END -->

If you run the tests, you will notice that the change in the README has not triggered a re-compilation, meaning the app still thinks there are no doctests. To fix this, we just need to add an @external_resource module attribute into the main module. This tells mix to recompile when the README changes:

defmodule ChonkOMeter do
  @external_resource Path.expand("./README.md")
  # ^^ Add this line ^^
  @moduledoc File.read!(Path.expand("./README.md"))
             |> String.split("<!-- README START -->")
             |> Enum.at(1)
             |> String.split("<!-- README END -->")
 
  @doc """
  Returns the Chonk rating for a given number of story points.
  """
  def story_points(points) when is_integer(points) and points >= 0 and points < 3 do
    "A Fine Boi"
  end
 
  def story_points(points) when is_integer(points) and points >= 3 and points < 5 do
    "He Chomnk"
  end
 
  def story_points(points) when is_integer(points) and points >= 5 and points < 8 do
    "A Heckin' Chonker"
  end
 
  def story_points(points) when is_integer(points) and points >= 8 and points < 10 do
    "H E F T Y C H O N K"
  end
 
  def story_points(points) when is_integer(points) and points >= 10 and points < 15 do
    "Mega Chonk"
  end
 
  def story_points(points) when is_integer(points) and points >= 15 do
    "Oh Lawd He Comin'"
  end
end

When we run our tests, this now results in one passing doctest! We can also add a doctest to our function doc like so:

...
  @doc """
  Returns the Chonk rating for a given number of story points.
 
      iex> ChonkOMeter.story_points(5)
      "A Heckin' Chonker"
  """
  def story_points(points) when is_integer(points) and points >= 0 and points < 3 do
    "A Fine Boi"
  end
 
  def story_points(points) when is_integer(points) and points >= 3 and points < 5 do
    "He Chomnk"
  end
 
  def story_points(points) when is_integer(points) and points >= 5 and points < 8 do
    "A Heckin' Chonker"
  end
 
  def story_points(points) when is_integer(points) and points >= 8 and points < 10 do
    "H E F T Y C H O N K"
  end
 
  def story_points(points) when is_integer(points) and points >= 10 and points < 15 do
    "Mega Chonk"
  end
 
  def story_points(points) when is_integer(points) and points >= 15 do
    "Oh Lawd He Comin'"
  end
...

Adding Livebook to Elixir

So let's recap. Right now, we have a README as the source of truth for our moduledoc. We can have doctests and images and all the usual goodies that a moduledoc is allowed, but we don't have to repeat ourselves and risk copy/paste errors.

We want to keep that same energy going for our Livebook, to avoid repeating ourselves manually, but still have an interactive playground for our library on top of the usual moduledocs.

To do that, we can generate our Livebook from our module. To help with this, I've written a library we can include called livebook_helpers:

defp deps do
  [
    {:ex_doc, ">= 0.0.0", runtime: false, only: [:docs, :dev]},
    {:livebook_helpers, ">= 0.0.0", only: [:docs, :dev]},
  ]
end

Once we have fetched the deps with mix deps.get, running mix help shows one extra mix task:

...
mix create_livebook_from_module # Creates a livebook from the docs in the given module.
...

We can see from the docs that we run the mix task by providing a module and a path to a Livebook. Let's try that:

mix create_livebook_from_module ChonkOMeter "chonk_o_meter_introduction"

You should see a successful output that links to the generated Livebook! 🎉 There is one last thing we can do to make our workflow seamless. Let's add create_livebook_from_module to the end of the mix docs command.

defmodule ChonkOMeter.MixProject do
  use Mix.Project
 
  def project do
    [
      ...
      aliases: aliases(),
      ...
    ]
  end
 
  defp aliases() do
    [docs: ["docs", &copy_pictures/1, &create_livebook/1]]
  end
 
  defp copy_pictures(_) do
    File.cp_r(Path.expand("./images/"), Path.expand("./doc/images/"))
  end
 
  defp create_livebook(_) do
    Mix.Task.run("create_livebook_from_module", ["ChonkOMeter", "chonk_o_meter_introduction"])
  end
end

Whenever we run mix docs, we will copy over any static images used in the README and generate a Livebook from our main module!

alt picture of the generated livebook showing the same moduledocs

Running Livebook in Elixir

So far, so good! We have a nice pipeline to create a useful Livebook, but now we need to think about running the Livebook. Start the Livebook app like so:

livebook server

By default, the Elixir sections only have access to the Elixir and Erlang standard library. If we run our generated library and then attempt to run an Elixir cell that calls the library, it will fail because the library code is not there. To solve this, we have two options — Mix.install or Livebook runtime.

Add Mix.install to Livebook

We could add a section to the beginning of the Livebook that does this:

Mix.install([:chonk_o_meter])
alt text

When called, we will get the latest version of the library from Hex. It will be made available to all subsequent Elixir cells, just like when you run Mix.install inside an IEx REPL.

You can also easily specify a version and provide live documentation for any version of a given library:

Mix.install([{chonk_o_meter: ">=0.0.1"}])

LivebookHelpers can even generate a Livebook with a Mix.install at the beginning if we supply deps to the mix task:

mix create_livebook_from_module ChonkOMeter "chonk_o_meter_introduction" "[:chonk_o_meter"]"

This works great for any library that is deployed to Hex. However, you'll run into problems if, for example, you want a Livebook for a main branch. In that case, you can do this:

Mix.install [{:chonk_o_meter, path: "./"}]

This tells mix to look in the provided path for a local version of the library. This, of course, makes some assumptions about where the Livebook will run, so it's good to make that clear. If you put the Livebook at the root of the repo and a user starts the Livebook server from there, then the path "./" will work.

If you don't want to rely on this, though, Livebook has your back with a powerful feature: runtime!

Livebook Runtime

There are three kinds of runtime, but they all let you point to code and call it in any Elixir cell within your Livebook.

The three runtime options are:

  • Embedded
  • Attached node
  • Mix standalone

You select the runtime by clicking the cog symbol here:

alt text

Let's look at each runtime option below.

Embedded Mode

Embedded Mode lets us run the notebook code within the Livebook node itself! This is really for specific cases where there is no option to start a separate Elixir runtime — for example, on embedded devices.

Code defined in one notebook may interfere with code from another notebook. So this mode should only be used if you have no alternative and is not relevant here.

Attached Node

The attached node runtime connects a Livebook to a running Elixir app via the usual Erlang magic that we use to connect two running nodes. It looks like this:

alt attached node option

As long as the app starts with a cookie that you know and a sname, you can give them to Livebook and connect. This is like getting a remote shell in a running app but with a more full-featured text-editing environment.

Using an attached node gives you complete control over how your app starts. You get much more control than the mix standalone and can do all sorts of things, like set env vars and start other services (like a database).

An attached node could be especially relevant for creating internal (live!) documentation for closed-source repos at work, but is not relevant for us and our library.

Mix Standalone

Finally, the Mix standalone runtime lets you point to a mix project which Livebook will compile and start (analogous to running iex -S mix in your terminal).

alt mix standalone option

Mix standalone confers a great advantage over Mix.install, as you can recompile it! It's useful if you write a Livebook from scratch; in that case, you'll likely add something to the library that you'll then want to use in the Livebook.

Instead of having to kill the Livebook server and restart to access the new functions, you can add an Elixir cell with the following:

IEx.Helpers.recompile()

This will recompile the connected mix app (i.e., our library) when you call it, meaning you get access to any new functionality therein.

Livebook for Docs: Try It Yourself in Your Elixir App

That concludes our tour of Livebook for docs. You can see the chonk_o_meter library example here. For an example of these ideas in action in a real library, check out my data_schema library too.

Currently, GitHub doesn't recognize the .livemd extension, so if you play around with Livebook, I would encourage you to push to public repos. The more we do this, the more chance we have of getting GitHub to parse the files with nice syntax highlighting and markdown rendering.

Until that happens, though, we can put a magic line at the top of a Livebook to force GitHub to render it as markdown:

<!-- vim: syntax=markdown -->

The Livebook that Livebook helpers generates will include this line for you. Now you have all the knowledge you need go forth and create live docs!

Happy coding!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Share this article

RSS
Adam Lancaster

Adam Lancaster

Our guest author Adam is a software engineer that puts the "wow" in "wow, who the hell wrote this?!".

-> All articles by Adam Lancaster-> Become an AppSignal author

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps