DEV Community

HTML Templating: The functional Way

Foreword

In this article I will reconstruct my development process for a tiny but powerful helper I've built myself to generate DOM nodes using Javascript. ๐Ÿ˜

The reason why I built this in the first place is that I just don't like writing HTML, and most templating engines out there are essentially just glorified string interpolation. ๐Ÿ˜–

This also isn't really new code: for the most part, it's a rewrite of a Lua library that implements the exact same mechanism. ๐Ÿ˜…

With that being said...

Let's get started!

For a starting point, let's define a simple function that creates an HTML element:

export const node = (name) => {
   const element = document.createElement(name)
   return element
}
Enter fullscreen mode Exit fullscreen mode

Okay, simple enough. But that's little more than an alias to document.createElement(). We'll want to pass more arguments; the function should return the (mostly) finished DOM node.

Setting up additional arguments

To allow nested structures, let's use a recursive function to handle the arguments. First add it to the node function:

export const node = (name, args) => {
   const element = document.createElement(name)
   parseArgs(element, args)
   return element
}
Enter fullscreen mode Exit fullscreen mode

Note: some readers might already realize that we could collect many args into an array with ...args; don't worry, I'll get to that in another step ๐Ÿ˜‰

Attributes

And now we implement it. Let's start with a simple case: an empty element with some attributes: node("div", [{class:"box"}]) should return an element like <div class="box">.

+ const parseArgs = (element, args) => {
+    for (arg of args)
+       for (key in arg)
+          element.setAttribute(key, arg[key])
+ }
Enter fullscreen mode Exit fullscreen mode

Text Content

Good! That's progress! But what's the point of HTML elements if they can only be empty? When we call node("p", ["Hello, World!"]), it should return a <p> element with the text "Hello, World!". Let's add that logic as well:

  const parseArgs = (element, args) => {
     for (arg of args)
+       if (typeof(arg) == "string")
+          element.appendChild(document.createTextNode(arg))
+       else
            for (key in arg)
               element.setAttribute(key, arg[key])
  }

Enter fullscreen mode Exit fullscreen mode

And with that, most of the hard work is done already. Structurally, we're done; all that's left is add more cases.

Nested Arrays

First, let's do the recursion thing. It's useful if we don't have to flatten our input before passing it in, so let's add a recursive case for array arguments. Let's turn node("p", ["foo", ["bar", "baz"]]) into <p>foobarbaz</p>:

  const parseArgs = (element, args) => {
     for (arg of args)
        if (typeof(arg) == "string")
           element.appendChild(document.createTextNode(arg))
+       else if ("length" in arg)
+          parseArgs(element, arg)
        else
           for (key in arg)
              element.setAttribute(key, arg[key])
  }

Enter fullscreen mode Exit fullscreen mode

Child Elements

And lastly, the nicest part: adding other HTML elements as children. This is very similar to the string case:

  const parseArgs = (element, args) => {
     for (arg of args)
        if (typeof(arg) == "string")
           element.appendChild(document.createTextNode(arg))
+       else if ("nodeName" in arg)
+          element.appendChild(arg)
        else if ("length" in arg)
           parseArgs(element, arg)
        else
           for (key in arg)
              element.setAttribute(key, arg[key])
  }

Enter fullscreen mode Exit fullscreen mode

Trying it out

And with that, our basic HTML rendering mechanism is done. We can now write code like:

let navlink = (name) =>
   node("a", [{href: "/"+name}, name])

let menu = (items) => node("nav", [
   node("ul", [
      items
      .map(navlink)
      .map(link => node("li", [link])
   ])
])

body.appendChild(menu(["home", "about", "contact"]))
Enter fullscreen mode Exit fullscreen mode

It works, and we can do a whole lot of code-reuse with that setup already. But it feels very cumbersome to use:

  • Passing the node type as a string ๐Ÿ’ข
  • Wrapping arguments in an array ๐Ÿ˜ฉ
  • Having to use a wrapper function with map ๐Ÿค”

Another layer of convenience

To remedy these, let's do some meta-programming and build ourselves a nice wrapper around this:

export const html = new Proxy({}, {
   get: (_, prop) => (...args) => node(prop, args)
})
Enter fullscreen mode Exit fullscreen mode

And that's it! When we index our new html Proxy object with a string, like html.div; it will automatically return a wrapper for the node function that adds the node type and collects all the arguments into an array.

This only leaves the problem with map: this array method passes the array item as the first argument to the callback function, but then also adds the index and the array as well. This means those values will get passed to our node function and cause problems.

To fix this, we can change the Proxy like this:

export const html = new Proxy(Window, {
   get: (target, prop, receiver) => {
      if (prop.search(/^[A-Z]/)+1)
         return (arg) => node(prop, [arg])
      else
         return (...args) => node(prop, args)
   }
})
Enter fullscreen mode Exit fullscreen mode

Now, when we index our html proxy with a string starting with uppercase, it will return a different closure that only passes its first argument.

Trying out the changes

We can now rewrite the example above as:

let navlink (name) => html.a({href: "/"+name}, name)

let menu = items => html.nav(
   html.ul(
      items
      .map(navlink)
      .map(html.Li)
   )
)

body.appendChild(menu(["home", "about", "contact"]))
Enter fullscreen mode Exit fullscreen mode

And that was it ๐Ÿ˜

If you have any questions or feedback, please leave a comment. If there's anything in the code that needs clarifying, tell me and I will extend the post or maybe write a new one ๐Ÿ’–

Top comments (0)