Core (@glw907/cairn-cms)
The root export is the engine. It carries the adapter and schema contract a site declares, the
markdown render pipeline, the composed runtime, the content and manifest projections, and the auth
and GitHub App primitives. A site imports it at src/lib/cairn.config.ts and in its admin and
delivery code. Anything proposed here must be construction surface a cairn.config.ts builds
with, or a read helper a site’s own route calls directly; a SvelteKit route factory belongs on
/sveltekit, and an admin Svelte component on /components,
even though a site’s adapter config also feeds both.
import { defineAdapter, defineConcept, fieldset, fields, createRenderer } from '@glw907/cairn-cms';
import type { CairnAdapter, ComponentDef } from '@glw907/cairn-cms';
The . entry carries the public construction surface: the adapter and schema constructors, the read
helpers a site calls on its own routes, and the types that name their signatures. Stable API is
the deliberate public surface, each primary entry point with a worked snippet. Types is a table
of the public type aliases and interfaces. The TypeScript types in src/lib are the source of
truth, and the export-coverage gate checks every name here against them.
The public delivery read surface lives at /delivery and
/delivery/data; the root no longer re-exports it.
Stable API
Adapter and schema
A site’s adapter is the one seam the engine consumes. It declares the content concepts, the render, and the GitHub backend.
defineAdapter
Stability tier: Extension API.
declare function defineAdapter<const A extends CairnAdapter>(adapter: A): A;
Declare a site’s adapter while preserving each concept’s concrete fieldset type for typed reads. The
return value is the adapter itself, narrowed. The adapter has six groups: content, backend,
email, rendering, media, and editor.
// examples/showcase/src/theme/cairn.config.ts
import { defineAdapter, defineConcept, fieldset, fields, githubApp, createRenderer } from '@glw907/cairn-cms';
import { registry, icons } from './components.js';
const { renderMarkdown } = createRenderer(registry);
export const cairn = defineAdapter({
content: {
posts: defineConcept({
dir: 'src/content/posts',
label: 'Posts',
summaryFields: ['description'],
routing: 'feed',
fields: fieldset({
title: fields.text({ label: 'Title', required: true }),
date: fields.date({ label: 'Date' }),
description: fields.textarea({ label: 'Description' }),
}),
}),
},
backend: githubApp({ owner: 'showcase', repo: 'demo', branch: 'main', appId: '1', installationId: '2' }),
email: { from: 'cms@showcase.test' },
rendering: {
render: ({ body, resolve, resolveMedia }) => renderMarkdown(body, { resolve, resolveMedia }),
components: registry,
icons,
},
});
githubApp
Stability tier: Extension API.
declare function githubApp(config: {
owner: string;
repo: string;
branch: string;
appId: string;
installationId: string;
}): GithubAppProvider;
The default backend: a GitHub App over a repo branch, and the value the adapter’s backend field
takes. It carries the App’s non-secret identity, the owner, repo, appId, and installationId.
The private key stays the Worker secret GITHUB_APP_PRIVATE_KEY_B64, which the engine reads at request
time and never from the adapter source. The engine resolves one live Backend per request from the
provider, so a different store such as GitLab, Gitea, or plain git can supply its own provider later
without the engine changing. The backend covers read, commit, and branch operations over files. It’s
deliberately not a query interface, so content querying stays build-time over the committed manifest.
defineConcept
Stability tier: Extension API.
declare function defineConcept<const C extends ConceptConfig>(concept: C): C;
Declare one concept while preserving its fieldset type for typed reads, the concept-level companion
to defineAdapter. It also validates the concept’s URL policy at declaration, so a bad permalink,
datePrefix, or routing throws at module load rather than defaulting or resolving silently. A
concept declares its routing with routing (the 'feed', 'page', or 'embedded' shorthand only)
and its URL policy with permalink and datePrefix; an omitted routing is 'page'. When the
resolved permalink uses a date token (:year, :month, or :day), the concept must declare a
field named date of type date; defineConcept and normalizeConcepts both throw at
declaration on a missing or wrong-typed one, and both normalize the declared field to
required: true, since the permalink can’t resolve without it. An omitted singular falls back to
label with no warning, so a plural label like 'Posts' reads “New Posts” on the create
affordances until you declare singular: 'post'. Declare singular on every concept.
The concept key fragments reserves reusable content: declare it to include one entry’s body
inside another with the ::include directive. It
must use routing: 'embedded', and normalizeConcepts throws otherwise. The include directive
resolves against a non-routable concept, and an embedded entry publishing its own live page would
make the same content reachable two ways.
posts: defineConcept({
dir: 'src/content/posts',
routing: 'feed',
permalink: '/:year/:month/:slug',
datePrefix: 'month',
fields: fieldset({
title: fields.text({ label: 'Title', required: true }),
date: fields.date({ label: 'Date' }),
}),
}),
supportContact (adapter editor member)
A free-form string the in-admin help points a stuck editor to: an email address, a URL, or a name and
instruction. Unset, composeRuntime defaults it to https://cairn.pub/help, cairn’s own hosted editor
help. A site that sets its own value overrides that default, and a site that sets an explicit empty
string gets the prior self-serve state back: the Help home renders no hand-off. Optional.
supportContact: 'help@example.org',
preview (adapter editor member)
interface PreviewConfig {
stylesheets: string[];
bodyClass?: string;
containerClass?: string;
byConcept?: Record<string, { bodyClass?: string; containerClass?: string }>;
}
How the edit page’s preview frame reproduces the live site’s content styling. Chrome isolation
means the admin deliberately never loads the site’s CSS, so a design-accurate preview needs the
site to name its compiled stylesheets here; without the knob the preview renders unstyled markup.
composeRuntime passes the value through to the runtime untouched.
stylesheets holds absolute or root-relative URLs linked inside the preview document. A Vite
?url import of the site’s CSS entry resolves the hashed asset URL at build time. bodyClass
applies theme or typography root classes to the preview document’s body, and containerClass
wraps the rendered content in the site’s content container (a prose or measure class); when
omitted, the content renders bare. The frame’s srcdoc pins a white body background by default,
deliberately overridable, so a site whose ground is not white should state its body background in
one of the named stylesheets.
byConcept overrides bodyClass and containerClass per concept, keyed by concept id, for a
site whose concepts wrap content differently (a blog whose posts render inside a post module while
its pages use a static-page wrapper). An entry’s preview resolves the override for its concept
over the top-level values, key by key: a missing override key keeps the top-level value, and only
a string replaces it. Stylesheets are always shared. editLoad ships the already-resolved flat
shape, so the map itself never reaches the client.
preview: {
stylesheets: [siteCssUrl],
bodyClass: 'static-page',
containerClass: 'page-measure',
byConcept: {
posts: { bodyClass: 'post-body', containerClass: 'post-module' },
},
},
The named sheet must be referenced only through ?url, with the site layout linking the resolved
URL from a <svelte:head>. A layout that also imports the same file statically folds it into the
layout’s CSS chunk, and that chunk’s basename differs between the client and server builds, so the
URL the server-rendered edit page hands the frame names a file the client build never serves.
Even done right, the ?url import in the layout and the one in the adapter resolve through the
client and the server build pipelines separately, so two hashed copies of the same sheet ship: the
page links the client copy, the preview frame links the server copy. That is by design, not a
defect: the build legitimately ships both copies.
// src/lib/cairn.config.ts
import { defineAdapter } from '@glw907/cairn-cms';
import appCssUrl from './app.css?url';
export const cairn = defineAdapter({
// ...content, backend, email, rendering...
editor: {
preview: {
stylesheets: [appCssUrl],
bodyClass: 'bg-base-100',
containerClass: 'prose mx-auto',
},
},
});
<!-- src/routes/(site)/+layout.svelte: the same URL, linked instead of statically imported -->
<script lang="ts">
import appCssUrl from '$lib/app.css?url';
</script>
<svelte:head>
<link rel="stylesheet" href={appCssUrl} />
</svelte:head>
media (adapter member)
interface AssetConfig {
bucketBinding: string;
publicBase?: string;
urlForm?: 'slug' | 'opaque';
maxUploadBytes?: number;
allowedTypes?: string[];
variants?: Record<string, VariantSpec>;
transformations?: boolean;
}
interface VariantSpec {
width?: number;
height?: number;
quality?: number;
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
gravity?: 'auto' | 'face' | string;
format?: 'auto' | 'webp' | 'avif' | string;
}
A site turns on R2-backed media by declaring media; omitting it leaves media off. bucketBinding
names the R2 bucket bound to the Worker and is the one required field. publicBase is the delivery
base path (default /media), and urlForm chooses whether the public URL carries the slug
(/media/<slug>.<hash>.<ext>, the default) or stays opaque (/media/<aa>/<hash>.<ext>).
maxUploadBytes (default 25 MB) and allowedTypes (default the common web image types) bound an
upload. variants are named Cloudflare Images presets, merged over the built-in thumb, inline,
card, and hero presets, so a same-named entry overrides a built-in.
transformations (default false) declares whether Cloudflare Image Transformations are enabled
for the zone. This is a per-zone setting that the dashboard or API turns on, not something a Worker
can flip. While it is off, the media resolver serves the bare full-size delivery path and ignores
any preset, so a fresh zone gets correct full-size thumbnails rather than dead /cdn-cgi/image
URLs. Flip it to true only after enabling Transformations on the zone.
Content references a stored asset by a logical handle, media:<slug>.<hash> (or the bare
media:<hash>), the same shape as the cairn: link scheme. The hash is the content identity and the
slug is cosmetic, so a rename never breaks a reference. At render, the handle rewrites to a delivery
URL, and a variant becomes a /cdn-cgi/image/<options>/... transform over that path. See the
media storage explanation for the full model. This grew from a
reserved seam, so it is additive: a site that declares no media is unchanged, and the author-facing
upload surface lands in a later phase on this substrate.
defineRegistry
Stability tier: Extension API.
declare function defineRegistry({ components }: { components: ComponentDef[] }): ComponentRegistry;
Build a component registry from a site’s component definitions. The render pipeline (the directive stamp plus the rehype dispatch) and the editor palette both read it.
import { defineRegistry } from '@glw907/cairn-cms';
import { callout, alert } from './components.js';
const registry = defineRegistry({ components: [callout, alert] });
defineComponent
Stability tier: Extension API.
declare function defineComponent<const D extends ComponentDef>(def: D): D & { attributeSchema: Fieldset };
Declare one component while building its attribute validator from the fields.* descriptors, the
component-level companion to defineConcept. A directive attribute is one flat string, so the
attributes are a fields.* record of scalar leaves: text, textarea, number, select, url,
email, date, datetime, boolean, and icon. An object, array, reference, or image
attribute throws at declaration. It validates at declaration like defineConcept, so a bad type or a
malformed pattern fails at module load rather than at first insert. The built attributeSchema is a
Fieldset, the engine’s own component-grammar validator runs it, so a component attribute and a
concept field validate through identical code. A cross-field attribute rule lives in the co-bundled
behavior table, keyed by attribute name.
A component opts into client hydration with hydrate?: boolean | 'visible'. With it set, the render
pipeline wraps the component’s build() output in an island boundary, and the live Svelte component the
site registers under the same name on rendering.islands mounts
over that fallback in the browser. true mounts eagerly on first load and after every client-side
navigation; 'visible' defers to first intersection. The build() output is the no-JS fallback, so
keep it class-driven and high-fidelity. Absent leaves the component static and server-only. The
/islands reference carries the runtime, the boundary contract, and the props trust
boundary.
ComponentDef.build is deliberately synchronous: it returns a hast Element,
never a Promise, because it runs inline inside the render pipeline’s synchronous hast transform,
once per rendered directive occurrence. A component needing data it does not already have
pre-fetches that data outside the render pipeline (at content build time, or in the adapter’s own
resolver) and passes the result through attributes, since build itself has no seam to await one.
import { defineComponent, fields } from '@glw907/cairn-cms';
import { h } from 'hastscript';
const callout = defineComponent({
name: 'callout',
label: 'Callout',
description: 'A highlighted note with an optional icon.',
build: (ctx) =>
h('aside', { className: ['callout'] }, [
h('p', { className: ['callout-title'] }, ctx.slot('title')),
h('div', { className: ['callout-body'] }, ctx.slot('body')),
]),
attributes: {
tone: fields.select({ label: 'Tone', required: true, options: ['note', 'tip', 'warning'] }),
icon: fields.icon({ label: 'Icon' }),
},
slots: [
{ name: 'title', label: 'Title', kind: 'inline', required: true },
{ name: 'body', label: 'Body', kind: 'markdown' },
],
});
Fields
The field vocabulary. A concept declares its fields with the fields constructor namespace, then
bundles them into a fieldset. The fieldset is the single source of truth for the editor form, the
validator, and the inferred frontmatter type, and the descriptors it carries are plain data.
fields
Stability tier: Extension API.
fields is the constructor namespace, one function per field type. The leaf constructors are text,
textarea, number, select, multiselect, url, email, date, datetime, boolean, icon,
image, and reference. The container constructors are object (a labeled group of leaves) and
array (a repeatable list over one item). Each one takes the field’s options and returns a plain-data
descriptor; a select or multiselect preserves its literal option list so the inferred type
narrows to that union. A closed multiselect (an options list) renders as checkboxes; a
creatable: true multiselect renders as an open tag input and accepts an optional placeholder.
fields.icon declares a glyph chosen from the adapter’s icon set, and its stored value is the glyph’s
name string.
A concept’s tag field, the top-level multiselect it marks taxonomy: true, becomes a closed
vocabulary-sourced picker once the site configures a tag vocabulary (the vocabulary key in
site.config.yaml, read onto CairnRuntime.vocabulary through extractVocabulary). On save and on
edit, the engine sources the field’s options from the vocabulary, so the editor picks from the
configured tags rather than typing free-form values, and a save of a value that is neither in the
vocabulary nor already on the entry is rejected. A value already on an entry that the vocabulary does
not list, an orphan, the engine preserves rather than silently dropping. It renders as a checked,
removable option flagged “not in your tag list.” This enforcement is opt-in. A site that configures no vocabulary
leaves the taxonomy field the open creatable multiselect it is by default, and the build-time
tags-as-data read on ContentSummary.tags is identical either way; enforcement is a save-and-edit
concern, not a build one. The vocabulary supplies the field’s options, so declare the taxonomy field
as an open creatable multiselect with no literal options of its own. A field that pre-declares its
own options enforces those at validation, which is a different, fixed-list shape, not the
vocabulary-sourced one.
fields.object({ fields }) groups leaf fields under one frontmatter key, storing a nested object. Its
label is optional, because an object inside an array is labeled by the array. fields.array(item, options?) declares a repeatable list: the item is any leaf (a scalar, an image, or a reference)
or a flat object of leaves. An array of references renders the reference picker; an array of any
other item renders the repeatable-row editor with add, remove, and reorder. The optional
itemLabel names a row from one leaf field key.
import { fieldset, fields } from '@glw907/cairn-cms';
const set = fieldset({
// a repeatable group of flat rows; the array labels the group, the object carries no label
faq: fields.array(
fields.object({ fields: { question: fields.text({ label: 'Question', required: true }), answer: fields.textarea({ label: 'Answer' }) } }),
{ label: 'FAQ', itemLabel: 'question' },
),
// a repeatable list of a single leaf
gallery: fields.array(fields.image({ label: 'Image' }), { label: 'Gallery' }),
// a labeled group under one key
meta: fields.object({ label: 'Meta', fields: { note: fields.text({ label: 'Note' }) } }),
});
Containers nest one level only. An object holds leaves, never another container. An array holds a
leaf or a flat object, never another array and never an object of objects. A reference inside
an object and an seo image inside any container are not supported yet, and a deeper nesting, a
nested reference, or a nested seo image throws at the fieldset() call. No field key may contain a
dot, top-level or nested, because the editor addresses a nested value by a dotted path. See
Structured fields and The one-level nesting
cap for the why and the escape hatch.
Every constructor also accepts an optional help: one author-facing sentence the editor renders under
the field in the Details panel, associated with the input through aria-describedby. It is not a
validation rule. The date field shows a built-in publish-clarity default when its help is unset,
so the date never reads as if it schedules publishing; a field help replaces that default, and the
date hint cannot be suppressed entirely.
import { fieldset, fields } from '@glw907/cairn-cms';
const set = fieldset({
title: fields.text({ label: 'Title', required: true }),
status: fields.select({ label: 'Status', options: ['draft', 'published'], default: 'draft' }),
});
fieldset
Stability tier: Extension API.
declare function fieldset<const R extends Record<string, FieldDescriptor>>(
record: R,
options?: FieldsetOptions,
): Fieldset<R>;
Build a fieldset from a key-to-descriptor record. The returned schema carries the descriptors as
plain data for the editor form, a server-derived validator that coerces each value to its type and
returns field-keyed errors or normalized data, and a Standard Schema conformance property whose
issues map each error to a single-segment path. The validator enforces each descriptor’s declared
constraints: a text or textarea field’s min, max, length, and
pattern, and a date field’s min and max. A malformed pattern throws at the fieldset()
call, not on a later save. The validator reads a parsed value as well as a form string, so a numeric
number, a Date on a datetime field, and a lone scalar on a multiselect all normalize.
options.refine runs after the per-field rules pass, for cross-field and body-dependent checks.
FieldsetOptions.refine is deliberately synchronous: it returns
Record<string, string> | undefined directly, never a Promise, because it runs inline in the
save action’s own request path, on every save. A site needing async validation (a uniqueness check
against a database, an external lookup) pre-fetches whatever data the check needs and reads it
inside the synchronous callback, rather than awaiting inside it.
The validator recurses one level into an object and an array, so a clean nested value normalizes
and a nested failure reports. On failure the result carries the flat errors map keyed by the
top-level field, plus an additive issues array of ValidationIssue, each located by a
multi-segment path (a row index, a leaf sub-key) so the form routes a nested error to its input.
Field types
Stability tier: Extension API.
FieldDescriptoris the plain-data descriptor union the form, validator, and inference all read.Fieldsetis the schema afieldsetcall returns, carrying the descriptors, the behavior table, the validator, and the Standard Schema property.InferFieldsetextracts the normalized frontmatter type from aFieldset, where a descriptor declaredrequired: trueis a required key.FieldsetOptionscarries therefinecross-field check and thebehaviortable.
Render
The render pipeline turns markdown into HTML, dispatching registered components and resolving
cairn: links.
createRenderer
Stability tier: Extension API.
declare function createRenderer(
registry?: ComponentRegistry,
options?: RendererOptions,
): {
remarkPlugins: PluggableList;
rehypePlugins: PluggableList;
renderMarkdown: (content: string, opts?: ResolveOptions) => Promise<string>;
renderDocument: (content: string, opts?: ResolveOptions) => Promise<{ html: string; headings: DocHeading[] }>;
};
createRenderer exports its return type by name as Renderer.
Compose a site’s render pipeline from its component registry: directive syntax, then stamped
markers, then registry-built hast. It returns renderMarkdown plus the fully composed remark and
rehype plugin arrays, so the admin editor preview reuses the exact same set. RendererOptions
carries the sanitize and anchor controls, the table-scroll default, and a
remarkPlugins/rehypePlugins seam for a site’s own plugins.
RendererOptions.sanitizeSchema is deliberately synchronous too: (defaults: Schema) => Schema, called inline while the pipeline composes its sanitize floor for every render
call, with no seam to await external data before extending the allowlist.
renderDocument takes the same options as renderMarkdown and additionally returns headings: a
DocHeading[] collected from the final rehype tree, after rehypeSlug stamps ids and after any
RendererOptions.rehypePlugins a site supplied have run, so a site rewrite of a heading’s id is
the id collected. Headings come back in document order, one entry per h1-h6, with text flattened
to plain content (inline code, emphasis, and links reduce to their text). A page that needs a
table of contents or a heading anchor list calls renderDocument instead of renderMarkdown.
import { createRenderer } from '@glw907/cairn-cms';
import { registry } from './components.js';
const { renderDocument } = createRenderer(registry);
const { html, headings } = await renderDocument('# Title\n\n## Section');
// headings: [{ id: 'title', text: 'Title', depth: 1 }, { id: 'section', text: 'Section', depth: 2 }]
// examples/showcase/src/theme/cairn.config.ts
import { createRenderer } from '@glw907/cairn-cms';
import { registry } from './components.js';
const { renderMarkdown } = createRenderer(registry);
// the adapter's render delegates to it:
// render: ({ body, resolve, resolveMedia }) => renderMarkdown(body, { resolve, resolveMedia }),
RendererOptions.tableScroll (default true) wraps every rendered table in a labeled,
keyboard-reachable role="region" div, so a narrow viewport scrolls the wrapper instead of
squeezing the table’s columns while the table itself keeps its role in the accessibility tree. Set
it to false for a site that supplies its own table wrapping.
RendererOptions.remarkPlugins and RendererOptions.rehypePlugins add a site’s own unified
plugins to the pipeline. A remark plugin runs after cairn’s own markdown-stage steps (directive
stamping, cairn: link resolution, figures, media: resolution) and before the conversion to
hast. A rehype plugin runs after cairn’s own hast-stage steps (dispatch, the sanitize floor, heading
slugs, highlighting, anchor hardening, the sink guard, the default table-scroll wrap) and before
stringification. A site’s own post-render transform composes here over the hast tree directly,
instead of re-parsing renderMarkdown’s returned HTML string:
import { createRenderer, defineRegistry } from '@glw907/cairn-cms';
import { visit } from 'unist-util-visit';
import type { Root, Element } from 'hast';
/** Defer every image's load until it nears the viewport. */
function rehypeLazyImages() {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
if (node.tagName === 'img') (node.properties ??= {}).loading = 'lazy';
});
};
}
const { renderMarkdown } = createRenderer(defineRegistry({ components: [] }), {
rehypePlugins: [rehypeLazyImages],
});
SiteRender
type SiteRender = (input: {
body: string;
concept?: string;
frontmatter?: Record<string, unknown>;
resolve?: LinkResolve;
resolveMedia?: MediaResolve;
resolveFragment?: FragmentResolve;
}) => Promise<string>;
The type of the adapter’s rendering.render member: the one renderer the editor preview and every
public page call. It takes a single object and returns a Promise<string>. body is the markdown
to render. resolve rewrites cairn: links to live permalinks; the build passes a
site-resolver-backed resolver and the preview passes a manifest-backed one. resolveMedia resolves
media: references the same way. resolveFragment resolves an ::include directive’s fragment id
to its raw markdown body, the same way; a custom renderer need not read it unless it wants to vary
fragment resolution. concept and frontmatter carry the entry’s context, so a custom renderer can
vary its output per concept or per frontmatter field. Both are optional: an entry render supplies
them, and the standalone component-insert preview omits them.
rendering.islands (adapter member)
{ islands?: IslandRegistry }
The live Svelte components for hydrated directives, keyed by directive name, declared beside render
in the adapter’s rendering group. Every component whose hydrate is set needs an
entry here, and every entry needs a matching hydrate component; defineAdapter fails closed at
declaration on either mismatch, naming the offending directive. An absent registry keeps the site
static, and the client runtime is never imported. The runtime that consumes it lives at the
/islands subpath; that page carries the boundary contract and the props trust
boundary.
rendering: {
render: ({ body, resolve, resolveMedia }) => renderMarkdown(body, { resolve, resolveMedia }),
components: registry,
islands: { converter: Converter },
},
parseMarkdown
Stability tier: Extension API.
declare function parseMarkdown(source: string): {
frontmatter: Record<string, unknown>;
body: string;
};
Parse a markdown file into its frontmatter and body. The write side, reassembling a file for committing, is the engine’s own save-path concern, not a construction-time call a site makes.
import { parseMarkdown } from '@glw907/cairn-cms';
declare const fileText: string;
const { frontmatter, body } = parseMarkdown(fileText);
Component-author helpers
Stability tier: Extension API.
These build hast inside a component’s build function, so a site arranges markup without walking the
tree. The showcase alert component composes them.
declare function glyph(name: string, icons: IconSet): Element;
declare function iconSpan(glyphEl: Element, role?: string): Element;
declare function cardShell(classes: string[], body: ElementContent[]): Element;
declare function headRow(title: ElementContent[], icon?: Element): Element;
glyph builds an inline SVG glyph from the site’s icon set. iconSpan wraps a glyph in an
ec-icon span. cardShell builds a <section> wrapper with a card body. headRow builds a
title-plus-optional-icon head row.
// examples/showcase/src/theme/cairn.config.ts
const makeIcon = (name, role) => iconSpan(glyph(name, icons), role);
build: (ctx) =>
cardShell(['alert'], [
headRow(ctx.slot('title'), makeIcon('leaf')),
h('div', { className: ['alert-body'] }, ctx.slot('body')),
]),
Runtime and config
The runtime folds an adapter and its site-config into the shape the admin and delivery paths read.
composeRuntime
Stability tier: Scaffold API.
declare function composeRuntime({ adapter, siteConfig }: ComposeInput): CairnRuntime;
Fold an adapter and its site-config into the composed runtime (seam 2). The per-concept URL policy is derived from the site-config, the same source delivery uses, so the runtime and delivery permalinks cannot diverge.
// src/lib/cairn.server.ts
import { composeRuntime } from '@glw907/cairn-cms';
import { createCairnAdmin } from '@glw907/cairn-cms/sveltekit';
import { cairn, siteConfig } from './cairn.config.js';
export const runtime = composeRuntime({ adapter: cairn, siteConfig });
export const admin = createCairnAdmin(runtime);
parseSiteConfig
Stability tier: Extension API.
declare function parseSiteConfig(raw: string): SiteConfig;
Parse the YAML site-config text into a typed object. Throws SiteConfigError on a malformed root,
and enforces the config boundary between site.config.yaml and cairn.config.ts: every top-level key
must be one the engine reads from the YAML (siteName, description, author, locale, menus,
spellcheck, tidy, vocabulary). A key that belongs on the adapter instead (content, backend,
email, rendering, media, editor) throws a message naming cairn.config.ts as its correct
home; any other unrecognized key throws listing the known keys.
// examples/showcase/src/theme/site-config.ts
import { parseSiteConfig } from '@glw907/cairn-cms';
import siteYaml from './site.config.yaml?raw';
export const siteConfig = parseSiteConfig(siteYaml);
extractMenu
Stability tier: Extension API.
declare function extractMenu(config: SiteConfig, name: string, maxDepth: number): NavNode[];
Extract one named menu from a parsed config and validate it. Returns [] when the menu is absent.
import { extractMenu } from '@glw907/cairn-cms';
import { siteConfig } from './cairn.config.js';
const primary = extractMenu(siteConfig, 'primary', 2);
extractVocabulary
Stability tier: Extension API.
declare function extractVocabulary(config: SiteConfig): VocabularyEntry[];
Read the editor-owned tag vocabulary from a parsed config and validate it. Returns [] when the
vocabulary key is absent, so a site that configures no vocabulary stays on the open creatable
taxonomy field. composeRuntime calls this to set CairnRuntime.vocabulary, the snapshot the save
and edit paths read.
import { extractVocabulary } from '@glw907/cairn-cms';
import { siteConfig } from './cairn.config.js';
const vocabulary = extractVocabulary(siteConfig);
Content and manifest
The manifest is the committed, build-verified link graph. The content index that projects raw
markdown into the query surfaces lives at /delivery. The write and diff side of
the manifest is the engine’s own save path, so only its serialize and verify operations stay public,
for a build script or a custom regenerate tool to call.
Each manifest entry also records which fragment ids its body includes, an inclusion edge alongside
the outbound link and reference edges the same entry already carries. The delete guard reads it to
refuse deleting a fragment a published entry still includes, the same way it refuses deleting a
linked entry. This edge carries no build-time integrity check of its own: the fragment resolver
throws on a dangling ::include when the build renders the entry, which is the same backstop a
dangling cairn: link relies on.
An entry also carries an optional publishedAt, an ISO 8601 timestamp in UTC of when the entry
first went live. Unlike every other field, no content file carries it: a publish sets it once, at
the commit that first lands the entry non-draft, and nothing afterward overwrites or clears it. A
stamp marks the first publish, never the most recent edit. An entry still in draft has no
publishedAt, and neither does one published before the field existed. Because the field belongs to
the manifest rather than to the corpus, regenerating with
cairn-manifest merges the committed stamps back into the rebuilt file,
and verifyManifest accepts a committed stamp the corpus can’t produce.
Manifest serialize and verify
Stability tier: Extension API.
declare function serializeManifest(manifest: Manifest): string;
declare function verifyManifest(built: Manifest, committedRaw: string): void;
declare function verifyReferences(manifest: Manifest): void;
serializeManifest writes the canonical, sorted, deduped form that diffs cleanly. The cairnManifest
Vite plugin uses it in write mode. verifyManifest throws when the committed manifest drifts from the
corpus, so a raw-git edit fails the build loudly. verifyReferences throws when any frontmatter
reference edge points at a missing target, naming the source entry, the field, and the missing target.
References have no prerender backstop, so this build gate is their only integrity authority.
import { verifyManifest, type Manifest } from '@glw907/cairn-cms';
declare const built: Manifest;
declare const committedRaw: string;
verifyManifest(built, committedRaw); // throws on drift
Auth and GitHub App
Sending the magic-link email and minting the GitHub App token are the engine’s own save and login
paths, not construction-time calls a site makes. The error classes stay public so a custom route can
catch them: they are defined in the package, so instanceof is reliable across the peer boundary.
Error classes
Stability tier: Extension API.
declare class CommitConflictError extends Error {
readonly path: string;
constructor(path: string);
}
declare class SiteConfigError extends Error {
readonly conditionId: string;
}
CommitConflictError signals a lost SHA race on a commit, so the save fails safe. SiteConfigError
is thrown by parseSiteConfig on a malformed root, and its conditionId (always
config.site-config-invalid) names the registered diagnostic condition the fault maps to.
Roles
The engine hard-codes three capability levels, owner, editor, and none, but not the role
names a site’s people carry. defineRoles maps a site’s own role names onto those three levels; a
site that declares no roles gets the implicit { owner: 'owner', editor: 'editor' } pair, so a
zero-config site sees no change here.
defineRoles
Stability tier: Extension API.
declare function defineRoles<const R extends RolesDeclaration>(roles: R): R;
declare const DEFAULT_ROLES: { owner: 'owner'; editor: 'editor' };
Declare a site’s role vocabulary on the adapter’s roles member, the const-generic companion to
defineAdapter and defineConcept: it const-captures the literal role names for the caller’s own
use, and validates at construction, so a misdeclared vocabulary fails at build. It throws on an
empty record, an empty role name, a malformed declaration, a home that is not an absolute
/admin-prefixed path, a missing owner key, or an owner mapped to anything but owner
capability; owner is the one reserved name, since the last-owner guard and the bootstrap owner
both anchor on it. Every other name is free, and a common name like editor may be omitted, or
declared like any other name. A role name types as string everywhere the engine reads one
(Editor.role, an AccessMap value, a navLayout entry’s roles); only the three-way
capability (owner, editor, none) is a closed union, since that vocabulary is genuinely
fixed while a site’s own role names are not.
// src/lib/cairn.config.ts
import { defineAdapter, defineRoles } from '@glw907/cairn-cms';
export const roles = defineRoles({
owner: 'owner',
'club-admin': 'editor',
instructor: { capability: 'none', home: '/admin/classes' },
});
export const cairn = defineAdapter({
// ...content, backend, email, rendering...
roles,
});
resolveCapability, roleHome, ownerLevelRoles
Stability tier: Extension API.
declare function resolveCapability(roles: RolesDeclaration | undefined, role: string): Capability;
declare function roleHome(roles: RolesDeclaration | undefined, role: string): string | undefined;
declare function ownerLevelRoles(roles: RolesDeclaration | undefined): string[];
The engine calls these to resolve locals.cairnEditor.capability and the /admin landing at the guard
and the routes; a custom admin route reads the same helpers to gate itself against a vocabulary
without re-deriving the mapping. resolveCapability returns the mapped capability, treating an
undefined vocabulary as DEFAULT_ROLES, and returns 'none' for a role name absent from the
vocabulary, so a pruned config or a hand-edited row fails closed rather than locking the person out
of sign-in. roleHome returns the declared home, or undefined when the role declares none or
is unknown. ownerLevelRoles lists every name mapped to owner capability, the set the last-owner
guard counts across instead of the literal 'owner' string.
Access map
A role vocabulary says who has which name; the access map says what each name may reach.
defineAccess declares the map once, and canReach is the one authority function every
enforcement and visibility point reads: the guard’s requireAccess
helper, the engine’s own route gates, and the nav resolver. Capability is always the floor, and
the map only narrows it, never widens it, so a site that declares no map sees no behavior change.
See Restrict admin access by role for the worked guide.
defineAccess
Stability tier: Extension API.
declare function defineAccess<const A extends AccessMap>(roles: RolesDeclaration, map: A): A;
Declare a site’s access map: a target, either an engine screen id (a declared concept, or one of
media, vocabulary, nav, settings) or an /admin-prefixed route path, to the role names admitted
to it. Validates at construction, defineRoles-style: throws an actionable
defineAccess:-prefixed error on an empty map, a role name outside the given vocabulary, an
empty role list (owner-only must be written explicitly as ['owner']), or a key that is neither a
plausible screen id (non-empty, no /) nor a well-formed /admin-prefixed path (no query, hash,
trailing slash, or the bare /admin root). A screen-id key’s existence against the site’s real
concepts, and an href key’s collision with a built-in admin route, validate later, at composition,
once the runtime knows the real concept list.
// src/lib/cairn.access.ts
import { defineAccess } from '@glw907/cairn-cms';
import { roles } from './cairn.config.js';
export const access = defineAccess(roles, {
pages: ['webmaster'],
media: ['webmaster', 'publisher'],
'/admin/money': ['club-admin'],
});
Pass the same map to createAuthGuard’s access option and to
the adapter’s access member: declaring it once and importing it twice is the pattern roles
already follows.
canReach, hasAccessRule
Stability tier: Extension API.
declare function canReach(access: AccessMap | undefined, editor: Editor, target: string): boolean;
declare function hasAccessRule(access: AccessMap | undefined, target: string): boolean;
canReach is the one decision point every enforcement and visibility check reads. none
capability reaches nothing, mapped or unmapped. Owner capability reaches every target, including
the editors screen and any target with no rule; every other capability’s reach stops at
editors, which stays owner-only no matter what the map says (the roster screen’s existing
floor, restated here so the one authority function covers it too). In practice a site cannot even
declare a rule for editors and have it silently ignored: composition-time validation
(validateAccessComposition) admits only a declared concept id or one of the fixed engine screens
as a map key, and throws an actionable error at server start on anything else. A screen-id target absent from
the map admits any editor-capability session; present, it admits only the named roles. An href
target matches the deepest path-segment-prefix key in the map (/admin/money covers
/admin/money/refunds unless the deeper key is separately mapped; /admin/moneyx never matches
/admin/money); an href with no matching key admits any editor-capability session, the nav
semantics a navFilter-free sidebar relies on.
hasAccessRule reports whether the map carries any rule at all for target (exact match for a
screen id, deepest-prefix match for an href). It backs
requireAccess’s fail-closed contract: a route that opts into the
map refuses every session with a 403, owner included, when the map has no opinion on its path at
all, distinct from canReach’s own any-editor reading used for nav visibility.
Deny at the route, never merely hide. Nav placement is never authorization: hiding a screen
or entry from the sidebar (a navLayout node’s hidden: true, an unmapped href) does not stop a
signed-in editor from reaching it by typing its URL directly. The access map, enforced by
canReach at the route and read by the nav resolver for visibility, is what actually gates a
screen; declaring the map is what makes the sidebar and the route agree.
Types
The public type aliases and interfaces. Each carries a signature and a one-line meaning. The function signatures above reference these.
| Name | Stability | Signature | Meaning |
|---|---|---|---|
CairnAdapter | Extension API | interface CairnAdapter | The one seam the engine consumes, declared at src/lib/cairn.config.ts. |
ConceptConfig | Extension API | interface ConceptConfig<S> | Per-site configuration for one content concept: dir, label, singular, fields, routing, permalink, datePrefix, summaryFields. The optional singular names the create affordances (“New post”) and defaults to label; routing/permalink/datePrefix set the concept’s URL policy. |
ConceptDescriptor | Extension API | interface ConceptDescriptor | The engine-internal, uniform view of one concept after normalization, including the resolved singular (defaulted to label). |
Backend | Extension API | interface Backend | The live, connected content store the engine resolves per request: read, commit, and branch operations over files, never a query. |
BackendProvider | Extension API | interface BackendProvider | The adapter’s backend value: carries the kind and default branch, and connect(env)s to a live Backend. |
GithubAppProvider | Extension API | interface GithubAppProvider | What githubApp(...) returns: a BackendProvider plus the GitHub App’s non-secret identity (owner, repo, appId, installationId). |
FileChange | Extension API | interface FileChange | One path change in a commit: write content, or delete the path when content is null. |
SenderConfig | Extension API | interface SenderConfig | Magic-link sender identity for Cloudflare Email Sending. |
NavMenuConfig | Extension API | interface NavMenuConfig | A git-committed YAML menu the nav editor manages. |
PreviewConfig | Extension API | interface PreviewConfig | The live site’s stylesheets and container classes for the edit page’s preview frame, with optional per-concept wrapper overrides. |
AssetConfig | Extension API | interface AssetConfig | A site’s media configuration: the R2 bucket binding, the delivery base and URL form, the upload limits, and the named Cloudflare Images variant presets. Omitting it leaves media off. See the assets adapter member above. |
AiPosture | Extension API | type AiPosture = 'invite' | 'decline' | A site’s stated stance toward AI training crawlers, named by CairnAdapter.aiPosture and read by buildRobots. Unset states nothing. Declining is a request that named crawlers say they honor, not enforcement. See Choose an AI posture for what each direction does and doesn’t buy. |
CairnRuntime | Extension API | interface CairnRuntime | The composed runtime the engine serves from. |
ComposeInput | Extension API | interface ComposeInput | The input to composeRuntime: adapter, siteConfig. |
NamedField | Extension API | type NamedField | A field descriptor with its frontmatter key re-attached as name, the normalized shape ConceptDescriptor.fields carries. |
ImageValue | Extension API | interface ImageValue | The stored value of an image field: a media: src, an alt, and an optional caption. |
ValidationResult | Extension API | type ValidationResult | A validator’s verdict: normalized data, or field-keyed errors plus the additive located issues. |
ValidationIssue | Extension API | interface ValidationIssue | One validation failure located by a path (a top-level key, then a row index and/or a leaf sub-key) and its message. |
StandardInput | Extension API | interface StandardInput | The validate input the adapter takes: raw frontmatter and the body. |
StandardSchemaV1 | Extension API | interface StandardSchemaV1<I, O> | A local copy of the Standard Schema v1 interface, for ecosystem interop. |
CairnRef | Extension API | interface CairnRef | A resolved reference to a content entry by its concept and permanent id. |
LinkResolve | Extension API | type LinkResolve = (ref: CairnRef) => string | undefined | Resolve a CairnRef to its live permalink. undefined is a preview miss; a resolver that throws is the build backstop. |
FragmentResolve | Extension API | type FragmentResolve = (id: string) => string | undefined | Resolve a fragment id to its raw markdown body, for the ::include directive. undefined is a preview miss; a resolver that throws is the build backstop. |
Manifest | Extension API | interface Manifest | The whole corpus as one committed file, with a version guard. |
ComponentDef | Extension API | interface ComponentDef | A site component: how it inserts (editor) and how it renders (rehype). Its attributes are a fields.* record of scalar leaves, with any cross-field rule in the co-bundled behavior table; defineComponent builds the attributeSchema from them. The optional icon and group place its picker row, hidden keeps it off the top-level picker, preview is a sample that seeds the guided form and opts the configure step into the two-pane live preview, and hydrate opts the directive into a client island. |
ComponentRegistry | Extension API | interface ComponentRegistry | The single source the render pipeline and the editor palette both read. |
IconSet | Extension API | type IconSet | A glyph name to SVG path-data map the site owns. |
SiteRender | Extension API | type SiteRender | The site’s one renderer seam: an entry-aware render({ body, concept?, frontmatter?, resolve?, resolveMedia?, resolveFragment? }): Promise<string> the editor preview and every public page call. |
RendererOptions | Extension API | interface RendererOptions | The render pipeline’s sanitize, anchor, table-scroll, and plugin-seam controls. |
Renderer | Extension API | type Renderer | What createRenderer returns: the composed plugin arrays plus renderMarkdown/renderDocument, shown expanded in createRenderer. |
DocHeading | Extension API | interface DocHeading | One heading renderDocument collected from a rendered page: id, flattened text, and depth (1-6), in document order. |
SiteConfig | Extension API | interface SiteConfig | The shape of the YAML site-config file. |
NavNode | Extension API | interface NavNode | One navigation node: label, optional url, optional children. |
VocabularyEntry | Extension API | interface VocabularyEntry | One editor-owned tag: a frozen slug value (the stored frontmatter token and filter key) and an editable display label. The vocabulary site-config key is a list of these. |
Capability | Extension API | type Capability | The three levels the engine understands: 'owner' (manages the roster), 'editor' (edits content), 'none' (an authenticated identity with no engine content access). |
RoleDeclaration | Extension API | type RoleDeclaration | One role’s mapping in a defineRoles vocabulary: a bare Capability, or { capability: Capability; home?: string } naming the /admin route that role lands on. |
RolesDeclaration | Extension API | type RolesDeclaration | A site’s whole role vocabulary: role name to RoleDeclaration, the shape defineRoles validates and returns. |
Editor | Extension API | interface Editor | The signed-in admin identity the whole admin reads: email, displayName, an open role (string; any site-declared name), and its resolved capability. locals.cairnEditor carries it for every /admin/** route (a custom route reads it directly or through requireSession/requireOwner/requireEditor), and the ambient declaration that types locals.cairnEditor ships from the ./ambient subpath. Email is always trimmed and lowercased, an invariant held at every write and lookup path (the auth.role-vocabulary and auth.email-normalization doctor checks flag a drift). |
AccessMap | Extension API | type AccessMap = Record<string, string[]> | A site’s whole access declaration: a target (an engine screen id or an /admin-prefixed route path) to the role names admitted to it. A target absent from the map keeps today’s behavior. See Access map. |
CairnEnv | Extension API | interface CairnEnv | The Worker bindings and vars the whole engine reads, all optional: AUTH_DB, PUBLIC_ORIGIN, CAIRN_DEV_BACKEND, EMAIL, GITHUB_APP_PRIVATE_KEY_B64. One shape for every factory that needs platform bindings; a site’s app.d.ts names {@link CairnPlatformBindings} instead, a recommended convenience preset that makes the required subset compile-checked. |
EmailRecipient | Extension API | type EmailRecipient = string | { email: string; name?: string } | A cc/bcc recipient for the Email Sending API: a bare address, or an address with a display name. |
EmailAttachment | Extension API | interface EmailAttachment | A file or inline attachment for the Email Sending API. |
EmailSender | Extension API | interface EmailSender { send(message: MagicLinkMessage): Promise<unknown> } | The email-sending seam CairnEnv['EMAIL'] and CairnPlatformBindings['EMAIL'] both reference. Promise<unknown>, not Promise<void>, so a Cloudflare Email Sending binding’s SendEmail.send (Promise<EmailSendResult>) satisfies it with no cast. |
AuthBranding | Extension API | interface AuthBranding | Per-site identity for the magic-link email. |
MagicLinkMessage | Extension API | interface MagicLinkMessage | The message a built magic-link email carries: the five required fields, plus optional cc, bcc, replyTo, and attachments widening the Email Sending API surface, live-verified 2026-07-07. replyTo takes a single address only; the platform rejects an array there. |
SendMagicLink | Extension API | type SendMagicLink | The injected send a custom SendMagicLink implements: (env, message) => Promise<void>; production sends through Cloudflare Email Sending. |
RepoFile | Extension API | interface RepoFile | A markdown file in a concept directory: id, name, path. |
CommitAuthor | Extension API | interface CommitAuthor | A commit author: the signed-in editor’s name and email. |
TextField | Extension API | interface TextField | A single-line text input: min/max/length and a pattern constraint. One of FieldDescriptor’s fifteen arms (export-rule sweep, C2 breaking-window pass). |
TextareaField | Extension API | interface TextareaField | A multi-line text input, with the same length and pattern constraints as TextField. |
NumberField | Extension API | interface NumberField | A numeric input, with min/max and an integer constraint. |
SelectField | Extension API | interface SelectField | A single-choice input over a closed options list. |
MultiselectField | Extension API | interface MultiselectField | A multiple-choice input; creatable opens it to author-added values, and taxonomy: true marks a site’s tag field. |
UrlField | Extension API | interface UrlField | A URL input whose format the validator enforces. |
EmailField | Extension API | interface EmailField | An email-address input whose format the validator enforces. |
DateField | Extension API | interface DateField | A calendar-date input, with min/max bounds as YYYY-MM-DD. |
DatetimeField | Extension API | interface DatetimeField | A date-and-time input, with min/max bounds as ISO strings. |
BooleanField | Extension API | interface BooleanField | A checkbox; absent means false. |
IconField | Extension API | interface IconField | A glyph chosen from the adapter’s icon set; the stored value is the glyph’s name. |
ImageField | Extension API | interface ImageField | A hero image whose stored value is the nested ImageValue object; seo marks it as the social-card image. |
ObjectField | Extension API | interface ObjectField | A group of leaf fields, stored as a nested object. Holds only leaves, no nested container. |
ReferenceField | Extension API | interface ReferenceField | A single edge to one entry of a named concept, stored as that target’s permanent id. |
ArrayField | Extension API | interface ArrayField | A repeatable field whose stored value is a list of its item’s values. |
BehaviorTable | Extension API | type BehaviorTable = Record<string, FieldBehavior> | The behavior table co-bundled with a fieldset, keyed by field name. Empty for a behavior-free fieldset. |
FieldBehavior | Extension API | interface FieldBehavior { validate?; itemLabel? } | Function-valued behavior a field descriptor cannot carry as plain data: a cross-field validate and an array row’s itemLabel deriver. Resident in the app bundle, never the load payload. |
DatePrefix | Extension API | type DatePrefix = 'year' | 'month' | 'day' | Filename date-prefix granularity for a dated concept: the leading YYYY[-MM[-DD]]- on the stem. |
RoutingRule | Extension API | interface RoutingRule { routable: boolean; dated: boolean; inFeeds: boolean } | Concept-fixed routing for a normalized concept (spec §7.2). Posts are dated feed entries; pages are plain navigable structure. |
SlotDef | Extension API | interface SlotDef | One named content region of a component. title and body are special: title serializes to the directive [label], body to the unmarked content. |
ComponentContext | Extension API | interface ComponentContext | The structured input a component’s build receives: the declared attributes, slot(name)/items(name) readers, and the stamped node. |
ManifestEntry | Extension API | interface ManifestEntry | One committed manifest entry’s projection: its identity, routing, draft flag, and outbound cairn: edges. Manifest.entries carries these. |
ReferenceEdge | Extension API | interface ReferenceEdge { field: string; concept: string; id: string } | One typed frontmatter edge from a content entry to a target entry, recorded per manifest entry and reverse-mapped by the cross-branch index. |
MediaRef | Extension API | interface MediaRef { slug: string | null; hash: string } | A resolved reference to a media asset by its content-hash prefix, with an optional display slug. MediaResolve’s own parameter. |
TidyConfig | Extension API | interface TidyConfig { enabled?; model?; conventions? } | The tidy block on the site config; every field optional so the YAML can carry as little as tidy: { enabled: true }. |
TidyConventions | Extension API | interface TidyConventions | The corrected convention set the tidy prompt builder consumes, every field resolved to a concrete value from a site’s partial TidyConfig.conventions. |
NavLayout | Extension API | type NavLayout = (NavLayoutEntry | NavLayoutEngineRef | NavLayoutSection)[] | A site’s whole declared sidebar: engine references, its own entries, and sections. The adapter’s editor.navLayout field takes this shape; see the navLayout seam for the full member types. |
NavLayoutEntry | Extension API | interface NavLayoutEntry | A site’s own nav entry inside a navLayout tree; see NavLayoutEntry for its members. |
NavLayoutEngineRef | Extension API | interface NavLayoutEngineRef | A navLayout node that places one of the engine’s own screens; see NavLayoutEngineRef. |
NavLayoutSection | Extension API | interface NavLayoutSection | One named group inside a navLayout tree; see NavLayoutSection. |
PublishActionsConfig | Extension API | type PublishActionsConfig = PublishActionEntry[] | A site’s raw publishActions config: next-step links rendered on the publish-success moment. |
PublishActionEntry | Extension API | interface PublishActionEntry { label: string; href: string; concepts?: string[] } | One developer-declared publish-success next-step link; href is a template string substituted with the published entry’s identity at resolve time. |
VariantSpec | Extension API | interface VariantSpec | A single image variant: the resize and format directives Cloudflare Images applies to the original bytes. See the preceding media adapter member. |
IslandRegistry | Extension API | type IslandRegistry = Record<string, Component> | A site’s hydratable client components, keyed by the name a component uses; hydrateIslands mounts over the matching hydrate directive’s static fallback. |
MediaResolve | Extension API | type MediaResolve = (ref: MediaRef) => string | undefined | Resolve a media: reference to its live delivery URL. undefined is a preview miss; a resolver that throws is the build backstop. |
ResolveOptions | Extension API | type ResolveOptions = { resolve?; resolveMedia?; resolveFragment? } | The per-call resolver hooks renderMarkdown and renderDocument both accept, threaded onto the VFile’s data so the cairn: link, media:, and ::include steps read them at process time. |