santoku
santoku

santoku

A Lua framework for building applications end to end

Lua 5.1 from the build system to the server to the browser. Every example on this site runs in your browser. Edit one and run it.


santoku targets Lua 5.1, because 5.1 is the version its platforms actually share. LuaJIT implements 5.1 and OpenResty embeds LuaJIT, so server code, client code and the build harness all run the same language. Compiled to WebAssembly, the 5.1 core is smaller to embed, and its single number type sidesteps the integer and float split introduced in 5.3, which keeps numeric values crossing the JavaScript boundary simple. Support for newer versions is meant to come from compatibility shims layered over the 5.1 core rather than version branches in the tree; contributions and design ideas for that layer are welcome.

santoku-template

View on LuaRocks

compile-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 })

Hidden blocks still execute

Gating filters output only: every block runs regardless of the stack, so side effects made inside a false gate are visible afterward.

local template = require("santoku.template") local arr = require("santoku.array") local t = template.compile(arr.concat({ "<% push(false) %><% ran = true %>", "<% return 'hidden' %><% pop() %>", "<% return ran and 'the hidden block ran' or 'it did not run' %>", })) return t()

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)
Illustrative: runs outside the browser.

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)
Illustrative: runs outside the browser.

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) %>, }
Illustrative: runs outside the browser.

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) %>
Illustrative: runs outside the browser.

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, })) %>
Illustrative: runs outside the browser.

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)
Illustrative: runs outside the browser.

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
Illustrative: runs outside the browser.