astwerk

The Node tree

Node, Build, BuildOptions — the shape of the tree and how it's written.

A Node describes one directory in the output. Build walks the tree from the root and writes it. That is the whole mechanism — there is no config file, no convention scanner, no code generation, no second system. What each page receives when it renders is on Ctx & URLs.

Documentation

Node

type Node struct {
	Title       string
	Page        PageFunc
	Files       map[string]PageFunc
	Generate    func() map[string]Node
	CopyFrom    string
	CompileFrom string
	Children    map[string]Node
}

Every field is optional; the zero value only recurses into Children, so a node can exist purely to group.

Field Effect at this node's directory
Title passed to pages as Ctx.Title
Page renders index.html
Files renders extra files by name — 404.html, rss.xml, .nojekyll
Generate children computed at build time, merged into Children
CopyFrom copies a directory's contents in as-is
CompileFrom compiles a scripts directory in — see Scripts & WebAssembly
Children subdirectories, keyed by name

Children keys

Keys may contain slashes to nest ("assets/style"), but may not escape the output directory. Children are visited in sorted key order, so a build is reproducible. A node with nothing to do creates no directory.

Why keys are validated

A key like ../secrets or /etc/passwd would write outside the output directory if it were used as a path. .. and absolute paths are rejected up front so a tree can't escape its OutDir.

Why children are visited in sorted order

Map iteration order in Go is random, and a build that shuffles its output layout is a build you can't trust. Sorted keys make two things deterministic: the output is byte-identical across builds, and when a build fails, the same node is reported every time instead of whichever the map handed over first.

Generate

Generate runs at build time and returns children to merge into Children — generated keys win. This is how a directory of markdown becomes a set of pages:

Generate: func() map[string]ssg.Node {
	pages, _ := content.LoadDir("content/projects")
	out := map[string]ssg.Node{}
	for _, slug := range content.Slugs(pages) {
		out[slug] = ssg.Node{Page: post(pages[slug])}
	}
	return out
},

The full collection pattern — index page, tiles, pagination — is on Content & collections.

PageFunc, Templ, Static

Page and every Files entry is a PageFunc:

type PageFunc func(Ctx) templ.Component

Adapters cover the common shapes:

"about": {Title: "About", Page: ssg.Templ(views.About)}, // (title, path, prefix)
Page: ssg.Static(views.Home),                            // needs nothing
Page: func(c ssg.Ctx) templ.Component {                  // anything else
	return views.Projects(c.Title, c.Path, c.Prefix, tiles)
},

ssg.Templ adapts the common (title, path, prefix) signature — the walker fills in path and prefix, so only the title is yours to give. ssg.Static is for components that need nothing. Anything else — extra arguments, computed values — is a closure.

Files

Page always writes index.html, because pretty URLs want a directory per page. Some files can't work that way — hosts look for 404.html at the root, feed readers want rss.xml, and GitHub Pages needs .nojekyll or it runs the output through Jekyll:

Files: map[string]ssg.PageFunc{
	"404.html":  ssg.Templ(views.NotFound),
	"rss.xml":   feed,
	".nojekyll": empty,
},

Files entries render after Page, so a key of "index.html" overwrites it.

BuildOptions

type BuildOptions struct {
	OutDir       string // default "build"
	Dev          bool   // skip the clean, overwrite in place
	BaseURL      string // "/astwerk" when served from a subdirectory
	RelativeURLs bool   // emit URLs relative to each page
	Parallel     bool   // render sibling nodes concurrently
}

OutDir defaults to "build". BaseURL and RelativeURLs control the URLs pages generate — see Ctx & URLs. Dev exists so a running dev server doesn't have the ground pulled out from under it: without it, Build removes OutDir first, so a renamed page can't leave a stale file behind.

Parallel, and the escape hatch

Parallel renders siblings across goroutines, bounded by GOMAXPROCS. The output and error reporting are identical either way; only wall time changes. It's opt-in because it runs your Page, Files and Generate functions concurrently — rendering templ components is safe, a Generate closure appending to a captured slice is not. CompileScripts is always parallel; each .wasm is built by its own go build process, so there's nothing of yours to race.

The tree won't always be enough, and that's fine:

if err := ssg.Build(root, opts); err != nil {
	log.Fatal(err)
}
if err := writeSitemap("build/sitemap.xml", pages); err != nil {
	log.Fatal(err)
}

That's the intended escape hatch, not a workaround: the tree covers the repeating shape, anything genuinely one-off stays plain Go.

Example

root := ssg.Node{
	Title: "Site",
	Page:  ssg.Templ(views.Home),
	Children: map[string]ssg.Node{
		"about": {Title: "About", Page: ssg.Templ(views.About)},
		"style": {CopyFrom: "style"},
	},
}
if err := ssg.Build(root, ssg.BuildOptions{}); err != nil {
	log.Fatal(err)
}

That writes build/index.html, build/about/index.html, and copies style/ in. Page renders the index; each Children key becomes a directory; CopyFrom is copied verbatim.