Building Template Engines in Lua
Overview
Lua doesn’t have a built-in template syntax, but the language gives you everything you need to build one. String manipulation is fast, string.gsub supports pattern capture, and tables serve as natural namespaces for template data. This guide walks through building a template engine from scratch, starting with simple interpolation and working up to a buffered engine with include support.
String Interpolation
The simplest approach is string interpolation. Given a template string with placeholders and a table of values, replace each placeholder with the corresponding value.
Lua’s manual includes a canonical example using string.gsub:
local function interpolate(str, vars)
return (str:gsub("%$(%w+)", function(key)
return tostring(vars[key] ~= nil and vars[key] or "nil")
end))
end
local template = "Hello, $name! You have $count messages."
local output = interpolate(template, { name = "Alice", count = 5 })
print(output)
-- Hello, Alice! You have 5 messages.
The parentheses around str:gsub force a single return value instead of the full match result. Without them, the function returns the string plus the number of replacements.
The pattern %$(%w+) matches a dollar sign followed by one or more word characters. For a more flexible syntax that supports dots and brackets in names:
local function interpolate(str, vars)
return (str:gsub("%$(%-?%w[%w%.%[%]]+)", function(key)
return tostring(vars[key] ~= nil and vars[key] or "nil")
end))
end
Lua Code in Templates
Interpolation alone covers simple cases. Real templates need logic: conditionals, loops, partials. A common pattern is marking code blocks with special delimiters:
local function eval(template, env)
local code = template
:gsub("<%%=(.+)", function(expr) -- <%= expression %>
return " .. tostring(" .. expr .. ") .. "
end)
:gsub("<%%(.-)%%>", function(stmt) -- <% code %>
return stmt
end)
:gsub("%%", "%%%%") -- escape remaining %
local fn = assert(load("return " .. code, "template"))
return fn()
end
This converts <%= name %> into .. tostring(name) .. and <% print("hi") %> into raw Lua code. Use with caution — the load call can execute arbitrary Lua.
A Buffered Template Engine
For production use, a buffered engine gives you better control over output and errors. Each template compiles into a function that writes to a buffer table:
local function compile(template)
local chunks = {}
local pos = 1
local function emit(str)
if str == "" then return end
table.insert(chunks, "out(" .. string.format("%q", str) .. ")")
end
local escaped = (template
:gsub("\\", "\\\\")
:gsub("\0", "\\0")
:gsub("\n", "\\n"))
escaped = escaped
:gsub("<%%=([^\n]-)%>", function(expr)
return "'; out(tostring(" .. expr .. ")); out('")
end)
:gsub("<%%(.-)%%>", function(code)
return "'; " .. code .. "; out('")
end)
emit(escaped)
local src = "local out = ... return function() out('" .. table.concat(chunks) .. "'); end"
return load(src, template) or function() return "" end
end
local function render(template, data)
local fn = type(template) == "string" and compile(template) or template
local buf = {}
local function out(s) buf[#buf + 1] = s end
local success, err = pcall(fn(nil, out), data)
if not success then
error("Template error: " .. tostring(err))
end
return table.concat(buf)
end
Usage:
local tmpl = [[
<h1><%= title %></h1>
<ul>
<% for i, item in ipairs(items) do %>
<li><%= item %></li>
<% end %>
</ul>
]]
local result = render(tmpl, {
title = "Shopping List",
items = { "Milk", "Bread", "Eggs" }
})
Handling Missing Values
What happens when a key is missing? Silently returning nil or empty string can hide bugs. A useful practice is to mark missing keys explicitly:
local MISSING = {}
local function safe_interpolate(str, vars)
return (str:gsub("%$(%w+)", function(key)
local v = vars[key]
if v == nil then
return "<" .. key .. "?>"
end
return tostring(v)
end))
end
This renders <name?> for any missing key, making it obvious in output that something is wrong.
Layouts and Partials
Once you have a buffer, adding partial include is straightforward. Store compiled templates in a registry:
local registry = {}
local function register(name, template)
registry[name] = compile(template)
end
local function partial(name, data)
local fn = registry[name]
if not fn then
error("Unknown partial: " .. name)
end
local buf = {}
local function out(s) buf[#buf + 1] = s end
pcall(fn(nil, out), data)
return table.concat(buf)
end
local function compile_with_partials(template)
return compile(template:gsub("<%include%[(%w+)%]%[(.-)%]%]", function(name, vars)
return "'; out(partial('" .. name .. "', {" .. vars .. "})); out('"
end))
end
Use in templates:
<%include[header][title="My Site"]%>
<main>
<%= content %>
</main>
<%include[footer][]%>
Escaping Output
Web templates need HTML escaping to prevent injection. A escape function handles the common characters:
local escape_map = {
["&"] = "&",
["<"] = "<",
[">"] = ">",
['"'] = """,
["'"] = "'"
}
local function h(s)
return (tostring(s):gsub("[&<>\"']", escape_map))
end
Then in the compiler, wrap interpolated expressions:
escaped = escaped
:gsub("<%%=(.-)%>", function(expr)
return "'; out(h(tostring(" .. expr .. "))); out('"
end)
Using LPeg for Complex Templates
For complex grammars — nested layouts, custom syntax, or mixed languages — LPeg is a better tool than pattern matching. You can define a grammar that parses the template into a sequence of Lua code chunks, then compile that.
Here’s a minimal LPeg template parser:
local lpeg = require("lpeg")
local Space = lpeg.S(" \t\n")^0
local Code = lpeg.P(function(_, pos, line)
local stop = line:find("<%%", pos, true)
if stop then return pos else return #line + 1 end
end)
local function parse(tmpl)
return lpeg.match((Code + Space)^0, tmpl)
end
For most cases, the string.gsub approach is sufficient and faster to write. Reach for LPeg when your template syntax gets complicated enough that raw patterns become fragile.
When to Use an Existing Library
Building your own template engine is educational and fine for small projects. For anything serving HTML to users, consider using an established library like Orbit (for Lua 5.1), Temple (Luarocks), or stencil (also on Luarocks). These handle edge cases — whitespace control, performance optimizations, security escaping — that are easy to overlook in a homebrew implementation.
See Also
- /guides/lua-string-patterns/ — Lua’s pattern language and what you can match with
string.gsub - /guides/lua-lpeg-parsing/ — LPeg for parsing complex grammars including template syntax
- /tutorials/pattern-matching/ — pattern matching fundamentals in Lua