santoku-make
View on LuaRocksthe project build harness behind toku: a dependency-graph engine, a descriptor-driven project layer for lib, bin, and web packages, the four ways code is gated out of or varied for WebAssembly builds, and LICENSE and SPDX header management
santoku-make is the build system behind the toku CLI. It has two layers: santoku.make, a small dependency-graph engine that resolves file targets by modification time, and santoku.make.project, which turns a make.lua descriptor into a built, tested, and installable luarocks package, as a library, an executable, or a WASM-client web app served by OpenResty. This tab is a guided tour: the engine first, then a library project end to end (init, descriptor, test, install, release), then the web project pipeline (client pages, asset hashing, nginx, dev server). Builds touch the real filesystem and toolchain, so most examples are shown for reading; the descriptor, env-merge, and template examples are plain Lua and run in the page. The best way to learn the layer is reading real descriptors: the base library's minimal make.lua, shipped in its source tarball, is the canonical lib project.
The engine: a target graph
make() returns a submake; target(targets, deps, fn) registers nodes and build(targets, verbosity) resolves the DAG. fn = true marks a phony aggregate that is always stale; a registered file with no fn is a source leaf. At verbosity 2 and up every node is traced as [make], [ok], [src], or [phony].
local make = require("santoku.make")
local fs = require("santoku.fs")
local arr = require("santoku.array")
local m = make()
m.target({ "out/header.txt" }, {}, function (ts)
fs.mkdirp(fs.dirname(ts[1]))
fs.writefile(ts[1], "Header\n")
end)
m.target({ "out/body.txt" }, {}, function (ts)
fs.writefile(ts[1], "Body\n")
end)
m.target(
{ "out/main.txt" },
{ "out/header.txt", "out/body.txt" },
function (ts, ds)
local parts = {}
for i = 1, #ds do
parts[i] = fs.readfile(ds[i])
end
fs.writefile(ts[1], arr.concat(parts))
end)
m.target({ "all" }, { "out/main.txt" }, true)
m.build({ "all" }, 3)
print("[make] header, body, main were logged, then [phony] all")
return fs.readfile("out/main.txt")
Staleness by modification time
A target with a build fn reruns only when a dependency is newer than it. A second build is a no-op, and rewriting a dependency restales only its consumers. A missing target with no registered fn is an error.
local make = require("santoku.make")
local fs = require("santoku.fs")
local str = require("santoku.string")
local m = make()
m.target({ "out/upper.txt" }, { "out/lower.txt" }, function (ts, ds)
fs.writefile(ts[1], str.upper(fs.readfile(ds[1])))
end)
fs.mkdirp("out")
fs.writefile("out/lower.txt", "hello\n")
m.build({ "out/upper.txt" }, 2)
print("first run: [src] out/lower.txt, [make] out/upper.txt")
m.build({ "out/upper.txt" }, 2)
print("second run: [ok] out/upper.txt, nothing rebuilt")
fs.writefile("out/lower.txt", "changed\n")
m.build({ "out/upper.txt" }, 2)
print("third run: [make] out/upper.txt again")
return fs.readfile("out/upper.txt")
Sidecar .d files: transitive dependencies
For any target t the engine also reads t .. ".d", a make-style dependency file, and folds the newest listed file into staleness. The project layer writes one next to every rendered template. The render env's readfile records each file the template reads and that file's directory, so a template that lists a directory and reads each entry restales when a file is added or removed; depend records a file or directory tree without reading it, and serialize_deps saves the set as one line. Editing a recorded file then restales the output even though it is not a direct dep. A copy under res, lib, bin or test in a build tree whose source is gone is removed at the next init.
local make = require("santoku.make")
local fs = require("santoku.fs")
local tmpl = require("santoku.template")
local m = make()
m.target({ "out/page.html" }, { "res/page.tk.html" }, function (ts, ds)
local deps = {}
local env = {
title = "Docs",
readfile = function (fp)
deps[fp] = true
return fs.readfile(fp)
end,
depend = function (fp)
deps[fp] = true
return fp
end,
}
fs.mkdirp(fs.dirname(ts[1]))
fs.writefile(ts[1], tmpl.renderfile(ds[1], env))
fs.writefile(ts[1] .. ".d", tmpl.serialize_deps(ds[1], ts[1], deps))
end)
m.build({ "out/page.html" }, 2)
print("if the template read res/header.html, touching it restales out/page.html")
return true
depend: track files a template never reads
Every project template render gets depend(path, prune) beside readfile. depend records a file in the .d sidecar without reading it, and returns path, so it wraps anything a block reaches through fs.runfile, require, or a directory walk. On a directory it also records every entry under it, so adding or removing a file restales the output. prune(path, mode) returns true for an entry or subtree to leave out.
<%
local fs = require("santoku.fs")
local helper = fs.runfile(depend("res/app_html.lua"))
depend("res/icons", function (fp)
return fs.basename(fp) == "drafts"
end)
return helper.render()
%>
toku init: scaffold a library project
The project layer ships a boilerplate; toku init extracts it, renames every tokuboilerplate occurrence to your project name, and runs git init. The result is a complete lib project: a descriptor, a templated Lua module, a C extension stub, a bin entry point, and a spec.
$ toku init --name my-lib
Created library project: my-lib
Next steps:
cd my-lib
toku test # Run tests
toku install # Install locally
$ find my-lib -type f -not -path "*/.git/*"
my-lib/.gitignore
my-lib/make.lua
my-lib/bin/my-lib.lua
my-lib/lib/my_lib.tk.lua
my-lib/lib/my_lib/capi.c
my-lib/res/migrations/0.0.1.sql
my-lib/test/spec/my_lib.lua
What make.lua contains
A project is a make.lua returning a table with an env field: plain data, no framework calls. name and version are required; dependencies are luarocks constraints; test.dependencies stay out of the runtime rockspec; an env.release table of hooks from santoku.make.release is what enables toku release, and a top-level public key is an error. The rockspec filename is derived from name and version exactly as shown.
local descriptor = {
type = "lib",
env = {
name = "my-lib",
version = "0.0.1-1",
license = "MIT",
copyright = "Your Name",
dependencies = {
"lua == 5.1",
"santoku >= 2.0.0, < 3.0.0",
},
test = {
dependencies = { "luacov >= 0.15.0-1" },
},
},
}
local env = descriptor.env
print("rockspec:", env.name .. "-" .. env.version .. ".rockspec")
print("runtime deps:", #env.dependencies)
print("test-only deps:", #env.test.dependencies)
return env.name
License notices: copyright, vendored, license_exclude
toku license reads these keys. copyright is the holder named in LICENSE and in every SPDX header. license_exclude lists path globs for generated files that get no header. vendored has one entry per piece of third-party code: name and license are required; version is optional; path is a glob or list of globs for code in the repo; source is the URL of code fetched at build time, required when there is no path; copyright is a string or a list; text replaces the fetched SPDX text, for code with no SPDX id; note is a paragraph after the license text. Globs follow REUSE: * stays inside one directory, ** crosses directories. Vendored paths get no header, and each entry gets its own LICENSE section. check and apply fail when a tracked file under a vendor, vendored, third_party or third-party directory matches no vendored path or exclude, when a path glob matches no tracked file, or when an entry's LICENSE label would match a different entry. check also fails on a license_exclude entry that matches no tracked file, or that starts with ^ or contains %, since those are Lua pattern syntax and never match as globs.
local env = {
name = "my-lib",
version = "0.0.1-1",
license = "MIT",
copyright = "Your Name",
license_exclude = { "src/parser.c" },
vendored = {
{
name = "LPeg", version = "1.1.0",
path = { "lib/my_lib/lp*", "lib/my_lib/grammar.lua" },
copyright = "(C) 2007-2023 Lua.org, PUC-Rio.",
license = "MIT",
note = "Renamed for coexistence with an external lpeg.",
},
{
name = "SQLite", version = "3.49.2",
source = "https://www.sqlite.org/2025/sqlite-amalgamation-3490200.zip",
license = "blessing",
},
},
}
for _, c in ipairs(env.vendored) do
print(c.name, c.path and "vendors" or "links", c.license)
end
return #env.vendored
One env, two build environments
The project layer builds a test env and a build env from your descriptor with santoku.table merge, which fills only missing keys and merges tables deeply, so phase-specific fields win and everything else inherits. This snippet mirrors the real merge chain, including the variable_prefix default derived from the project name.
local tbl = require("santoku.table")
local str = require("santoku.string")
local config_env = {
name = "my-lib",
version = "0.0.1-1",
cflags = { "-O2" },
}
local base_env = { root_dir = "/src/my-lib", is_wasm = false }
local test_env = tbl.merge(
{ environment = "test", build_dir = "build/default/test" },
config_env, base_env)
local build_env = tbl.merge(
{ environment = "build", build_dir = "build/default/build" },
config_env, base_env)
print(test_env.environment, test_env.name, test_env.cflags[1])
print(build_env.environment, build_env.build_dir)
local prefix = config_env.variable_prefix
or str.upper(str.gsub(config_env.name, "%W+", "_"))
print("variable_prefix:", prefix)
return test_env.root_dir
The descriptor's environment is part of the staleness key
A descriptor that reads env.var parameterises the build by the environment, and mtimes alone would miss that: make.lua is the tracked file, not the variables it read. So santoku-make writes the resolved descriptor to build/<env>/config.stamp (build/<env>-wasm/config.stamp for a wasm build) and tracks that stamp alongside the descriptor. WORKERS=4 toku build re-renders everything the value feeds without touching make.lua, and running again with the environment unchanged is still a no-op. One limitation to know: env.var called inside the configure hook is not captured, because the hook runs after the stamp is written and those values never land in the config table. Read such values in the descriptor and pass them through env if you want them tracked.
-- make.lua
local env = require("santoku.env")
return { env = { nginx = { workers = env.var("WORKERS", "auto") } } }
$ toku build --test # worker_processes auto
$ WORKERS=4 toku build --test
$ WORKERS=4 toku build --test # no-op, the stamp matches
$ cat build/default/config.stamp # the key: the whole resolved descriptor
Dependency trees and the source cache
Each project installs its dependencies into its own tree under build/. When the toku tree gains a newer release of a rock that a project tree holds an older in-range copy of, the next build clears that tree so luarocks reinstalls at the new version. Before clearing, toku downloads the rockspec and source rock of everything the tree will need into TOKU_CACHE (~/.local/share/toku/cache, set by toku itself), and every generated luarocks config lists that cache ahead of luarocks.org. The rebuild then works offline. If the download fails, toku keeps the old tree and says why. The cache holds sources only, so each tree still compiles for its own target and flags: native, wasm or OpenResty's LuaJIT.
$ toku test
toku: clearing build/default/test/lua_modules: it holds santoku 2.8.0-1, and 2.8.1-1 is installed and in range
$ cat build/default/test/luarocks.lua | grep rocks_servers
rocks_servers = { "/home/you/.local/share/toku/cache", "https://luarocks.org" }
$ ls ~/.local/share/toku/cache
manifest santoku-2.8.1-1.rockspec santoku-2.8.1-1.src.rock ...
.tk files: templates rendered with the project env
Any file named *.tk or *.tk.* is rendered through santoku.template against the project env, with .tk stripped from the output name; everything else is copied verbatim. In a lib project that env is the descriptor's env table. In a web or api project, server and client templates see only client, server, nginx, name and version from the descriptor plus the build's own fields, so app settings belong under env.server or env.client. A block returns a string or nothing; any other value fails the render naming the file and line. The framework's own Makefiles and rockspec are the same kind of template. So lib/version.tk.lua becomes lib/version.lua with the blocks already evaluated.
local template = require("santoku.template")
local env = { name = "my-lib", version = "0.0.2-1" }
local src = "return { name = \"<% return name %>\", " ..
"version = \"<% return version %>\" }"
local out = template.render(src, env)
print(out)
return out
Conditionals in a .tk file need push and pop
Each <% %> block is compiled as its own chunk, so a Lua if cannot span two of them. Writing one is the common first mistake in a .tk file. The error names the file and the line of the block that failed to compile, then Lua's own message, 'end' expected near '<eof>'. Emit conditionally with push(cond) and pop() instead, which the template engine injects for exactly this: text between them is emitted only when the condition held, and pairs nest. The santoku-template tab has the full treatment, including showing().
# server/nginx.tk.conf, the form that fails to compile
<% if access_log_on then %>
access_log <% return n.access_log %>;
<% else %>
access_log off;
<% end %>
toku: Invalid Lua code in template: server/nginx.tk.conf:1: server/nginx.tk.conf:1:1: 'end' expected near '<eof>'
# the form that works
<% push(access_log_on) %>
access_log <% return n.access_log %>;
<% pop() %>
<% push(not access_log_on) %>
access_log off;
<% pop() %>
toku test: build the test env, run the suite
toku test renders everything into build/<env>/test, runs luarocks make there so the rock plus its test deps land in a local lua_modules, then runs the spec suite via the santoku test runner followed by luacheck. --iterate watches sources (and everything recorded in .d files) with inotifywait and reruns on change. --wasm compiles each spec to a .js bundle with emcc and runs it under node --expose-gc.
$ toku test
$ toku test --iterate
$ toku test --single test/spec/my_lib.lua
$ toku test --skip-check
$ toku test --wasm
$ toku test --env prod
test.openresty: run a lib's specs inside OpenResty
A lib whose modules need OpenResty (ngx, cosockets, or resty.openssl, which loads OpenSSL through LuaJIT's ffi) sets test = { openresty = true }. The test tree's C rocks then build against the managed OpenResty's LuaJIT and link libluajit, and each spec runs under its resty script, so ngx is real and OpenSSL is in the process. It needs toku setup --resty; without the managed OpenResty, init fails naming that command. The install and release trees keep building for the toku Lua, so the rock itself installs as before. santoku-resty uses it.
return {
env = {
name = "my-resty-lib",
version = "0.0.1-1",
dependencies = { "lua == 5.1", "santoku >= 2.8.0, < 3.0.0" },
test = { openresty = true },
},
}
toku build, install, pack, release
build renders and compiles under build/<env>/build; install runs luarocks make on the built rock, or with --bundled compiles every bin/*.lua to a standalone executable via santoku-bundle and copies it into <prefix>/bin (default $PREFIX, falling back to ~/.local). pack tars lib, bin, deps, LICENSE, and the Makefiles into name-version.tar.gz. release requires an env.release table and a clean git tree: it tags the version and runs the declared hooks in order, push (release.git_push), host (release.s3_upload or release.gh_release) and registry (release.luarocks_upload, key from LUAROCKS_API_KEY). luarocks_upload uploads the rockspec alone, and luarocks installs fetch the tarball from the rockspec's source url, so a published rock needs a host. The source url derives from the host hook; without one it's the bare tarball name, and luarocks_upload refuses it.
$ toku build
$ toku install
$ toku install --bundled --prefix "$HOME/.local"
$ toku pack
$ LUAROCKS_API_KEY=... toku release
-- make.lua
local release = require("santoku.make.release")
env.release = {
push = release.git_push(),
host = release.s3_upload({ bucket = "my-releases", base_url = "https://releases.example.com" }),
registry = release.luarocks_upload(),
}
Driving the lifecycle from Lua
toku is a thin front end: project.init reads the descriptor and returns the lifecycle table. test builds and runs the suite, install runs luarocks make, lua_env(tree) builds a lua tree and returns its interpreter with absolute lua_path and lua_cpath, which toku lua --tree and toku env --tree use, and clean returns the list of paths it removed (or would remove with dry_run).
local project = require("santoku.make.project")
local sys = require("santoku.system")
local m = project.init({ env = "default" })
m.test({ skip_check = true })
m.install()
local t = m.lua_env("test")
sys.execute({ t.lua, "-e", "print('runs against the test tree')",
env = { LUA_PATH = t.lua_path, LUA_CPATH = t.lua_cpath } })
local removed = m.clean({ deps = true, dry_run = true })
print("clean --deps would remove", #removed, "paths")
return m.config.env.name
C extensions: cflags, ldflags, and deps/ Makefiles
A lib project compiles every lib/**/*.c to a shared object. To link an upstream C library, add a deps/<name>/Makefile that builds it and writes results.mk; the generated build discovers each deps directory, runs its Makefile, and includes the result. The idiom is to compile upstream sources directly with $(CC) $(CFLAGS) and archive them, never running the upstream's own configure, as santoku-sqlite does. The results.mk rule must list Makefile as a prerequisite, or editing the Makefile would rebuild nothing, so the build stops with an error when it doesn't. Objects compile with -MMD -MP, so editing a header recompiles every object that includes it. Each C module also gets a .requires file listing the other C modules whose headers it includes and whose names appear in its object; it installs next to the module, and santoku-bundle reads it.
cflags = {
"-I$(PWD)/deps/sqlite3/",
},
ldflags = {
"$(PWD)/deps/sqlite3/sqlite-amalgamation-3490200/libsqlite3.a",
"-lm",
},
results.mk: Makefile
[ -f sqlite-amalgamation-$(V).zip ] || exit 1
unzip sqlite-amalgamation-$(V).zip
cd $(DIR) && $(CC) -c $(SQLITE_CFLAGS) $(CFLAGS) -o sqlite3.o sqlite3.c
cd $(DIR) && $(AR) rcs libsqlite3.a sqlite3.o
touch results.mk
Headers from another rock: santoku.make.rock
A C extension that includes another rock's headers lists that rock in dependencies and adds rock.include(name) to cflags. include returns -I plus incdir(name), a Makefile expression that resolves to the include directory of the rock installed in the tree being built. The build stops with an error naming the rock when it isn't installed there.
local rock = require("santoku.make.rock")
return {
type = "lib",
env = {
name = "my-lib",
version = "0.0.1-1",
cflags = { rock.include("santoku-monocypher") },
dependencies = {
"lua == 5.1",
"santoku-monocypher >= 2.0.1, < 3.0.0",
},
},
}
Vendoring with sha256 pins
A deps Makefile never downloads. The archive path is declared data in rules.include, so it flows through the build, test, and release-tarball file lists like a tracked file, and a configure hook registers the producer. vendor.fetch downloads to dest .. ".part", renames on success, and verifies the digest, discarding an existing file that fails. Omit sha256 on a new entry and the fetch prints the computed digest to paste in, then exits non-zero. Consumers never fetch: the archive ships inside the release tarball. It distills the santoku-sqlite and santoku-mustache descriptors.
local fs = require("santoku.fs")
local vendor = require("santoku.make.vendor")
local spec = {
file = "deps/sqlite3/sqlite-amalgamation-3490200.zip",
url = "https://www.sqlite.org/2025/sqlite-amalgamation-3490200.zip",
sha256 = "921fc725517a694df7df38a2a3dfede6684024b5788d9de464187c612afb5918",
}
return {
env = {
name = "santoku-sqlite",
version = "2.2.0-1",
dependencies = { "lua == 5.1" },
rules = { include = { spec.file } },
configure = function (submake, envs)
local dest = fs.join(envs.root.build_dir, spec.file)
submake.target({ dest }, { "make.lua" }, function ()
vendor.fetch(spec, dest)
end)
end,
},
}
local_deps: sibling source rocks
local_deps lists paths (typically git submodules) to lib projects that are installed from source into every luarocks tree the build manages, before the consuming rock. Reinstall is triggered by mtime changes under the dep's lib, bin, deps, and res directories and its make files. A local dep must be a lib project and must not declare local_deps of its own; do not also list it in dependencies. A shared library is commonly a submodule of both an app repository and its CLI repository.
return {
env = {
name = "my-app",
version = "0.0.1-1",
dependencies = {
"lua == 5.1",
"santoku >= 2.0.0, < 3.0.0",
},
local_deps = {
"submodules/my-shared-lib",
},
},
}
build.dependencies: rocks for template time
build.dependencies are installed once per env into a separate build-deps tree, and that tree's paths are spliced into package.path around every template render. A .tk file can then require them at generation time even though they are not runtime deps. Declare santoku-web here so a client/res/pre.tk.js can inline santoku.web.pwa.wrap_events at render time.
return {
env = {
name = "my-app",
version = "0.0.1-1",
dependencies = { "lua == 5.1" },
build = {
dependencies = {
"santoku-web >= 2.2.3, < 3.0.0",
},
},
},
}
Native and WASM variant flags
One source tree, two toolchains. Top-level cflags and ldflags apply everywhere; native and wasm blocks scope by variant, build and test scope by phase, and the combined forms (build.wasm.ldflags, test.native.cflags, ...) scope by both. WASM builds are selected with --wasm, land in build/<env>-wasm, and the Makefile templates branch on the compiler being emcc. santoku-web's Emscripten export flags are set in exactly these fields, and santoku-learn's make.common.lua is a full real example: OpenMP and BLAS confined to native, wasm ldflags per phase.
return {
env = {
name = "my-native-lib",
version = "0.0.1-1",
dependencies = { "lua == 5.1" },
cflags = { "-O2" },
native = {
cflags = { "-fopenmp" },
ldflags = { "-fopenmp" },
},
build = { wasm = { ldflags = { "-sWASM_BIGINT" } } },
test = { wasm = { ldflags = { "-sWASM_BIGINT" } } },
},
}
Linking a system library: dependencies.external
A C extension that links a library installed on the host, such as OpenSSL, declares it under dependencies.external as a list of luarocks external dependency entries. luarocks finds the header and library at install time, and make receives the result as <NAME>_INCDIR and <NAME>_LIBDIR, so cflags and ldflags reference those variables and never a path. A host without the library fails the install and names what it looked for. santoku-socket links libcrypto this way.
return {
env = {
name = "my-tls-lib",
version = "0.0.1-1",
cflags = { "-I$(OPENSSL_INCDIR)" },
ldflags = { "-L$(OPENSSL_LIBDIR)", "-lcrypto" },
dependencies = {
external = { { OPENSSL = { header = "openssl/x509.h", library = "crypto" } } },
"lua == 5.1",
},
},
}
Choosing a wasm mechanism
Four mechanisms make code behave differently, or not exist, under wasm, and which one applies follows from what is actually different about the build. A capability compiled out of a C extension is absent, so a nil check settles it. A capability that exists and raises when called needs one invocation probe, because presence reads as available. A module with its own wasm implementation is a filename variant, selected while building rather than branched on at runtime. Text that should not appear in a generated file at all is a template gate, covered on the santoku-template tab under conditional sections with push and pop.
absent under wasm gate on presence if fs.hardlink then ... end
exists but always raises probe once local ok = pcall(io.popen, "true")
separate implementation filename variant lib/santoku/web/js.wasm.lua
must not be emitted template gate <% push(is_wasm) %> ... <% pop() %>
Compiling a capability out of a C extension
emcc defines __EMSCRIPTEN__, so a C extension can drop a function the platform cannot support. Guard the body and the luaL_Reg entry together: an entry without a body fails to link, a body without an entry is dead code. santoku-fs does this for hardlink and symlink in lib/santoku/fs/posix.c, which is why fs.hardlink is nil in this site's own wasm bundle and the fs tab feature-detects before calling it. When a module must keep the function and change what it does, guard inside the body, and let Lua see which build it got: santoku.sqlite.db exposes a wasm field set from the same ifdef.
#ifndef __EMSCRIPTEN__
int tk_fs_posix_hardlink (lua_State *L)
{
lua_settop(L, 2);
const char *oldpath = luaL_checkstring(L, 1);
const char *newpath = luaL_checkstring(L, 2);
if (link(oldpath, newpath) == -1)
return tk_fs_posix_err(L, errno);
return 0;
}
#endif
luaL_Reg tk_fs_posix_fns[] =
{
{ "mode", tk_fs_posix_mode },
#ifndef __EMSCRIPTEN__
{ "hardlink", tk_fs_posix_hardlink },
{ "symlink", tk_fs_posix_symlink },
#endif
{ "touch", tk_fs_posix_touch },
{ NULL, NULL }
};
Gating a spec on the capability it needs
One spec file runs under both toolchains, registering only the tests its target supports. Gate on the capability rather than on the platform: with fs.hardlink nil the suite is shorter under wasm and the spec never names emscripten. santoku-fs gates its hardlink and symlink tests this way, which is what keeps toku test and toku test --wasm both green on the same tree.
local test = require("santoku.test")
local fs = require("santoku.fs")
if fs.hardlink then
test("hardlink", function ()
fs.writefile("test/res/hl_src.txt", "hello")
fs.hardlink("test/res/hl_src.txt", "test/res/hl_dst.txt")
assert(fs.readfile("test/res/hl_dst.txt") == "hello")
fs.rm("test/res/hl_src.txt")
fs.rm("test/res/hl_dst.txt")
end)
end
When the presence check lies: io.popen
A presence check only answers for capabilities that are genuinely absent, and io.popen is not one of them. toku builds its vendored Lua 5.1.5 for wasm with make all under emmake and MYCFLAGS that do not define LUA_USE_POSIX, so LUA_USE_POPEN is undefined and luaconf.h expands lua_popen to a luaL_error reading 'popen' not supported; liolib.c registers popen regardless. io.popen is therefore a function under emscripten that raises only when called, and a nil check reports it as available. Probe it once at file scope and gate on the result, not inside each test. The same pattern covers anything whose failure is deferred to the call: os.execute, host paths, spawning an interpreter.
local test = require("santoku.test")
local spawn_ok, spawn_probe = pcall(io.popen, "true")
local can_spawn = spawn_ok and spawn_probe ~= nil
if can_spawn then
spawn_probe:close()
end
if can_spawn then
test("generators are seeded per process", function ()
local f = io.popen("lua5.1 -e \"print(require('santoku.random').fast_random())\"")
local out = f:read("*a")
f:close()
assert(out ~= "" and out ~= nil)
end)
end
Filename variants: .wasm. sources and specs
A file named <name>.wasm.<ext> is chosen by the build, not by the running program. The generated lib Makefile sets _WASM when CC contains emcc: native builds filter %.wasm.lua, %.wasm.c and %.wasm.cpp out of the file list, wasm builds keep them and strip the .wasm. infix when naming objects and install targets, so lib/santoku/web/js.wasm.lua installs as santoku/web/js.lua and is required as santoku.web.js. Specs follow the same rule in the project layer: test/spec/**/*.wasm.lua is dropped from a native run, and under --wasm each one is bundled to the stripped .js name. A spec named *.browser.wasm.lua runs only under --wasm-browser, in headless Chromium, and --wasm skips it by name. This is variant selection, not capability gating. It says nothing about a file both targets share, and a variant with no native counterpart is missing rather than stubbed when you build native. That is how santoku-web ships: its browser runtime modules exist only as .wasm. files, while the build-time pwa helpers, which templates require natively, are plain sources alongside them.
lib/santoku/web/js.wasm.lua installs as santoku/web/js.lua, wasm builds only
test/spec/santoku/web/val.wasm.lua runs under toku test --wasm and --wasm-browser
test/spec/santoku/web/dom.browser.wasm.lua runs under toku test --wasm-browser only
ifneq (,$(findstring emcc,$(CC)))
_WASM = 1
endif
ifdef _WASM
LIB_LUA = $(shell find * -name '*.lua')
else
LIB_LUA = $(filter-out %.wasm.lua, $(shell find * -name '*.lua'))
endif
INST_LUA = $(patsubst %.wasm.lua,%.lua,$(addprefix $(INST_LUADIR)/, $(LIB_LUA)))
Per-file rules
rules.exclude, rules.copy, and rules.template are Lua patterns that drop files, force a copy, or force templating. rules.include is literal paths declared into the file set even when absent from the tree (they must gain a producer target or the build fails). Pattern keys map matched files to extra compiler flags, injected as dedicated Makefile rules for just those objects.
return {
env = {
name = "my-lib",
version = "0.0.1-1",
dependencies = { "lua == 5.1" },
rules = {
exclude = { "%.draft%.lua$" },
copy = { "^res/raw/" },
template = { "^res/index%.css$" },
include = { "deps/upstream/upstream-1.0.tar.gz" },
["capi%.c$"] = { cflags = { "-DFAST_PATH" } },
},
},
}
Environment profiles: make.common.lua
Real projects keep shared config in make.common.lua and override per profile: make.prod.lua merges its overrides over the common table, and because merge never overwrites existing keys, the profile's values win. toku --env prod selects make.prod.lua and builds under build/prod; make.common.lua is tracked as a config dependency, so editing it restales every rendered target. In a profile file the common table comes from fs.runfile("make.common.lua").
local tbl = require("santoku.table")
local common = {
env = {
name = "my-app",
version = "0.0.1-1",
client = { files = true, verbose = true },
nginx = { domain = "localhost", port = "8080" },
},
}
local prod = tbl.merge({
env = {
client = { files = false },
nginx = { port = "443" },
},
}, common)
print("client.files:", prod.env.client.files)
print("client.verbose:", prod.env.client.verbose)
print("nginx:", prod.env.nginx.domain, prod.env.nginx.port)
return prod.env.name
toku init --web: the web scaffold
A web project pairs a client tree (compiled to WebAssembly) with a server tree (OpenResty Lua) plus shared root lib and res. client/bin holds the page entry points, client/static the templated HTML, server/nginx.tk.conf the server config template, and res/ the assets both sides render. The build copies root res/ into the client tree, the server tree and the server rock, so a helper there that only templates use still ships in the server rock. The layout below is abridged to the files that matter.
$ toku init --web --name my-app
my-app/client/bin/bundle.lua
my-app/client/lib/my-app/db.tk.lua
my-app/client/lib/my-app/main.lua
my-app/client/res/pre.tk.js
my-app/client/static/index.html
my-app/client/test/spec/my-app.lua
my-app/make.lua
my-app/res/client/migrations/0.0.1.sql
my-app/res/server/migrations/0.0.1.sql
my-app/server/lib/my-app/db.tk.lua
my-app/server/lib/my-app/web/init.lua
my-app/server/lib/my-app/web/sync.lua
my-app/server/nginx.tk.conf
my-app/server/test/spec/my-app.lua
The web descriptor: server, client, nginx
server and client carry separate dependency sets because the client is compiled to WASM under emcc while the server runs native under OpenResty. nginx is free-form context handed to your own server/nginx.tk.conf template, except modules, which the engine resolves to installed lua_modules paths. The whole project builds a lib project per component.
return {
env = {
name = "my-app",
version = "0.0.1-1",
server = {
dependencies = {
"lua == 5.1",
"santoku-resty >= 2.0.0, < 3.0.0",
"santoku-sqlite >= 4.0.6, < 5.0.0",
},
},
client = {
dependencies = {
"lua == 5.1",
"santoku-web >= 2.2.3, < 3.0.0",
},
},
nginx = {
ssl_self_signed = true,
domain = "localhost",
port = "8443",
workers = "auto",
},
},
}
Client pages: client/bin to WASM
Every client/bin/*.lua is one page: it is bundled with santoku-bundle under emcc into page.js plus page.wasm in the public dist. bundle_mods preloads modules the bundler cannot see statically. client.files = true keeps modules as loose files for fast dev rebuilds; false, for production, strips them with luac and embeds the bytecode in the binary. Pattern keys under client.rules add per-page link flags, here a pre-js and a CSP-friendly flag for the bundle page. A client with no client/bin is valid: it builds no wasm, toku test skips the client suite, and client/static alone makes a static site.
client = {
files = false,
public = { "roboto.woff2", "index.c9c791b043ea.css", "favicon.f18d929ffecd.svg" },
bundle_mods = { "santoku.mtx" },
dependencies = {
"lua == 5.1",
"santoku >= 2.0.0, < 3.0.0",
"santoku-web >= 2.2.3, < 3.0.0",
},
rules = {
["bundle$"] = {
ldflags = {
"--pre-js", "res/pre.js",
"-sDYNAMIC_EXECUTION=0",
},
},
},
},
client.public and the configure hook
File sets are declared data; configure only wires up producers. client.public declares the public filenames your hook generates (fonts, compiled CSS, icons), and they join the hashed-asset manifest exactly like walked static files. The hook runs once per environment (main and test) with the merged envs; on web projects envs has root, server, and client. Attach each product to client.target so the build waits for it.
local fs = require("santoku.fs")
local sys = require("santoku.system")
return {
env = {
name = "my-app",
version = "0.0.1-1",
client = {
public = { "index.c9c791b043ea.css", "favicon.f18d929ffecd.svg" },
dependencies = { "lua == 5.1", "santoku-web >= 2.2.3, < 3.0.0" },
},
server = {
dependencies = { "lua == 5.1", "santoku-resty >= 2.0.0, < 3.0.0" },
},
configure = function (submake, envs)
local client = envs.client
if not client then
return
end
local css_out = fs.join(client.public_dir, "index.c9c791b043ea.css")
local css_in = fs.join(client.build_dir, "res/index.css")
submake.target({ client.target }, { css_out })
submake.target({ css_out }, { css_in }, function ()
sys.execute({ "tailwindcss", "-i", css_in, "-o", css_out, "--minify" })
end)
local icon_out = fs.join(client.public_dir, "favicon.f18d929ffecd.svg")
local icon_src = fs.join(client.root_dir, "res/favicon.svg")
submake.target({ client.target }, { icon_out })
submake.target({ icon_out }, { icon_src }, function (ts, ds)
fs.writefile(ts[1], fs.readfile(ds[1]))
end)
end,
},
}
Asset hashing and the PWA pipeline
Static files and declared public files are staged, content-hashed into names like index.a1b2c3d4e5f6.html, and copied into the public dist with every textual cross-reference rewritten (the manifest iterates until it converges), except a quoted name of a client.stable file, which stays plain. The mapping is saved as hash-manifest.lua and exposed to templates as hashed(), which emits a placeholder resolved from the converged manifest, so every shipped file's name is the hash of the bytes it ships. A pwa block, if you add one, is free-form data that santoku-make does not interpret: nothing in the engine reads it, and the same is true of any ssl_ keys. It is useful as one place to keep the values your own configure hook and templates pass to the santoku.web.pwa helpers, the minification transforms among them. server.preserve_public protects paths like .well-known from the dist rebuild.
server = {
preserve_public = { "%.well%-known" },
},
client = {
pwa = {
title = "My App",
name = "My App",
description = "...",
theme_color = "#262626",
background_color = "#262626",
transforms = {
js = build.minify_js,
css = build.minify_css,
html = lp.minify_html,
},
},
},
Stable names, link checks, and a sitemap
client.stable lists public files that also ship under their own unhashed names, for URLs other sites link to; each must be in the hash manifest. client.check_links = true fails the build when a shipped page links to a page or asset that doesn't exist; { allow = { ... } } takes Lua patterns for links to let through. It also fails a link to #id when the target page has no element with that id, checks src= references, and skips anything inside <code>. client.sitemap = "https://example.com" writes sitemap.xml for that site and ships it under its own name. Write the site with no trailing slash, since each page URL starts with /. The table form { site = ..., lastmod = fn } adds a lastmod wherever fn(url) returns a YYYY-MM-DD date. A page's href="/feed.xml" to a stable file keeps the plain name, and hashed("feed.xml") gives the hashed copy.
client = {
stable = { "feed.xml", "press-kit.zip" },
check_links = { allow = { "^/api/" } },
sitemap = "https://example.com",
},
client = {
sitemap = {
site = "https://example.com",
lastmod = function (url) return url == "/docs" and "2026-10-15" or nil end,
},
},
santoku.make.site: llms.txt headers in nginx
headers(meta) returns the response headers that advertise the same files, as { name, value } pairs. Link carries markdown as alternate, llms as describedby and llms-txt, and llms_full as llms-full-txt. X-Llms-Txt carries llms. nginx_headers(meta) writes those as add_header lines with always, and raises on a value that holds a single quote. nginx drops every server-level add_header in a location that sets its own, and the scaffold's page locations set Cache-Control, so emit the lines inside each page's location. List llms.txt in client.stable, so it keeps its URL.
<% seo = require("santoku.make.site").nginx_headers({ llms = "/llms.txt" }) %>
location = / {
limit_except GET { deny all; }
try_files /<% return hashed("index.29f7a64754b8.html") %> =404;
add_header Cache-Control 'private, max-age=60';
<% return seo %>
}
location = /docs {
limit_except GET { deny all; }
try_files /<% return hashed("docs/index.html") %> =404;
add_header Cache-Control 'private, max-age=60';
<% return seo %>
}
location = /llms.txt {
limit_except GET { deny all; }
try_files /llms.txt =404;
}
# rendered seo:
# add_header Link '</llms.txt>; rel="describedby", </llms.txt>; rel="llms-txt"' always;
# add_header X-Llms-Txt '/llms.txt' always;
santoku.make.site: pages, sitemap and check_links
client.sitemap and client.check_links call these after hashing, and a configure hook can call them too. pages(manifest) maps the hash manifest's HTML entries to URLs: index.html is /, docs/index.html is /docs, and about.html stays /about.html. sitemap(site, manifest, lastmod) returns the sitemap XML with URLs sorted, and raises on a lastmod that isn't YYYY-MM-DD. check_links(opts) takes public_dir, manifest, stable and allow, raises link check failed on the first dead reference, and returns the pages table.
local site = require("santoku.make.site")
local manifest = {
["index.29f7a64754b8.html"] = "index.0123456789ab.html",
["docs/index.html"] = "docs/index.0123456789ab.html",
["about.html"] = "about.0123456789ab.html",
["app.js"] = "app.0123456789ab.js",
}
local pages = site.pages(manifest)
print(pages["/"], pages["/docs"], pages["/about.html"], pages["/app.js"])
-- index.0123456789ab.html docs/index.0123456789ab.html about.0123456789ab.html nil
print(site.sitemap("https://example.com", manifest, function (url)
return url == "/docs" and "2026-10-15" or nil
end))
-- <url><loc>https://example.com/</loc></url>
-- <url><loc>https://example.com/about.html</loc></url>
-- <url><loc>https://example.com/docs</loc><lastmod>2026-10-15</lastmod></url>
server/nginx.tk.conf: rendering the server config
The engine renders your server/nginx.tk.conf into nginx.conf and a foreground variant, with a context holding your nginx block merged with daemon, pid, and log settings, the resolved module paths, lua_package_path and cpath for the installed lua_modules, and the hashed() lookup. One technique, shown below: keep the actual nginx directives in a separate mustache file under res and render them with that context, so hashed entry points land directly in the config.
<%
local ctx = nginx
ctx.index_hashed = hashed("index.29f7a64754b8.html")
ctx.sw_hashed = hashed("bundle.bc0cd9639874.js")
return require("santoku.mustache")(readfile("res/nginx.conf"))(ctx)
%>
The web loop: build, start, test, stop
toku build --test assembles the test environment (server rock installed, pages bundled, assets hashed), and runs the client specs and luacheck whenever the client tree rebuilds; toku start runs the generated run.sh in the dist dir, backgrounded with a server.pid, or in the foreground with --fg. toku test runs the client specs under node, the root specs as a lib project, then stops any running server, starts the test server, and runs the server suite against it. Every suite runs on every call, even when nothing changed; --root, --client, and --server run one each. toku stop signals the pid files. clean scopes by component and can preview with --dry-run.
$ toku build --test
$ toku start --test
$ toku test
$ toku test --server --show-logs
$ toku stop
$ toku build --env prod
$ toku start --env prod --fg
$ toku clean --client --wasm
$ toku clean --all --dry-run