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-mustache

View on LuaRocks

mustache template rendering in C: compile once, render Lua values to strings, with sections, partials, and dedent

santoku-mustache is the runtime template renderer of the framework: a single C module wrapping the vendored mustach 1.2.10 library with every extension enabled, rendering Mustache templates against plain Lua values. The module is one function: calling it compiles a template (optionally with partials, and dedented by default) and returns a render closure; calling that closure with a table, number, boolean, or nil produces a string. On top of core Mustache (variables, dotted paths, sections, inversion, iteration, partials, comments, custom delimiters) the extension set adds equality and comparison guards on tag names, object iteration with {{*}}, and escaped-dot and JSON pointer syntax for awkward keys. toku web projects use it for HTML template packs and generated config; it is distinct from santoku-template, which expands <% ... %> Lua blocks at build time.

Compile once, render many

Variables, dot notation, and missing keys. Numbers format through %.14g, booleans render as true or false, and the template is captured at compile time.

local mch = require("santoku.mustache") local render = mch("{{greeting}} {{target}}") print(render({ greeting = "hello", target = "world" })) print(mch("{{a.b.c}}")({ a = { b = { c = "value" } } })) print("missing renders empty:", "[" .. mch("{{missing}}")({}) .. "]") print(mch("{{num}} {{flag}}")({ num = 42, flag = true })) print(mch("{{a}} {{b}}")({ a = 2.5, b = 1e15 })) return render({ greeting = "goodbye", target = "moon" })

Scalar contexts

The root context does not have to be a table: numbers, booleans, and nil are accepted, reached with {{.}} and testable with sections. Strings are not valid roots.

local mch = require("santoku.mustache") local dot = mch("{{.}}") print(dot(42)) print(dot(true)) print("nil root:", "[" .. dot(nil) .. "]") print(mch("{{#.}}yes{{/.}}")(true)) print(mch("{{^.}}no{{/.}}")(false)) return mch("{{.}} bottles")(99)

Sections and truthiness

Sections gate on Lua-flavored truthiness: nil, false, 0, the empty string, and the empty table are falsy; everything else, including a single space, is truthy.

local mch = require("santoku.mustache") local sec = mch("{{#show}}yes{{/show}}{{^show}}no{{/show}}") print("true:", sec({ show = true })) print("false:", sec({ show = false })) print("zero:", sec({ show = 0 })) print("empty string:", sec({ show = "" })) print("empty table:", sec({ show = {} })) print("space:", sec({ show = " " })) return sec({ show = "truthy" })

Inverted sections as fallbacks

Pairing a section with its inversion is the idiomatic default-value pattern: render the value when present, a literal otherwise.

local mch = require("santoku.mustache") local host = mch("{{#host}}{{.}}{{/host}}{{^host}}localhost{{/host}}") print(host({ host = "db1.internal" })) print(host({})) local flag = mch("{{^quiet}}verbose logging on{{/quiet}}") print(flag({})) return flag({ quiet = true })

Iterating arrays

A contiguous array-like table repeats its section once per element: {{.}} is the element itself, and field tags read from element records.

local mch = require("santoku.mustache") print(mch("{{#items}}{{.}} {{/items}}")({ items = { 1, 2, 3 } })) print(mch("{{#items}}{{.}}{{/items}}")({ items = {} })) local rows = mch("{{#items}}{{name}}={{val}};{{/items}}") return rows({ items = { { name = "a", val = 1 }, { name = "b", val = 2 }, } })

Nested context and the scope chain

Inside a section, bare names walk outward through every enclosing scope until one has the key. Dotted paths are stricter: only the first key uses the chain, the rest must resolve directly.

local mch = require("santoku.mustache") local ctx = { top = "T", name = "outer", child = { name = "inner" } } print(mch("{{name}}-{{#child}}{{name}}{{/child}}")(ctx)) print(mch("{{#child}}{{top}}/{{name}}{{/child}}")(ctx)) print("no chain for dots:", "[" .. mch("{{child.top}}")(ctx) .. "]") return mch("{{child.name}}")(ctx)

Sections over scalars and nested arrays

