Skip to main content

Connect an external content source

Replace the filesystem with a CMS or database provider without changing page-level content queries.

Register a provider by name and point it at a module:

content.config.ts
import { defineContentConfig } from '@lupinum/ginko-content/config'
import { docs } from './content.collections'

export default defineContentConfig({
  provider: 'acme',
  providers: {
    acme: '~~/server/providers/acme'
  },
  collections: { docs }
})

Page code keeps calling useContentPage, one, many, and navigation with the same collection handles. The provider changes only the server-side source.

Stay on the filesystem first

Markdown and Git cover many sites. Add a provider when the source or publishing workflow requires one:

  • a CMS or database is the real source of your content;
  • editors publish to an SSR or hybrid app without rebuilding static output;
  • access control at runtime changes which documents a request can see;
  • several apps read one published content source.

Keep the filesystem provider until one of these requirements exists.

Implement the minimum contract

Of the provider operations, only query is required. Handle all three terminal modes in the query plan:

server/providers/acme.ts
import type { ContentProvider } from '@lupinum/ginko-content/provider'

export default {
  name: 'acme',
  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 pagination = query.plan.pagination
        if (pagination.mode === 'cursor') throw new Error('Cursor pagination is not advertised')
        const { skip, limit = 100 } = pagination
        const page = await loadDocuments(query, { skip, limit })
        return {
          result: page.items,
          skip,
          limit,
          total: page.total
        }
      }
    }
  }
} satisfies ContentProvider

The helper functions above stand in for your source adapter. They must honor the relevant filter, sort, projection, locale, and variant fields in query.plan before returning the matching envelope.

Return raw documents. Ginko derives the public route and resolution envelopes from these facts:

ts
const document = {
  collection: 'docs',
  canonicalKey: 'docs:getting-started',
  locale: 'de',
  contentPath: '/dokumentation/einstieg',
  routeVariants: [
    { locale: 'en', contentPath: '/docs/getting-started' },
    { locale: 'de', contentPath: '/dokumentation/einstieg' }
  ],
  title: 'Einstieg',
  body: {
    type: 'root',
    children: []
  }
}

contentPath is a leading-slash, site-relative route without the Nuxt locale prefix. Return that path and let Ginko project the public URL.

Use the same canonicalKey for every locale variant of one document. It is required on every provider document and must remain stable when route mounts or translated slugs change.

Advertise only what the provider executes. Ginko trusts capabilities.query.operators and capabilities.query.pagination. Claiming unsupported semantics can return plausible but incorrect query results.

The full wire shapes and return envelopes are documented in the provider contract.

Implement optional operations deliberately

navigation, surroundings, search, siteData, and routes are optional. Method presence advertises support, so do not add stubs. Implement an operation only when the source can return its complete contract.

Prove every capability

Run the official suite against your provider:

test/provider.test.ts
import { runProviderContractSuite } from '@lupinum/ginko-content/testing/provider-contract'

runProviderContractSuite({
  name: 'acme',
  expectedProviderName: 'acme',
  loadProvider: async () => provider,
  createEvent: () => createTestEvent(),
  expectedCapabilities: provider.capabilities,
  operatorProbes: {
    $eq: equalityProbe,
    $in: membershipProbe
  },
  logicalProbes: {
    and: andProbe,
    or: orProbe,
    not: notProbe
  },
  sortProbe,
  terminalProbes: {
    first: firstProbe,
    count: countProbe
  },
  paginationProbes: {
    offset: offsetProbe
  }
})

Every advertised operator and pagination mode needs a probe. The suite also requires discriminating probes for logical filters, sorting, and terminal modes. Each assertion must fail if the provider ignores the requested behavior; successful dispatch alone does not prove semantics.