astwerk

x — compiled reactive markup

Markers that generate both the HTML and the JavaScript for a page at build time — no WASM, no framework.

x is interactive UI without WASM and without a JavaScript framework. Markers are ordinary Go functions that render where they are used, and each one owns the element it produces — @x.Text(count) writes the value and the JavaScript that keeps it live, in the same call. No build pass, no selector to keep in sync.

Everything below is opt-in: outside a Document the markers render plain static HTML, so a page with no markers ships no script at all.

Markers that change the document's structure or run scripts of their own — Effect, When, List, Script — are on x — structure & scripts.

Documentation

Signal

var count = x.Named("count", 0) // named, so scripts can read count.get()
var open  = x.NewSignal(false)

A signal holds a value that exists both as a build-time Go value and as a live JavaScript state cell. Named fixes the signal's name in the generated JavaScript; auto-named signals are fine for bindings, which never need to know the name.

Why the name matters

The name is the bridge to hand-written JavaScript: a script can read and write count.get() / count.set(...) because the generated code declares count as a real variable. An auto-named signal has no stable name, so only the compiled bindings can reach it.

Text

@x.Text(count)

Renders the current value and emits the binding that keeps it in sync. The marker owns the <span>, so there is no selector to keep in sync by hand.

El

@x.El("button", x.On("click", count.Set(count.Add(1)))) {
	+
}

Renders the element with its bindings attached. Every binding owns one property of one element.

Bindings

@x.El("a", x.Attr("href", url))        { link }
@x.El("span", x.Class("on", active))   { … }
@x.El("button", x.Disabled(busy))      { save }
@x.El("p", x.Show(visible))            { … }
@x.El("p", x.Style("color", colour))   { … }
@x.El("button", x.On("click", handler)){ + }
Binding Effect
Attr(name, value) sets a string attribute, removes it when the value is empty
Class(name, on) toggles one class
Disabled(on) toggles the boolean attribute — present or absent, not true or false
Show(on) toggles visibility
Style(prop, value) sets one CSS property through style.setProperty, so a value can't break out of its property
On(event, action) binds an event handler whose action is built from combinators

Combinators

count.Set(x.Lit(0))      // store a constant
count.Set(x.Not(open))   // negate a bool
count.Set(count.Add(1))  // arithmetic: Add, Sub, Mul, Div
count.Set(count.Add(other)) // … or another reactive value of the same type

The set is deliberately small — every operator is a step toward a language. When an expression stops reading like the JavaScript it produces, that's the boundary; use the script escape hatch instead.

Computed

var price = x.NewSignal(10.0)
var qty   = x.NewSignal(2.0)
var total = x.Computed(price.Mul(qty))
@x.Number(price)
@x.Number(qty)
@x.Text(total)

Derived state is an expression built from combinators. It is evaluated at build time to render the static value, and compiled to JavaScript that recomputes it when its dependencies change.

Input, Number, Checkbox

@x.Input(name)
@x.Number(age)
@x.Checkbox(subscribed)

All three are two-way — typing writes the signal, setting the signal updates the field — without feeding back on themselves. Number ignores input that isn't a number rather than clobbering the signal with zero.

Why two-way doesn't feed back

Typing fires an input event that writes the signal; the reverse direction is a plain value assignment on the field. Assigning an input's .value does not dispatch an input event, so the two directions never chase each other.

Document

@x.Document(counter())

The page's entry point: it renders its body and emits the runtime and state declarations before the markup, the bindings after it. Outside a Document the markers render plain static HTML — which is what makes reactivity opt-in, and why a page with no markers ships no script at all.

@x.Document(counter(), x.Minified()) // compact production output

Without Minified, the generated JavaScript is indented and readable.

How a marker works, and how this differs from reactive

@x.Text(count) renders <span data-x="7">0</span> into the page and appends aw.text("7", s0) to a script buffer. The marker owns the span, so it can stamp it with an internal id at the exact spot it was written — there is no second pass and no way for the wiring to drift out of sync with the markup.

State that is never used in markup generates no JavaScript, and the runtime itself is assembled from fragments — a counter page ships roughly the runtime for a counter, not a framework.

x and the WASM reactive package are two implementations of the same reactive model — signals with tracked dependencies, fine-grained bindings, keyed lists. x is the lightweight default for the common 90%; reactive is the complete path for anything that needs full Go in the browser — goroutines, the standard library, the router.

Example

var count = x.Named("count", 0)
@x.Document(counter())

templ counter() {
	<div>
		@x.Text(count)
		@x.El("button", x.On("click", count.Set(count.Add(1)))) {
			+
		}
	</div>
}

The whole model in one component: named state, a text binding, and a handler whose action is built from combinators.