Skip to content

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:

gsx
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 shapeModelGenerated signature
Single named-struct param (component Button(p Props))Bring-your-own (byo) — use the author's type directly; no wrapper generatedfunc Button(p Props) gsx.Node
Inline params — multiple params or a single non-struct paramGenerated <Name>Props struct (field per param + Children/Attrs when used)func Card(p CardProps) gsx.Node
Nullary — zero non-receiver paramsNo 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:

gsx
<Button variant="primary" featured full-width data-id="7">Save</Button>

go
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):

  1. Identifier → Go-capitalized: variantVariant, fullWidthFullWidth
  2. Kebab → CamelCase: full-widthFullWidth, aria-labelAriaLabel
  3. No matching field → falls through to the Attrs gsx.Attrs field

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:

gsx
<Button { data... }/>

go
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:

gsx
<p.Content { pd... }/>   // → p.Content(pd)

Quick reference

FormMeaning
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 tagconditional 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...)):

gsx
<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.

ContextWhat gsx doesOpt-out (trusted)
Text / attribute ({ x }, attr={ x })HTML / attribute escapegsx.Raw(s)
URL attribute (href, src, action, hx-*, …)scheme-sanitize + escapegsx.RawURL(s)
JS value (@{ x } in <script> or a JS attr like x-data/@click/hx-on*)JSON-encode (HTML-safe), Go value → JS literalgsx.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 placeholdergsx.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.

TopicCorpus cases
Elements, void, DOCTYPE, SVG, web componentselements/, doctype/
Interpolation, raw HTML, escaping contextsinterpolation/, security/
if / for / switch, fragmentscontrol_flow/
component decls, props, {children}, slotscomponents/, slots/
The full attribute systemattrs/, class/, style/, jsattr/
`>` pipelines & filters
Markup-vs-Go corner casesparser/
Method components, page compositionmethods/
Children & attribute fallthroughfallthrough/
Byo props: field-build, splat, shared propsprops/

Status — alpha. .gsx compiles to plain Go via gsx generate; syntax is stable but still evolving. Follow the roadmap.