Gastro

Components

Components are reusable .gastro files in the components/ directory. They accept typed props and can render children.

Defining a Component

A component uses gastro.Props() to declare its props type. The Props struct defines what the component accepts:

---
type Props struct {
    Title  string
    Author string
}

Title := gastro.Props().Title
Author := gastro.Props().Author
---
<article>
    <h2>{{ .Title }}</h2>
    <p>By {{ .Author }}</p>
</article>

gastro.Props() is a compile-time marker that tells the code generator this file is a component. The Props struct must be defined in the same frontmatter.

Frontmatter scope: package-level vs per-request

The same scope rule that applies to pages applies to components:

type Props struct{} is itself a top-level type declaration, so it's always at package scope. You can declare additional helpers next to it:

---
import "strings"

type Props struct {
    Title string
}

// Hoisted to package scope — runs once at server startup.
func displayTitle(t string) string {
    return strings.ToUpper(t)
}

// Per-render — runs each time the component is rendered.
p := gastro.Props()
Title := displayTitle(p.Title)
---
<h2>{{ .Title }}</h2>

Referencing per-request state (gastro.Props(), gastro.Children(), or request-scoped values) inside a hoisted decl is a build error; use := instead.

Computed Values

When you need derived values from multiple props, assign the whole struct first:

---
import "fmt"

type Props struct {
    Label string
    X     int
}

p := gastro.Props()
Label := p.Label
CX := fmt.Sprintf("%d", p.X + 135)
---
<text x="{{ .CX }}">{{ .Label }}</text>

Importing & Using Components

Import components in the frontmatter with the .gastro file extension. The identifier is the local name used in the template:

---
import (
    Layout "components/layout.gastro"
    PostCard "components/post-card.gastro"
)

Title := "Home"
---
{{ wrap Layout (dict "Title" .Title) }}
    {{ PostCard (dict "Title" "My Post" "Slug" "my-post") }}
{{ end }}

Filenames are the public API

A component's Go identifier is derived from its file path. Path separators (/) and dashes (-) fold to PascalCase; underscores are kept verbatim:

File Render method / Props type
components/card.gastro Render.Card / CardProps
components/post-card.gastro Render.PostCard / PostCardProps
components/blog/post-card.gastro Render.BlogPostCard / BlogPostCardProps
components/interest_chips.gastro Render.Interest_chips / Interest_chipsProps

The import alias (the name left of the path) is what you write in templates and is free-form, so the derived name only surfaces when calling a component from Go. Prefer single-word or kebab-case filenames so the generated identifier reads as idiomatic Go — interest-chips.gastro gives you Render.InterestChips, while interest_chips.gastro gives you the awkward Render.Interest_chips.

Prop Syntax

Props are passed as attributes on the component tag:

<!-- Template expression -->
{{ PostCard (dict "Title" .Title "Slug" .Slug) }}

<!-- String literal -->
{{ Layout (dict "Title" "About") }}

<!-- Pipe expression -->
{{ PostCard (dict "Date" (.CreatedAt | timeFormat "Jan 2, 2006")) }}
Syntax Meaning
{.Expr} Go template expression, evaluated in parent's data context
"literal" String literal
{.Val | func "arg"} Pipe expression

Multi-line dict calls

When a component takes more than a few props, a single-line dict call becomes hard to read. Go's template parser is whitespace-insensitive inside parenthesised expressions, so you can spread the call across multiple lines and align the key–value pairs:

{{ Card (
    dict
    "Title"   .Title
    "Count"   5
    "Active"  true
    "Meta"    .Meta
    "Items"   .Items
    "Class"   "primary"
    "Href"    "/about"
    "Target"  "_blank"
    "Size"    "lg"
) }}

The leading ( on the first line and ) on the last line keep the parser happy. Aligning keys and values with whitespace is optional but makes the pairs visually obvious. Trailing commas are not required.

This has no effect on behaviour — dict arguments are positional, not named — but the readability improvement is substantial for components with many props. Build-time key validation still catches typos.

