On this page
  1. Index builders
  2. createSiteIndexes
  3. Feeds, sitemap, and robots
  4. buildRssFeed
  5. buildJsonFeed
  6. buildSitemap
  7. feedView
  8. sitemapView
  9. unlistedRoutes
  10. buildRobots
  11. rssResponse
  12. jsonFeedResponse
  13. sitemapResponse
  14. robotsResponse
  15. SEO and manifest builders
  16. buildSeoMeta
  17. buildSiteManifest
  18. buildLinkResolver
  19. buildFragmentResolver
  20. resolveReferences
  21. Pure helpers
  22. deriveExcerpt
  23. resolveImageUrl
  24. readSeoFields
  25. jsonLdScript
  26. siteDescriptors
  27. Types

Delivery data (@glw907/cairn-cms/delivery/data)

This subpath holds the index builders, the feed, sitemap, and robots builders and responders, the SEO head builder, and the small pure helpers. All of it is node-safe pure projection: nothing pulls @sveltejs/kit into the module graph, so a plain-Node tool such as the manifest bin or the Vite plugin can import the builders. A SvelteKit site usually imports these symbols through the /delivery barrel, which re-exports this whole surface. Import from /delivery/data directly for a builder used outside the SvelteKit runtime.

import { createSiteIndexes, rssResponse } from '@glw907/cairn-cms/delivery/data';

The showcase reaches these symbols through /delivery, so the snippets below come from the /delivery showcase routes. The same imports point at /delivery/data in a plain-Node context. The TypeScript types in src/lib/delivery are the source of truth, and the export-coverage gate checks every name here against them.


Index builders

These turn a site’s raw markdown into the typed query surfaces a route reads.

createSiteIndexes

Stability tier: Extension API.

function createSiteIndexes<const A extends CairnAdapter>(
  adapter: A,
  config: SiteConfig,
  globs: SiteGlobs<A>,
  opts?: { validate?: boolean },
): SiteIndexes<A>;

Build the typed per-concept indexes and the cross-concept site resolver from one adapter. Pass the per-concept raw globs keyed by concept id. Vite needs the literal glob at the call site, so the engine cannot glob on the site’s behalf. The returned object carries one ContentIndex per concept plus a site field, so a concept literally named site is not supported. validate: false opts out of the build gate. The showcase builds its one content layer this way.

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;

Feeds, sitemap, and robots

Each output has a pure builder that returns a string and a responder that wraps the string in a Response with the right content type. A SvelteKit +server.ts calls the responder; a static tool calls the builder.

buildRssFeed

Stability tier: Extension API.

function buildRssFeed(channel: FeedChannel, items: FeedItem[]): string;

Build an RSS 2.0 document. channel carries the feed metadata with absolute URLs; each FeedItem carries one entry, with contentHtml for a full-content feed.

buildJsonFeed

Stability tier: Extension API.

function buildJsonFeed(channel: FeedChannel, items: FeedItem[]): string;

Build a JSON Feed 1.1 document from the same channel and items.

buildSitemap

Stability tier: Extension API.

function buildSitemap(urls: SitemapUrl[]): string;

Build a sitemap XML document from a list of SitemapUrl entries, each a loc and an optional lastmod date.

feedView

Stability tier: Unstable API.

function feedView(
  site: SiteResolver,
  descriptors: ConceptDescriptor[],
  origin: string,
): FeedItem[];

Project a site’s feed-eligible concepts into feed items. It iterates the concepts whose routing.inFeeds flag is set and maps each entry to a FeedItem in the concept’s own date order. Each item carries the entry’s taxonomy values as tags, which become the RSS <category> and the JSON Feed tags. Pass origin because each FeedItem.url is absolute and the engine carries no ambient origin.

The view is summary-only. It sets summary from the entry excerpt and omits contentHtml, the full-content body. A full-content feed needs a per-item render and a link-resolver pass, which the pure view does not carry. A site that wants full content maps render itself, as the feed.xml showcase route does.

sitemapView

Stability tier: Extension API.

function sitemapView(
  site: SiteResolver,
  descriptors: ConceptDescriptor[],
  origin: string,
  extraRoutes?: string[],
): SitemapUrl[];

