Site / foldkit-site

Core concepts

Nodes. Site.route(router, case, options?) declares one location. Options are title (a function of the route value), section, landing (the section's nav address as this node's params), shortcut (the keys that go to its landing, as Foldkit's key bindings name them: '/', ['G', 'H']), history, access (who may be shown it: see Common workflows), and surface (below). The node is frozen; keep the exported value and use it for targets and hrefs. To annotate in steps, or to share an annotation bundle across nodes, build incrementally instead — the options are sugar over this builder:

ts
const Person = Site.node(personRouter, AppRoute.Person)
  .title(({ personId }) => `Person ${personId} | Routing`)
  .section('People')
  .node

Methods — not pipeable fragments — because each stage of a pipe is a separate generic call and could not infer the route. Read .node at any stage: every step freezes, so intermediates are usable nodes too, never drafts. Named bundles also compose through .pipe (Effect-style, one bundle; chain for more) or free pipe(builder, bundle) — the bundle stays generic over the builder it meets, so it instantiates with no annotation.

Trees. Site.mount(node, children?) mounts a node under children without mutating it — children may be bare nodes or mounts. Site.make(...roots) freezes the topology. Two nodes sharing a tag, or one node mounted twice, throw at make: both would make chain inspection ambiguous.

Targets. Site.target(node, params) is { node, route, url }. Site.href(target) — or Site.href(node, params) — is its URL. Because the URL is built by the node's own router, a round trip holds by construction: a built URL parses back to the target's route (there is a test proving it).

Both directions. A tree's type carries its routes, so the tree is the one declaration of address to route and back. Site.parse(site, NotFound) is the application's parser: the first node, in tree order (depth-first, parents first), whose router takes the whole address, or the fallback; put a node that takes any segment (/:slug) after the nodes whose literal it would take too. Site.hrefOf(site, route) builds a route's address with its node's router, and refuses at compile time a route no node holds.

One tree, one document. A tree holds the routes one application draws. Site.routeAt(site, url) is the route a node reads at an address, as Site.parse reads it, or None when no node takes it: an address the tree does not hold is another document's, reached with a full load, not a Navigate Command that could only draw NotFound.

Every address. Site.targets(site, paths) lists the site in tree order: a node whose route has no params once, and a node with params by its Site.paths(node, list), where list is an Effect giving each address's params and, for a sitemap, when it last changed:

ts
const list = Site.paths(People, Effect.succeed([{ params: { searchText: Option.none() } }]))
const people = Site.paths(
  Person,
  Effect.succeed([{ params: { personId: 3 }, modified: '2026-10-01' }]),
)
const listed = Site.targets(AppSite, [list, people])
// an Effect of [..., { node: Person, route, url: '/people/3', modified: '2026-10-01' }]

A build renders what it lists, a sitemap keeps what Site.indexedOf keeps, and prefetch can warm it. A node with params and no paths, paths for a node the tree does not hold, and paths given twice are refused, so no page is left out unsaid; Site.paths(node, Effect.succeed([])) lists none. Its failures and services are those of the lists, typed.

Inspection. Site.nodeOf(site, route) and Site.chainOf(site, route) resolve a route value to its node and its root-first chain ([] for a tag the tree does not hold, as an inactive Surface resolves to nothing). Site.parentOf, Site.ancestorsOf, Site.depthOf, and Site.nodesOf (depth-first, parents before children) cover the rest.

History. Site.historyOf(prev, next) declares push-vs-replace once: to another node — or from an unknown location, so Back can still return to it — a step; within a node, its history rule (a string, or a function for nodes where some param changes are entries of their own, such as another person, and others are views of one entry, such as another search). This unifies the mirror's per-key spelling with the routed code that used to spell the same rule by hand.

Navigation metadata. Titles and sections read with Site.titleOf and Site.sectionOf. Site.landing(site, section) is where the section's nav item points: the first node of the section that declares a landing, as a target. Site.shortcuts(site, toMessage) is the tree's shortcuts as key bindings for Dom.streamFromKeyBindings, each sending the Message toMessage makes of its node's landing target; a shortcut with no landing, or one used twice, is refused. Annotate once; derive nav, titles, shortcuts, and breadcrumbs from it.

Head metadata. meta is what a node says of itself to a search result and a link preview: a function of its route and the Model, since a post's excerpt is data, not an address. It returns foldkit-ssr's Meta (description, image, type, article dates, JSON-LD, ...). indexed: false keeps a node, and every node below it that does not say otherwise, out of search engines. Site.metaOf(site, route, model) reads the route's chain root first, a deeper node's field replacing its ancestor's, the preview title falling back to the node's title, and noindex for an unindexed route whatever its meta says. It is an SSR plan's meta as it stands:

ts
const Person = Site.node(personRouter, AppRoute.Person)
  .title(({ personId }) => `Person ${personId} | Routing`)
  .meta<Model>(({ route, model }) => ({ description: model.bios[route.personId] }))
  .node
const Nested = Site.route(nestedRouter, AppRoute.Nested, { indexed: false })

const plan = SSR.plan(App, {
  id: 'site',
  state: Projection.pick(App.model.route, App.model.bios),
  meta: model => Site.metaOf(AppSite, model.route, model),
})

The plan writes it into the generated page and keeps the head in step as the reader moves; the Site only says what it is. meta must read what crosses to the browser (the route, a resumed read), as any plan's meta must. Site.indexedOf(site, route) is the same flag for a sitemap.