Pre-rendering in frontmatter

For pages with many components or deeply nested props, you can pre-render components in the page's frontmatter and pass the resulting HTML directly to the template. This keeps the template body lean and moves all typing into Go:

--- pages/dashboard.gastro
Header := gastro.Render.Header(HeaderProps{Title: "Dashboard", User: user})
Stats  := gastro.Render.Stats(StatsProps{Events: 142, Cost: 53.20})
Items  := gastro.Render.ItemList(ItemListProps{Items: deps.Items})

Title := "Dashboard"
---
{{ wrap Layout (dict "Title" .Title) }}
  {{ .Header }}
  <div class="grid">
    {{ .Stats }}
    {{ .Items }}
  </div>
{{ end }}

Uppercase frontmatter variables (Header, Stats, Items) are exported to the template as {{ .Header }}, {{ .Stats }}, {{ .Items }}. The template has no dict calls at all for component rendering — it's just a layout skeleton.

Route Use
Template dict Quick, one-off component calls with a few props
Multi-line dict Components with 5+ props — readability
Pre-render in frontmatter Many components, deeply nested props, full type safety in Go
gastro.Render from Go code SSE handlers, tests, background workers

For components that should accept pre-rendered markdown as a prop (e.g. a hero block where the body copy lives in a .md file), see Markdown — it covers the //gastro:embed directive plus the template.HTML prop pattern.

Type Coercion

Gastro automatically coerces prop values to match struct field types:

Target Type Accepted Values
string Any value (converted via fmt.Sprintf)
bool bool, string ("true", "false")
int int, int64, float64, string (parsed)
float64 float64, float32, int, string (parsed)

Children

Children let a component render content provided by its parent. Place {{ .Children }} where children should appear:

---
type Props struct {
    Title string
}

Title := gastro.Props().Title
---
<html>
<head><title>{{ .Title }}</title></head>
<body>
    <nav>...</nav>
    <main>
        {{ .Children }}
    </main>
    <footer>...</footer>
</body>
</html>

The parent passes children by wrapping content in the component tags:

{{ wrap Layout (dict "Title" "Home") }}
    <h1>Welcome</h1>
    <p>This becomes the children content.</p>
{{ end }}

Children are rendered in the parent's data context, so they can reference the parent's template data. Only one {{ .Children }} is supported per component.

Forwarding attributes