Project a site’s routable concepts into sitemap URLs. It iterates the concepts whose routing.routable flag is set and maps each entry to a SitemapUrl. The loc is the origin-anchored permalink. The lastmod is the entry’s updated date when present, else its date. An embedded, non-routable concept never appears. Pass origin because each loc is absolute.

extraRoutes carries the site’s own bespoke, non-concept pages, such as an about page or a tag index, as root-relative paths like ['/about', '/tags']. Each becomes an origin-anchored SitemapUrl with no lastmod, ahead of every concept URL, in the order given. Omit it and only the concept URLs appear.

unlistedRoutes

Stability tier: Extension API.

function unlistedRoutes(routeIds: string[], listedPaths: string[]): string[];

Flag the site’s own static route ids missing from listedPaths, typically the same extraRoutes array passed to sitemapView. Pass it the route ids under the site’s public route tree, one per page directory, using SvelteKit’s own route id form, such as /(site)/about. The check strips a route-group segment like (site) before comparing, since a group contributes no URL segment of its own. It also skips a dynamic route id, one carrying a [param] or [...rest] segment: a tag loop or a content index enumerates those per entry, never once by the route id itself. A site wires this helper into its own test suite, so a new page directory that never joined the sitemap’s hand-list fails the build instead of shipping a silent gap:

import { unlistedRoutes } from '@glw907/cairn-cms/delivery';

const EXTRA_ROUTES = ['/', '/about', '/archives', '/tags'];

// Parentheses are a glob metacharacter, so a pattern naming a route group literally (for example
// /src/routes/(site)/**) matches nothing; glob every +page.svelte in the project instead. A
// dynamic route elsewhere (an admin catch-all) never trips the check, since unlistedRoutes skips
// any route id carrying a `[param]` or `[...rest]` segment.
const pageModules = import.meta.glob('/src/routes/**/+page.svelte');
const routeIds = Object.keys(pageModules).map((path) =>
  path.replace(/^\/src\/routes/, '').replace(/\/\+page\.svelte$/, ''),
);

if (unlistedRoutes(routeIds, EXTRA_ROUTES).length) {
  throw new Error(`route missing from the sitemap: ${unlistedRoutes(routeIds, EXTRA_ROUTES).join(', ')}`);
}

buildRobots

Stability tier: Extension API.

function buildRobots(opts: { sitemapUrl: string; disallow?: string[] }): string;

Build a robots.txt body that points at the sitemap and disallows the given paths.

rssResponse

Stability tier: Extension API.

function rssResponse(channel: FeedChannel, items: FeedItem[]): Response;

Wrap an RSS 2.0 feed in a Response. The showcase feed route builds its items from the posts index, then hands them to the responder.

import type { RequestHandler } from './$types';
import { rssResponse, buildLinkResolver, type FeedItem } from '@glw907/cairn-cms/delivery';
import { site, ORIGIN, SITE_DESCRIPTION } from '$lib/content';
import { cairn, siteConfig } from '$lib/cairn.config';

export const prerender = true;

export const GET: RequestHandler = async () => {
  const posts = site.concept('posts');
  const toPermalink = buildLinkResolver(site);
  const resolve = (ref: Parameters<typeof toPermalink>[0]) => ORIGIN + toPermalink(ref);
  const items: FeedItem[] = await 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(posts!.byId(p.id)!.body, { resolve }),
      tags: p.tags,
    })),
  );
  return rssResponse(
    { title: siteConfig.siteName, description: SITE_DESCRIPTION, siteUrl: ORIGIN, feedUrl: ORIGIN + '/feed.xml' },
    items,
  );
};

jsonFeedResponse

Stability tier: Extension API.

function jsonFeedResponse(channel: FeedChannel, items: FeedItem[]): Response;

Wrap a JSON Feed 1.1 feed in a Response. The showcase feed.json route mirrors the RSS route and calls this responder instead.

sitemapResponse

Stability tier: Extension API.

function sitemapResponse(urls: SitemapUrl[]): Response;

Wrap a sitemap in a Response. The showcase sitemap route maps every site entry to a SitemapUrl.

import { sitemapResponse, type SitemapUrl } from '@glw907/cairn-cms/delivery';
import { site, ORIGIN } from '$lib/content';

export const GET = () => {
  const urls: SitemapUrl[] = [
    { loc: ORIGIN + '/' },
    ...site.all().map((s) => ({ loc: ORIGIN + s.permalink, ...(s.date ? { lastmod: s.date } : {}) })),
  ];
  return sitemapResponse(urls);
};

