UI
What it is for
Section titled “What it is for”gas/ui renders HTML pages with Go’s html/template and serves static files.
It reads template source through gas.TemplateProvider, so where templates
live is the business of gas/template, and it
implements gas.UIProvider, the core interface the app’s services render
through. A service that renders pages takes gas.UIProvider and never imports
this module.
It gives you:
- a page convention with layouts and partials, compiled on first render and cached, or recompiled on every render in development;
Render,RenderWithStatus, andRenderFragment, which skips the layout for HTMX swaps;- built-in template functions, plus functions any service can add;
- a static file route, from a directory or an
fs.FSsuch as anembed.FS.
Install
Section titled “Install”go get github.com/gasmod/gas/ui github.com/gasmod/gas/templateWiring
Section titled “Wiring”// assets holds the templates and static files, compiled into the binary.////go:embed templates staticvar assets embed.FSapp := gas.NewApp( gas.WithServiceInstance[gas.ConfigProvider](cfg), gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// Where templates come from: any gas.TemplateProvider. gas.WithSingletonService[gas.TemplateProvider](templatefs.NewStore(templates)),
// What renders them. ui.New's type parameter is the type the // template store is registered under; the registration key is the // interface the app's services ask for. gas.WithSingletonService[gas.UIProvider]( ui.New[gas.TemplateProvider](ui.WithStaticFS(static)), ),
gas.WithSingletonService[*Pages](NewPages),)templates and static are fs.Sub(assets, "templates") and
fs.Sub(assets, "static").
ui.New[T]() returns a constructor that resolves T, *gas.Router,
gas.ConfigProvider, and gas.Logger from the container. T is the type the
template store is registered under, so ui.New[gas.TemplateProvider]() takes
whatever store the app registered for that interface. Register the result under
gas.UIProvider so consumers depend on the interface. The constructor never
fails; its error result is always nil.
To serve static files from an embed.FS, pass ui.WithStaticFS(fsys). See
Options and startup for ui.WithConfig.
Template layout
Section titled “Template layout”Directorytemplates/
Directorylayouts/
- base.html defines “base”, the entry point
Directorypartials/
- nav.html
- notes.html
- home.html rendered as “home”
- 404.html rendered as “404”
Directorynotes/
- list.html rendered as “notes/list”
The engine lists every template in the provider and sorts each into one of three kinds by its name:
- Names under
layouts/andpartials/are parsed into every page. - Every other template is a page, rendered by its name without the
.htmlextension:home.htmlis"home",notes/list.htmlis"notes/list". - When at least one layout exists,
RenderandRenderWithStatusrender every page through the template thatui.layoutnamenames,baseby default. A page fills the layout’s blocks with{{define}}. With no layouts at all, a page renders on its own.
{{define "base"}}<!doctype html><html><head><title>{{block "title" .}}Gas{{end}}</title><link rel="stylesheet" href="/static/app.css?v={{buildId}}"></head><body>{{template "nav" .}}<main>{{block "content" .}}{{end}}</main></body></html>{{end}}{{define "title"}}Home{{end}}{{define "content"}}<h1>Hello, {{.Name}}</h1>{{end}}Each page is compiled with all the layouts and partials, so two pages can both
define content without clashing.
Rendering
Section titled “Rendering”// Pages registers the HTML routes. It takes gas.UIProvider and never// imports gas/ui.type Pages struct { router *gas.Router ui gas.UIProvider}
func NewPages(router *gas.Router, ui gas.UIProvider) *Pages { return &Pages{router: router, ui: ui}}
func (p *Pages) Name() string { return "pages" }
func (p *Pages) Init(context.Context) error { p.router.Get(p, "/", p.home) p.router.Get(p, "/notes", p.notes) p.router.NotFound(p, p.notFound) return nil}
func (p *Pages) Close(context.Context) error { return nil }
// home renders templates/home.html inside the base layout.func (p *Pages) home(ctx gas.Context) error { return p.ui.Render(ctx.ResponseWriter(), "home", map[string]any{"Name": "visitor"})}
// notFound renders templates/404.html with a 404 status.func (p *Pages) notFound(ctx gas.Context) error { return p.ui.RenderWithStatus(ctx.ResponseWriter(), http.StatusNotFound, "404", nil)}Render(w, name, data)writes the page with status 200.RenderWithStatus(w, status, name, data)writes it withstatus.- Both execute the template into a buffer first. A template error is returned
and nothing is written, so a failed render never sends half a page. On
success they set
Content-Type: text/html; charset=utf-8. - An unknown name returns
ui: template "<name>" not found.
In development and testing, every render recompiles the templates, so edits show on the next request. See Compilation.
HTMX fragments
Section titled “HTMX fragments”RenderFragment(w, name, data) renders a page without its layout, for an HTMX
swap. It sets the same Content-Type and writes no explicit status.
Until that changes, put the shared markup in a partial and call it both from
the content block and from the page’s top level:
{{define "notes"}}<ul id="notes">{{range .Notes}}<li>{{.}}</li>{{end}}</ul>{{end}}{{/* Render fills the layout's "content" block. */}}{{define "content"}}{{template "notes" .}}{{end}}
{{/* RenderFragment executes only what is outside the define blocks. */}}{{template "notes" .}}// notes serves templates/notes/list.html two ways: the whole page to a// browser, and only the list to an HTMX request.func (p *Pages) notes(ctx gas.Context) error { data := map[string]any{"Notes": []string{"first", "second"}}
if ctx.Request().Header.Get("HX-Request") == "true" { return p.ui.RenderFragment(ctx.ResponseWriter(), "notes/list", data) } return p.ui.Render(ctx.ResponseWriter(), "notes/list", data)}Render sees the list inside the layout; RenderFragment sees only the list.
Template functions
Section titled “Template functions”Every template can call these:
| Function | Does |
|---|---|
upper, lower, trimSpace |
strings.ToUpper, strings.ToLower, strings.TrimSpace |
contains, hasPrefix, hasSuffix, join, split |
strings.Contains, strings.HasPrefix, strings.HasSuffix, strings.Join, strings.Split |
replace |
strings.ReplaceAll |
title |
strings.ToTitle, which upper-cases every letter; it does not capitalize words |
truncate n s |
The first n bytes of s followed by ..., or s when it is not longer than n |
now |
time.Now() |
formatTime layout t, formatTimePtr layout t |
t.Format(layout); the pointer form returns an empty string for nil |
add a b, sub a b |
Integer arithmetic |
dict k1 v1 k2 v2 ... |
A map[string]any; pairs whose key is not a string are skipped |
list a b ... |
A []any |
json v |
v marshaled to JSON, or nil when it cannot be |
env |
The app’s GasEnv |
buildId |
An ID fixed for the life of the process; in development or testing, a new dev- ID on every call |
dict is how a partial gets more than one value:
{{template "card" dict "Name" .UserName "Role" "admin"}}. buildId makes a
cache-busting query parameter for asset URLs, as the layout above uses it.
Two ways to add functions. Config.FuncMap, set through ui.WithConfig, is
merged over the built-ins when the service starts, and a name there replaces
the built-in. And any service can call RegisterFuncs from its Init:
// Markdown contributes a template function. It depends on gas.UIProvider,// so it starts after the ui service, whose engine is then ready to take// functions.type Markdown struct { ui gas.UIProvider}
func NewMarkdown(ui gas.UIProvider) *Markdown { return &Markdown{ui: ui} }
func (m *Markdown) Name() string { return "markdown" }
func (m *Markdown) Init(context.Context) error { m.ui.RegisterFuncs(map[string]any{ "shout": func(s string) string { return strings.ToUpper(s) + "!" }, }) return nil}
func (m *Markdown) Close(context.Context) error { return nil }See Registering functions for the merge rules.
Static files
Section titled “Static files”Three settings, three separate jobs:
| Setting | Answers |
|---|---|
ui.staticdir or ui.WithStaticFS |
What to serve |
ui.staticpath or ui.staticpaths |
Which URL patterns serve it |
ui.staticstripprefix |
What to strip from the request path before looking up the file |
With the defaults, /static/app.css is served from app.css at the root of
the static directory or filesystem. To serve several URL trees, set
ui.staticpaths; with no strip prefix, the request path is used as it is:
// staticPaths serves two URL trees from ./public. With StaticPaths set and// no StaticStripPrefix nothing is stripped, so /css/app.css is served fromfunc staticPaths() gas.Option { cfg := ui.DefaultConfig() cfg.UI.StaticDir = "public" cfg.UI.StaticPaths = []string{"/css/*", "/js/*"}
return gas.WithSingletonService[gas.UIProvider]( ui.New[gas.TemplateProvider](ui.WithConfig(cfg)), )}See Static file serving for how the prefix is stripped.
Testing
Section titled “Testing”uitest.MockUI implements gas.UIProvider. Each method records the call in
Calls and delegates to RenderFn, RenderFragmentFn, RenderWithStatusFn,
or RegisterFuncsFn when set. Unset, a method returns nil and writes nothing to
the ResponseWriter. CallCount(method) counts the calls to one method, and
Reset clears them.
func TestHomeRendersHomePage(t *testing.T) { t.Parallel() mock := &uitest.MockUI{} p := &Pages{ui: mock}
rec := httptest.NewRecorder() ctx := gas.NewContext(context.Background(), rec, httptest.NewRequest(http.MethodGet, "/", nil)) if err := p.home(ctx); err != nil { t.Fatalf("home: %v", err) }
if got := mock.CallCount("Render"); got != 1 { t.Fatalf("Render calls = %d, want 1", got) } if name := mock.Calls[0].Args[0]; name != "home" { t.Fatalf("rendered %v, want home", name) }}To test the templates themselves, build an engine over a store with
ui.NewEngine(provider, funcMap, layout, devMode, logger) and render into an
httptest.ResponseRecorder, or start the app and send requests to
App.Handler().
Configuration
Section titled “Configuration”Unless ui.WithConfig is given, the service binds its settings from the
gas.ConfigProvider over ui.DefaultConfig() when it starts, so a key that is
not set keeps its default. The service also reads GasEnv, the app
environment; see Config.
| Key | Type | Default | Description |
|---|---|---|---|
ui.staticdir |
string | static/ |
Directory of static files. Empty disables the directory; ignored when ui.WithStaticFS is given |
ui.staticpath |
string | /static/* |
URL pattern of the static route. Must start with / |
ui.staticpaths |
list of strings | empty | URL patterns, one static route each, used instead of ui.staticpath. Each must start with / |
ui.staticstripprefix |
string | empty | Prefix stripped before the file lookup; empty means the default above. Must start and end with / |
ui.layoutname |
string | base |
The {{define}} name of the layout entry point. Must not be empty |
The service validates the configuration when it starts, whether it was bound
or given with ui.WithConfig. Besides the rules in the table, ui.staticdir
must be an existing directory inside the working directory, unless a static
filesystem was given; that check is skipped when running under go test.
Errors
Section titled “Errors”The package defines no sentinel errors. Errors from the service start with
gas/ui:, and errors from the template engine with ui:.
| Error | Returned by | When |
|---|---|---|
gas/ui: config binding: ... |
Startup | The config cannot be bound |
gas/ui: invalid configuration: ... |
Startup | A rule in Configuration fails |
gas/ui: service is closed |
Render, RenderWithStatus, RenderFragment |
After Close; the method also writes a 503 response |
gas/ui: no template engine configured |
Render, RenderWithStatus, RenderFragment |
Before the service has started; nothing is written |
ui: listing templates: ..., ui: loading template ..., ui: parsing layout ..., ui: parsing partial ..., ui: parsing page ... |
The render that compiles | The provider or a template fails |
ui: template "<name>" not found |
The render methods | No page has that name |
ui: executing template ..., ui: executing fragment ... |
The render methods | Template execution fails; nothing is written |
The render methods return the error and leave the response to you; a handler that returns it hands it to the router’s error handler (see Errors).
Behavior details
Section titled “Behavior details”The sections above cover everyday use. This section is the full contract, for when an exact edge case matters.
Options and startup
Section titled “Options and startup”Two options:
ui.WithStaticFS(fsys)serves static files fromfsysinstead ofui.staticdir.ui.WithConfig(cfg)usescfgas the whole configuration and skips theConfigProvider. Start fromui.DefaultConfig(). Since nothing is bound,GasEnvstays empty unless you set it, which turns development mode off.
When the service starts it binds and validates the configuration, builds the template engine, and registers the static route. It does not read any template yet.
Compilation
Section titled “Compilation”Templates are compiled on the first render, not when the service starts, so
template functions other services add while starting are in place. When
GasEnv is development or testing, every render recompiles the templates,
so edits to a store that reads from disk show on the next request. A failed
compile returns its error, and every later render retries the build and
returns the error until a compile succeeds; the previously compiled templates
are not served in the meantime
(KI-053).
Registering functions
Section titled “Registering functions”RegisterFuncs merges the functions into the engine and marks the templates
for recompiling on the next render. A name that already exists is replaced,
with a warning in the log. Called before the ui service has started, it logs a
warning and drops the functions; a service that takes gas.UIProvider as a
dependency always starts after it.
Static file serving
Section titled “Static file serving”The service registers a GET route for static files when it starts, owned by
the ui service, on ui.staticpath (/static/* by default), or on each pattern
in ui.staticpaths when that is set. Files come from the ui.WithStaticFS
filesystem when one was given, otherwise from ui.staticdir. With neither, no
route is registered.
When ui.staticstripprefix is empty, it defaults to ui.staticpath’s literal
part up to its first wildcard or path parameter: /static/* strips /static/,
so /static/app.css is served from app.css at the root of the static
directory or filesystem. If your files mirror the URLs instead
(static/app.css served at /static/app.css), set ui.staticstripprefix to
/. With ui.staticpaths set and no strip prefix, nothing is stripped:
A request whose path ends in /, or that names the strip prefix itself without
the slash (/static), gets 404, so directory listings are never served. The
same handler is exported as ui.StaticHandler(prefix, dir) and
ui.StaticHandlerFS(prefix, fsys) for routes of your own.
Health and readiness
Section titled “Health and readiness”ui.Service implements gas.ReadyReporter, not gas.HealthReporter.
CheckReady returns gas/ui: template engine not initialized before the
service has started and gas/ui: service is closed after Close.
Close marks the service closed: the render methods then answer 503 and
return an error. The static route keeps serving.