A component that wraps a native element — a Button, Input, Link — often needs to pass through arbitrary HTML attributes (type, href, data-*, aria-*, Datastar's data-on:*) without declaring a typed prop for each. Declare a field of type gastro.Attrs and it collects every dict key that does not match a declared prop:

--- components/button.gastro
type Props struct {
    Label string
    Attrs gastro.Attrs
}

Label := gastro.Props().Label
Attrs := gastro.Props().Attrs
---
<button {{ attrs .Attrs (dict "class" "btn px-4" "type" "button") }}>{{ .Label }}</button>

Call it forwarding any attributes alongside the typed Label:

{{ Button (dict
    "Label"         "Save"
    "type"          "submit"
    "class"         "px-2"
    "data-on:click" (safeJS "@post('/save')")
) }}

Label binds to the typed field; type, class, and data-on:click fall through into .Attrs and are emitted by the attrs func.

The attrs func

{{ attrs .Attrs (dict ...base) }} renders the bag as HTML attributes, taking an optional second dict of base defaults:

Merging classes (twJoin / twMerge)

The base class and the forwarded class are combined through a class merger rather than overwritten. The built-in merger only concatenates:

attrs .Attrs (dict "class" "btn px-4")  +  caller class "px-2"
  →  class="btn px-4 px-2"

Plain concatenation does not resolve Tailwind conflicts — px-4 and px-2 both survive, and which wins is decided by the generated CSS order, not the attribute. For real conflict resolution, plug a Tailwind-aware merger via WithClassMerger in your main.go:

import twmerge "github.com/Oudwins/tailwind-merge-go" // lives in YOUR module

router := gastro.New(gastro.WithClassMerger(twmerge.Merge))
//  →  class="btn px-2"   (px-4 dropped)

The dependency stays in your module — gastro core ships only the plain twJoin merger and never imports a Tailwind library. The same merger backs the twMerge template func; twJoin always plain-joins. See Template Helpers.

Typos become attributes

Because a gastro.Attrs field accepts arbitrary keys, the compile-time prop validation below is relaxed for that component: an unknown key is a forwarded attribute, not a typo. A misspelled Lable silently becomes a lable="…" attribute rather than a build error. Components without an Attrs field keep the strict typo guard.

Compile-time prop validation

Gastro statically validates the literal string keys you pass to (dict ...) against the destination component's Props schema. A typo in a key surfaces as a compile-time warning that names the component, the typo'd key, and the valid prop names:

gastro: warning: pages/index.gastro:14: unknown prop "Tite" on component Card (valid: Body, Title)

This catches the "blank card in production" failure mode where MapToStruct would otherwise silently drop the unknown key at render time, leaving the field at its zero value.

The validator skips two cases by design so it doesn't false-positive:

  1. Dynamic dict keys. If any odd-indexed dict argument isn't a string literal (e.g. (dict $key $value)) the entire call is skipped — we can't know at compile time which keys it contains.
  2. Components without a Props struct. A component that takes no typed props can be invoked with an arbitrary dict; the runtime ignores extra keys.

Warnings are promoted to errors by gastro generate, gastro build, and gastro check (i.e. anything CI runs). The dev server (gastro dev) prints warnings but keeps rendering, so live editing isn't blocked.

Calling Components from Go

Components can be rendered directly from Go code — useful for SSE handlers that patch the DOM with fresh component markup, for tests, or for any handler that produces HTML outside the page-routing flow.

For every component in components/, gastro generates a typed method on the package-level Render value:

import gastro "myapp/.gastro"

html, err := gastro.Render.Card(gastro.CardProps{
    Title: "Hello",
    Body:  "World",
})
if err != nil {
    // handle
}

Components with children expose a Children template.HTML field on their Props struct (auto-added by codegen when the template references {{ .Children }}):

html, err := gastro.Render.Layout(gastro.LayoutProps{
    Title:    "Home",
    Children: template.HTML("<h1>Welcome</h1>"),
})

Named content areas (sidebar, footer, etc.) follow the same pattern — add an explicit template.HTML field to your Props struct and reference it as {{ .Sidebar }} in the template body. There is no separate slots: keyword.

Render lives in the generated .gastro/render.go. Each method calls the same underlying component function used by the template renderer, so frontmatter logic (computed values, validation) runs identically whether a component is invoked from a template or from Go.

Router-bound Render() for parallel tests and multi-router setups

The package-level gastro.Render dispatches to the most-recently-constructed Router via an internal atomic pointer. That's fine for a single-router app, but tests that run t.Parallel() and construct a Router per test, or servers that host multiple tenants on different Routers, should prefer the router-bound API:

router := gastro.New(opts...)
html, err := router.Render().Card(gastro.CardProps{Title: "Hello"})

Methods on the value returned by router.Render() dispatch directly to that Router's template registry and never read the global active-router pointer, so they're race-free regardless of how many Routers exist concurrently.

When to use Render vs Routes

Goal Use
Mount file-based page routes on an HTTP server router := gastro.New(...); router.Handler()
Render a single component to an HTML string (single-router app) gastro.Render.<Name>(...)
Render a single component to an HTML string (parallel tests, multi-router) router.Render().<Name>(...)

A typical SSE example combines both:

mux := http.NewServeMux()
mux.HandleFunc("GET /api/increment", func(w http.ResponseWriter, r *http.Request) {
    n := count.Add(1)
    html, _ := gastro.Render.Counter(gastro.CounterProps{Count: int(n)})
    sse := datastar.NewSSE(w, r)
    sse.PatchElements(html)
})
mux.Handle("/", gastro.Routes())

See examples/sse/ for the full version.