Provider contract
Implement a server-side content source for Ginko Content.
A provider supplies raw documents and route facts behind Ginko's public APIs. The filesystem provider is built in; remote stores implement ContentProvider from @lupinum/ginko-content/provider.
import type {
ContentProvider,
ProviderDocumentInput
} from '@lupinum/ginko-content/provider'Registration
Map a provider name to its server module, then select it:
export default defineContentConfig({
provider: 'custom',
providers: {
custom: '@acme/content-provider'
},
collections: { docs }
})First-party provider modules can register themselves, so consumers do not always need a providers entry.
Interface
interface ContentProvider {
name: ContentProviderName
capabilities: ContentProviderCapabilities
query(
event: H3Event,
query: ContentProviderQuery
): Promise<MaybeContentProviderResult<ContentQueryResponse<ProviderDocumentInput>>>
navigation?(
event: H3Event,
query: ContentProviderQuery
): Promise<MaybeContentProviderResult<ContentProviderNavigationItem[]>>
surroundings?(
event: H3Event,
collection: string,
contentPath: string,
options?: ContentProviderSurroundingsOptions
): Promise<MaybeContentProviderResult<Array<ContentProviderSurroundItem | null>>>
search?(
event: H3Event,
request: ContentProviderSearchRequest
): Promise<MaybeContentProviderResult<ContentProviderSearchResult[]>>
siteData?(
event: H3Event,
request: ContentProviderSiteDataRequest
): Promise<MaybeContentProviderResult<ContentProviderSiteDataResponse>>
routes?(
event: H3Event
): Promise<MaybeContentProviderResult<ContentRouteRecord[]>>
}name, capabilities, and query() are required. query() is the only required operation; optional methods enable their matching public features. There are no separate operation-capability booleans.
| Method | Enables | Raw return value |
|---|---|---|
navigation | navigation() | Tree of { title, route?, children?, ...selected } |
surroundings | surround() | Previous/next array of { title, route, ...selected } | null |
search | content.search.engine: 'provider' | { title, excerpt?, score, route, ...selected }[] |
siteData | querySiteData() | { data, updatedAt? } |
routes | Sitemap, prerender, and route enumeration | ContentRouteRecord[] |
Every operation may return its result directly or wrap it with withContentCache().
Query implementation
The query boundary is the closed, JSON-pure version 4 wire:
interface ContentProviderQuery {
v: 4
collection: string | null
plan: ContentProviderQueryPlan
}Branch on query.plan.mode and return the corresponding envelope:
import type { ContentProvider } from '@lupinum/ginko-content/provider'
export default {
name: 'custom',
capabilities: {
query: {
operators: ['$eq', '$in'],
pagination: ['offset']
}
},
async query(event, query) {
switch (query.plan.mode) {
case 'count':
return { result: await countDocuments(query) }
case 'first':
return { result: await loadFirstDocument(query) }
case 'all': {
const page = await loadDocuments(query)
const pagination = query.plan.pagination
if (pagination.mode === 'cursor') throw new Error('Cursor pagination is not advertised')
return {
...(pagination.mode === 'offset' ? { mode: 'offset' as const } : {}),
result: page.items,
skip: pagination.skip,
limit: pagination.limit ?? 100,
total: page.total
}
}
}
}
} satisfies ContentProviderThe response shapes are:
| Plan | Response |
|---|---|
mode: 'count' | { result: number } |
mode: 'first' | { result: ProviderDocumentInput | undefined } |
Offset or ordinary mode: 'all' | { mode?: 'offset', result, skip, limit, total } |
Cursor mode: 'all' | { mode: 'cursor', result, limit, pageInfo: { endCursor, hasNext } } |
Offset responses must echo the requested skip and limit exactly. Cursor responses may contain fewer rows because pageInfo.endCursor is the continuation authority. Cursor responses do not include synthetic totals.
plan.variant is the single variant-selection state. A provider receives one
of these closed forms for public route and reference lookups:
type ProviderVariantSelector =
| {
by: 'path'
path: string
locale?: string
fallback?: string[]
exact?: boolean
}
| {
by: 'route'
requestedRoute: string
requestedLocale: string
candidates: Array<{ locale: string, contentPath: string }>
}
| {
by: 'ref'
requestedRef: string
requestedLocale: string
localeChain: string[]
}Route candidates are ordered and already include each locale's collection
mount, matching the contentPath shape returned by provider documents. Return
the first match. Reference lookups walk localeChain in order. The provider
wire never carries a second raw route/ref fallback request beside this selector.
Path selectors also use the mounted provider coordinate. Canonical,
mount-agnostic paths are internal to Ginko's graph executor and never cross
the provider contract.
Provider and conformance tooling can use toContentProviderQuery(input) for
context-free filters and explicit mounted providerPath selectors. Route/reference fixtures must
construct their already-closed v4 selectors explicitly. Application reads
should use one(), many(), and paginate() instead.
Navigation always targets one named collection. This is required because
mounts and locale fallback are collection policy; a cross-collection
navigation query has no single path vocabulary.
toContentProviderNavigationQuery(input) applies the same context-free
lowering rules and additionally requires collection, so a navigation fixture
cannot compile without one.
Capabilities
capabilities: {
query: {
operators: ProviderCapabilityOperator[]
pagination: Array<'offset' | 'cursor'>
}
}Advertise only operators and pagination modes the provider executes. All providers must handle ordered plan.sort, first, and structural and, or, and not filter nodes. A provider advertising offset must also execute count; count is unavailable to cursor-only providers.
Projection is an optional pushdown. A provider may reduce authored fields, but it must still return the raw identity fields needed for validation. Core applies the public select shape after validation.
When no sort is present, order is provider-defined. Deterministic pagination requires a deterministic provider order. Version 4 has no legacy dispatch or negotiation, and $nin is a native comparison operator.
Raw documents
Return query rows as ProviderDocumentInput:
const row: ProviderDocumentInput = {
collection: 'docs',
canonicalKey: 'docs:intro',
locale: 'de',
contentPath: '/dokumentation/einstieg',
routeVariants: [
{ locale: 'en', contentPath: '/docs/intro' },
{ locale: 'de', contentPath: '/dokumentation/einstieg' }
],
body: { type: 'root', children: [] },
title: 'Einstieg'
}| Field | Required | Contract |
|---|---|---|
collection | yes | Must match query.collection for collection-scoped queries |
locale | yes | Concrete returned variant |
contentPath | yes | Locale-specific path before the Nuxt locale prefix |
body | yes | Markdown root, structured JSON value, or null |
canonicalKey | yes | Opaque locale-independent identity authored by the provider |
routeVariants | no | Concrete variants visible to this request |
id | no | Derived when absent |
type | no | 'markdown' by default; also 'yaml', 'json', or 'csv' |
file | no | File provenance when a backing file exists |
contentPath includes the collection's locale-specific mount but excludes the
application locale prefix. For example, canonical /intro becomes provider
/dokumentation/intro and public /de/dokumentation/intro. It must be a
leading-slash, site-relative path without an absolute URL, query, fragment,
backslash, or traversal segment.
canonicalKey is always required and must remain stable when a collection mount or locale-specific slug changes. The routeVariants entry for the row's locale must equal contentPath. Return only variants visible under the current request policy. When plan.variant has a by discriminator, return its first matching candidate in order so core can label a proven fallback route.
Core derives canonical/public paths and the route/resolution envelope. Do not return top-level path, variants, localePaths, unprefixedPath, dir, resolved, route, or resolution.
normalizeProviderDocument() validates the provider row and fills transport defaults such as id, type, and routeVariants; it does not invent canonical identity. Structured bodies and all selected fields must be JSON-pure. Markdown bodies must be a root Markdown AST or null.
Route facts
Navigation, surroundings, search, and route enumeration share this raw identity:
interface ContentProviderRouteFact {
collection: string
canonicalKey: string
locale: string
contentPath: string
}The same content-path rules apply as for documents. Extra selected fields must be JSON-pure.
routes() may add draft and sitemap. sitemap is false or { lastmod?, images?: Array<{ loc }> }; lastmod must be normalized UTC ISO text. Ginko applies collection inclusion, visibility, and final URL policy after the provider returns.
Direct provider results are limited to 64 KiB per UTF-8 JSON record, 32 MiB aggregate, 16 sitemap images per route, and 2 KiB per sitemap image location. These are rejection ceilings, not target page sizes.
Framework-neutral data sources may enumerate routes with cursors before bindContentProvider() adapts them. That boundary caps a route page at 250 entries and the logical enumeration at 100,000. It must keep one external snapshot across every page, bind cursors to that snapshot and exact scope, and make forward progress. A changed snapshot, repeated cursor, empty page with continuation, or non-progress is invalid.
Visibility
The provider receives the active H3Event. It owns published-versus-preview selection and authentication for every provider surface. Apply the same policy to list, first, and count execution; Ginko does not retrieve all remote rows and filter hidden content afterward.
Return only visible routeVariants and route facts. The filesystem provider's sealed-snapshot preview behavior is not encoded in ContentProviderQuery and does not apply automatically to third-party providers.
Site data
siteData() receives { key, locale? } and returns { data, updatedAt? }. data is required and must be a JSON value or null. updatedAt, when present, is a non-negative safe-integer timestamp; omit it rather than returning null.
The request remains the source of truth for key and locale. Ginko builds the public response identity from that request.
Cache hints
Wrap any operation result with withContentCache():
import { withContentCache } from '@lupinum/ginko-content/provider'
return withContentCache(result, {
tags: [`collection:${collection}`],
paths: ['/docs/intro'],
maxAge: 300,
swr: 60
})The wrapper cannot collide with document fields named data or cache. Providers describe dependencies and freshness; the application-owned ContentCacheAdapter applies response metadata and may expose an explicit host-cache invalidation capability.
Errors and conformance
Ginko rejects invalid provider modules, unadvertised query work, malformed response envelopes, impure JSON, invalid route ownership, and unsupported optional operations. Use createContentProviderError(code, message, details?, cause?) when an implementation needs to produce one of Ginko's stable provider error codes without exposing its internal cause.
Run runProviderContractSuite() from @lupinum/ginko-content/testing/provider-contract. Supply one result-asserting probe for every advertised operator and pagination mode, plus probes for ordered sort, first, and, or, not, and count when offset pagination is advertised. The suite also checks versioned query envelopes, raw documents, route facts, optional-method presence, and response semantics.
Framework-neutral source authors should also run runContentDataSourceContractSuite() from @lupinum/ginko-content/testing/data-source-contract. Repository maintainers changing the boundary must follow packages/content/docs/PROVIDER_CONTRACT.md.