gothic syntax
This is the reference for the gothic view language and the Go surface around it. For a guided tour, start with the quickstart.
A .gth file contains ordinary Go followed by one or more views. One view,
App, is the root. The others are components. main.go remains the entrypoint.
File layout
A .gth app file has two sections:
- A Go prelude: package, imports, model, messages,
Init,Update, commands, and procedure interfaces. - One or more views, starting with a line that begins with
view.
Everything before the first top-level view line is parsed as Go. Everything
from that line onward is parsed as gothic markup.
package app
import "github.com/metruzanca/gothic"
type Model struct{ Count int }
type Increment struct{}
func (m Model) Init() gothic.Cmd { return nil }
func (m Model) Update(msg gothic.Msg) (gothic.Model, gothic.Cmd) {
switch msg := msg.(type) {
case Increment:
m.Count++
}
return m, nil
}
view App(model Model) {
<div>{ model.Count }</div>
}A component file contains only a package clause and views. It has no model and
no Init or Update. Declare a separate component's props struct in the app
prelude. Pure components may live in the app file or a separate .gth file in
the same package. Stateful components live in the app file.
Application model
The app follows Bubble Tea and the Elm Architecture. The model implements:
type Model interface {
Init() Cmd
Update(Msg) (Model, Cmd)
View() Component
}Msgisany.Cmdisfunc() Msg.Updatereturns the next model and an optional command.Initreturns an optional command.Viewis generated from theAppview.
main.go mounts the app:
s := gothic.NewServer()
RegisterProcedures(s, procedures{})
s.MountApp("/", gothic.NewApp(Model{}))
s.ListenAndServe(":8080")Views
App view
view App(model Model) {
<div class="app">...</div>
}The App view takes one model parameter. Its name and type are used to generate
the Go View method and the client renderer.
Pure components
A pure component takes exactly one parameter named props, backed by a props
struct:
type BadgeProps struct {
Text string
Hidden bool
}
view Badge(props BadgeProps) {
<span class="badge" class:hidden={ props.Hidden }>{ props.Text }</span>
}Call it with a capitalized tag:
<Badge text={ model.Status } hidden />- Tag names map to fields case-insensitively.
- A tag without a value passes
true. - Non-pointer fields are required.
- Pointer fields are optional. Omitting the tag leaves the field
nil.
Pure components are stateless. They render exactly one root element and should reference only their props and package-level declarations. The compiler expands them into the caller before generating Go and JavaScript.
Children
A component accepts children when its props struct embeds gothic.Children:
type FieldProps struct {
gothic.Children
Label string
}
view Field(props FieldProps) {
<div class="field">
<span class="field-label">{ props.Label }</span>
{ props.Children }
</div>
}<Field label="Your name">
<input value={ model.Name } oninput={ NameChanged{Text: event.target.value} } />
</Field>- Children are expanded in the caller's scope, so they can use the App model, loop variables, events, conditionals, and stateful components.
- The component must render
{ props.Children }exactly once. - A named
Children gothic.Childrenfield works too. - Named slots and fallback content are not supported.
- Precompiled library stateful components cannot accept children.
Stateful components
A stateful component's first parameter is a model with Init and Update. A
second parameter is an optional props struct:
type TabsProps struct {
Items []Tab
}
type TabsModel struct {
Active int
}
func (m TabsModel) Init() gothic.Cmd { return nil }
func (m TabsModel) Update(msg gothic.Msg, props TabsProps) (TabsModel, gothic.Cmd) {
switch msg := msg.(type) {
case TabClicked:
m.Active = msg.Index
}
return m, nil
}view Tabs(model TabsModel, props TabsProps) {
<div class="tabs">
for i, item := range props.Items {
<button key={ i } class:active={ model.Active == i } onclick={ TabClicked{Index: i} }>
{ item.Label }
</button>
}
</div>
}<Tabs items={ model.Tabs } />A stateful component with no props uses view Box(model BoxModel), Init(),
and Update(msg).
Each instance owns its own state. The parent passes props but cannot read or write child state. State starts at the zero value unless seeded from props.
Stateful components can render in static positions, inside if branches, and
as keyed list items. Closing a branch or removing a keyed list item unmounts the
component, so its state resets when it is created again.
Props and Init
Update may take the props struct. Init takes no parameters and runs after
the initial PropsChanged:
func (m TabsModel) Init() gothic.Cmd
func (m TabsModel) Update(msg gothic.Msg, props TabsProps) (TabsModel, gothic.Cmd)Props changes dispatch gothic.PropsChanged:
case gothic.PropsChanged:
if m.Active >= len(props.Items) {
m.Active = len(props.Items) - 1
}Comparison is by reference. Assign new slices or structs instead of mutating in place.
Seeding state from props
On mount, PropsChanged arrives with Initial: true. Later changes use
Initial: false. Seed local state only on the first message:
case gothic.PropsChanged:
if msg.Initial && props.Active != nil {
m.Active = *props.Active
}The server applies PropsChanged{Initial: true} before rendering, then
serializes the resulting state. Client-created instances apply the same message
before their first render. Hydration reads the server state first. There is no
separate constructor.
Optional pointer props
type TabsProps struct {
Items []Tab
Active *int
}<Tabs items={ model.Tabs } active={ model.ActiveTab } />If the tag is omitted, the pointer is nil in Go and null in the client props
object. Use *props.Active after a nil check:
if props.Active != nil {
<p>{ *props.Active }</p>
}Address-of expressions such as &model.Field are not supported in views.
Component commands
Init and Update can return commands. Generated commands run in the
component's own loop and dispatch back to the component:
func (m BoxModel) Update(msg gothic.Msg) (BoxModel, gothic.Cmd) {
switch msg.(type) {
case Load:
return m, func() gothic.Msg {
return Loaded{}
}
case Loaded:
m.Status = "loaded"
}
return m, nil
}Unmounting destroys the component. Late command results and prop updates are ignored. Procedures remain App-owned; components cannot call them.
Actions
A component emits an action to the App with emit:
type ButtonProps struct {
Label string
Onclick gothic.Msg
}view Button(model ButtonModel, props ButtonProps) {
<button onclick={ emit(props.Onclick) }>{ props.Label }</button>
}<Button label="Add todo" onclick={ AddClicked{} } />emit is reserved and only available in component event expressions. The App
owns the message and any procedure it starts.
Nested stateful components
view Dashboard(model DashboardModel, props DashboardProps) {
<section>
<Counter label="static" onpick={ PickedChild{Label: "static"} } />
if model.ShowBranch {
<Counter label="branch" onpick={ PickedChild{Label: "branch"} } />
}
for i, item := range model.Items {
<Counter key={ i } label={ item } onpick={ PickedChild{Label: item} } />
}
</section>
}- Each child owns its own model.
- Props flow from the parent on every render.
- A child
emitgoes to the App. - Branches and keyed list removal destroy children.
- Unmounting the parent destroys the whole tree.
Markup
Elements, constant attributes, text, and interpolation work as in templ:
<div class="app">
<p>Hello</p>
<span>{ model.Count }</span>
</div>Interpolation takes a Go expression parsed with go/parser.
Attributes
<input value={ model.Draft } checked={ todo.Completed } />Expression attributes update a DOM property or attribute. class:name={ expr }
adds or removes a class:
<li class:completed={ todo.Completed } class:editing={ editing } class:hidden={ hidden }>focus={ expr } focuses the element when the expression becomes true.
key={ expr } declares a list key. key is reserved and is not passed to
components as a prop.
Lists
for _, item := range model.Items {
<li key={ item.ID }>{ item.Text }</li>
}The range source is a Go expression, normally a model field. Add key={ expr }
to the root of the loop body, or to a component call, to enable keyed list
reconciliation. Keyed lists reuse nodes by key and update, reorder, or remove
them as the model changes.
Lists without a key append and remove from the end only.
The server renders the initial children. The client adopts them and hydrates their events.
Conditionals
if len(model.Todos) > 0 {
<ul>...</ul>
} else if model.Loading {
<p>Loading todos.</p>
} else {
<p class="empty">Nothing to do yet.</p>
}The condition is a Go expression in the client Go subset. else if is sugar
for a nested conditional.
- The server renders only the chosen branch.
- The client adopts it and swaps branches when the condition changes.
len(x)is supported and counts slices, arrays, strings, and map keys.- A branch can contain stateful components. They mount when the branch appears and unmount when it leaves.
- A pure component still renders exactly one root element, so an
ifcannot be the component root. - A branch-level
formust have one element as its body. switchis not part of the view language.
Text that begins with the word if is parsed as a conditional. Rephrase such
prose or place it inside an element.
Events
An event attribute is a Go expression that produces a message:
<button onclick={ PingClicked{} }>Ping</button>
<button onclick={ DeleteClicked{ID: item.ID} }>Delete</button>The generated JavaScript dispatches the message value. There is no imperative handler block.
Event handlers receive the DOM event as event:
<input oninput={ DraftChanged{Text: event.target.value} } />onenter and onescape are shortcuts for keydown handlers:
<input onenter={ SaveEdit{ID: todo.ID} } onescape={ CancelEdit{ID: todo.ID} } />Routing
Define a LocationChanged message:
type LocationChanged struct {
Path string
Query string
Hash string
}The client dispatches it on load and on popstate. Mark internal anchors with
route:
<a href="/about" route>About</a>route is only supported on <a> elements and only in the App view.
Path patterns and parameters come from the route package:
params := route.Params("/todos/:id", msg.Path)
if params != nil {
m.ID = params["id"]
}route.Params returns map[string]string or nil. Patterns are
slash-separated, : captures one non-empty segment, and the pattern and path
must have the same number of segments.
Programmatic navigation comes from the browser module:
browser.Navigate("/about")
browser.Replace("/about")
browser.Back()These are client-only and valid in Init, Update, or commands. They require a
LocationChanged message.
MountApp registers a catch-all below the pattern, so deep links reach the app.
Use a request-scoped model to render the right state:
s.MountApp("/", gothic.NewAppFunc(func(r *http.Request) Model {
return NewModel(r.URL.Path)
}))See Routing and documents for a full walkthrough.
Documents
MountApp renders a full HTML document. The zero Document gets a title of
Gothic App, no icons, and no image. Set server-wide defaults with
SetDocument, then override them for a mount with WithDocument:
s := gothic.NewServer()
s.SetDocument(gothic.Document{SiteName: "Gothic"})
s.MountApp("/", gothic.NewApp(NewModel()),
gothic.WithDocument(gothic.Document{
Title: "Gothicbook",
Description: "A tiny design system demo.",
Image: "/og.png",
Icons: []gothic.Icon{{Href: "/favicon.svg"}},
Styles: []string{"/gothic-ui.css", "/style.css"},
}),
)WithDocumentreplaces the server document for that mount. Zero fields fall back to the built-in defaults.- Leave
Imageempty to omitog:image. LeaveIconsnil to omit icons. - Relative
og:imageandog:urlvalues become absolute using the request host and forwarding headers. gothic.WriteDocument(w, r, doc, body)renders the same shell around any component, without a Model./_gothic/is reserved for the runtime and generated modules.
Browser interop
The browser module is a curated, client-only surface:
import "github.com/metruzanca/gothic/browser"
browser.Alert("Saved")
browser.Log("loaded", count)
browser.SetTitle("Todos")
browser.SetItem("theme", "dark")
theme := browser.GetItem("theme")
browser.RemoveItem("theme")
browser.Copy("text to copy")
browser.Focus("#search")
height := browser.ClientHeight("#rows")
browser.Reload()Calls are lowered to browser APIs in the generated JavaScript. They are only
valid in Init, Update, or commands; calling them from a view is a compile
error. The package does not expose arbitrary window access.
Client Go subset
Init, Update, commands, and component views compile a growing subset of Go:
item := m.Items[0]
m.Items[0] = item
page := m.Items[1:3]
tail := m.Items[1:]
m.Counts = map[string]int{"open": 1, "done": 2}
m.Total = len(m.Counts)
delete(m.Counts, "done")
m.Total += 1
m.Count--Supported:
- field access, indexing, and two-index slicing,
- map literals,
len,delete, and map indexing, append, assignments, and compound assignments,++and--,ifandif err != nil,- range loops over slices and maps with
breakandcontinue, - non-type switches, including tagless switches and init statements,
- procedure calls and command closures.
Not supported:
- three-index slices,
&^=,- labeled branches,
goto,fallthrough,- declaring variables in view expressions.
Differences from server Go:
dom.Px(n)formats pixel lengths for style attributes.browser.ClientHeight(selector)reads an element height.- A missing map key reads as
undefined, not the zero value. - Map iteration uses JavaScript insertion order, and map keys arrive as strings.
/=is floating division.- Slices copy instead of sharing a backing array.
Procedures
Declare client-callable procedures as an interface that embeds
gothic.RPCService:
type Procedures interface {
gothic.RPCService
Ping(ctx context.Context) (string, error)
AddTodo(ctx context.Context, text string) error
ListTodos(ctx context.Context) ([]Todo, error)
}The first parameter must be context.Context. Remaining parameters are sent by
the client as JSON. The result is (T, error) or error.
The generator emits:
- a registrar
RegisterProcedures(s, impl), - a client-side Go stub used to compile commands,
- a JavaScript RPC function.
Commands call procedures directly:
func callPing() gothic.Msg {
pong, err := Ping()
if err != nil {
return PingFailed{Message: err.Error()}
}
return PongReceived{Text: pong}
}The Go stub panics if called on the server. The server never runs commands, so
this is safe. An interface without gothic.RPCService is an ordinary Go
interface.
Commands
A command performs work and returns a message:
func loadItems() gothic.Msg {
items, err := ListItems()
if err != nil {
return LoadFailed{Message: err.Error()}
}
return Loaded{Items: items}
}Update returns it as the second value. A command can also be a closure:
return m, func() gothic.Msg {
if err := AddTodo(m.Draft); err != nil {
return TodoFailed{Message: err.Error()}
}
return loadTodos()
}Compose commands with gothic.Batch or gothic.Sequence:
return m, gothic.Batch(loadItems(), loadStats())
return m, gothic.Sequence(
func() gothic.Msg { return FirstDone{} },
func() gothic.Msg { return SecondDone{} },
)Batchstarts commands concurrently.Sequenceruns them in order.- Each non-nil result is dispatched.
- Nil commands are ignored.
- Component commands stop after unmount.
Both are client-only. The Go stubs exist so Init, Update, and commands
compile.
Rendering
- The server renders the initial model and serializes it into the page.
- Components are expanded into the app before either target is generated.
- Interpolated text is wrapped in comment markers. The client updates the text node in place.
- The client reads the model, adopts the server-rendered list nodes, hydrates their events, and binds event attributes.
- On dispatch, the runtime calls
Update, replaces the model, updates text and lists, and runs the returned command. - Rendering is direct DOM updates. There is no virtual DOM.
Current limits
Supported:
- one application model per package,
- one
Appview with one model parameter, - pure components with a props struct,
- stateful components with their own model, props struct,
Init,Update, and view, - named tag props mapped case-insensitively,
- optional pointer props with
nilandnullsemantics, - children through
gothic.Childrenon pure and stateful components, - components in the same file or separate files in the same package,
- nested pure and stateful components,
- stateful components inside conditionals and keyed lists, with unmount,
gothic.PropsChangedwithInitialfor prop-seeded state,- component commands with unmount guards,
if,else if, andelse,forlists with keyed reconciliation,- message-valued events and
eventvalues, - dynamic attributes, class toggles, and focus,
- indexing, slicing, maps,
len,delete, compound assignment,++/--, - range loops and non-type switches,
appendand assignments,if err != nil,- commands with procedure calls and closures,
BatchandSequence,- procedure arguments,
- path, query, and hash state through
LocationChanged, routelinks androute.Params,- request-scoped models and catch-all mounting,
- documents, metadata, icons, stylesheets, and previews,
- curated browser interop through
browser, - headless long lists through
virtual.
Not supported yet:
- component-declared procedures; procedures are App-only,
- address-of expressions in views,
- controlled components and function-valued props,
- multiple app models,
- named slots and fallback content,
- Go-callable component functions,
switchin views,- multiple
Appparameters, - typed procedure argument validation,
- arbitrary browser globals outside
browser, - wildcard or optional route segments and reverse URL building,
- message serialization beyond JSON,
go/packagestype information,- formatter, LSP, and source maps.