References Plugin
The references plugin lets a document point at another one: a post at its author, or at related posts in the same source.
Mark the field in a source schema with reference(), store the target's path in your content, and ask for it to be inlined when you query:
const posts = await content.list('posts', { populate: ['author'] })
posts[0].data.author?.data.name // 'Ada Lovelace'You get autocomplete on the fields you can populate, and the return type is rewritten to the target's shape. Nothing is loaded until you ask for it, so listings stay cheap.
Defining references
Use reference() in a source schema to mark a field as a link to another document. Pass the target source name, or omit it for a self-reference:
import { comarkContent, type JsonSchema } from 'comark-content'
import fs from 'comark-content/sources/fs'
import json from 'comark-content/plugins/json'
import yaml from 'comark-content/plugins/yaml'
import references, { reference } from 'comark-content/plugins/references'
// Authors are plain documents, the targets we point at.
const authorSchema: JsonSchema = {
type: 'object',
properties: {
name: { type: 'string' },
role: { type: 'string' },
},
required: ['name'],
}
const postSchema: JsonSchema = {
type: 'object',
properties: {
title: { type: 'string' },
// A single reference to a document in the `authors` source
author: reference('authors'),
// An array of references. No argument means a self-reference,
// so these resolve within `posts`.
relatedPosts: {
type: 'array',
items: reference(),
},
},
required: ['title'],
}
const content = comarkContent({
sources: {
authors: fs('./content/authors', { schema: authorSchema }),
posts: fs('./content/posts', { schema: postSchema }),
},
plugins: [json(), yaml(), references()],
})In your content, a reference is just a string: the target's public path, or its source/path-in-source key:
title: Design Systems That Survive Contact With Reality
author: /grace-hopper # → content/authors/grace-hopper.yaml
relatedPosts:
- posts/scaling-comark.json # the `source/path-in-source` key form
- /nonexistent-post # no such document, dropped when populatedNothing is inlined into data at load time: read the document without populate and you get the strings back exactly as stored.
| Export | Description |
|---|---|
reference(source?) | Returns { type: 'string', 'x-content': { type: 'reference', source? } }. Omit source for a self-reference (the same source as the field). |
Lookup goes through content.stat(), and the target must belong to the declared source. A /grace-hopper value on a field declared reference('posts') will not resolve.
Reading references
Three ways to resolve, depending on what you already have:
| I want to… | Use |
|---|---|
| Inline references while listing a source | content.list(source, { populate }) |
| Inline references on a single document | content.get(path, { populate }) |
| Resolve one field on a document I already loaded | content.populate(file, field) |
The first two rewrite data in place, so data.author becomes the target document. content.populate() leaves the document alone and returns the targets.
// Stored strings only
const raw = await content.list('posts')
raw[0].data.author // '/grace-hopper'
// Inlined
const posts = await content.list('posts', { populate: ['author', 'relatedPosts'] })
posts[0].data.author?.data.name // 'Grace Hopper'
posts[0].data.relatedPosts // broken entry dropped
// Or one field at a time
const post = await content.get('/design-systems')
const related = await content.populate(post, 'relatedPosts')Partial vs full targets
By default targets are partial: manifest listing entries (ContentListFile), which carry data but no nodes. That keeps listings cheap. Pass resolve: 'full' when the UI needs the target's rendered body:
const posts = await content.list('posts', { populate: ['author'], resolve: 'full' })
posts[0].data.author?.nodes // now availableAPI
references()
Returns a ContentPlugin that:
- Overrides
listandgetwith optional{ populate }. - Adds
content.populate()for single-field resolution. - Hooks
typegen:fieldso generated data types emit brandedRef<'source'>for reference fields (row types staystring).
Returns: ContentPlugin
No options. Behaviour is controlled per call.
content.list(source, opts?)
With the plugin installed, the single-source form accepts populate options. Field names in populate autocomplete to keys typed as Ref / Ref[] on that source's data type. The return type rewrites those fields to the resolved target shape.
| Parameter | Type | Description |
|---|---|---|
source | registered source name | Source to list. |
opts.populate | string[] | Top-level reference field names to inline. |
opts.resolve | 'partial' | 'full' | 'partial' (default): listing entries. 'full': fully parsed documents. |
const posts = await content.list('posts', { populate: ['author', 'relatedPosts'] })
// posts[n].data.author?: ContentListFile<AuthorsData> | null
// posts[n].data.relatedPosts: ContentListFile<PostsData>[]
const full = await content.list('posts', { populate: ['author'], resolve: 'full' })
// full[n].data.author?: ContentFile<AuthorsData> | null (has nodes)Array form list(['posts', 'authors']) and unscoped list() keep the base API (no typed populate overload).
content.get(path, opts?)
Same populate options as list. Rewrites the returned document's data for the listed fields.
const post = await content.get('/design-systems', { populate: ['author'] })
post?.data.author // ContentListFile | nullcontent.populate(file, field, opts?)
Resolve one reference field on an already-loaded file. Always returns an array (length 0–1 for a single ref, N for an array ref), and unresolved values are dropped.
| Parameter | Type | Description |
|---|---|---|
file | ContentFile | ContentListFile | null | undefined | Document that owns the field. |
field | string | Top-level data field name. |
opts.resolve | 'partial' | 'full' | Default 'partial' → listing entries; 'full' → full ContentFiles. |
const post = await content.get('/design-systems')
const author = (await content.populate(post, 'author'))[0] // ContentListFile | undefined
const related = await content.populate(post, 'relatedPosts') // ContentListFile[]
const fullAuthor = await content.populate(post, 'author', { resolve: 'full' })How population works
- Only top-level reference fields on
dataare supported (not nested paths likeseo.author). - Single refs become the target entry, or
nullwhen unresolved. - Array refs become the list of resolved targets; unresolved entries are omitted.
- Non-reference fields named in
populateare left untouched.
Type generation
When references is registered, writeSourceTypes() emits branded path strings on data interfaces:
// Generated stored shape, nothing populated yet
export interface PostsData {
title: string
author?: Ref<'authors'>
relatedPosts?: Array<Ref<'posts'>>
}Ref<K> is a branded string carrying the target source name, so populate can suggest only the fields that are actually references. Query row types keep plain string for those columns.
const posts = await content.list('posts', { populate: ['author'] })
posts[0].data.author // ContentListFile<AuthorsData> | null | undefined
posts[0].data.relatedPosts // still Ref<'posts'>[], not populatedWithout the plugin, fields type as string even if the schema carries x-content metadata.
HTTP client
Pair the server plugin with the client half so browser/HTTP apps get the same API over content.handler():
import { createContentClient } from 'comark-content/client'
import referencesClient from 'comark-content/plugins/references/client'
const content = createContentClient({
basePath: '/api/content',
plugins: [referencesClient()],
})
const posts = await content.list('posts', { populate: ['author', 'relatedPosts'] })The client plugin overrides list / get to send query params:
| Query | Meaning |
|---|---|
populate | JSON array of field names to inline |
resolve | partial (default, listing entries) or full (parsed bodies) |
content.populate(file, field) on the client re-fetches the document with { populate: [field] } and extracts the inlined value.
references() plugin installed too.Notes
null for a single ref, and is dropped from the array for an array ref. Content and links move independently, so a typo or a deleted document shouldn't take a page down. A strict mode that throws on unresolved references may be added later.schema-validation checks the shape of the stored strings, not link integrity. It won't tell you a reference points at a document that no longer exists.