Lua can be a really cool HTML templating engine
Have you ever used Lua?
It’s a pretty cool language. It is also one of my favourite programming languages, for which I made a case in a past blog post.
One of my favourite aspects of Lua’s design that I like to preach about is how it’s really tight and small, while also being genuinely really sweet to write. Today, I’d like to focus on its Lisp-like aspect: domain specific languages (DSLs)—specifically, we will use it to build a templating language for HTML.
But first, let me set some background.
What’s in a template engine?
As a fellow blogger on the Internet maintaining their own blogging software, I’ve used my fair share of templating engines for generating HTML.
The premise of a templating engine is really simple: you have a bunch of literal text, and into that literal text is sandwiched a bunch of instructions on how to expand that literal text, given some parameters. Then, a web server can render the template, providing it the parameters to render with.
In other words, a template is a function params -> string.
Parameters go in, string goes out.
On this website, I’m using Handlebars: a fairly popular templating engine in the JavaScript world, although I’m using a Rust implementation of it myself. The syntax looks like this (this is the actual template I’m using to generate the “Blog” sidebar on the homepage):
<section class="feed">
<h1>{{ page.feed.title }}</h1>
{{#each page.feed.entries}}
<article>
<h2><a href="{{ url }}">{{{ title }}}</a></h2>
<div class="info">
<time datetime="{{ updated }}">{{ iso_date updated }}</time>
<ul class="categories">
{{#each tags as |tag|}}
<li><a href="{{ config.site }}/tag/{{ tag }}">#{{ tag }}</a></li>
{{/each}}
</ul>
</div>
</article>
{{/each}}
</section>
Between the HTML tags, you will find instructions for the template engine, enveloped in curly braces {{ }}.
This is a pretty common syntax among various template engines, although parts of it will vary from engine to engine—such as Handlebars’s non-escaping instructions, which triple the curlies {{{ }}}, or its block helpers {{#each}} and {{/each}}, which I’ll get to in a moment.
The most basic type of instruction is a lookup, which inserts a literal value into your output text. Let’s zoom in a bit:
<h2><a href="{{ url }} ">{{{ title }}} </a></h2>
Output
<h2><a href="https://riki.house/album-listener ">The album listener </a></h2>
Here, the template engine will look up the fields url and title in the parameters, and insert them into the output text.
Note how I had to use triple curly braces here, because Handlebars will escape the expanded text by default, such that any HTML tokens are presented literally on the page.
My server provides raw HTML in the title parameter, so we want to turn that off.
Note that lookups don’t have to be limited to simple field names, as the parameters passed may be an arbitrarily deep data structure—as is seen with the header:
<h1>{{ page.feed.title }} </h1>
Output
<h1>Blog </h1>
In addition to lookups, Handlebars also has helpers, which is a slightly redundant name for functions that are exposed to the template in addition to the parameters. (I mean, you could just name them functions!)
With helpers, the template may transform the input parameters in different ways, such as with iso_date in the example above, which is defined in Rust and used like this:
Definition
handlebars_helper!(iso_date: |d: DateTime<Utc>| d.format("%F").to_string());
handlebars.register_helper("iso_date", Box::new(iso_date));
Usage
<time datetime="{{ updated }} ">{{ iso_date updated }} </time>
Output
<time datetime="2026-03-19T14:22:00Z ">2026-03-19 </time>
Finally, we have a special class of helpers, called block helpers. While regular helpers take single values as parameters, block helpers take in a block in addition, allowing them to execute the block similarly to a closure in a real programming language—conditionally, in a loop, and so on.
Since block helpers need a start and an end delimiter, Handlebars opts for prefixing the helper name with # and / for the start and the end of the block respectively, like {{#each}} {{/each}}.
Let’s look at the example of {{#each}} from above, though shortened a bit for brevity:
{{#each page.feed.entries}}
<h2><a href="{{ url }} ">{{{ title }}} </a></h2>
{{/each}}
Output
<h2><a href="/album-listener ">The album listener </a></h2>
<h2><a href="/communal-chat-room ">The communal chat room </a></h2>
<h2><a href="/text-editor-wishlist ">Scheming on a text editor </a></h2>
<h2><a href="/along-rivers ">Out now: along rivers, forever adrift. </a></h2>
Here, {{#each}} will expand to as many headings as there are elements in the list provided to it.
An interesting feature (and, in my honest opinion, footgun) of many template engines that’s demonstrated here is their scoping system, which is akin to JavaScript’s with statements.
Essentially, lookups are done within a scope of the parameters.
In the beginning, if you use an identifier like url, the template engine will look it up in the parameters’ root.
However, once you enter a loop, that scope walks down to the current loop element—which is why {{ url }} in the example above refers to the feed entry’s URL, rather than the whole page’s URL.
Because of this, when you want to look up an identifier that isn’t within the loop element, you have to walk up the scope chain to get to it—in Handlebars, that’s written like {{ ../url }}.
I think you can imagine how this can quickly get error-prone and annoying to work with.
Either way, that about sums up the essentials of template engines.
Now, don’t get me wrong: I think template engines are great. They are simple and fast, when done well.
One of my favourite templating engines, Go’s text/template—whose design you should totally copy! (though, maybe sans the scoping…)—fits in less than 3000 lines of code as of writing this post.
It is a really comprehensive template engine, featuring basically everything I mentioned above, with a syntax design much nicer to look at than Handlebars, in my humble opinion.
… yeah I’m just dissatisfied with Handlebars, am I not.
Let’s be real, Handlebars doesn’t do anything more than Go’s text/template does, yet is 3x larger, at around 9000 lines of code excluding comments! (Meanwhile my estimation of text/template’s size was done entirely with a web browser and a calculator, no fancy comment exclusions.)
You could blame a lot of this on Rust’s lack of reflection system, but I don’t believe you need a reflection system to do templates…
In fact, you’re probably better off without one, since passing data to templates is like serialising to JSON, which I think should be done imperatively, kept separate from your data structure definitions. The static type system does not know about your templates, so by exposing your data structures to templates, you’re introducing a contract about your data structures’ API stability—which is not ideal, and annoying to enforce with how template engines tend to be dynamically typed.
But even then, Handlebars still has another ergonomic problem.
Remember that triple curly brace {{{ }}}?
<h2><a href="{{ url }}">{{{ title }}} </a></h2>
Yeah, that.
Kind of sucks that I have to do that, doesn’t it.
What if the data model changes, and the server no longer provides the template with a string that’s known-good-HTML? Then we’ve got ourselves an XSS vulnerability cooking, if the template is serving anything based on user input (which it will in most dynamic services.)
This is why Go also has a sister module for its text/template, called html/template.
Here’s how it fixes this problem:
-
Any time you want to provide raw HTML to the template, you’re encouraged to do it in your server code, by passing in
HTMLinstead of astring. - To my knowledge, there is no special syntax to tell the template engine, “don’t escape this.”
-
The module knows how to render non-HTML strings into templates by…
PARSING THE HTML??
It parses.
The HTML.
It parses.
(My browser chugged hard trying to load that page.)
At this point, it feels like we’re doing something backwards here. Why are we trying to parse the HTML we’re trying to render, a.k.a. generate, a.k.a. serialise?
Which, I guess, is exactly what the person behind maud was thinking when they made it.
Devising an approach that isn’t ass-backwards
Maud is a different kind of HTML template engine. Instead of being implemented as a generic language for transforming text, it is implemented as a Rust macro with its own syntax, that always produces valid HTML.
This seems like the more sensible approach—instead of trying to parse the HTML complexity monster, why don’t we rely on the host language to provide us with enough information to generate it correctly?
And the result is actually pretty neat-looking! I haven’t used it personally, but here’s an example from their website.
html! {
h1 { "Hello, world!" }
p.intro {
"This is an example of the "
a href="https://github.com/lambda-fairy/maud" { "Maud" }
" template language."
}
}
It’s pretty lightweight, and doesn’t lose the structure of the HTML. I like it.
The one thing I don’t like is that it’s a complicated, 3000 line-of-code long Rust macro.
It is an improvement in terms of density of functionality compared to html/template, and doesn’t suffer from the same ergonomic annoyances as Handlebars does.
But I don’t like that it’s a 3000 lines long Rust macro.
For those of you wondering why I’m so much against implementing this with Rust macros, here’s a short breakdown:
-
The tooling support sucks. The language server pretty much stops working inside macros, and the
rustfmtwill no longer format your code.That alone is 80% of why I hate doing that kind of stuff with macros. The remaining 20% is…
-
The compilation times.
maud depends on
synto parse Rust expressions, which means that has to get compiled before any other crate does (which takes a bit of time).The worse part, though, is that once you get to using the macros, the compiler—instead of just parsing your code—has to invoke the macro, which will parse your code, and then spit out a bunch of Rust code for the compiler to parse—increasing the amount of work that has to be done.
Don’t get me started on
#[derive]macros… maud fortunately isn’t one of them, but I’ll just briefly ruin your day by saying that they’re even worse, because each macro you use has to parse your type definition separately, in addition to the compiler, as well as other#[derive]macros you use. Shivers. -
And lastly, it’s inventing new syntax.
I’d love it if an HTML generation thing like maud could just use existing syntax instead of having to invent anything new. It would result in having less to learn, and less context switches having to look stuff up in the library’s documentation after not using it for a month.
For this same reason, I don’t really like JSX despite its unquestionable ergonomic benefits. It’s bolting on new features to a language in a pretty inelegant way, instead of reusing existing syntax. And it requires a compilation step.
Fortunately, there exists a programming language that ticks all the boxes.
Enter…
The Dark Side of the Moon
Lua, that ugly language with 1-based indexing, a weird inequality operator, and do..end blocks instead of my beloved curlies?
Look, I’m also one for curly hair, but I genuinely like Lua’s syntax. It can be pretty wordy, but in essence it is a bit like a tiny, dynamically typed Go.
A language that gets out of the way and lets you get things done.
At the same time, Lua has a pretty long history having started as a DSL for defining data, which influenced its design towards some really interesting choices that make it perfect for the thing we’re trying to make.
Let me start by introducing Lua’s most awesome feature: its single data structure, the table.
On the surface, they’re familiar: a table is nothing more than a hash table with dynamically typed keys. Since there is no other data structure in the language, they feature syntax sugar which lets them double as records or structs.
local cat = {
name = "meowy",
age = 1,
}
print(cat["name"]) --> meowy
print(cat.name) --> meowy
At the same time, since the language has no dedicated array data structure, there’s also a syntax sugar for declaring a table with incrementing integer keys, starting at 1. Simply omit the key:
local words = {"yippy", "foxgirl", "uwu"}
print(words[1]) --> yippy
It’s also possible to get the number of elements using the # operator.
print(#words) --> 3
Storing elements in this manner activates an optimisation called the table’s array part. When a table has a contiguous sequence of elements starting at 1, Lua will store them in a contiguous array in memory, making iteration and indexing more efficient than with a hash table.
Those are the basics, but a cool feature is that you can combine the two syntaxes together in one table initialiser. This will create a table both with string keys and an array part:
local document = {
language = "English",
"Hello! This is a line of text.",
"Meow."
}
print(document.language) --> English
print(document[1]) --> Hello! This is a line of text.
Notably, the length of the table is reported as 2 in this case, and there’s a function ipairs to iterate over only the array part in order of increasing indices, in addition to pairs which iterates over all keys in an arbitrary order.
print(#document) --> 2
for i, line in ipairs(document) do
print(i, line) --> 1 Hello! This is a line of text.
--> 2 Meow.
end
for k, v in pairs(document) do
-- Note how order is not preserved, and "language" comes last:
print(k, v) --> 1 Hello! This is a line of text.
--> 2 Meow.
--> language English
end
Another neat piece of syntax sugar Lua gained during its DSL roots is the ability to call a function with a table initialiser as its sole argument, without having to add extra parentheses:
print {"hello"} --> table: 0x55d727071ec0
The rationale was that this allows for validating data very easily, as you can inspect the table, check its fields for correctness, and then return it.
But technically, this can extend to returning a completely different, transformed table, which we’re going to use to great advantage.
Combining these two little syntax sugars with the flexibility of tables allows us to invent pretty sweet DSLs, like this one for constructing GUIs that I had mentioned in my old blog post on Lua:
root_window {
width = 800, height = 600,
title = "he is behind the tree",
align {
horz = "center", vert = "center",
vertical_stack {
text "Hello!",
button {
text = "waff",
on_click = function ()
print(":neofox_floof:")
end,
}
},
},
}
Here, all the little primitives like root_window, align, vertical_stack, or text, are functions in the global scope that take in a table as an argument, and return a widget.
So naturally, what’s stopping us from writing something like this for generating HTML?
…
Nothing, of course. So let’s go ahead and do it!
The part where I do the thing
Let’s establish what we’d like to accomplish with our DSL for generating HTML. The end result we’d like to achieve is for an expression like this:
h.Document{
lang = "en",
h.head{
h.meta{charset = "UTF-8"},
h.title{"Hello, world!"},
},
h.body{
h.h1{"Hello, world!"},
h.p{
"This is an example of the little HTML templating framework I wrote ",
"in Lua in like 20 minutes."
},
h.p{
"As you can see, it is fully capable of generating any kind of markup ",
"you'd ever want.", h.br(),
"It can even do things like ", h.b"bold text", "!"
},
h.p"This is some embedded HTML <p></p>"
h.img{
alt = "riki sitting in pink space",
src = "https://riki.house/static/character/riki/sitting.png",
width = 2223, height = 1796,
style = "width: 20%; height: auto;"
},
},
}
To generate HTML equivalent to the following:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello, world!</title>
</head>
<body>
<h1>Hello, world!</h1>
<p>This is an example of the little HTML templating framework I wrote in Lua in like 20 minutes.</p>
<p>As you can see, it is fully capable of generating any kind of markup you'd ever want.<br>
It can even do things like <b>bold text</b>!</p>
<p>This is some embedded HTML <p></p></p>
<img alt="riki sitting in pink space"
src="https://riki.house/static/character/riki/sitting.png"
width="2223" height="1796"
style="width: 20%; height: auto;">
</body>
</html>
I will begin by creating a namespace for our library. In Lua, you use an ordinary table for that. In a new file:
local html = {}
return html
From now on, treat any code examples as sandwiched between these two lines of boilerplate.
Going back to the example for a bit—note how any literal Lua string we try to put into the HTML must be escaped correctly.
For this, we will need a separate data type for differentiating HTML from plain strings.
I will call mine Html, because I like the convention of starting type names with an uppercase letter.
local Html = {}
function html.Html(text)
assert(type(text) == "string", "html.Html expects a string")
return setmetatable({text = text}, Html)
end
function Html:__tostring()
return self.text
end
We use a metatable to give the table returned by html.Html a prototype, which will help us differentiate our Html-type tables from any others.
I wrote about metatables at length in my article on implementing classes in Lua, so you might want to read that if you don’t know what they are.
We also give the metatable a __tostring function, which will allow us to call the standard tostring function on the table to get the rendered HTML.
tostring is also used when printing things to the console, so that’ll also take care of that functionality.
Armed with an Html type, we can write a basic function—no sugar yet—to produce HTML for us, given a tag and a definition table, containing the element’s attributes and children.
-- This little function will make it a bit more convenient to
-- append a bunch of new elements to a table.
local function write(t, ...)
local n = select('#', ...)
for i = 1, n do
table.insert(t, (select(i, ...)))
end
end
local function write_children(el, def)
for _, child in ipairs(def) do
if type(child) == "string" then
-- Text node
table.insert(el, child)
elseif type(child) == "table" and getmetatable(child) == Html then
-- HTML node
table.insert(el, child.text)
elseif type(child) == "table" then
-- Nested table.
-- This allows us to insert children from a `for` loop.
write_children(el, child)
end
end
end
function html.Element(kind, def)
-- open tag
local el = {"<", kind}
for k, v in pairs(def) do
if type(k) == "string" then
write(el, " ", k, '="', v, '"')
end
end
table.insert(el, ">")
-- children
write_children(el, def)
-- close tag
write(el, "</", kind, ">")
return html.Html(table.concat(el))
end
The function is called Element, again with an uppercase letter, because I’d like to make space for the syntax sugar we’re about to add later.
HTML is case-insensitive, so we’ll reserve uppercase names for specific functions, and lowercase names for element constructors—similar to how JSX works with lowercase names for literal elements, and uppercase for components.
A surprising technique to note here is that we use a table to collect all the HTML text to be rendered, which we then table.concat to obtain our final string.
We do this because strings in Lua are immutable, and using the string concatenation operator a..b incurs a new allocation every time we use it, which can get expensive really quickly.
It can get especially bad if the strings in question are big—because each allocation must copy the old string into the new string, thus getting worse and worse with each use of the operator.
So to avoid writing a Shlemiel the painter’s algorithm, we accumulate all the pieces into a table, and then join them together in one fell swoop.
In other languages, such as Go, this is called a string builder.
Anyways, with that out of the way, we can now construct some HTML!
Let’s try it out.
local html = require "html"
return html.Element("p", {"Hello, world!"})
Output
<p>Hello, world!</p>
So far, so good! Let’s keep going, and render a user comment. I’ll use some mock data, because this isn’t the time to be writing a comment section backend.
local function Comment(c)
return html.Element("article", {
class = "comment",
html.Element("span", {class = "user", comment.user}),
" says: ", comment.text
})
end
return Comment{
user = "riki",
text = "meow"
}
Output
<article class="comment"><span class="user">riki</span> says: meow</article>
Great! We can use this in pretty much the same way as React components.
Hang on, what are you–
local function Comment(c)
return html.Element("article", {
class = "comment",
html.Element("span", {class = "user", comment.user}),
" says: ", comment.text
})
end
return Comment{
user = "riki",
text = "<script>alert(1)</script>"
}
Output
<article class="comment"><span class="user">riki</span> says: <script>alert(1)</script> </article>
We forgot to escape the HTML, didn’t we.
Fortunately for us, this seems like a pretty simple thing to do; it involves substituting out all the illegal characters out of our untrusted input strings into nice, safe HTML escape codes. Let’s write a function that will do the substitution.
local escape_subs = {
["&"] = "&",
["<"] = "<",
[">"] = ">",
['"'] = """,
["'"] = "'",
}
local function escape_html(str)
return (str:gsub("([&<>\"'])", escape_subs))
end
There are a couple things to note here.
First of all, if you want to do this 100% correctly, it is not that simple. HTML is a weird language because it embeds CSS and JS inside, and those have their own rules as to how values have to be escaped. I’m skipping over that because HTML escapes handle 90% of the cases for a simple blog, but you’ll have to be wary around CSS and JS.
But regarding the code, what’s with the oddly formatted string argument we pass in to string.gsub?
local function escape_html(str)
return (str:gsub("([&<>\"'])" , escape_subs))
end
A lot of languages have libraries for regular expressions, but Lua aims to be really small. So small you can put it on a microcontroller! Meanwhile, regular expression engines tend to be really big, so bundling one with Lua would be a no-go.
Except, text processing is a really useful thing to have in a garbage collected language! Which is why Lua invented its own small language for matching strings, called patterns.
Patterns are really simple; they offer no backtracking, so you won’t find a | operator like in regular expressions.
Among what they can do however, is matching characters from a set—as seen above.
The pattern [&<>\"'] will match one of &, <, >, \, ", and '.
Used in string.gsub, it finds all occurrences of those characters, and replace them with… escape_subs, a table?
local escape_subs = {
["&"] = "&",
["<"] = "<",
[">"] = ">",
['"'] = """,
["'"] = "'",
}
local function escape_html(str)
return (str:gsub("([&<>\"'])", escape_subs ))
end
While we could use string.gsub like a simple plain text search-and-replace, and then chain a few of them together to replace all the illegal characters in several passes, it lets us do something a little bit more clever.
string.gsub will interpret a table-typed replacement argument as a lookup table for replacements.
If you wrap your pattern in a capture group—those parentheses ([&<>\"'])—string.gsub will use what’s matched inside those parentheses as a key to that table you provide, and replace the matched string with a string looked up from that table.
Pretty neat, huh?
This allows us to write a lookup table for all the characters we want to replace, instead of running string.gsub multiple times—which means better performance (as we only scan the string once), but also means less opportunity for bugs to creep in.
For example, the following is not the correct way to implement this function. Can you tell why? (Hint: it’s not that any of the substituted characters mean anything special when used in a pattern.)
local function escape_html(str)
return str
:gsub("<", "<")
:gsub(">", ">")
:gsub('"', """)
:gsub("'", "'")
:gsub("&", "&")
end
Going back to the original function for a bit once again, I’d like to explain another thing that might seem odd:
local function escape_html(str)
return ( str:gsub("([&<>\"'])", escape_subs))
end
Notice the extra parentheses around the returned value?
This is because string.gsub returns two values, with one being the string after replacements, and the other being the number of replacements it made.
Therefore, we have to collapse it back to just the output string, which is most conveniently done by wrapping the function call in parentheses.
With that out of the way, we can upgrade our html.Element function to escape untrusted strings and avoid wreaking XSS havoc all over our website.
function html.Element(kind, def)
-- open tag
local el = {"<", kind}
for k, v in pairs(def) do
write(el, " ", k, '="', escape_html(v) , '"')
end
table.insert(el, ">")
-- children
write_children(el, def)
-- close tag
write(el, "</", kind, ">")
return html.Html(table.concat(el))
end
Now, if someone tries to post an evil comment, their plans will be foiled:
return Comment{
user = "riki",
text = "<script>alert(1)</script>"
}
Output
<article class="comment"><span class="user">riki</span> says: <script>alert(1)</script> </article>
There is still one more important thing we have to implement for our implementation to be fully HTML-compliant, though: void elements.
Some elements in HTML cannot have children, and only have an opening tag.
Those elements include things like <img> or <input>.
We need to filter out those elements, and never emit any children or closing tags for them:
local void_elements = {
area = true, base = true,
br = true, col = true,
embed = true, hr = true,
img = true, input = true,
link = true, meta = true,
param = true, source = true,
track = true, wbr = true,
}
function html.Element(kind, def)
-- open tag
local el = {"<", kind}
for k, v in pairs(def) do
write(el, " ", k, '="', escape_html(v), '"') -- here
end
table.insert(el, ">")
if void_elements[kind] then
return html.Html(table.concat(el))
end
-- children
write_children(el, def)
-- close tag
write(el, "</", kind, ">")
return html.Html(table.concat(el))
end
Now if we try to create an <img> element, it will correctly be missing a closing tag.
return html.Element("img", {
alt = "riki sitting in pink space",
src = "https://riki.house/static/character/riki/sitting.png",
})
Output
<img alt="riki sitting in pink space" src="https://riki.house/static/character/riki/sitting.png">
And… that’s pretty much all we need to create well-formed HTML elements! That said, there is still one correctness improvement I’d like to do.
Remember how I said that pairs’s iteration order is undefined?
Knowing how hash tables work, there is a risk of it being non-deterministic, depending on the Lua implementation.
Now, I don’t know of any implementations of Lua that would randomise hash table order between runs of the program like Rust does, but it does feel kind of awry to be relying on an implementation detail that can change between versions like that. Who knows what LuaJIT could be doing?
And so, the last improvement I’d like to make is to sort all the attributes alphabetically to ensure implementation differences cannot introduce any non-determinism.
local function attr_cmp(a, b)
return a[1] < b[1]
end
function html.Element(kind, def)
local attr = {}
for k, v in pairs(def) do
if type(k) == "string" then
table.insert(attr, {k, v})
end
end
table.sort(attr, attr_cmp)
-- open tag
local el = {"<", kind}
for _, a in ipairs(attr) do
write(el, " ", a[1] , '="', escape_html(a[2] ), '"')
end
table.insert(el, ">")
table.insert(el, ">")
-- (omitted)
end
This is the kind of mistake that you only make once in life. Ask me how I know.
With that out of the way, we can now move onto some ergonomic improvements, because typing html.Element("p", {"This is some text"}) just to display some silly text gets old pretty quick.
So does reading it.
Ergonomic improvements
Remember how I hyped up all the cool domain specific language stuff Lua is capable of, only to never follow up on it?
This is that section where I follow up on it.
Let’s start with the most important bit: how do we make it so that we can create elements using the syntax html.p{} instead of html.Element("p", {})?
If you’ve read my blog post on implementing classes, you may remember that metatables have an __index metamethod that allows us to override the indexing operator a[b] (and likewise a.b) for keys that are not in the table.
Therefore, let’s override html’s __index, to return a function which creates an element with the given name—accepting only a single table argument, therefore making it possible to use the shorthand f{} syntax with it.
setmetatable(html, html)
function html.__index(html, key)
local function thunk(def)
return html.Element(key, def)
end
html[key] = thunk -- memoise for future use; __index will not be called then
return thunk
end
Note how we save the function in the html namespace for later, so that subsequent calls to it do not recreate it—reducing the amount of meaningless work for the computer to do.
Now, lo and behold, our comment example from before can become much cleaner:
-- Before
local function Comment(c)
return html.Element("article", {
class = "comment",
html.Element("span", {class = "user", comment.user}),
" says: ", comment.text
})
end
-- After
local function Comment(c)
return html.article {
class = "comment",
html.span {class = "user", comment.user},
" says: ", comment.text
}
end
An annoying thing is that custom elements are still a bit of a bother to use.
For those of you unfamiliar with custom elements, it’s an API that allows you to declare custom HTML elements from JavaScript.
class Clock extends HTMLElement {
connectedCallback() {
this.format = this.getAttribute("data-format") ?? "short";
this.update();
setInterval(() => this.update(), 1000);
}
update() {
let now = new Date();
this.innerText = now.toLocaleTimeString([], { timeStyle: this.format });
}
}
customElements.define("riki-clock", Clock);
With this script, when we write <riki-clock data-format="short"></riki-clock> in our HTML, the extra behaviour from the Clock class will be attached to it.
A notable thing is that all names of custom elements have to include a dash - in them, to encourage namespacing.
It’s a pretty useful API, and I use it all the time in my web apps, so it’d be good if constructing those custom elements in our Lua templating engine didn’t have to look like this:
return html["riki-clock"]{["data-format"] = "short"}
Output
<riki-clock data-format="short"></riki-clock>
That’s not even shorter than the raw HTML!
Fortunately, underscores aren’t really used at all in HTML tags and attribute names, so I think the right opinion to take here is to substitute them into dashes automatically.
function html.Element(kind, def)
local attr = {}
for k, v in pairs(def) do
if type(k) == "string" then
local k = k:gsub("_", "-")
table.insert(attr, {k, v})
end
end
table.sort(attr, attr_cmp)
-- open tag
local el = {"<", kind}
-- (omitted)
end
function html.__index(html, key)
key = key:gsub("_", "-")
local function thunk(def)
return html.Element(key, def)
end
html[key] = thunk -- memoise for future use; __index will not be called then
return thunk
end
With that out of the way, the riki clock can now be assembled with much less line noise than before.
return html.riki_clock{data_format = "short"}
Output
<riki-clock data-format="short"></riki-clock>
As another space for improvement, I’d like to turn your attention to how <img> tags have to be constructed right now.
print(h.img{
alt = "riki sitting in pink space",
src = "https://riki.house/static/character/riki/sitting.png",
width = "2223", height = "1796",
})
If you compare this to the version we wanted to have at the beginning, you will notice one small detail: in this version, the width and height attributes have to be provided as strings!
That’s kind of annoying, especially if the sizes are coming in from some outside function that returns them as integers.
To deal with that, we will add one last line to html.Element, to convert all attribute values to strings automatically.
function html.Element(kind, def)
local attr = {}
for k, v in pairs(def) do
if type(k) == "string" then
local k = k:gsub("_", "-")
local v = tostring(v)
table.insert(attr, {k, v})
end
end
table.sort(attr, attr_cmp)
-- open tag
local el = {"<", kind}
-- (omitted)
end
To static typing-minded folks, this might look horrible, but I feel like this is one of those cases where being liberal in what you accept and strict in what you produce gives you some really nice results.
Besides, this behaviour is consistent with the rest of Lua, where concatenating strings will call tostring implicitly:
print("Score: "..1) --> Score: 1
As one last minor improvement, let’s make the function call itself a little bit more ergonomic, and make it easier to construct tags containing only text—such as <b>bold</b>—or nothing at all, such as <br> or <wbr>.
function html.Element(kind, def)
if type(def) == "string" then
def = {def}
end
if def == nil then
def = {}
end
local attr = {}
for k, v in pairs(def) do
if type(k) == "string" then
-- (omitted)
end
To tie the library together, let’s make it a bit easier to start a document with a <!doctype html> at the beginning.
function html.Document(def)
return html.Html("<!doctype html>"..tostring(html.Element("html", def)))
end
And there you go!
The whole library can be wrapped up in 117 lines of code. Here’s the full code listing, feel free to add it into your projects and tweak it to taste:
local html = {}
setmetatable(html, html)
local escape_subs = {
["&"] = "&",
["<"] = "<",
[">"] = ">",
['"'] = """,
["'"] = "'",
}
local function escape_html(str)
return (str:gsub("([&<>\"'])", escape_subs))
end
local function write(t, ...)
local n = select('#', ...)
for i = 1, n do
table.insert(t, (select(i, ...)))
end
end
local Html = {}
function html.Html(text)
assert(type(text) == "string", "html.Html expects a string")
return setmetatable({text = text}, Html)
end
function Html:__tostring()
return self.text
end
local void_elements = {
area = true,
base = true,
br = true,
col = true,
embed = true,
hr = true,
img = true,
input = true,
link = true,
meta = true,
param = true,
source = true,
track = true,
wbr = true,
}
local function attr_cmp(a, b)
return a[1] < b[1]
end
local function write_children(el, def)
for _, child in ipairs(def) do
if type(child) == "string" then
table.insert(el, escape_html(child))
elseif type(child) == "table" and getmetatable(child) == Html then
table.insert(el, child.text)
elseif type(child) == "table" then
write_children(el, child)
end
end
end
function html.Element(kind, def)
if type(def) == "string" then
def = {def}
end
if def == nil then
def = {}
end
local attr = {}
for k, v in pairs(def) do
if type(k) == "string" then
local k = k:gsub("_", "-")
local v = tostring(v)
table.insert(attr, {k, v})
end
end
table.sort(attr, attr_cmp)
-- open tag
local el = {"<", kind}
for _, a in ipairs(attr) do
write(el, " ", a[1], '="', escape_html(a[2]), '"')
end
table.insert(el, ">")
if void_elements[kind] then
return html.Html(table.concat(el))
end
-- children
write_children(el, def)
-- close tag
write(el, "</", kind, ">")
return html.Html(table.concat(el))
end
function html.Document(def)
return html.Html("<!doctype html>"..tostring(html.Element("html", def)))
end
function html.__index(html, key)
key = key:gsub("_", "-")
local function thunk(def)
return html.Element(key, def)
end
html[key] = thunk -- memoise for future use; __index will not be called then
return thunk
end
return html
Idioms
I hear you say, “riki, but this library is incomplete! It doesn’t even have ifs and fors like Handlebars or maud do.”
Here is how you would implement ifs:
local content_editable, username = ...
local function iif(cond, t, f)
if cond then return t else f end
end
return html.p{
contenteditable = iif(content_editable, "", nil),
"Hello, "..(username or "guest").."!",
username and html.a{href = "/settings", "settings"} or "",
}
In short:
-
For conditionally including text or HTML, you can use the Lua
cond and true_value or false_valueidiom.Note that you don’t want any stray
nils in the table, because that would break iteration viaipairs—so you have to use an empty string or an empty table. -
For conditionally enabling attributes, you can write an
iif(immediate if) function that returns one of two options.The
cond and t or fidiom I mentioned above can’t work due tonilbeing false, and therefore theoroperator always taking the left branch—which will not work as expected ifcond == false.
And here is how you would implement fors:
local users = ...
local function map(t, f)
local r = {}
for i, v in ipairs(t) do
r[i] = f(v)
end
return r
end
return html.ul{
map(users, function (user)
return html.li{user.name}
end)
}
Of course, an immediately invoked function will also do fine, but having a map function around in your codebase is really handy for transforming all kinds of data.
And that’s about it, I think. Hope you enjoyed the post! It took me many hours to finish.
And I hope you learned something nice about Lua :3
Thank you to my good friend Anya for giving me some thorough feedback on a draft of this post.
