Skip to main content

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.

ts
import type {
  ContentProvider,
  ProviderDocumentInput
} from '@lupinum/ginko-content/provider'

Registration

Map a provider name to its server module, then select it:

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

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

MethodEnablesRaw return value
navigationnavigation()Tree of { title, route?, children?, ...selected }
surroundingssurround()Previous/next array of { title, route, ...selected } | null
searchcontent.search.engine: 'provider'{ title, excerpt?, score, route, ...selected }[]
siteDataquerySiteData(){ data, updatedAt? }
routesSitemap, prerender, and route enumerationContentRouteRecord[]

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:

ts
interface ContentProviderQuery {
  v: 4
  collection: string | null
  plan: ContentProviderQueryPlan
}

Branch on query.plan.mode and return the corresponding envelope:

server/provider.ts
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 ContentProvider

The response shapes are:

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

ts
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

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

ts
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'
}
FieldRequiredContract
collectionyesMust match query.collection for collection-scoped queries
localeyesConcrete returned variant
contentPathyesLocale-specific path before the Nuxt locale prefix
bodyyesMarkdown root, structured JSON value, or null
canonicalKeyyesOpaque locale-independent identity authored by the provider
routeVariantsnoConcrete variants visible to this request
idnoDerived when absent
typeno'markdown' by default; also 'yaml', 'json', or 'csv'
filenoFile 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:

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

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