Custom Sources

Load content from any backend you control.

If no built-in source or unstorage driver fits your backend, implement the Source interface yourself.

Interface

Source.ts
interface Source {
  prefix?: string
  schema?: JsonSchema
  keys: () => Promise<string[]> | string[]
  getItem: (key: string) => Promise<string> | string
  getItemRaw: (key: string) => Promise<unknown> | unknown
  watch?: Driver['watch']
}
MemberTypeDescription
keys() => string[] | Promise<string[]>Required. List every content key in the source.
getItem(key) => string | Promise<string>Required. Return a key's text content.
getItemRaw(key) => unknown | Promise<unknown>Required. Return a key's raw bytes, or null.
prefixstringPrepended to each entry's public path.
schemaJsonSchemaExplicit data schema for types and query columns.
watchDriver['watch']Forward change events to content.watch().

keys

List every content key the source exposes, as forward-slash paths with no leading slash (e.g. posts/hello.md). Keys must round-trip: whatever keys() returns is exactly what getItem and getItemRaw receive. Keys ending in .md / .markdown are parsed as documents out of the box; other extensions need a parser registered by a plugin.

getItem

Return a key's text content: the raw Markdown (or JSON/YAML) that Comark parses. Throw if the key is unreachable; the Content instance surfaces the error to the caller rather than swallowing it.

getItemRaw

Return a key's raw bytes, as a Uint8Array, for media files. Required even if you only ship documents: return null when a key has no binary form.

prefix

Prepended to every entry's public path (e.g. /blog). It does not affect the key, only the path returned by content.get() and content.list().

schema

An explicit JSON Schema for the source's data. When set, the Content instance reports Markdown, JSON, and YAML validation issues during source load and on direct updates, and uses the schema to generate types and query columns.

watch

Optional. When present, content.watch() forwards your source's change events. It receives a (event, key) callback (event is 'update' or 'remove') and returns an unsubscribe function. See Source with watch support for a worked example.

Examples

HTTP source

http-source.ts
import type { Source } from 'comark-content'

export function httpSource(baseUrl: string): Source {
  return {
    async keys() {
      const res = await fetch(`${baseUrl}/index.json`)
      if (!res.ok) throw new Error(`index.json: ${res.status}`)
      return (await res.json()) as string[]
    },
    async getItem(key) {
      const res = await fetch(`${baseUrl}/${key}`)
      if (!res.ok) throw new Error(`${key}: ${res.status}`)
      return await res.text()
    },
    async getItemRaw(key) {
      const res = await fetch(`${baseUrl}/${key}`)
      if (!res.ok) throw new Error(`${key}: ${res.status}`)
      return new Uint8Array(await res.arrayBuffer())
    },
  }
}

Database source

db-source.ts
import type { Source } from 'comark-content'

interface Row { path: string; content: string; binary: Buffer | null }

export function dbSource(query: (sql: string, params?: any[]) => Promise<Row[]>): Source {
  return {
    async keys() {
      const rows = await query('SELECT path FROM documents')
      return rows.map(r => r.path)
    },
    async getItem(key) {
      const [row] = await query('SELECT content FROM documents WHERE path = ?', [key])
      if (!row) throw new Error(`${key}: not found`)
      return row.content
    },
    async getItemRaw(key) {
      const [row] = await query('SELECT binary FROM documents WHERE path = ?', [key])
      return row?.binary ?? null
    },
  }
}

Watch support

watch lets content.watch() forward your source's change events. It returns an unsubscribe function:

pollable-source.ts
import type { Source } from 'comark-content'

export function pollSource(loader: () => Promise<Map<string, string>>, intervalMs = 5000): Source {
  let cache = new Map<string, string>()
  let initialized = false

  async function ensureCache() {
    if (!initialized) {
      cache = await loader()
      initialized = true
    }
  }

  return {
    async keys() {
      await ensureCache()
      return [...cache.keys()]
    },
    async getItem(key) {
      await ensureCache()
      return cache.get(key) ?? ''
    },
    async getItemRaw(key) {
      await ensureCache()
      return cache.get(key) ?? null
    },
    watch(callback) {
      const handle = setInterval(async () => {
        const next = await loader()
        for (const [key, value] of next) {
          if (cache.get(key) !== value) callback('update', key)
        }
        for (const key of cache.keys()) {
          if (!next.has(key)) callback('remove', key)
        }
        cache = next
      }, intervalMs)
      return () => clearInterval(handle)
    },
  }
}
The watcher contract (event names, key shape, callback signature) is described in the Watcher guide.