References Plugin

Let one document point at another, like a post at its author, and inline the target when you query.

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.

Checkout the Vite + Vue references example for a runnable demo with createContentClient.

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:

content.ts
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:

posts/design-systems.yaml
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 populated

Nothing is inlined into data at load time: read the document without populate and you get the strings back exactly as stored.

ExportDescription
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 sourcecontent.list(source, { populate })
Inline references on a single documentcontent.get(path, { populate })
Resolve one field on a document I already loadedcontent.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.

query.ts
// 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 available

API

references()

Returns a ContentPlugin that:

  1. Overrides list and get with optional { populate }.
  2. Adds content.populate() for single-field resolution.
  3. Hooks typegen:field so generated data types emit branded Ref<'source'> for reference fields (row types stay string).

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.

ParameterTypeDescription
sourceregistered source nameSource to list.
opts.populatestring[]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 | null

content.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.

ParameterTypeDescription
fileContentFile | ContentListFile | null | undefinedDocument that owns the field.
fieldstringTop-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 data are supported (not nested paths like seo.author).
  • Single refs become the target entry, or null when unresolved.
  • Array refs become the list of resolved targets; unresolved entries are omitted.
  • Non-reference fields named in populate are 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 populated

Without 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():

client
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:

QueryMeaning
populateJSON array of field names to inline
resolvepartial (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.

Make sure the Content instance on the server have the references() plugin installed too.

Notes

Resolution never fails a request: a missing target becomes 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.