Create a Comark Content Plugin

Customize the Content instance with your own plugins to handle new file types, subscribe to hooks, and expose custom methods.

A plugin is a typed factory that extends the Content instance at construction time: it can register a parser for new file extensions, subscribe to hooks, and expose new methods on the instance.

Two plugin APIs are exposed to extend the Content instance:

defineContentPlugin(factory)

Wraps a plugin factory, providing type safety for both its options and the methods it contributes to the Content instance.

Parameters:

  • factory: a function (options?: Options) => { setup(content: ContentPluginContext): Methods | void | Promise<Methods | void> }. The setup hook runs at construction: it registers parsers, subscribes to hooks, and returns the methods to merge onto the instance. See ContentPluginContext.

Type parameters:

ParameterDefaultDescription
OptionsunknownShape of the options the factory accepts.
Methods{}Methods the plugin contributes; intersected into comarkContent()'s return type.

Returns: a typed plugin factory (options?: Options) => ContentPlugin<Methods>, ready to pass to comarkContent({ plugins }).

plugin.ts
import { defineContentPlugin } from 'comark-content'

export default defineContentPlugin(() => ({
  setup(content) {
    return { hello: (name: string) => `Hello, ${name}` }
  },
}))

defineContentClientPlugin(factory)

The client counterpart, imported from comark-content/client. Same shape, but setup receives the client (with its resolved options: baseURL, basePath, fetch) instead of the Content instance plugin context, and runs synchronously. Use it to add a method that calls a serve handler.

Parameters:

  • factory: a function (options?: Options) => { setup(client: ContentClientWithOptions): Methods | void }.

Type parameters:

ParameterDefaultDescription
OptionsunknownShape of the options the factory accepts.
Methods{}Methods the plugin contributes; intersected into createContentClient()'s return type.

Returns: a typed plugin factory (options?: Options) => ContentClientPlugin<Methods>, for createContentClient({ plugins }).

client-plugin.ts
import { defineContentClientPlugin } from 'comark-content/client'

export default defineContentClientPlugin(() => ({
  setup(client) {
    return {
      hello: (name: string) =>
        client.options.fetch(`${client.options.basePath}/hello?name=${name}`),
    }
  },
}))

Usage

A full round trip: a server plugin that adds content.wordCount(path), a route that exposes it, and a client plugin that consumes it from the browser.

Create plugin

The server plugin adds content.wordCount(path) and keeps a per-entry count in sync via hooks:

word-count.ts
import { defineContentPlugin, type ContentFile } from 'comark-content'

interface WordCountMethods {
  wordCount: (path: string) => Promise<number>
}

export default defineContentPlugin<{}, WordCountMethods>(() => {
  const counts = new Map<string, number>()

  function count(item: ContentFile): number {
    let total = 0
    function walk(node: unknown) {
      if (typeof node === 'string') total += node.trim().split(/\s+/).filter(Boolean).length
      else if (Array.isArray(node)) node.slice(2).forEach(walk)
    }
    item.nodes.forEach(walk)
    return total
  }

  return {
    setup(content) {
      content.hooks.hook('file:upsert', (_source, _key, file) => counts.set(file.path, count(file)))
      content.hooks.hook('file:remove', (_source, key) => counts.delete(key))

      return {
        async wordCount(path: string) {
          if (counts.has(path)) return counts.get(path)!
          const item = await content.get(path)
          if (!item) return 0
          const n = count(item)
          counts.set(path, n)
          return n
        },
      }
    },
  }
})

Expose server route

Register a serve handler so wordCount is reachable under the word-count section, then forward the route to content.handler():

server/api/content/[...path].ts
import { comarkContent } from 'comark-content'
import fs from 'comark-content/sources/fs'
import wordCount from './word-count'

const content = comarkContent({ source: fs('./content'), plugins: [wordCount()] })

content.addServeHandler('word-count', async (request) => {
  const path = new URL(request.url).searchParams.get('path') ?? ''
  return Response.json(await content.wordCount(path))
})

// Forward every /api/content/** request to the Content instance handler (Nitro shown).
export default defineEventHandler(event => content.handler(toWebRequest(event)))

Consume from client

A client plugin calls the word-count section. Registered on a createContentClient() instance, it adds a typed content.wordCount() method to the browser client:

word-count.client.ts
import { createContentClient, defineContentClientPlugin } from 'comark-content/client'

const wordCountClient = defineContentClientPlugin<{}, { wordCount: (path: string) => Promise<number> }>(() => ({
  setup(client) {
    return {
      wordCount: (path) =>
        client.options.fetch(`${client.options.basePath}/word-count?path=${encodeURIComponent(path)}`),
    }
  },
}))

const content = createContentClient({ basePath: '/api/content', plugins: [wordCountClient()] })

const words = await content.wordCount('/blog/hello')
//    ^? number — computed on the server, fetched over HTTP
Plugins run synchronously at construction time. Don't await long-running work inside setup: do it lazily inside the methods you expose.

Hooks

The content.hooks bus (typed as ContentHooks) fires on every content change. Subscribe with content.hooks.hook(name, callback); handlers run in registration order.

HookWhenPayload
file:parsedAfter a file parser assembles a ContentFileFileParsedContext — mutate or null file
typegen:fieldWhile generating source typesTypegenFieldContext — override type, register imports
file:upsertAfter content.get() parses, content.update(), or a watcher update(source, key, file)
file:removeAfter content.remove() or a watcher removal(source, key)
file:bulk:loadAfter a source finishes parsing a batch(source, files)
watch:file:updateRaw watcher event before bookkeeping(source, key, file)
watch:file:removeRaw watcher event before bookkeeping(source, key)

See content.hooks for the full event reference.