robotsResponse

Stability tier: Extension API.

function robotsResponse(opts: { sitemapUrl: string; disallow?: string[] }): Response;

Wrap a robots.txt body in a Response. The showcase route points at the sitemap and disallows /admin.

import { robotsResponse } from '@glw907/cairn-cms/delivery';
import { ORIGIN } from '$lib/content';

export const GET = () =>
  robotsResponse({ sitemapUrl: ORIGIN + '/sitemap.xml', disallow: ['/admin'] });

SEO and manifest builders

buildSeoMeta

Stability tier: Extension API.

function buildSeoMeta(input: SeoInput): SeoMeta;

Build the plain-data head for a page: the title, the meta tags, the link tags, and one JSON-LD object. All URLs in SeoInput are absolute, built from the site origin. The /delivery createPublicRoutes loader calls this so a public entry ships a full head.

import { buildSeoMeta, type ContentEntry } from '@glw907/cairn-cms/delivery/data';
import { ORIGIN } from '$lib/content';
import { siteConfig } from '$lib/cairn.config';

declare const entry: ContentEntry;

const seo = buildSeoMeta({
  title: entry.title,
  description: entry.excerpt,
  canonicalUrl: ORIGIN + entry.permalink,
  siteName: siteConfig.siteName,
  type: 'article',
  published: entry.date,
});

buildSiteManifest

Stability tier: Extension API.

function buildSiteManifest<A extends CairnAdapter>(
  adapter: A,
  config: SiteConfig,
  globs: SiteGlobs<A>,
): Manifest;

Build the whole-corpus manifest from a site’s adapter, config, and per-concept globs. Drafts are included and flagged, so the admin picker and the link guards see the full graph. The Vite plugin and the manifest bin call this in a plain-Node context, which is why it lives on this node-safe surface.

import { buildSiteManifest } from '@glw907/cairn-cms/delivery/data';
import { cairn, siteConfig } from '$lib/cairn.config';
import { postsRaw, pagesRaw } from './content-globs.js';

const manifest = buildSiteManifest(cairn, siteConfig, { posts: postsRaw, pages: pagesRaw });

buildLinkResolver

Stability tier: Extension API.

function buildLinkResolver(site: SiteResolver): LinkResolve;

Build a cairn: link resolver backed by the site resolver, for the build. A miss throws, so a dangling cairn: token fails the prerender. The feed routes above use it to turn an internal link into an absolute URL.

buildFragmentResolver

Stability tier: Extension API.

function buildFragmentResolver(site: SiteResolver): FragmentResolve;

Build a fragment-body resolver backed by the site resolver, for the build. A miss (an unknown fragment id, or a site with no fragments concept declared) throws, so a dangling ::include fails the prerender the same way a dangling cairn: link does. createPublicRoutes wires this into every entry render as resolveFragment.

resolveReferences

Stability tier: Extension API.

function resolveReferences(
  site: SiteResolver,
  descriptor: ConceptDescriptor,
  frontmatter: Record<string, unknown>,
): Record<string, ResolvedReference | ResolvedReference[]>;

Resolve an entry’s reference and array(reference) frontmatter edges to their target identities, keyed by the field name, so a public route renders a reference as a link to its target’s page. A reference field resolves to one ResolvedReference and an array(reference) field to a ResolvedReference[] in edge order. The resolution lives on the cross-concept resolver because only that layer reaches another concept’s entries: a post’s author edge targets a pages entry the posts index alone can’t read. The resolver drops an id with no live target rather than throwing. The build’s verifyReferences gate already fails a true dangling edge, so an unresolved id at request time is a mid-flight or draft target. A route reads the resolved map alongside the entry and renders each target as a link.

import { resolveReferences, type ResolvedReference, type ContentEntry } from '@glw907/cairn-cms/delivery';
import type { ConceptDescriptor } from '@glw907/cairn-cms';
import { site } from '$lib/content';

declare const postsDescriptor: ConceptDescriptor;
declare const entry: ContentEntry;

const refs = resolveReferences(site, postsDescriptor, entry.frontmatter);
const author = refs.author as ResolvedReference | undefined;

Pure helpers

Small pure functions the builders and the routes share.

deriveExcerpt

Stability tier: Extension API.