Entering a truthy scalar exposes it as {{.}}, and {{#.}} re-enters the current item, which makes matrices walkable without names.

local mch = require("santoku.mustache") print(mch("{{#n}}n is {{.}}{{/n}}")({ n = 5 })) print(mch("{{#rows}}{{#.}}{{.}}{{/.}}|{{/rows}}")({ rows = { { 1, 2 }, { 3, 4 } }, })) return mch("{{#word}}[{{.}}]{{/word}}")({ word = "hi" })

What counts as an array

Iteration requires integer keys 1..n with no gaps. A sparse or mixed table is entered once as a plain object scope instead, and a table interpolated as a value renders empty.

local mch = require("santoku.mustache") print(mch("{{#items}}x {{/items}}")({ items = { 1, 2, 3 } })) local sparse = { 1, 2, 3 } sparse[5] = 9 print(mch("{{#items}}x {{/items}}")({ items = sparse })) local mixed = { "a", "b", label = "tagged" } print(mch("{{#m}}{{label}}{{/m}}")({ m = mixed })) return "[" .. mch("{{t}}")({ t = { 1, 2 } }) .. "]"

Object iteration with .* and {{*}}

Appending .* to a section name iterates a table's key-value pairs: {{*}} is the current key, {{.}} the value, and record fields resolve normally. A bare {{#*}} iterates the root. Order follows Lua's pairs and is unspecified for multi-key tables.

local mch = require("santoku.mustache") print(mch("{{#config.*}}{{*}}={{.}}{{/config.*}}")({ config = { host = "localhost" }, })) print(mch("{{#users.*}}{{*}}:{{name}}{{/users.*}}")({ users = { u1 = { name = "Ada" } }, })) return mch("{{#*}}{{*}}={{.}};{{/*}}")({ greeting = "hello" })

HTML escaping

Double braces escape exactly four characters: angle brackets, ampersand, and double quote (single quotes pass through). Triple braces and the ampersand form emit raw.

local mch = require("santoku.mustache") print(mch("{{h}}")({ h = "<b>" })) print(mch("{{{h}}}")({ h = "<b>" })) print(mch("{{&h}}")({ h = "<b>" })) print(mch("{{q}}")({ q = "\"a\" & 'b'" })) return mch("<span>{{{icon}}}</span>")({ icon = "<svg viewBox=\"0 0 24 24\"></svg>", })

Comments and custom delimiters

{{! ... }} emits nothing, and {{=open close=}} swaps the delimiter pair (up to 8 characters each) for the rest of the template, useful when the output format itself uses braces.

local mch = require("santoku.mustache") print(mch("a{{! hidden note }}b")({})) local alt = mch("{{=<% %>=}}<% name %> and <%# on %>a section<%/ on %>") print(alt({ name = "world", on = true })) return mch("{{=[ ]=}}[greeting]")({ greeting = "hi" })

Awkward key names

A backslash-escaped dot keeps a literal dot inside one key, a leading colon takes the rest of the tag verbatim, and a colon plus slash switches to JSON pointer syntax where slashes separate keys and dots are literal.

local mch = require("santoku.mustache") local ctx = { a = { b = "nested" }, ["a.b"] = "flat" } print(mch("{{a.b}}")(ctx)) print(mch("{{a\\.b}}")(ctx)) print(mch("{{:#tag}}")({ ["#tag"] = "hash key" })) print(mch("{{:/server/host}}")({ server = { host = "db1" } })) return mch("{{:/a.b}}")(ctx)

Equality guards

{{#name=value}} enters only when the selected value equals the literal, {{#name=!value}} only when it differs; numbers compare numerically, booleans as true/false. The same guard on a value tag renders the value conditionally.

local mch = require("santoku.mustache") local guard = mch("{{#env=prod}}PROD{{/env=prod}}{{#env=!prod}}NOT PROD{{/env=!prod}}") print(guard({ env = "prod" })) print(guard({ env = "dev" })) print(mch("{{#n=42}}the answer{{/n=42}}")({ n = 42 })) print(mch("{{#ok=true}}enabled{{/ok=true}}")({ ok = true })) return mch("{{env=prod}}")({ env = "prod" })

Comparison guards

Sections can compare with <, >, <=, >=: numbers numerically (parsed from the literal), strings byte-wise. The closing tag repeats the full guarded name.

local mch = require("santoku.mustache") local size = mch("{{#n>=10}}big{{/n>=10}}{{#n<10}}small{{/n<10}}") print(size({ n = 42 })) print(size({ n = 3 })) print(mch("{{#price<=99.5}}deal{{/price<=99.5}}")({ price = 42.5 })) return mch("{{#name>m}}late alphabet{{/name>m}}")({ name = "zed" })

Partials

The partials table maps names to template strings or already compiled render closures (the compiled template is recovered from the closure). A partial name with no match renders empty.

local mch = require("santoku.mustache") local item = mch("<li>{{.}}</li>") local list = mch("{{#items}}{{>item}}{{/items}}", { partials = { item = item }, }) print(list({ items = { 1, 2, 3 } })) local wrap = mch("({{>inner}})", { partials = { inner = "[{{val}}]" }, }) print(wrap({ val = "hello" })) return mch("a{{>nope}}b")({})

Dedent

Compile strips the shared indentation of nonblank lines and one leading newline, so long-string templates can sit indented in source; pass dedent = false to keep the text verbatim.

local mch = require("santoku.mustache") local tpl = mch([[ hello world ]]) print(tpl({})) print(mch("\nhello")({})) local raw = mch("\n hello\n world\n", { dedent = false }) return raw({})

Render-time errors

Compile only dedents and captures: parsing happens per render, so malformed templates and bad roots (anything other than table, number, boolean, nil) raise when the closure is called. Extra arguments beyond the context are ignored.

local mch = require("santoku.mustache") local render = mch("{{x}}") print(render({ x = 1 }, "extra args ignored")) print("string root:", (pcall(render, "nope"))) local unclosed = mch("{{#a}}never closed") print("compiles, fails on render:", (pcall(unclosed, { a = true }))) return "errors surface at render time"

Templating an nginx server block

santoku server projects render their nginx conf from a context table, iterating location blocks and falling back on defaults.

local mch = require("santoku.mustache") local conf = mch([[ server { listen {{port}}; server_name {{domain}}; worker_connections {{#workers}}{{.}}{{/workers}}{{^workers}}512{{/workers}}; {{#locations}} location {{path}} { proxy_pass {{upstream}}; } {{/locations}} } ]]) return conf({ port = 8080, domain = "docs.example.com", locations = { { path = "/api", upstream = "http://127.0.0.1:9001" }, { path = "/", upstream = "http://127.0.0.1:9000" }, }, })

The nginx pipeline

A server/nginx.tk.conf is a santoku-template block expanded by the build harness, and one way to write it is to compute hashed asset names and then render a mustache conf source with the project config as context. Such a conf reaches nested config with dotted paths like {{nginx.port}} and {{nginx.error_log}}, and uses escaped dots, {{modules.my-app\.search\.init}}, to index a module table whose keys are full dotted module names.

<% local arr = require("santoku.array") local ctx = nginx ctx.context_path = (client and client.context_path) or "" 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.

Template packs with mutual partials

The loader below reads every file under res/web/templates, keys it by dotted path, then compiles each one with the whole set as its partials table, so any template can include any sibling with {{>path.name}}. The runtime branch instead renders a Lua module that embeds the serialized pack and recompiles it on load.

local fs = require("santoku.fs") local str = require("santoku.string") local mch = require("santoku.mustache") local serialize = require("santoku.serialize") return function (readfile, root_dir, runtime) local tpl_dir = fs.join(root_dir, "res/web/templates") local tpl = {} for path, tp in fs.files(tpl_dir, true) do if tp == "file" then local key = str.match(path, "^.*/res/web/templates/(.*)%.[^.]+$") if key then tpl[str.gsub(key, "/", ".")] = readfile(path) end end end if not runtime then for k, v in pairs(tpl) do tpl[k] = mch(v, { partials = tpl }) end return tpl else return mch([[ local mch = require("santoku.mustache") local tpl = {{{embed}}} for k, v in pairs(tpl) do tpl[k] = mch(v, { partials = tpl }) end return tpl ]])({ embed = serialize(tpl) }) end end
Illustrative: runs outside the browser.