Custom Sources
If no built-in source or unstorage driver fits your backend, implement the Source interface yourself.
Interface
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']
}| Member | Type | Description |
|---|---|---|
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. |
prefix | string | Prepended to each entry's public path. |
schema | JsonSchema | Explicit data schema for types and query columns. |
watch | Driver['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
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
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:
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)
},
}
}