Wire the delivery surface
The adapter, schema, and renderer describe your content. A public cairn site adds three routes on
top. A catch-all serves one entry per request. The feed and sitemap routes give a crawler what it
reads. An archive filter narrows a long list by tag. Every snippet below is code from
examples/showcase.
Build the site’s indexes
createSiteIndexes turns your adapter’s raw markdown into the typed query surfaces the rest of
delivery reads: one ContentIndex per concept, plus a cross-concept site resolver.
import { createSiteIndexes } from '@glw907/cairn-cms/delivery';
import { cairn, siteConfig } from './cairn.config.js';
const postsRaw = import.meta.glob('/src/content/posts/*.md', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;
const pagesRaw = import.meta.glob('/src/content/pages/*.md', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;
const indexes = createSiteIndexes(cairn, siteConfig, { posts: postsRaw, pages: pagesRaw });
export const site = indexes.site;
export const posts = indexes.posts;
import.meta.glob needs a literal pattern at its call site, so the engine can’t glob on your
behalf. You write one glob per concept and pass the raw records in, keyed by concept id. The
showcase keeps this in one module, src/chassis/content.ts, so every route imports the same
site and per-concept indexes instead of rebuilding them.
Each ContentIndex gives you all(), byId(id), byTag(tag), allTags(), and adjacent(id)
for prev/next links. The cross-concept site resolver adds byPermalink(path), the one lookup
the catch-all route below needs, and all() for a site-wide listing like the sitemap. A malformed
entry fails the whole build by default. Pass { validate: false } as createSiteIndexes’s fourth
argument only if you want a bad entry to drop out of the index instead of failing the build.
Serve one entry through the catch-all route
createPublicRoutes builds the loader a single [...path] route mounts. It resolves whatever
path the request carries against the site resolver, renders the entry through your adapter’s
render, and folds in the SEO head and, when media is on, the hero image.
import type { PageServerLoad, EntryGenerator } from './$types';
import { createPublicRoutes } from '@glw907/cairn-cms/delivery';
import { site, ORIGIN, SITE_DESCRIPTION } from '$lib/content';
import { cairn, siteConfig } from '$lib/cairn.config';
export const prerender = true;
const routes = createPublicRoutes({
site,
render: cairn.rendering.render,
origin: ORIGIN,
siteName: siteConfig.siteName,
description: SITE_DESCRIPTION,
defaultImage: ORIGIN + '/og/default.png',
feeds: { rss: ORIGIN + '/feed.xml', json: ORIGIN + '/feed.json' },
});
export const entries: EntryGenerator = () => routes.entries();
export const load: PageServerLoad = async ({ url }) => {
return routes.entryLoad({ url });
};
entries() enumerates every permalink across every concept, so SvelteKit can prerender the whole
site from this one route. entryLoad resolves the current request’s path to one entry and throws
a 404 on a miss. The template reads data.entry, data.html, and data.seo and renders them; it
never touches the resolver directly.
<script lang="ts">
import type { PageData } from './$types';
import { CairnHead } from '@glw907/cairn-cms/delivery/head';
let { data }: { data: PageData } = $props();
</script>
<CairnHead seo={data.seo} />
<article>
<h1>{data.entry.title}</h1>
{@html data.html}
</article>
CairnHead renders the SEO head, title, meta tags, and one JSON-LD script, into
<svelte:head> from the plain-data seo object entryLoad returned. It carries no CSS, so
mounting it never pulls in an admin style. Pass title={false} if your template wants to own the
<title> tag itself.
If a schema field points at another concept, resolve it the same way the showcase’s post pages
resolve author: the cross-concept site resolver, not the per-concept index, is what can reach
a different concept’s entries.
import type { PageServerLoad, EntryGenerator } from './$types';
import { createPublicRoutes, resolveReferences, siteDescriptors, type ResolvedReference } from '@glw907/cairn-cms/delivery';
import { site, ORIGIN, SITE_DESCRIPTION } from '$lib/content';
import { cairn, siteConfig } from '$lib/cairn.config';
export const prerender = true;
const routes = createPublicRoutes({
site,
render: cairn.rendering.render,
origin: ORIGIN,
siteName: siteConfig.siteName,
description: SITE_DESCRIPTION,
});
// The concept descriptors, by id, so the load can hand resolveReferences the right field schema
// for the entry it resolved.
const descriptorById = new Map(siteDescriptors(cairn, siteConfig).map((d) => [d.id, d]));
export const entries: EntryGenerator = () => routes.entries();
export const load: PageServerLoad = async ({ url }) => {
const data = await routes.entryLoad({ url });
const descriptor = descriptorById.get(data.concept);
const references: Record<string, ResolvedReference | ResolvedReference[]> = descriptor
? resolveReferences(site, descriptor, data.entry.frontmatter)
: {};
return { ...data, references };
};
resolveReferences drops an id with no live target instead of throwing. The build’s
verifyReferences gate fails a genuinely dangling edge, so an id still unresolved at request time
points at a target that is mid-flight or a draft.
Read tags as data
Cairn ships no public tag pages. A taxonomy field’s values are read-model data: your template
filters over them, and no engine route maps a tag to a URL.
The field that marks a concept’s tags, multiselect with taxonomy: true, is already covered in
Define an adapter and schema.
Its validated values surface on every ContentSummary as tags: string[].
The showcase home reads that data directly, filtering the archive to a selected tag once it grows past a size worth narrowing:
import type { ContentSummary } from '@glw907/cairn-cms/delivery';
declare const entries: ContentSummary[];
declare const selected: string;
const filtered = selected ? entries.filter((p) => p.tags.includes(selected)) : entries;
The filter’s option labels come from the site’s curated vocabulary rather than the raw tag values,
so an editor’s topics label reads the same in the filter and in the picker:
import { extractVocabulary } from '@glw907/cairn-cms';
import { posts } from '$lib/content';
import { siteConfig } from '$lib/cairn.config';
export const load = () => ({ posts: posts.all(), vocabulary: extractVocabulary(siteConfig) });
extractVocabulary reads the {value, label} list a site curates in site.config.yaml. Pair
each option’s value against an entry’s tags to filter, and its label to display. A site with
no vocabulary configured still gets tags on every summary. That list is open and uncurated, because
the tag field never depends on a vocabulary existing.
ContentIndex also exposes byTag(tag) and allTags() directly, for a dedicated archive-by-tag
view if your site wants one instead of an inline filter. Both read the same validated tags this
section already covers.
Publish feeds, a sitemap, and robots.txt
Each of these has a pure builder that returns a string and a responder that wraps it in a
Response with the right media type. A +server.ts route calls the responder.
A feed needs one item per entry. The showcase builds its items once in a shared helper, so the RSS and JSON routes can never drift from each other:
import { buildLinkResolver, type FeedItem } from '@glw907/cairn-cms/delivery';
import { site, ORIGIN } from '$lib/content';
import { cairn } from '$lib/cairn.config';
export async function buildFeedItems(): Promise<FeedItem[]> {
const posts = site.concept('posts');
const toPermalink = buildLinkResolver(site);
const resolve = (ref: Parameters<typeof toPermalink>[0]) => ORIGIN + toPermalink(ref);
return Promise.all(
(posts?.all() ?? []).map(async (p) => ({
title: p.title,
url: ORIGIN + p.permalink,
date: p.date,
summary: p.excerpt,
contentHtml: await cairn.rendering.render({ body: posts!.byId(p.id)!.body, resolve }),
tags: p.tags,
})),
);
}
feedItem.tags rides straight from the same ContentSummary.tags the previous section covers, so
it becomes the RSS <category> and the JSON Feed tags with no extra mapping. Each format route
then wraps those items in its own responder:
import type { RequestHandler } from './$types';
import { rssResponse } from '@glw907/cairn-cms/delivery';
import { ORIGIN, SITE_DESCRIPTION } from '$lib/content';
import { siteConfig } from '$lib/cairn.config';
import { buildFeedItems } from '$lib/feed';
export const prerender = true;
export const GET: RequestHandler = async () => {
const items = await buildFeedItems();
return rssResponse(
{ title: siteConfig.siteName, description: SITE_DESCRIPTION, siteUrl: ORIGIN, feedUrl: ORIGIN + '/feed.xml' },
items,
);
};
feed.json mirrors this exactly and calls jsonFeedResponse instead. Both responders take the
same channel and item shapes, so the shared buildFeedItems helper feeds both.
The sitemap and robots routes read straight off the site resolver, with no intermediate helper:
import type { RequestHandler } from './$types';
import { sitemapResponse, type SitemapUrl } from '@glw907/cairn-cms/delivery';
import { site, ORIGIN } from '$lib/content';
export const prerender = true;
export const GET: RequestHandler = () => {
const urls: SitemapUrl[] = [
{ loc: ORIGIN + '/' },
...site.all().map((s) => ({ loc: ORIGIN + s.permalink, ...(s.date ? { lastmod: s.date } : {}) })),
];
return sitemapResponse(urls);
};
import type { RequestHandler } from './$types';
import { robotsResponse } from '@glw907/cairn-cms/delivery';
import { ORIGIN } from '$lib/content';
export const prerender = true;
export const GET: RequestHandler = () => {
return robotsResponse({ sitemapUrl: ORIGIN + '/sitemap.xml', disallow: ['/admin'] });
};
The showcase’s feed and sitemap both map every entry by hand, because a full-content feed needs
its own render pass and the sitemap wants every entry regardless of concept. A site with several
feed-eligible concepts and no need for full content can skip the hand-written map: feedView and
sitemapView project a site’s routing.inFeeds and routing.routable concepts into feed items
and sitemap URLs directly and summary-only, with no render pass. The delivery data
reference documents both.
A site also carries bespoke pages no concept describes, an about page, a tag index, and the like.
sitemapView’s fourth argument lists those as root-relative paths, so the whole sitemap comes from
one call instead of a hand-built array:
import { sitemapView, sitemapResponse, siteDescriptors } from '@glw907/cairn-cms/delivery';
import { site, cairn, ORIGIN } from '$lib/content';
import { siteConfig } from '$lib/cairn.config';
const EXTRA_ROUTES = ['/', '/about', '/archives', '/tags'];
export const GET = () => {
const urls = sitemapView(site, siteDescriptors(cairn, siteConfig), ORIGIN, EXTRA_ROUTES);
return sitemapResponse(urls);
};
A page directory that grows under the site’s own route group and never joins EXTRA_ROUTES ships
a silent sitemap gap. unlistedRoutes catches it: hand it the route ids under that directory and
the same EXTRA_ROUTES list, and assert the result is empty in the site’s own test suite.
The delivery data reference has the full example.
Related reference
createSiteIndexes,
createPublicRoutes, and
CairnHead cover the full call shapes this guide builds
from. resolveReferences documents the
reference-resolution helper on its own. Configure rendering covers the
render function this guide’s routes call. The media reference covers
makeMediaResolver and the resolveMedia render option that resolves a media: reference to its
delivery URL.