A minimal Go template engine built on html/template. Layout wrapping, reusable partials, and per-render overrides.
- Layout wrapping: Pages render inside a layout via
{{ yield }}. Override or skip the layout per call. - Partials: Render reusable partials with
{{ partial "name" . }}. Parsed once and cached for the lifetime of the engine. fs.FS-based: Passos.DirFSfor development (reads from disk) or an embedded FS for production (templates in the binary, no disk I/O).- Zero dependencies: Only the Go standard library.
- Safe for concurrent use: After construction the engine is read-only except for the partial cache, which uses
sync.Map.
go get lowbit.dev/stencil
import (
"io/fs"
"net/http"
"lowbit.dev/stencil"
_ "embed"
)
//go:embed views layouts partials
var templateFS embed.FS
func main() {
views, _ := fs.Sub(templateFS, "views")
layouts, _ := fs.Sub(templateFS, "layouts")
partials, _ := fs.Sub(templateFS, "partials")
engine := stencil.New(stencil.Config{
Views: views,
Layouts: layouts,
Partials: partials,
DefaultLayout: "base",
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if err := engine.Render(w, "home", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
}For development, skip the embed and pass os.DirFS directly — templates are re-read from disk on every render:
engine := stencil.New(stencil.Config{
Views: os.DirFS("views"),
Layouts: os.DirFS("layouts"),
Partials: os.DirFS("partials"),
DefaultLayout: "base",
})| Field | Type | Description |
|---|---|---|
Views |
fs.FS |
Filesystem rooted at the page templates directory |
Layouts |
fs.FS |
Filesystem rooted at the layouts directory |
Partials |
fs.FS |
Filesystem rooted at the partials directory |
DefaultLayout |
string |
Layout name used when no option is passed (without extension) |
Extension |
string |
Template file extension, defaults to .html |
Funcs |
template.FuncMap |
Custom functions available in all templates |
DisableCache |
bool |
Re-parse partials on every render; useful during development with os.DirFS |
// Use a different layout for this call
engine.Render(w, "about", data, stencil.WithLayout("minimal"))
// Render the page without any layout — useful for HTMX partials
engine.Render(w, "table-rows", data, stencil.WithoutLayout())Renders the current page inside a layout. Call it once in your layout template:
<!-- layouts/base.html -->
<html>
<body>{{ yield }}</body>
</html>Renders a partial by name, optionally passing data:
{{ partial "nav" . }}
{{ partial "alert" "Something went wrong" }}Partial templates live in the Partials FS. They are parsed on first use and cached — the parse cost is paid once per partial per process lifetime.
Partial templates are cached in memory after the first render. For production (embed.FS) this cache is effectively permanent.
For development with os.DirFS you have two options:
- Set
DisableCache: truein the config. Partials are re-parsed from disk on every render — no manual invalidation needed. - Leave the cache enabled and call
Reload()after changing a partial file to pick up the new version without restarting.
// Option 1 — always fresh, no manual step
engine := stencil.New(stencil.Config{
...
DisableCache: true,
})
// Option 2 — cache on, invalidate manually
engine.Reload()MIT