luaguides

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 = {
  ["&"] = "&amp;",
  ["<"] = "&lt;",
  [">"] = "&gt;",
  ['"'] = "&quot;",
  ["'"] = "&#39;"
}

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