DEV Community

Peter Szerzo
Peter Szerzo

Posted on • Edited on

Rich interactive notebooks with elm-markup

Whether it's Observable, Google Colab or Jupyter, I love interactive notebooks. If none of these names are familiar, such notebooks are documents where regular text snippets, headings etc. mingle with working code snippets. They are great communication tools and allow a kind of fluid play with code that is hard to get inside regular IDE's.

I have been interested in making my own interactive notebooks for a long time not because there was anything missing in existing solutions, but because a lot of the times I find them to be too flexible: if a notebook allows its user to change everything, they can also be break its logic pretty easily and with little help towards recovery.

And besides, how much effort would it take to make an interactive notebook on my own terms anyway...

Well, without good crutches, probably a lot, so I didn't attempt it right until I stumbled upon a package in the Elm programming language called elm-markup. Turns out, making a basic interactive notebook in it is much simpler than I had imagined: just under 200 lines and without even touching the package's more involved modules.

This experiment is what brings me to writing this series of blog posts, covering a delightful journey looking at interactive notebooks using elm-markup. We will start with a simple counter app, working our way up to some interactive drawings.

What is elm-markup?

It is officially described as the Elm-friendly markup language, one that brings Elm's promise of type safety and friendly error messages into the world of markup languages. Roughly speaking, I would sum it up as a kind of markup language that exposes its own internal structure (AST) so we can render it as we please: plain text, HTML, elm-ui, or any other Elm value really. Spoiler: in this post, we will be turning markup into functions.

tldr the full example with comments is available in this Ellie. The rest of the post will go over it in detail.

A basic elm-markup document

Let's define a simple Title block. This block would tell elm-markup that whenever it sees this piece of text:

|> Title Interactive counteradder 
Enter fullscreen mode Exit fullscreen mode

It should render the indented text inside an h1 tag. The code for that would look like this:

titleBlock : Mark.Block (Html msg) titleBlock = Mark.block "Title" (\str -> h1 [] [ text str ] ) Mark.string 
Enter fullscreen mode Exit fullscreen mode

Using this block, we can create a document like so:

Mark.compile (Mark.document identity titleBlock) """ |> Title Interactive counteradder """ 
Enter fullscreen mode Exit fullscreen mode

Rendering this into a running Elm app involves a few more steps that will become clear later in the post. If you're curious, the package docs are your friends.

Adding a counter

The second block we will define is a named counter that renders some UI and emits the updated value as a message. The markup syntax looks like this:

|> Counter name = var1 
Enter fullscreen mode Exit fullscreen mode

And without further ado, here is the code for it:

counterBlock : Mark.Block (Html ( String, Float )) counterBlock = Mark.record "Counter" (\name -> let -- This is a placeholder value -- We'll wire this up to proper state values in the next step val = 0 in div [] [ text (name ++ " = ") , button [ onClick ( name, val - 1 ) ] [ text "-" ] , text (String.fromFloat val) , button [ onClick ( name, val + 1 ) ] [ text "+" ] ] ) |> Mark.field "name" Mark.string |> Mark.toBlock 
Enter fullscreen mode Exit fullscreen mode

Adding interactivity

So far, this elm-markup document looks pretty static. In order to make it interactive and link counters to actual values by name, we will parse these them into functions that will allow us to inject values stored in the application model. This mind-bend is probably best illustrated by this shift in type signatures for the rendered block:

-- before counterBlock : Mark.Block (Html ( String, Float )) counterBlock = _ -- after type alias Values = Dict.Dict String Float counterBlock : Mark.Block (Values -> Html ( String, Float )) counterBlock = _ 
Enter fullscreen mode Exit fullscreen mode

There is nothing inherent in elm-markup that would stop us from taking this shift: if we're in charge of what value a piece of markup renders to, it might as well be a function. We can use this function to inject a dictionary of Values directly from our model and wire up the (String, Float) events that changes it in response to interacting with the counter buttons.

Our finished counter block looks like this:

-- We will use this helper throughout the rest of the example getValue : String -> Values -> Float getValue name values = values |> Dict.get name -- Values that are not kept track of yet are assumed to be the default |> Maybe.withDefault 0 counterBlock : Mark.Block (Values -> Html ( String, Float )) counterBlock = Mark.record "Counter" (\name values -> let value = getValue name values in div [] [ text (name ++ " = ") , button [ onClick ( name, value - 1 ) ] [ text "-" ] , text (String.fromFloat value) , button [ onClick ( name, value + 1 ) ] [ text "+" ] ] ) |> Mark.field "name" Mark.string |> Mark.toBlock 
Enter fullscreen mode Exit fullscreen mode

Doing something with our values

Now that our wiring is complete, we can simply define a Sum block that works with this markup:

|> Counter name = var1 |> Counter name = var2 |> Sum arg1 = var1 arg2 = var2 
Enter fullscreen mode Exit fullscreen mode

What this block will do is simply take the values from the two named counters, add them together, and communicate the result as var1 + var2 == 3.

The sum block implementation looks like this:

sumBlock : Mark.Block (Values -> Html ( String, Float )) sumBlock = Mark.record "Sum" (\arg1 arg2 values -> let res = getValue arg1 values + getValue arg2 values in div [] [ text (arg1 ++ " + " ++ arg2 ++ " == ") , text (String.fromFloat res) ] ) |> Mark.field "arg1" Mark.string |> Mark.field "arg2" Mark.string |> Mark.toBlock 
Enter fullscreen mode Exit fullscreen mode

Wiring it all up

Now that we have our blocks, we can write a method that compiles a document like this one:

markup : String markup = """ |> Title Interactive counteradder |> Counter name = var1 |> Counter name = var2 |> Sum arg1 = var1 arg2 = var2 """ 
Enter fullscreen mode Exit fullscreen mode

The compiler for it would look like this:

compileMarkup : String -> Result String (Values -> Html ( String, Float )) compileMarkup markdownBody = Mark.compile (Mark.document identity (Mark.manyOf [ titleBlock , counterBlock , sumBlock ] ) ) markdownBody |> (\res -> case res of Mark.Success blocks -> Ok (\data -> div [] (List.map -- Inject data into each block -- This makes them into regular `elm-html` nodes (\block -> block data) blocks ) ) _ -> Err "Compile error" ) 
Enter fullscreen mode Exit fullscreen mode

And finally, the main model, update and view:

type alias Model = { values : Values } type Msg = SetValue ( String, Float ) update : Msg -> Model -> Model update msg model = case msg of SetValue ( key, val ) -> { model | values = Dict.insert key val model.values } view : Model -> Html Msg view model = case compileMarkup markup of -- The success case yields a function that takes the current `Values` dictionary Ok viewByData -> viewByData model.values -- Map to the program message |> map SetValue Err err -> text err 
Enter fullscreen mode Exit fullscreen mode

For a full implementation, head to the full example Ellie or the same code as a gist.

Where will we go next?

I know, I know, adding numbers is not that exciting. Eventually, we'll add sliders to make elm-webgl drawings like this one, interactive.

Until then ☺️

Top comments (0)