How it works
The walker, locale derivation, and dependency tracking — the three mechanisms, in full.
astwerk has three moving parts worth understanding. None of them is large; all of them are readable in one sitting.
The three mechanisms
| Mechanism | Where it lives | What it does |
|---|---|---|
| The walker | ssg/build.go |
depth-first recursion that writes the output |
| Locale derivation | ssg/locale.go |
mirrors the base tree per locale, applies overrides |
| Dependency tracking | reactive/signal.go |
signal reads register with the running effect |
The walker
Build is a depth-first recursion over Node. There is no plugin pipeline, no
lifecycle, no pass ordering to reason about — one function, roughly a hundred
lines, and this is all it does per node:
func buildNode(n Node, dir, sitePath string, loc *localeCtx, opts BuildOptions) error {
// 1. make the directory, if this node has any output
// 2. render Page to index.html, then each Files entry
// 3. copy CopyFrom
// 4. compile CompileFrom
// 5. merge Generate() into Children — generated keys win
// 6. recurse, appending each key to dir and sitePath
}
Two paths are threaded down in parallel: dir is where files land on disk, and
sitePath is what the page believes its URL to be. Keeping them separate is what
lets BaseURL change every generated link without moving a single output file.
Children are visited in sorted key order. That's deliberate: a build is reproducible, and when something fails you get the same node reported every time rather than whichever the map handed over first.
Parallelism
BuildOptions.Parallel fans siblings out across goroutines instead. Nodes write
to disjoint paths and share nothing, so the output is identical either way — and
errors are still collected by index and returned in sorted order, so a failure
names the same node whether you built in parallel or not.
It's opt-in for one reason: it moves your code onto other goroutines. Rendering
a templ component is safe; a Generate closure appending to a captured slice is
not. Making that a silent default would turn a routine upgrade into a data race,
so you say when you want it.
CompileScripts doesn't ask. Each .wasm is built by its own go build
process writing its own file — nothing of yours runs in astwerk's address
space — so there's no race to opt out of, and it's always parallel.
Failure
Errors propagate immediately, wrapped with the path being written:
ssg: render build/projects/thing/index.html: …
No partial-success reporting, no continuing past a broken page. A site that half-built is worse than one that didn't.
Locale derivation
BuildLocales copies the base tree and mounts the copy under the locale's code.
The copy carries Page, Files, Generate and Children — and deliberately
drops CopyFrom and CompileFrom.
That omission is the interesting part. Stylesheets and scripts are shared across
languages and served from the site root, so inheriting them would produce
build/de/style/ duplicating every asset per language. The rule is: a locale
subtree mirrors pages, not assets. Branches that end up with nothing to render
are pruned.
Overrides then patch nodes at a path, applied shallowest-first so an override of
projects lands before one of projects/foo. Each override is a
func(ssg.Node) ssg.Node handed the derived node, which is why patching one
field doesn't drop the rest and why the locale root needs no special case —
n already holds the inherited children. Locale identity is carried on an
unexported field of the locale's root node and inherited downward by the walker,
which is how a page five levels deep still knows its prefix.
Dependency tracking
reactive uses two package-level variables. That's the entire mechanism:
var (
owner *effect // who disposes what — nested effects attach here
observer *effect // who depends on what — signal reads register here
)
Signal.Get links the current observer to the signal's subscriber list.
Signal.Set walks that list and re-runs each subscriber. Before an effect
re-runs, it unlinks from everything it read last time and re-collects as it goes —
which is why dependencies are dynamic for free, and why a branch not taken creates
no subscription.
Keeping owner and observer separate matters more than it looks. Conflating
them was a bug during development: Untracked has to stop dependencies being
recorded without orphaning effects created inside it, and with one variable
doing both jobs those effects escaped their parent and were never disposed.
Change detection with an unconstrained type
Signal[T] accepts any T, so == isn't available at compile time. Comparison
goes through any, which works for comparable dynamic types and panics for
slices, maps and funcs:
func equalValues[T any](a, b T) (eq bool) {
defer func() {
if recover() != nil {
eq = false
}
}()
return any(a) == any(b)
}
Recovering to "not equal" is the safe direction: a spurious re-run is wasteful, a missed one is a bug.
Why no virtual DOM
A VDOM exists to make "re-render everything" cheap. Fine-grained bindings make it
unnecessary — a binding knows which property it owns, so it writes that property
and nothing else. BindList is the only place doing real reconciliation, and it
works from keys you supply rather than by diffing rendered output.
The cost is that you write bindings instead of templates for interactive parts. The benefit is that nothing is ever destroyed and rebuilt behind your back, so focus, scroll position and transitions survive without special handling.
Reading the source, and this site
Roughly in order of how much they'll teach you:
| File | What's in it |
|---|---|
ssg/build.go |
the walker |
ssg/node.go |
Node, Ctx, BuildOptions |
ssg/locale.go |
tree derivation and override application |
reactive/signal.go |
the dependency graph; no build constraints, so it's host-testable |
reactive/list.go |
keyed reconciliation |
wasmwrap/element.go |
the syscall/js wrapping style everything else follows |
reactive/signal.go carries no build constraint on purpose: the tracking logic is
pure Go and gets fast host tests, while only the DOM half needs node. Two real
bugs surfaced that way — the ownership split above, and BindList disposing the
very entries it was reusing.
This site is built with astwerk, source at
astwerk-website. Writing it
found four gaps that nothing else had: no base-path support (every link broke
under /astwerk/), no way to configure the markdown renderer (so no syntax
highlighting), no locale-independent path for a language switcher, and no
InsertBefore for keyed list ordering. All four are in the library now. That is
what a real test is for.