DEV Community

Peter Szerzo
Peter Szerzo

Posted on • Updated 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)