Transactional builds
You don't need to read this to use Steno day to day, steno build and
steno dev already handle everything below for you automatically. This page is
for when you're writing a plugin or hook that touches the filesystem, or you're
curious what actually happens if a build fails partway through.
The short version: a build never overwrites your existing dist/ folder until
every single page, asset, and hook has succeeded. If anything fails, your
previous output is left exactly as it was, nothing is half-written.
In detail: Steno builds every site into a temporary directory beside the configured output directory. Pages, theme assets, redirects, and lifecycle hooks must all succeed before the staged tree is promoted.
If parsing, rendering, a plugin, a hook, an asset copy, redirect generation, or promotion fails:
- the previous output remains available;
- the staging directory is removed;
- in-memory build state is not committed; and
- the persistent cache is not advanced.
Promotion moves the previous output to a sibling backup and then renames the staged tree into place. If the second rename fails, Steno restores the backup. On the next build, Steno also recovers a backup left by a process interruption during this narrow promotion window.
Because portable filesystems cannot replace a non-empty directory with one universal atomic syscall, consumers may observe a brief path transition during promotion. Steno guarantees transactional rollback and recovery, not a lock-free directory swap for concurrent readers. Deployment systems that need zero-gap switching should publish the completed output as a versioned release and atomically update their own symlink or release pointer.
Plugin and hook paths
beforeBuild and afterBuild receive a copied SiteConfig whose output
points to the staging directory. Build extensions must write only within that
directory.
Plugin afterPage hooks receive:
path: the writable staging path;finalPath: the path after promotion; andhtml: the generated document.
Caller-provided StenoHooks.afterPage keeps path as the final path for
compatibility and additionally receives stagingPath for transactional writes.
Writing directly to the final path from trusted code bypasses Steno's
transaction and is unsupported.
The SiteConfig passed to beforeBuild and afterBuild also carries a pages
array, populated just before rendering starts. Each entry has slug (the
output-relative path), title, and optional description and date, sourced
from that page's frontmatter. Plugins and hooks can use it to build a sitemap,
an RSS feed, or a search index without re-scanning contentDir.
Determinism and collisions
Fresh output trees remove stale pages and assets naturally. Steno rejects collisions between pages, theme assets, and redirects instead of allowing the last writer to win. Clean builds with identical inputs are tested for identical file paths and SHA-256 hashes.
When inputs are provably unchanged and no theme, plugin, lifecycle hook, redirect, data file, public environment value, or include can produce additional output, Steno performs a no-op warm build without materializing or promoting a new tree. Changed production builds remain transactional and are reported separately as atomic incremental builds in the benchmark suite.
A warm build qualifies as a no-op only when all of the following hold: no theme
is configured, no plugins are configured, none of beforeBuild, afterPage, or
afterBuild is set on the caller-provided hooks, no content/_data files
exist, no PUBLIC_* environment variables are set, no redirects are
configured, no active page's body contains {@include, and every previously
built page's source text and output path are unchanged and its output file still
exists on disk. Any one of these being false forces a full staged rebuild.
The filesystem root and the project working directory cannot be configured as the output because neither can be safely promoted.
Build cache
Steno keeps a build signature and per-page state in memory across build()
calls on the same Steno instance, and additionally persists it to
<contentDir>/.steno/build-cache.json after every committed build. On startup,
if the in-memory state does not already match the current build signature, Steno
reads this file and reuses it when its signature matches. The file lets a warm
no-op build or a cache-assisted incremental build happen on the first build()
call of a new process, not only on a long-running dev() session. It is safe to
delete; Steno rebuilds it on the next build. Add .steno/build-cache.json to
.gitignore.
The build signature that gates cache reuse is computed from the full site
config, the theme's layouts and components (sorted), the theme's resolved
config, and the source text of every configured hook and plugin hook function
(via Function.prototype.toString). Editing an inline hook or plugin function
invalidates the cache; editing the internals of a function called from that
hook, without changing the hook's own source text, does not.
Staging directory names
While a build runs, Steno creates a sibling directory next to the configured
output directory named .<output-name>.steno-stage-<id>. On promotion, the
previous output is moved to .<output-name>.steno-backup and then the staged
tree is renamed into place; a leftover backup from an interrupted promotion is
named .<output-name>.steno-backup.retired-<id>. These directories live beside
dist/ (or your configured output), not inside it. Add
.<output-name>.steno-stage-*, .<output-name>.steno-backup, and
.<output-name>.steno-backup.retired-* to .gitignore and to any custom
watcher or deployment script alongside the output directory itself.