Creating a HTML domain language in Elixir with macros
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.
-
[Polymorphism and Multimethods]
02-03-2023 -
[Global Day of CodeRetreat - recap]
07-11-2022 -
[House automation tooling - Part 4 - Finalized]
01-11-2022 -
[House automation tooling - Part 3 - London-School and Double-Loop]
02-07-2022 -
[Modern Programming]
14-05-2022 -
[House automation tooling - Part 2 - Getting Serial]
21-03-2022 -
[House automation tooling - Part 1 - CL on MacOSX Tiger]
07-03-2022 -
[Common Lisp - Oldie but goldie]
18-12-2021 -
[Functional Programming in (Common) Lisp]
29-05-2021 -
[Patterns - Builder-make our own]
13-03-2021 -
[Patterns - Builder]
24-02-2021 -
[Patterns - Abstract-Factory]
07-02-2021 -
[Lazy-sequences - part 2]
13-01-2021 -
[Lazy-sequences]
07-01-2021 -
[Thoughts about agile software development]
17-11-2020 -
[Test-driven Web application development with Common Lisp]
04-10-2020 -
[Wicket UI in the cluster - the alternative]
09-07-2020 -
[TDD - Mars Rover Kata Outside-in in Common Lisp]
03-05-2020 -
[MVC Web Application with Elixir]
16-02-2020 -
[Creating a HTML domain language in Elixir with macros]
15-02-2020 -
[TDD - Game of Life in Common Lisp]
01-07-2019 -
[TDD - classicist vs. London Style]
27-06-2019 -
[Wicket UI in the cluster - reflection]
10-05-2019 -
[Wicket UI in the Cluster - know how and lessons learned]
29-04-2019 -
[TDD - Mars Rover Kata classicist in Scala]
23-04-2019 -
[Burning your own Amiga ROMs (EPROMs)]
26-01-2019 -
[TDD - Game of Life in Clojure and Emacs]
05-01-2019 -
[TDD - Outside-in with Wicket and Scala-part 2]
24-12-2018 -
[TDD - Outside-in with Wicket and Scala-part 1]
04-12-2018 -
[Floating Point library in m68k Assembler on Amiga]
09-08-2018 -
[Cloning Compact Flash (CF) card for Amiga]
25-12-2017 -
[Writing tests is not the same as writing tests]
08-12-2017 -
[Dependency Injection in Objective-C... sort of]
20-01-2011