Skip to content

Serve HTML

Two modules, split along one line: gas/template decides where template source lives, gas/ui decides how it renders. That separation is what lets you develop against files on disk and ship a single binary with templates embedded, without the rendering code noticing.

Terminal window
go get github.com/gasmod/gas/template github.com/gasmod/gas/ui
main.go
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// Where templates come from. The fs store takes an fs.FS, so os.DirFS
// during development and an embed.FS in a shipped binary.
gas.WithSingletonService[gas.TemplateProvider](
templatefs.NewStore(os.DirFS("templates")),
),
// What renders them. The type parameter names the template provider to
// inject; the registration key is the interface consumers ask for.
gas.WithSingletonService[gas.UIProvider](ui.New[gas.TemplateProvider]()),
)
Backend Use it for
template/fs An fs.FS. os.DirFS in development, embed.FS in a release binary
template/dir A directory on disk, with an in-memory overlay for runtime additions
template/memory Tests and ephemeral content
template/db Templates users can edit, across multiple instances
template/composite Chain them: write to the first, read through all

The composite store is the interesting one. Put a database store first and an embedded store behind it, and you get user-editable templates that fall back to the versions you shipped.

  • Directorytemplates/
    • Directorylayouts/
      • base.html the entry point, {{define "base"}}
    • Directorypartials/
      • nav.html parsed into every render
      • footer.html
    • home.html a page, rendered as Render(w, "home", data)
    • Directorynotes/
      • list.html nested pages work, "notes/list"

Everything in layouts/ and partials/ is parsed into every render. Everything else is a page. A page that defines blocks gets wrapped in the layout; a page that defines none renders standalone.

notes/service.go
// A service takes gas.UIProvider and never imports gas/ui.
type Service struct {
ui gas.UIProvider
}
func New(ui gas.UIProvider) *Service { return &Service{ui: ui} }
func (s *Service) home(w http.ResponseWriter, r *http.Request) {
_ = s.ui.Render(w, "home", map[string]any{
"SiteName": "Gas",
"Name": "Ahmed",
})
}

Your service takes gas.UIProvider and never imports gas/ui, so it can be tested against uitest.MockUI.

RenderFragment renders a page without the layout wrapper, which is exactly what an HTMX swap wants. Same template, two entry points:

// HTMX asks for a fragment, a browser asks for a full page. Same template.
func (s *Service) notes(w http.ResponseWriter, r *http.Request) {
data := map[string]any{"Notes": []string{"first", "second"}}
if r.Header.Get("HX-Request") == "true" {
_ = s.ui.RenderFragment(w, "notes/list", data)
return
}
_ = s.ui.Render(w, "notes/list", data)
}

Built in: safe, safeAttr, safeURL, upper, lower, title, trimSpace, contains, hasPrefix, hasSuffix, replace, join, split, truncate, now, formatTime, formatTimePtr, add, sub, dict, list, json, and buildId.

dict is the one you will use most, since it is how a partial gets arbitrary data: {{template "user-card" dict "Name" .UserName "Role" "admin"}}.

buildId returns a stable id per process, and a fresh one per call in development, which makes it a good cache-busting query parameter on asset URLs.

Any service can contribute helpers during Init:

// Any service can contribute template helpers during Init. Templates are built
// lazily on first render, so registrations made in Init are always in time.
func (s *Service) Init() error {
s.ui.RegisterFuncs(map[string]any{
"formatDate": func(layout string) string { return layout },
})
return nil
}

Templates are built lazily on first render, so registrations made during Init are always in time. A name that collides with a built-in logs a warning.

Set UI.StaticDir (or pass ui.WithStaticFS for an embed.FS) and the service registers a route serving it, with directory listing blocked. Three settings, three separate jobs:

Setting Answers
StaticDir / WithStaticFS What to serve
StaticPath / StaticPaths Where to serve it, as URL patterns
StaticStripPrefix What to strip before looking up the file

If the FS mirrors your URLs, strip nothing. If the FS is flat and the URLs are not, strip the prefix. When empty, nothing is stripped.

When GasEnv is development-like, templates rebuild on every request, so edits show up on refresh. In production they are built once.