The build folder
With the data model and the engine that drives it behind us, the rest of the chapter is hands-on — and it all happens in one folder. build/ is the only folder structure we document publicly, because it’s the only one you work in. Everything below it is yours to shape; everything above it is platform machinery. That single boundary is what lets a non-technical editor change content all day without ever risking your code, and lets you ship a template change without ever touching their content.
What’s inside
build/
├── pages/ routable pages (a node with an @path renders here)
│ ├── place.php
│ └── home.php
├── blocks/ embeddable fragments (a node without a path renders here)
│ ├── place-card.php
│ └── stop.php
├── menus/ navigation elements
│ └── mainmenu.php
├── forms/ structured edit/submit forms
│ └── place.php
└── assets/
├── css/ site styling — fully yours
└── js/ browser modules — yours, behind the lint-gate
Those five are what every space has. Three more are optional overrides — the platform ships working defaults, and you only create the folder when you want to replace one:
build/
├── email/ email-template overrides (login code, address-change, …)
├── i18n/ per-locale UI strings ({locale}.json)
└── pwa/ PWA icons + offline page
A node’s @build property names its template. @build: pages/place resolves to build/pages/place.php; @build: blocks/stop resolves to build/blocks/stop.php. The mapping is direct — no router config, no registration step.
Routable vs embedded
The folder a template lives in answers one question: can this node be visited at a URL?
has an @path → pages/ → a full HTML page at /that-path
no @path → blocks/ → rendered only inside another node
A routable node has a @path and a pages/* build — together they grant it a URL. An embedded node has no path; it lives inside other nodes as a card, a stop, a list item. The same type can be either: a Place can be a full page in one space and a card in another. Type describes what something is; build and path decide how it’s reached. (More in Nodes & types.)
Push and pull, with a gate
You work on build/ the way you’d expect — locally, in your editor, in version control. Pushing it to a space and pulling it back is a Git-like sync. But unlike a raw git push, every push passes through a lint-gate before it’s accepted. The gate is what makes the code/content boundary safe rather than merely conventional.
The gate rejects anything that could let a template escape its sandbox:
| The gate blocks | Why |
|---|---|
Dangerous functions (exec, eval, system, raw file_* on absolute paths) | A template renders data; it must not run arbitrary code or touch the host |
| Out-of-bounds reads | A template may read its own space’s graph, nothing outside it |
| External dependencies | No Composer, no vendor/, no npm install — templates use the platform’s helpers and PHP’s standard library only |
If the push lints clean, it lands. If it doesn’t, you get a specific error and nothing changes on the live space.
Why the boundary holds both ways
The split runs deeper than “don’t break each other’s stuff.” Alongside build/, the space root holds folders you don’t reach as files — data/ (the node graph), config/ (space settings), feed/ (generated public extracts) and files/ (uploads) — managed entirely at runtime by the editor, the API and the compiler. You never read or write them by hand.
code-push → changes build/ → never touches data/ or config/
runtime edit → changes data/ config/ → never touches build/
That clean separation is why an editor approving a draft can’t corrupt a template, and why deploying a new template can’t lose a single edit. The lint-gate guards the code side; the tinycycle and API guard the data side. Neither can reach across.
One thing does span the boundary on purpose — the helper API that turns static markup into editable content — but that gets its own page shortly. First, the look of the site: Theming & assets.