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 }}

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.

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.