Site Anatomy
Most static site generators describe their structure in a configuration file. This one describes it in Swift, in a single function:
func siteTree(for site: SiteContext) -> SiteTree {
let root = site.root
return SiteTree {
if root.hasContent("articles") {
Articles(title: "Articles", folder: "articles")
}
if root.hasContent("guide") {
Book(title: "Guide", folder: "guide", pageNavigation: .top, in: root)
}
Pages()
}
}
That is the whole structure of this site. It is a result builder, so it reads like a list and behaves like code: if works, for works, and a section that needs a value computes it.
Sections are types
Articles, Book and Pages each conform to one small protocol:
protocol Section: Builtin, Sendable {
var title: String { get }
var path: String { get }
var isNavigable: Bool { get }
func register(into saga: Saga, context: SiteContext)
}
Because each kind is its own type, the fields it needs are the fields it has. A Book takes a pageNavigation; an Articles does not, and the compiler says so. Nothing has to validate a configuration table at runtime to discover that postsPerPage on a book is meaningless.
Adding a kind of section means adding a type. It means touching nothing else.
Navigation is derived, not declared
The header tabs are not written anywhere. They are the site tree, filtered:
var navigation: [SectionInfo] {
sections.filter { $0.path != "/" && $0.isNavigable }
}
A section appears in the navigation because it exists — never because someone also remembered to add a link for it. Pages sets isNavigable to false, because the site title already links to the root; a section that claims / is not a tab.
This is why the build runs the tree twice. The first pass collects the sections; only then is the navigation complete; only then does the first page render. Doing it the other way — render, then discover the sections — is how a generator ends up rewriting its own output afterwards to fix the sidebar. Nothing here rewrites its own output.
Declare only what exists
Every section above is guarded on root.hasContent(...). That guard is load-bearing rather than defensive.
Because navigation is derived from declarations, an unguarded declaration puts a tab on every site this binary builds — including the ones with nothing behind it. A tab that leads to a 404 is worse than a missing one, so a section is declared only when its folder is actually there.
Order matters at the end
Pages() is last on purpose. The pipeline hands each file to the first section that claims it, so the catch-all has to come after the sections that want particular folders. It takes content/index.md and turns it into /, and any stray Markdown becomes a standalone page.
Per-site structure
One binary, many sites: site build ~/sites/other resolves that directory, reads its manifest, and runs the same tree against it. The guards do the rest — a site with no articles/ folder gets no Articles tab, from exactly the same code.