Content
Site
A documentation-site layer on top of collections. Arrange collections into a navigable site: nav tree, prev/next, search, sitemap, and a shell, with read functions browser-safe and the corpus server-only.
On this page
A collection answers what exists: a set of typed entries. The site layer answers how they read as a site: the nav tree, the order, the prev/next, the search index, the sitemap, the shell around the page. It builds directly on collections and lives in the same ogygia/content barrel. This site is one: every page you are reading is arranged, navigated, and searched by it. The Playground ↗ is a second one: the stock shell, nine swappable themes, a version × locale switcher, a blog genre, and an OpenAPI reference, all minted by one site() call.
You hand it collections and an arrangement; it mints small, data-returning, browser-safe functions the routes and the shell consume. The corpus itself never crosses the wire.
When to use
Reach for site() when a collection needs to become a site: a sidebar, “keep reading”, a search box, llms.txt, versioned or localized docs. If you only need a list and a body on one page, content() alone is enough. The site layer is what turns a corpus into something you navigate.
From zero
Scaffold the whole thing (site module, content folder, routes, a DocsShell layout) in one command:
npx ogygia site initpnpm dlx ogygia site initThe scaffold is ordinary app code you own: a folder() collection, a site() next to it, a catch-all route, a layout mounting the shell. Everything below explains what those few files do.
The whole thing in one file
site() takes a single options object (outline is the only required key) and returns a Site. It lives in a .server.ts module, next to the collections it arranges:
// server-only: the corpus can never reach a client bundle
import { site, links } from 'ogygia/content';
import { guides } from './collections.server';
export const docs = site({
outline: guides, // a bare collection arranges itself by convention (see below)
prevNext: 'graph', // "keep reading" follows the content graph
checks: [links()], // a broken in-prose link fails the build
data: {
title: 'Example',
description: 'Docs for Example.',
origin: 'https://example.dev' // absolute URLs for sitemap/llms when prerendered
}
});That is the entire configuration for this site. docs is a folder() collection, so its NN- filename prefixes already encode order and its +meta.json sidecars name the sections. The outline reads that structure as data and builds the tree with nothing more to say. Point it at several collections, or a pick() selection, or explicit groups when you want to arrange by hand; that is the outline.
Collections belong in a server module
A collection’s loader is an eager glob: the whole compiled corpus. Define it in a .server.ts / .remote.ts file (or under src/lib/server/) and Kit mechanically prevents any client module from importing it, so the corpus can never leak into a browser bundle. Define one elsewhere and ogygia warns you at build time. See Content collections.
What site() returns
The minted site (name it what it is: docs, blog) is a set of read functions. Each returns plain data; none pulls the corpus into the browser:
| function | returns | used by |
|---|---|---|
docs.nav() | the sidebar tree, hrefs resolved | the shell / a nav remote |
docs.meta() | { nav, switcher, data } in one call | <DocsShell {meta}> |
docs.page(slug) | one page’s view — data, headings, body, prev/next | the page component |
docs.search(q) | ranked hits | a search remote / the worker |
docs.switcher(slug) | the version/locale picker (dimensioned sites) | <Switcher> |
docs.emit.* | GET handlers — sitemap, llms.txt, RSS, raw markdown, search index | +server.ts routes |
docs.check() | check findings, as data | vitest / CI |
docs.entries() | every leaf slug + declared old addresses | prerender entries |
docs.load | the 404 guard + alias 308s + per-page checks | the route’s load |
Because they are data, where they run is your call. Prerender the nav, or fetch it per request; query search on the server, or ship the static index to a client worker. The remotes helper mints the common wire surface for you.
The wire layer
The collection holds the compiled corpus, potentially megabytes of markdown. The site layer never serializes it. A page’s body crosses the wire as a held region ticket (baked HTML, not source); everything else is metadata. remotes() mints the crossing from site in a .remote.ts, server-only, so the corpus stays server-side and the browser gets only the ticketed data:
// the wire layer
import { remotes } from 'ogygia/content/server';
import { docs } from './site.server';
export const { nav, meta, page, search } = remotes(docs, { base: '/docs' });Render it
The page component reads one function and renders the view. The <Doc> component lays out the article, the on-this-page rail, and prev/next from a docs.page() view; the shell wraps it with the sidebar, header, and search:
<script>
import { page } from '$app/state';
import { Doc } from 'ogygia/content';
import * as docs from '$lib/site.remote';
const view = await docs.page(page.params.slug);
</script>
<Doc {view} />And the layout mounts the shell over the meta remote, so the corpus never enters the layout’s module graph:
<script>
import DocsShell from 'ogygia/content/docs-shell';
import 'ogygia/content/theme.css';
import 'ogygia/content/shell.css';
import { meta } from '$lib/site.remote';
let { children } = $props();
const shellMeta = await meta();
</script>
<DocsShell meta={shellMeta} base="/docs" title="Docs">
{@render children()}
</DocsShell>Request context: previews and roles
Every read function takes an optional read context: a plain object your context function derives from the request. Collections thread it into their filter, so one site serves a public projection and a preview projection from the same corpus:
export const docs = site({
outline: guides,
context: ({ cookies }) => ({ preview: cookies?.get?.('preview') === '1' })
});
// a draft page exists only in the preview projection
const publicView = await docs.page('roadmap'); // null — filtered out
const draftView = await docs.page('roadmap', { context: { preview: true } });The empty context {} is the public, prerendered projection. A context-gated site typically flips its remotes to query mode, so each request derives its own.
The full options bag
Every site() key, at a glance. outline is the only required one.
| key | what it does |
|---|---|
outline | a collection, outline([...]) spec, or dimensions() — the arrangement |
data | { title, description, origin } — site facts; emissions default from here |
base | default mount prefix for every read; per-call { base } overrides |
prevNext | 'order' (default) or 'graph' — how “keep reading” is chosen |
trail | 'site' (default) or 'group' — whether prev/next crosses section boundaries |
checks | [links(), ...yours] — corpus invariants; see Checks |
components | markdown element overrides, as real component values; see Shell |
context | derive { preview, roles, … } from the request; threads into collection filters |
search | { engine } — swap the search engine; see Search |
redirects | old addresses per entry (default reads data.redirect_from) — baked 308 stubs |
The map
The site layer is one surface with a few clear parts. Each has its own page:
- Outline: arrange collections into a tree. The spec grammar, filename conventions, the address map, prev/next.
- Dimensions: one site across a matrix of versions or locales, with a switcher and graceful fallback.
- Shell & components:
Frame(headless),DocsShellandBlogShell(batteries-included), plusDoc,Sidebar,Pager, and the theme. - Remotes: the wire layer.
nav,meta,page,search, prerendered or live. - Search: the search function, the static index, the on-device worker, the no-JS fallback.
- Emissions: machine-facing serializations. Sitemap,
llms.txt, RSS, raw markdown. - Checks: the link audit and custom checks that fail the build, not the visitor.
Do / don’t
- Do keep
site()and its collections in a server module, and mint the wire in a.remote.ts; that split is what keeps the corpus out of client bundles. - Do let filename conventions carry structure (
NN-order,+meta.jsonlabels). The outline reads them as data; you write no arrangement config for the common case. - Don’t pass a collection’s bodies over the wire yourself. Let
docs.page()bake the body into a region ticket; that is the only representation meant to cross. - Don’t reach for the site layer to render a single standalone page. That is
content()plus<Region>; the site layer is the site around many.