Syntax
Syntax is roughly fixed, not frozen. This page is a quick tour. The test corpus is the canonical, always-current reference — every accepted form is a case that parses, generates Go, and pins its rendered output, so it can never drift from what the compiler actually does.
A .gsx file is ordinary Go (package, imports, types, funcs) plus component declarations. A component has a templ-style header and a JSX-style body — the markup is the result, so there is no return type and no return:
component Card(title string, featured bool) {
<section class={ "card", "card-featured": featured }>
<h2>{title}</h2>
{ if featured { <span class="badge">Featured</span> } }
<div class="body">{children}</div>
</section>
}Elements vs components
Capitalization decides what a tag means:
- lowercase / hyphenated → HTML element:
<div>,<el-dialog> - Capitalized / dotted → component:
<Card>,<ui.Button>,<p.Content>
Component props — author owns the type
The param shape decides which props model is used:
| Param shape | Model | Generated signature |
|---|---|---|
Single named-struct param (component Button(p Props)) | Bring-your-own (byo) — use the author's type directly; no wrapper generated | func Button(p Props) gsx.Node |
| Inline params — multiple params or a single non-struct param | Generated <Name>Props struct (field per param + Children/Attrs when used) | func Card(p CardProps) gsx.Node |
| Nullary — zero non-receiver params | No props (unless {children} or fallthrough attrs are used, in which case a gsx-owned props is grown) | func Shell() gsx.Node |
The discriminator is discoverable: writing (p Props) (where Props resolves to a named struct in go/types) opts you onto the byo path. Receiver params are not counted.
Byo path — field-build
When gsx builds a byo-path component tag, it maps each attribute to a field of the author struct:
<Button variant="primary" featured full-width data-id="7">Save</Button>→
Button(Props{
Variant: "primary",
Featured: true, // bare bool attr → true
FullWidth: true, // kebab→Camel
Attrs: gsx.Attrs{"data-id": "7"}, // no matching field → Attrs bag
Children: gsx.Func(/* "Save" */), // children → Children field
})Field matching (default, in order):
- Identifier → Go-capitalized:
variant→Variant,fullWidth→FullWidth - Kebab → CamelCase:
full-width→FullWidth,aria-label→AriaLabel - No matching field → falls through to the
Attrs gsx.Attrsfield
Children and Attrs are explicit on the byo path — the author's struct must declare Children gsx.Node to use {children}, and Attrs gsx.Attrs to accept unmatched attrs. Missing the field is a clear codegen error.
Byo path — whole-struct splat { x... }
Pass a prebuilt struct as the entire prop value — the dominant real-world pattern:
<Button { data... }/>→
Button(data)Splat is all-or-nothing; build or modify the struct before passing it. The splat syntax is also the way method components and structpages page handlers receive a shared props type:
<p.Content { pd... }/> // → p.Content(pd)Quick reference
| Form | Meaning |
|---|---|
component X(params) { … } | component declaration (emission body — no return) |
component (p T) Name(params) { … } | method component (receiver) |
<div>, <el-dialog> | HTML element (lowercase / hyphenated) |
<Card>, <ui.Button> | component (Capitalized / dotted) |
{ expr } | interpolation in body (auto HTML-escaped) |
{ f() } where f returns (T, error) | auto-unwraps to T; the error propagates out of the enclosing Render (no marker needed) |
name="lit" | static string attribute |
name={ expr } | dynamic attribute (Go expression) |
name (bare) | boolean attribute = true |
disabled={ cond } | type-driven boolean attr (bool → bare/omitted) |
{ expr... } | spread/splat — on an element: spreads gsx.Attrs as HTML attrs; on a component: whole-struct splat (passes the prebuilt struct as props) |
{ if … } / { for … } inside a tag | conditional attributes |
{ if/for/switch … { <markup> } } | control flow contributing children |
| Go statement escape hatch (no output) |
<>…</> | fragment |
class={ a, "cls": cond } | composable class/style (comma list; conditional sugar) |
{children} | explicit children placement |
gsx.Raw(s) | unescaped HTML |
Spread operator — trailing { x... }
The spread operator mirrors Go convention (trailing dots, as in f(x...)):
<div { attrs... }> {/* element: spreads gsx.Attrs as HTML attributes */}
<Card { data... }/> {/* component: whole-struct splat → Card(data) */}
<ui.Button { btn... }/> {/* dotted component: same trailing-splat syntax */}The context (element vs component tag) determines the meaning — no type resolution is needed. The grammar treats both as the same spread_attribute node in the attribute list; the code generator interprets it based on the tag kind.
Markup vs Go (the one subtlety)
Inside { }, gsx decides markup-vs-Go positionally — the Babel rule: { <div/> } is markup, { a < b } is a Go expression. When in doubt, see the parser/ corpus cases.
Escaping & safe contexts
Encoding is automatic and context-aware — you write the value, gsx picks the escaper from where it sits (the codegen knows the context). Helpers are opt-outs for trusted values, never required for safety.
| Context | What gsx does | Opt-out (trusted) |
|---|---|---|
Text / attribute ({ x }, attr={ x }) | HTML / attribute escape | gsx.Raw(s) |
URL attribute (href, src, action, hx-*, …) | scheme-sanitize + escape | gsx.RawURL(s) |
JS value (@{ x } in <script> or a JS attr like x-data/@click/hx-on*) | JSON-encode (HTML-safe), Go value → JS literal | gsx.RawJS(s) |
JSON data island (<script type="application/json">@{ data }</script>) | JSON-encode the whole body | — |
CSS value (<style> body, CSS-context attrs) | value-filter (gw.CSS); risky tokens like ( / collapse to a safe placeholder | gsx.RawCSS(s) |
JSON and CSS are automatic, not filters. Any JS-value position JSON-encodes via the runtime JSVal; CSS values (<style> bodies, style= and CSS-context attrs, composable style={ … }) auto value-filter via gw.CSS/gw.Style. There is no |> json or |> css. Every context above is safe by default — CSS is just the most conservative (its value-filter drops (//, so a dynamic rgb(...)/calc(...)/url(...) needs gsx.RawCSS). The one genuinely fail-closed context is a JS event-handler expression value (onclick={ … }, @click={ … }, hx-on*), which is a compile error — use gsx.RawJS for trusted JS. See the security/, style/, jsattr/, and datajson/ corpus cases.
Learn by example
Each topic maps to a directory of corpus cases — every case is a .txtar holding the .gsx input, the generated Go, and the rendered output, all verified on every test run.
| Topic | Corpus cases |
|---|---|
| Elements, void, DOCTYPE, SVG, web components | elements/, doctype/ |
| Interpolation, raw HTML, escaping contexts | interpolation/, security/ |
| if / for / switch, fragments | control_flow/ |
component decls, props, {children}, slots | components/, slots/ |
| The full attribute system | attrs/, class/, style/, jsattr/ |
| ` | >` pipelines & filters |
| Markup-vs-Go corner cases | parser/ |
| Method components, page composition | methods/ |
| Children & attribute fallthrough | fallthrough/ |
| Byo props: field-build, splat, shared props | props/ |
Status — alpha.
.gsxcompiles to plain Go viagsx generate; syntax is stable but still evolving. Follow the roadmap.