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.
Serve a raw-markdown twin of every entry
createPublicRoutes also builds a raw-markdown twin of every routable, non-noindex entry: a
.md-suffixed path that returns the entry’s stored markdown body, unrendered, instead of the
rendered page. Wiring it is one small prerendered route, the same shape as the robots and sitemap
routes above, plus a param matcher so it doesn’t collide with the catch-all page route.
A param matcher at src/params/md.ts claims a rest-parameter segment ending .md:
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => param.endsWith('.md');
The route itself sits beside the existing [...path] catch-all, at [...path=md], naming the
matcher above so it claims only the suffixed path and leaves every other content request to the
plain catch-all:
import type { RequestHandler, EntryGenerator } from './$types';
import { createPublicRoutes, markdownResponse } from '@glw907/cairn-cms/delivery';
import { site, ORIGIN, SITE_DESCRIPTION } from '$lib/content';
import { cairn, siteConfig } from '$lib/cairn.config';
// Prerendered, same as every other public route. This is load-bearing rather than incidental: the
// build runs against committed main content, so there is no request path by which a pending
// cairn/* edit branch can reach this route. Switching it to runtime SSR reopens that hazard.
export const prerender = true;
const routes = createPublicRoutes({
site,
render: cairn.rendering.render,
origin: ORIGIN,
siteName: siteConfig.siteName,
description: SITE_DESCRIPTION,
});
export const entries: EntryGenerator = () => routes.markdownEntries();
export const GET: RequestHandler = async ({ url }) => {
const { body } = await routes.markdownLoad({ url });
return markdownResponse({ body });
};
params.path on the matched route carries the .md suffix; markdownLoad strips it itself, so
pass the request url through unchanged, the same way entryLoad reads it above.
CairnHead’s optional markdownUrl prop points a page at its own twin with a
rel="alternate" type="text/markdown" link, once the route above exists. Guard it: a noindex
entry has no twin (markdownEntries excludes it), so markdownUrl must read the same robots
verdict the head already rendered, not pass unconditionally:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const markdownUrl = $derived(
data.seo.meta.some((m) => m.name === 'robots' && m.content.includes('noindex'))
? undefined
: data.canonicalUrl + '.md',
);
</script>
<CairnHead seo={data.seo} {markdownUrl} />
data.canonicalUrl is already the entry’s absolute URL (EntryData),
so this needs no ORIGIN import.
examples/showcase/src/params/md.ts and
examples/showcase/src/routes/(site)/[...path=md]/+server.ts are the working reference this
section is built from. Choose an AI posture
covers why a site would wire this and what the served content type actually measures as.
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
buildMediaResolver and the resolveMedia render option that resolves a media: reference to its
delivery URL.