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

View on LuaRocks

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

santoku.make.site: page head tags

santoku.make.site renders SEO metadata at build time, from a client/static template or a helper it loads. head(meta) returns the tags for a page's <head>. url, type (website by default), title, description and site_name give the Open Graph and Twitter tags. image is a URL, or a table with url, alt, width, height and large, where large picks the summary_large_image card. markdown adds a text/markdown alternate link, llms a describedby link, and jsonld a JSON-LD script. Every value is HTML-escaped, a missing key emits nothing, and head({}) returns "". json(v) is the JSON-LD encoder head uses: it sorts keys, so a rebuild emits the same bytes, and it writes a table with only array keys as a list.

local site = require("santoku.make.site") print(site.head({ url = "https://example.com/docs", title = "Docs", description = "Setup & schedules", site_name = "Example", image = { url = "https://example.com/card.png", alt = "Example logo", width = 1200, height = 630, large = true }, markdown = "/docs/llms.txt", llms = "/llms.txt", jsonld = { ["@context"] = "https://schema.org", ["@type"] = "TechArticle", headline = "Docs", keywords = { "setup", "schedules" }, }, })) -- <meta property="og:type" content="website"> -- <meta property="og:url" content="https://example.com/docs"> -- <meta property="og:description" content="Setup &amp; schedules"> -- <meta name="twitter:card" content="summary_large_image"> -- <link rel="alternate" type="text/markdown" href="/docs/llms.txt"> -- <link rel="describedby" href="/llms.txt"> -- <script type="application/ld+json">{"@context":"https://schema.org","@type":"TechArticle", -- "headline":"Docs","keywords":["setup","schedules"]}</script> -- and the og:title, og:site_name, og:image*, twitter:title and twitter:image tags -- client/static/docs/index.tk.html <head> <% return require("santoku.make.site").head({ url = client.sitemap .. "/docs", title = "Docs", llms = "/llms.txt" }) %> </head>
Illustrative: runs outside the browser.

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

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

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