function deriveExcerpt(body: string, opts?: { description?: string; maxChars?: number }): string;

Return a plain-text excerpt: a trimmed frontmatter description when present, otherwise the stripped body cut at a word boundary near maxChars (default 200) with an ellipsis.

resolveImageUrl

Stability tier: Extension API.

function resolveImageUrl(image: string, origin: string): string | undefined;

Resolve an author-supplied image path to an absolute URL against the site origin. An absolute or protocol-relative URL passes through, a root-relative path anchors to the origin, and a malformed string returns undefined rather than throwing at build.

readSeoFields

Stability tier: Extension API.

function readSeoFields(frontmatter: Record<string, unknown>): SeoFields;

Read the known SEO head fields off an entry’s normalized frontmatter, keeping a present string trimmed and omitting an absent, empty, or non-string value. A field must be declared in the concept’s schema to survive the validate-once read.

jsonLdScript

Stability tier: Extension API.

function jsonLdScript(data: Record<string, unknown>): string;

Serialize a JSON-LD object into the inner text of a <script type="application/ld+json"> tag, with the characters that would break out of a script element escaped.

siteDescriptors

Stability tier: Extension API.

function siteDescriptors(adapter: CairnAdapter, siteConfig: SiteConfig): ConceptDescriptor[];

Build the per-concept descriptors for a site from its adapter content and its parsed site config. createSiteIndexes derives them internally. A public route calls this directly when it needs a ConceptDescriptor on its own, such as the descriptor resolveReferences takes.


Types

NameStabilitySignatureMeaning
ContentSummaryExtension APIinterface ContentSummary { concept; id; slug; permalink; title; date?; updated?; tags: string[]; excerpt; wordCount; draft; fields }The cheap plain-data view of one entry, for lists, feeds, and the sitemap.
ContentEntryExtension APIinterface ContentEntry<F = Record<string, unknown>> extends ContentSummary { frontmatter: F; body: string }The detail view: a summary plus the typed frontmatter and the body to render.
ContentProblemExtension APIinterface ContentProblem { id: string; draft: boolean; errors: Record<string, string> }One entry’s validation failure, recorded at build for the site aggregator’s gate.
ContentIndexExtension APIinterface ContentIndex<F = Record<string, unknown>> { all; byId; byTag; allTags; adjacent; problems }The per-concept query surface createSiteIndexes builds one of per concept.
SiteResolverExtension APIinterface SiteResolver { byPermalink; adjacent; entries; concept; all; routable }The cross-concept query surface a catch-all route and the sitemap read. byPermalink resolves one entry by request path; routable reports whether a concept id is publicly reachable.
SiteGlobsExtension APItype SiteGlobs<A extends CairnAdapter> = { [K in keyof A['content']]?: Record<string, string> }A per-concept raw glob record keyed by concept id, from import.meta.glob.
SiteIndexesExtension APItype SiteIndexes<A> = { [K in keyof A['content']]: ContentIndex<...> } & { readonly site: SiteResolver }The typed per-concept indexes plus the cross-concept site resolver, the return of createSiteIndexes.
FeedChannelExtension APIinterface FeedChannel { title; description; siteUrl; feedUrl; language?; author? }Feed channel metadata, with absolute URLs.
FeedItemExtension APIinterface FeedItem { title; url; date?; updated?; summary; contentHtml?; tags? }One feed entry; contentHtml carries the rendered body for a full-content feed.
SitemapUrlExtension APIinterface SitemapUrl { loc: string; lastmod?: string }One sitemap URL; lastmod is a YYYY-MM-DD date.
SeoInputExtension APIinterface SeoInput { title; description; canonicalUrl; siteName; type?; published?; modified?; feeds?; image?; imageAlt?; robots?; author? }The inputs for the head builder, all URLs absolute. imageAlt becomes twitter:image:alt when image is set.
SeoMetaExtension APIinterface SeoMeta { title; meta; links; jsonLd }The plain-data head: a title, meta tags, link tags, and one JSON-LD object.
SeoFieldsExtension APIinterface SeoFields { description?; image?; robots?; author? }The optional SEO head fields a concept can carry in frontmatter.
ResolvedReferenceExtension APIinterface ResolvedReference { id; concept; title; permalink; summary? }A reference edge resolved to its target’s identity, for a public route to render a linked target.

Edit this page on GitHub(opens in a new tab)