luaguides

Building Template Engines in Lua

Overview

Lua doesn’t have a built-in template syntax, but the language gives you everything you need for building template engines from scratch. String manipulation is fast, string.gsub supports pattern capture, and tables serve as natural namespaces for template data. This guide walks through the process step by step, starting with simple interpolation and working up to a buffered engine with partial includes and HTML escaping.

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

The extended pattern %$(%-?%w[%w%.%[%]]+) allows variable‑path syntax like $person.name or $config["port"] inside template placeholders, making the interpolation function usable with nested table structures. While interpolation handles simple value substitution well, real templates often need control flow — conditionals to show or hide sections, loops to repeat blocks. The next approach uses delimited code tags that get compiled into Lua expressions, letting you embed arbitrary logic inside template strings:

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. Any expression placed inside <%= %> runs with full access to the calling environment, including os.execute and file I/O. Template engines that accept untrusted templates must run inside a sandboxed environment created with load’s fourth argument; the code injection surface is the same as eval in other languages. For simple variable substitution, the interpolation approach is safer and faster, but when templates need genuine logic, the code-generation technique gives you a complete Lua runtime inside your templates.

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

The compile function builds a Lua source string by transforming template delimiters into buffer‑write calls, then uses load to turn that string into an executable function at runtime. The render wrapper sets up the buffer table, calls the compiled function with the data as its environment, and uses pcall to catch any errors during execution. The compiled function receives the data table as its only argument, making fields like title and items directly accessible inside the template code. Here is how you use this engine to produce HTML output from a template string and a data table:

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

The example above produces clean output when all keys are present, but template engines often encounter data tables with missing fields — an article might lack an author, or a user profile could be missing an avatar URL. Rather than silently inserting nil (which tostring(nil) renders as the string "nil" in the output), you can detect missing values and flag them visibly. This helps catch configuration mistakes during development before they reach production:

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

The sentinel approach catches missing values at the interpolation level, but a complete template engine also needs the ability to include reusable template fragments; headers, footers, navigation bars; that appear across multiple pages. In Lua, you can build this on top of the existing compile infrastructure by maintaining a registry of named templates and injecting a partial function into the generated code. The compile_with_partials wrapper scans for include directives in the template source and replaces them with buffer‑write calls that invoke the partial renderer:

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

The compile_with_partials function uses string.gsub to match <%include[name][vars]%> patterns and rewrites them as calls to the partial helper. When the compiled template runs, each include directive triggers a lookup in the registry, compiles (or retrieves) the cached partial, renders it with the supplied variables, and inserts the result into the output buffer. The square‑bracket syntax for variables keeps the include directive self‑contained without needing to parse Lua expressions. Here is a complete template that pulls in a header and footer:

<%include[header][title="My Site"]%>
<main>
  <%= content %>
</main>
<%include[footer][]%>

Escaping Output

The template engine now renders HTML, but passing raw user-supplied strings straight into the output is dangerous; a username like <script>alert('xss')</script> would inject executable JavaScript into the page. Any template engine that produces HTML must escape the five XML-significant characters (&, <, >, ", ') before inserting values into the output. In Lua, this is straightforward with a lookup table and string.gsub, which accepts a table as its replacement argument and substitutes each match with the corresponding table value:

local escape_map = {
  ["&"] = "&amp;",
  ["<"] = "&lt;",
  [">"] = "&gt;",
  ['"'] = "&quot;",
  ["'"] = "&#39;"
}

local function h(s)
  return (tostring(s):gsub("[&<>\"']", escape_map))
end

The h function converts any value to a string and replaces dangerous characters using the escape map; gsub looks up each matched character in the table and substitutes the HTML entity. To integrate this into the buffered engine, you modify the code-generation step inside compile so that expression interpolation calls h(tostring(...)) instead of bare tostring(...). This wraps every <%= expr %> output through the HTML escaper, protecting against injection while keeping the template syntax unchanged:

escaped = escaped
  :gsub("<%%=(.-)%>", function(expr)
    return "'; out(h(tostring(" .. expr .. "))); out('"
  end)

Using LPeg for complex templates

For complex grammars with 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, and security escaping that are easy to overlook in a homebrew implementation. Libraries like Temple go further by pre-compiling templates to Lua bytecode, caching the result, and supporting inheritance hierarchies that would take days to replicate from scratch. Even if you never adopt one directly, studying their source code reveals patterns (buffer assembly, incremental rendering, scope isolation) that you can adapt for your own projects regardless of the framework you eventually choose. The core ideas — separating logic from presentation, building output incrementally through buffer writes, and compiling templates to callable functions — are universal across languages and worth internalizing.

See Also