Gastro

Template Helpers

Gastro provides 24 built-in template functions available in all templates without registration. You can also add custom helpers.

String Functions

{{ .Name | upper }}        // "ALICE"
{{ .Name | lower }}        // "alice"
{{ .Bio | trim }}          // trims whitespace
{{ .Tags | join ", " }}    // "go, web, ssr"
Function Description
upper Converts string to uppercase
lower Converts string to lowercase
trim Trims leading and trailing whitespace
join Joins a slice of strings with a separator
split Splits a string by separator
contains Checks if a string contains a substring
replace Replaces occurrences in a string

Safety Functions

These functions mark content as safe for specific contexts, bypassing html/template's automatic escaping. Use them only with trusted content:

// Render trusted HTML
{{ .Body | safeHTML }}

// Safe attribute values
<div class="{{ .Class | safeAttr }}">

// Safe URLs
<a href="{{ .URL | safeURL }}">

// Safe CSS
<div style="{{ .Style | safeCSS }}">

// Safe JS
<script>var x = {{ .Data | safeJS }}</script>
Function Marks safe for
safeHTML HTML content (renders without escaping)
safeAttr HTML attribute values
safeURL URL values in href/src attributes
safeCSS CSS property values
safeJS JavaScript values

Utility Functions

// Default values
{{ .Name | default "Anonymous" }}

// Time formatting
{{ .CreatedAt | timeFormat "Jan 2, 2006" }}

// JSON output
{{ .Config | json }}

// Build maps and lists inline
{{ dict "key" "value" "other" 42 }}
{{ list "a" "b" "c" }}

// String operations
{{ split .Tags "," }}
{{ contains .Title "Go" }}
{{ replace .Text "old" "new" }}
Function Description
default Returns value, or fallback if empty/zero
timeFormat Formats a time.Time using Go's layout syntax
json JSON-encodes a value
dict Creates a map[string]any from key-value pairs
list Creates a []any from arguments

Membership and Lookup

Templates often need to ask "is this thing in that thing?" — e.g. "is this tab the active one?", "does this dict carry an optional field?". Without helpers, authors end up declaring activeSet := map[string]bool{...} in frontmatter; these helpers let templates ask the question directly.

// Slice membership
{{ if has .Tag .ActiveTags }}<span class="active">{{ end }}

// Variadic form (no slice needed)
{{ if has .Status "open" "in_progress" }}{{ end }}

// Map key presence (works against any map)
{{ if hasKey "Avatar" .User }}<img src="{{ .User.Avatar }}">{{ end }}

// The active-set idiom: build a set once, query repeatedly.
{{ $active := set "home" "about" "contact" }}
{{ range .Tabs }}
  <a class="{{ if hasKey . $active }}active{{ end }}">{{ . }}</a>
{{ end }}
Function Description
has Reports whether needle appears in haystack. Accepts a slice/array or variadic arguments. Uses reflect.DeepEqual.
hasKey Reports whether key is present in m. Works on any map (string-keyed, int-keyed, map[any]bool). Returns false for non-maps rather than panicking.
set Builds a map[any]bool from the given items. Combine with hasKey for efficient repeated membership tests. Unhashable items (slices, maps, funcs) are skipped silently.

Attribute Forwarding

These funcs render a component's gastro.Attrs bag and merge Tailwind class lists. See Components → Forwarding attributes for the full pattern.

// Render a bag of forwarded attributes, with base defaults.
<button {{ attrs .Attrs (dict "class" "btn" "type" "button") }}>

// Join class lists (plain concatenation, no conflict resolution).
<div class="{{ twJoin "card" .Variant }}">

// Merge class lists through the configured merger (plain join by
// default; conflict-aware once you call WithClassMerger).
<div class="{{ twMerge "p-4" .Class }}">
Function Description
attrs Renders a gastro.Attrs bag as HTML attributes. Optional second dict of base defaults; class is merged, other keys are overridable defaults. Escapes values (safe-typed values pass through), bool → bare/omitted, names validated.
twJoin Concatenates class lists with single spaces, dropping empties. Never resolves conflicts.
twMerge Same as twJoin until you call gastro.WithClassMerger — then it delegates to your merger (e.g. tailwind-merge-go) for conflict-aware merging.

By default twMerge and attrs's class handling only concatenate. Plug a Tailwind-aware merger to get conflict resolution; the dependency lives in your module, not gastro's:

import twmerge "github.com/Oudwins/tailwind-merge-go"

router := gastro.New(gastro.WithClassMerger(twmerge.Merge))

Custom Helpers

Register custom template functions in your main.go using gastro.WithFuncs():

router := gastro.New(
    gastro.WithFuncs(template.FuncMap{
        "formatEUR": func(cents int) string {
            return fmt.Sprintf("%.2f EUR", float64(cents)/100)
        },
        "slugify": func(s string) string {
            return strings.ToLower(strings.ReplaceAll(s, " ", "-"))
        },
    }),
)
http.ListenAndServe(":4242", router.Handler())

Custom functions are available in all pages and components, just like the built-in helpers.

Request-aware Helpers (WithRequestFuncs)

