[blog] | [projects] | [about] | [imprint]

Creating a HTML domain language in Elixir with macros

15 February 2020
 

In this post we'll do a bit of exploration with Elixir macros and create our own little HTML DSL that will be part of a larger exploration project that develops a simple MVC based web framework.

This DSL should have a frontend and a backend that actually generates the HTML representation. For now it should use Eml to generate the HTML representation and the to_string conversion.
However, it would be possible to also create an implementation that uses EEx as a backend. And we could switch the backend without having the API user change its code.

So here is what we have to do to create a HTML DSL.

First we need a collection of tags. I have hardcoded them into a list:

  @tags [:html, :head, :title, :base, :link, :meta, :style,
         :script, :noscript, :body, :div, :span, :article, ...]

Then I want to allow to define tags in two styles. A one-liner style and a style with a multi-line body to be able to express multiple child elements.

# one-liner
span id: "1", class: "span-class", do: "my span text"

# multi-liner
div id: "1", class: "div-class" do
  span do: "my span text"
  span do: "my second text"
end

We need two macros for this. The do: in the one-liner is seen just as an attribute to the macro. So we have to strip out the do: attribute and use it as body. The macro for this looks like this:

  defmacro tag(name, attrs \\ []) do
    {inner, attrs} = Keyword.pop(attrs, :do)
     quote do: HouseStatUtil.HTML.tag(unquote(name),
                                      unquote(attrs), do: unquote(inner))
  end

First we extract the value for the :do key in the attrs list and then pass the name, the remaining attrs and the extracted body as inner to the actual macro which looks like this and does the whole thing.

  defmacro tag(name, attrs, do: inner) do
    parsed_inner = parse_inner_content(inner)
    
    quote do
      %E{tag: unquote(name),
         attrs: Enum.into(unquote(attrs), %{}),
         content: unquote(parsed_inner)}
    end
  end

  defp parse_inner_content({:__block__, _, items}), do: items
  defp parse_inner_content(inner), do: inner

Here we get the first glimpse of Eml (the %E{} in there is an Eml structure type to create HTML tags). The helper function is to differentiate between having an AST as inner block or non-AST elements. But I don't want to go into more detail here.
Instead I recommend reading the book Metaprogrammning Elixir by Chris McCord which deals a lot with macros and explains how it works.

But something is still missing. We now have a tag macro. With this macro we can create HTML tags like this:

tag "span", id: "1", class: "class", do: "foo"

But that's not yet what we want. One step is missing. We have to create macros for each of the defined HTML tags. Remember the list of tags from above. Now we take this list and create macros from the atoms in the list like so:

for tag <- @tags do
  defmacro unquote(tag)(attrs, do: inner) do
    tag = unquote(tag)
    quote do: HouseStatUtil.HTML.tag(unquote(tag), unquote(attrs), do: unquote(inner))
  end
 
  defmacro unquote(tag)(attrs \\ []) do
    tag = unquote(tag)
    quote do: HouseStatUtil.HTML.tag(unquote(tag), unquote(attrs))
  end
end

This creates three macros for each tag. I.e. for span it creates: span/0, span/1 and span/2. The first two are because the attrs are optional but Elixir creates two function signatures for it. The third is a version that has a do block.

With all this put together we can create HTML as Elixir language syntax. Checkout the full module source in the github repo.

Testing the DSL

Of course we test this. This is a test case for a one-liner tag:

  test "single element with attributes" do
    elem = input(id: "some-id", name: "some-name", value: "some-value")
    |> render_to_string

    IO.inspect elem

    assert String.starts_with?(elem, "<input")
    assert String.contains?(elem, ~s(id="some-id"))
    assert String.contains?(elem, ~s(name="some-name"))
    assert String.contains?(elem, ~s(value="some-value"))
    assert String.ends_with?(elem, "/>")
  end

This should be backend agnostic. So no matter which backend generated the HTML we want to see the test pass.

Here is a test case with inner tags:

  test "multiple sub elements - container" do
    html_elem = html class: "foo" do
      head
      body class: "bar"
    end
    |> render_to_string

    IO.inspect html_elem

    assert String.ends_with?(html_elem, 
      ~s())
  end

The source file has more tests, but that should suffice as examples.

That was it. Thanks for reading.

[atom/rss feed]