santoku-template
View on LuaRockscompile-once template engine: text with embedded Lua blocks rendered against an env, the engine behind .tk file expansion in the toku build harness
santoku-template is a compile-once template engine: text with embedded <% ... %> Lua blocks becomes a render function that runs each block in an environment you provide. The surface is six functions (compile, compilefile, render, renderfile, serialize_deps, deserialize_deps) plus three names injected into every block (push, pop, showing), and the behavior on top is small and exact: blocks that return a string emit it, blocks that return nothing run for side effects and collapse the blank line they sit on, and a push/pop stack gates regions of output. It is the engine behind .tk file expansion in the toku build harness, where nginx confs, index.html, service workers, and Lua sources are rendered against the project configuration before bundling. String-based examples run in the page; the file-based and build-harness ones need a filesystem and are shown for reading.
Compile once, render many
compile(data, name) parses the template and returns a render function; call it with a fresh env each time. name, default "template", labels errors: a block that fails to compile raises "Invalid Lua code in template" with name:line. compilefile passes the file path as the name.
local template = require("santoku.template")
local render = template.compile("<title><% return title %></title>")
print(render({ title = "Hello, World!" }))
return render({ title = "Second render" })
The entry points
render(data, env, global) is compile-then-call in one step; text without blocks passes through verbatim. compilefile and renderfile are the same pair reading through santoku.fs.
local template = require("santoku.template")
print(template.render("plain text passes through verbatim"))
local via_render = template.render("<h1><% return heading %></h1>", { heading = "Docs" })
local via_compile = template.compile("<h1><% return heading %></h1>")({ heading = "Docs" })
print(via_render)
return tostring(via_render == via_compile)
Environments and globals
Blocks resolve names from env first, then fall back to the global table you pass (often _G), wired onto the env's __index chain.
local template = require("santoku.template")
local arr = require("santoku.array")
print(template.render("<% return tostring(#word) %>", { word = "loud" }, _G))
return template.render("<% return arr.concat(names, ', ') %>", { arr = arr, names = { "ada", "grace" } }, _G)
Each render gets a fresh environment
The render call copies env into a new table, so blocks share state within one render but assignments never write back to your table and never leak into the next render.
local template = require("santoku.template")
local t = template.compile("<% n = n + 1 %><% return 'n is ' .. n %>")
local env = { n = 1 }
print(t(env))
print(t(env))
return "caller still sees n = " .. env.n
Blocks return strings, or nothing
A returned value must be a string (convert numbers with tostring; anything else raises); a block with no return contributes only its side effects, visible to later blocks in the same render.
local template = require("santoku.template")
print(template.render("count: <% return tostring(count) %>", { count = 3 }, _G))
return template.render("<% total = price * qty %>total: <% return tostring(total) %>",
{ price = 3, qty = 4 }, _G)
Loops and markup from library helpers
Blocks are plain Lua, so list rendering is just santoku.array passed through env; this is the exact pattern the library's own spec uses to emit repeated attributes.
local template = require("santoku.template")
local arr = require("santoku.array")
local t = template.compile(
"<ul><% return arr.concat(arr.map(items, function (x) return '<li>' .. x .. '</li>' end)) %></ul>")
return t({ arr = arr, items = { "one", "two", "three" } })
Conditional sections with push and pop
push(cond) gates output until pop(); text and returned strings are dropped while the top is false.
local template = require("santoku.template")
local t = template.compile("<% push(admin) %>[admin panel] <% pop() %>welcome")
print(t({ admin = true }))
return t({ admin = false })
Nested gates compose with AND
push ANDs its condition with the current top, so an outer false gate silences everything inside it; showing() reads the current state from a block.
local template = require("santoku.template")
local t = template.compile("<% push(a) %>A<% push(b) %>B<% pop() %>a-tail<% pop() %>end")
print(t({ a = true, b = true }))
print(t({ a = true, b = false }))
return t({ a = false, b = true })
Whitespace collapsing
A block that returns nothing joins the blank line it would leave behind, and trailing whitespace after the final block is trimmed.
local template = require("santoku.template")
local out = template.render("first\n<% skipped = true %>\nlast")
print(out == "first\nlast")
local trimmed = template.render("value: <% return 'x' %>\n ")
return "[" .. trimmed .. "]"
Failures are immediate
A block that is not valid Lua raises during compile, before any render; a block returning a non-string raises during render. Both trap under santoku.error.pcall.
local err = require("santoku.error")
local template = require("santoku.template")
local ok = err.pcall(function ()
return template.compile("<% if %>")
end)
print("bad block compiles:", ok)
local ok2 = err.pcall(function ()
return template.render("<% return 42 %>")
end)
print("number return renders:", ok2)
return tostring(ok) .. " " .. tostring(ok2)
Includes are just env functions
The engine has no built-in include; pass a render helper through env and call it from a block.
local template = require("santoku.template")
local pages = {
layout = "<html><% return render(pages.body) %></html>",
body = "<b><% return msg %></b>",
}
local env
env = {
pages = pages,
msg = "hello",
render = function (s)
return template.compile(s)(env)
end,
}
return template.compile(pages.layout)(env)
Files: compilefile, renderfile, include chains
File variants read through santoku.fs; a renderfile helper threading a shared env nests templates across files, as in the library's index -> body -> body-content fixtures.
local template = require("santoku.template")
local fs = require("santoku.fs")
print(template.renderfile("test/res/template/title.html", { title = "Hello, World!" }))
local env
local function renderfile (fp)
return template.compile(fs.readfile(fp))(env, _G)
end
env = { renderfile = renderfile, title = "Hello, World!" }
return template.render("<% return renderfile('test/res/template/index.html') %>", env)
Makefile-style dependency rules
serialize_deps(source, dest, deps) emits one make rule, source: deps, from a dep set the caller recorded. dest must be a string but doesn't appear in the output, and dep order follows table iteration. deserialize_deps reads the first line back into a set.
local template = require("santoku.template")
local arr = require("santoku.array")
print(template.serialize_deps("index.html.tk", "index.29f7a64754b8.html", { ["res/header.html"] = true }))
local deps = template.deserialize_deps("index.html.tk: res/header.html res/footer.html")
local names = {}
for name in pairs(deps) do
names[#names + 1] = name
end
arr.sort(names)
return arr.concat(names, ", ")
How toku expands a .tk file
Any file with .tk in its name is templated and the .tk stripped from the destination (rules.copy, rules.exclude, and rules.template override the default); the harness renders against the project env plus a dep-recording readfile and depend, then writes the output and a one-line .d rule beside it. That step is add_file_target in santoku.make.common; at its core is one renderfile call.
local template = require("santoku.template")
local fs = require("santoku.fs")
local env = { client = { verbose = false }, readfile = fs.readfile }
local out = template.renderfile("client/lib/tasks/main.tk.lua", env, _G)
fs.writefile("client/lib/tasks/main.lua", out)
Config values into Lua source
A .tk.lua file bakes project configuration into the shipped module. toku web projects hand every template an env carrying name, version, client, server, nginx, environment, component, target, dist_dir, work_dir, hashed, readfile, and depend. Interpolation like this is for values. To leave code out of a build entirely, gate it with push and pop: a rendered runtime condition still compiles the dead branch and everything inside it into the output.
<% str = require("santoku.string") %>return {
name = <% return str.format("%q", name) %>,
version = <% return str.format("%q", version) %>,
verbose = <% return tostring(client.verbose or false) %>,
}
Templating an nginx conf
A server/nginx.tk.conf can be one block: it resolves content-hashed filenames with the build's hashed helper, assembles env directives from config, then hands a mustache conf source off to santoku.mustache against a context table. The block's readfile calls are recorded as dependencies, so the conf rebuilds when that source changes.
<%
local arr = require("santoku.array")
local ctx = nginx
ctx.index_hashed = hashed("index.29f7a64754b8.html")
ctx.common_js_hashed = hashed("common.js")
local env_lines = {}
for _, k in ipairs((server or {}).nginx_env_vars or {}) do
env_lines[#env_lines + 1] = "env " .. k .. ";"
end
ctx.nginx_env_lines = arr.concat(env_lines, "\n")
return require("santoku.mustache")(readfile("res/nginx.conf"))(ctx)
%>
Templating a PWA index.html
A static/index.tk.html builds the page with the santoku.web.pwa helpers: hashed asset links and the shared index builder over client.pwa config. csp = true has index compute a CSP meta tag from the finished markup's script hashes; the santoku-web tab covers the builder's options.
<%
local tbl = require("santoku.table")
local index = require("santoku.web.pwa.index")
return index(tbl.merge({
manifest = "/" .. hashed("manifest.20e5f4daaa85.json"),
favicon_svg = "/" .. hashed("favicon.f18d929ffecd.svg"),
}, client.pwa, {
title = "Example App",
sw = "/sw.js",
head = '<link rel="stylesheet" href="/' .. hashed("index.c9c791b043ea.css") .. '">',
csp = true,
}))
%>
The build engine templates itself
santoku-make's web project module is itself a .tk.lua file: when the engine is built, blocks inline its shell and config resources as base64 string constants, and at project build time add_templated_target_base64 decodes and renders them against the project env.
add_templated_target_base64(server_dir(base_server_run_sh),
<% return str.quote(str.to_base64(readfile("res/web/run.sh"))) %>, server_env)
add_templated_target_base64(server_dir(base_server_luarocks_cfg),
<% return str.quote(str.to_base64(readfile("res/web/luarocks.lua"))) %>, server_env)
What a .tk file can see, in a web project
The names available inside <% %> depend on which environment is rendering the file. The list below is what web projects inject. readfile and root_dir let a template pull in a sibling file, which is how a small .tk.conf renders a large template kept in res. hashed maps a logical asset name to its content-hashed filename and is the correct way to reference an asset from a template. In server/nginx.tk.conf, nginx is the render context and nginx.nginx is your descriptor's nginx block merged with the daemon, pid and log settings, which is why the scaffold opens with <% n = nginx.nginx %>. In every other template, nginx is the descriptor's block itself. modules maps a module name to the file path nginx should load it from. Note also that .tk expansion applies to every file in an environment, including test/spec, so a spec may be a template and embed build-time data the same way a source file can.
-- available inside <% %> when rendering a web project's templates:
readfile(path) -- read a file, relative to the project root
depend(path, prune) -- record a file or directory tree as a dependency
-- without reading it; returns path
root_dir -- absolute path to the project root
hashed(name) -- a deferred token, resolved after hashing;
-- in server/nginx.tk.conf it is the final name
version -- the descriptor's version
client, nginx -- the descriptor's client and nginx blocks
modules["a.b.c"] -- path of the file nginx should load for that module
openresty_dir -- the resolved OpenResty installation
lua_package_path -- this environment's private rock tree
lua_package_cpath
dist_dir, public_dir -- output directories for this environment
registered_public_files -- client.public, as a set
public_files_static_for_precache -- static files eligible for precaching