WithFuncs registers helpers at template-parse time — their bodies are fixed for the lifetime of the router. Request-aware helpers are different: their bodies close over a *http.Request and can read request state. That makes the same helper name (t, csrfField, cspNonce, …) return different values on different requests.

Use gastro.WithRequestFuncs(binder) to register them:

router := gastro.New(
    gastro.WithMiddleware("/", i18n.Middleware),
    gastro.WithRequestFuncs(func(r *http.Request) template.FuncMap {
        l := i18n.FromCtx(r.Context())
        return template.FuncMap{
            "t":  l.T,
            "tn": l.TN,
            "tc": l.TC,
        }
    }),
)

In a .gastro template:

---
---
<h1>{{ t "Welcome" }}</h1>
<p>{{ tn "1 item" "%d items" .Count }}</p>
<button>{{ tc "button" "Open" }}</button>

The binder runs once per request. The closures it returns capture r, so {{ t "Welcome" }} resolves against the request's locale, CSRF cookie, CSP nonce, or whatever else your middleware attached to the request context.

When to use it

Pattern Library Helpers registered
Internationalisation (gettext-style) gotext, go-i18n, hand-rolled t, tn, tc
CSRF protection gorilla/csrf, custom csrfToken, csrfField
CSP nonces custom (a few lines of crypto/rand) cspNonce
Named-route reversal custom routePath
Asset hashing custom asset
Feature flags flag library of choice flag

The common thread: anything that needs to read per-request state at template time, where pre-computing in frontmatter would be repetitive across many pages.

Rendering from handlers and SSE

When you call gastro.Render.X(props) from a Go handler, the static FuncMap is used — binders are not invoked, so request-aware helpers resolve to placeholders (typically the empty string). To bind a render call to a specific request, use Render.With(r):

func handleUpdate(w http.ResponseWriter, r *http.Request) {
    html, _ := gastro.Render.With(r).Card(gastro.CardProps{Title: "Hello"})
    datastar.NewSSE(w, r).PatchElements(html)
}

The returned *renderAPI is reusable within a single request — store it in a local and render multiple components from it. It is not goroutine-safe and must not be retained beyond the request.

Multiple binders compose

You can register WithRequestFuncs multiple times — e.g. one for i18n, one for CSRF — and the helper sets are merged. The only constraint is that helper names must be unique across the union of:

A collision panics at gastro.New() with both sources named:

gastro: helper name "t" registered twice
  - WithFuncs
  - WithRequestFuncs[1]

This is intentional — silent shadowing of a built-in or another binder would make template behaviour depend on registration order, which is brittle and hard to debug.

The binder contract

A WithRequestFuncs binder is a func(*http.Request) template.FuncMap. It MUST:

A binder SHOULD:

Runtime panic recovery

If a binder or any helper it returned panics during request handling, Gastro recovers the panic, logs it with the panicking binder's registration index, and dispatches to your WithErrorHandler (default: 500 Internal Server Error). One bad binder cannot crash the server.

Components, slots, and wrap blocks

Request-aware helpers propagate through every layer of a page render:

In every case, helpers like {{ t "…" }}, {{ csrfField }}, and {{ cspNonce }} resolve against the right per-request state. You don't need to translate strings in the page's frontmatter and pass them as props — the helper just works inside the component template body.

Under the hood, each request Clones the page's parsed template (or, in dev mode, re-parses it) and applies the per-request FuncMap to the clone. Bare component invocations are then dispatched through closures that thread the request all the way down the component tree. Cost is proportional to nesting depth; on an Apple M3 a typical component template clones in ~1.2 µs, so a 5-deep tree adds ~6 µs per request — well below the per-request budget of any real handler. See the BenchmarkNestedClone suite in internal/compiler/ for the per-depth roll-up.

Editor support

The Gastro LSP discovers WithRequestFuncs binder helpers by AST- parsing your project's main.go. As long as the binder returns a literal template.FuncMap{…} (either inline or via a one-hop named function reference in the same file), helper names show up in:

Binders that build their FuncMap dynamically (e.g. by ranging over a slice, or returning a template.FuncMap constructed in another package) still work at runtime, but the LSP can't statically extract their keys — so completion / hover / go-to-def don't list them. To make the trade-off visible, the LSP publishes an info-level diagnostic on the gastro.WithRequestFuncs(...) call site explaining the situation and pointing at the literal-FuncMap workaround.

Worked examples

Three example apps in examples/ exercise WithRequestFuncs along different axes:

Example What it stresses
examples/i18n/ The motivating case. Three helpers (t, tn, tc) from one binder, locale detection middleware, gettext-style PO catalogues.
examples/csrf/ Mixed return types in one binder (csrfToken returns string, csrfField returns template.HTML). Middleware mints + verifies tokens; helpers only read.
examples/csp/ Helper-to-middleware coordination: the middleware writes a Content-Security-Policy: nonce-X header and the cspNonce helper renders the matching <script nonce="X">. The two must agree.

Each example has its own README.md that walks through the recipe in depth, including how to swap in a battle-tested library (gotext, gorilla/csrf, etc.) without changing the WithRequestFuncs wiring.