Template store & hot reload
The store
Templates live behind the templates.Store interface (Fetch/List/Put). The
loaded store is a fallback store — a chain of sub-stores (one per loader
type) tried in order — so a name resolves to the first loader that provides it.
Loading
StoreLoader.BuildFreshStore (app/templates/fileloader/store_loader.go):
- loads plain templates from the templates directory using the ordered loaders (file → raw → remote-http);
- builds dependent loaders (compositions) that need the already-loaded store;
- loads compositions from the compositions directory;
- combines everything into the fallback store.
Per directory, loaders are tried in priority order; "not found" errors are
non-fatal (try the next loader). With --templates-recursive, loading descends
into nested directories, naming children parent/child.
BuildFreshStore is side-effect-free and returns a full snapshot, so the same
code path serves the initial load, the always-mode list re-scan, and every
watch-mode rebuild.
The LazyStore seam
The internal render server needs the store to
serve assets, but the store is built later in the DI graph. A LazyStore
implements both StoreProvider and StoreConsumer: Wire binds one instance to
both interfaces, ProvideLiveStore fills it, and the internal server reads it.
This is the late-binding seam that lets the graph wire up before the store
exists.
ProvideLiveStore wraps the loaded store in a LiveStore — a Store backed
by an atomic.Pointer whose inner store can be replaced with Swap. The render,
spec and validate services hold it by value and the callback path reads it
through the LazyStore, but all of them point at the same LiveStore, so a
single Swap is observed everywhere at once with no locking.
Hot reload
--templates-reload-mode:
none— a staticMapStorebuilt once at startup (production default).always— aDevStorethat re-reads each template from disk on every fetch; wrapped in areloadingListStorewhoseListre-scans the directory, so templates added or removed after startup also show up in listings (tester menu, spec) without a restart (development default).watch— the watcher rebuilds the whole store on filesystem changes and pushes a live update to the tester UI over SSE.
Live watch
In watch mode app/templates/watcher runs an fsnotify watch over the
template and composition trees. A debounce (--templates-reload-debounce,
default 250 ms) collapses a burst of file events into one rebuild via
BuildFreshStore; on success the watcher swaps
the new store into the LiveStore and only then publishes a changed event
on the in-process events.Hub. That order is load-bearing: the tester UI
refetches on the event, so publishing before the swap would let it read the old
store. A rebuild failure (e.g. a half-written file mid-edit) keeps the old store
and emits an error event instead.
The EventsHandler streams hub events to the browser as Server-Sent Events on
GET /_events — a route mounted only in watch mode. The tester UI's
LiveReloadProvider consumes them with a native EventSource, refreshing the
menu and re-rendering the current preview. Outside watch mode the endpoint is
absent and the UI runs unchanged without live updates.
The watch set is seeded from the template and composition directories as they exist at startup, and new subdirectories are added as they are created. One directory is not covered: a top-level templates or compositions directory that is absent at startup is not watched if it is created later (nothing watches its parent), so restart the server after creating it. Existing directories and any nesting beneath them are picked up live.
Engines & loaders
Engine dispatch (instance_loader.go) tries engines in priority order per part
(body/header/footer), first match wins. Sibling loaders handle schema, params,
assets, examples, and partials. The composition sub-package holds the YAML/jq
and JS planners and the pdfcpu merge; the remote sub-package holds the HTTP
template loader.
See Extension points to add a new engine, loader, or planner.