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:
const Person = Site.node(personRouter, AppRoute.Person)
.title(({ personId }) => `Person ${personId} | Routing`)
.section('People')
.nodeMethods — 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:
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:
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.