---
title: "Create a Comark Content Plugin"
description: "Customize the Content instance with your own plugins to handle new file types, subscribe to hooks, and expose custom methods."
canonical_url: "https://content.comark.dev/plugins/custom/plugin-api"
---
# 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](https://content.comark.dev/plugins/custom/parser-api) for new file extensions, subscribe to [hooks](#hooks), and expose new methods on the instance.

Two plugin APIs are exposed to extend the Content instance:

- `comark-content` exports [`defineContentPlugin`](#definecontentpluginfactory) for the [Content instance](https://content.comark.dev/reference/content/comark-content).
- `comark-content/client` exports [`defineContentClientPlugin`](#definecontentclientpluginfactory) for the [client](https://content.comark.dev/reference/client/create-content-client).

## `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) => { name: string, setup(content: ContentPluginContext): Methods | void }`. `name` identifies the plugin: a second plugin with the same name is ignored on setup. The `setup` hook runs at construction: it registers parsers, subscribes to hooks, and returns the methods to merge onto the instance. See [`ContentPluginContext`](https://content.comark.dev/reference/types/plugins#contentplugincontext).

**Type parameters:**

| Parameter | Default   | Description                                                                                                                                      |
| --------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Options` | `unknown` | Shape of the options the factory accepts.                                                                                                        |
| `Methods` | `{}`      | Methods the plugin contributes; intersected into [`comarkContent()`](https://content.comark.dev/reference/content/comark-content)'s return type. |

**Returns:** a typed plugin factory `(options?: Options) => `[`ContentPlugin`](https://content.comark.dev/reference/types/plugins#contentplugin)`<Methods>`, ready to pass to [`comarkContent({ plugins })`](https://content.comark.dev/reference/content/comark-content#options-plugins).

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

export default defineContentPlugin(() => ({
  name: 'hello',
  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](https://content.comark.dev/reference/client/create-content-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](https://content.comark.dev/reference/content/add-serve-handler).

**Parameters:**

- `factory`: a function `(options?: Options) => { name: string, setup(client: ContentClientWithOptions): Methods | void }`.

**Type parameters:**

| Parameter | Default   | Description                                                                                                                                                  |
| --------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Options` | `unknown` | Shape of the options the factory accepts.                                                                                                                    |
| `Methods` | `{}`      | Methods the plugin contributes; intersected into [`createContentClient()`](https://content.comark.dev/reference/client/create-content-client)'s return type. |

**Returns:** a typed plugin factory `(options?: Options) => `[`ContentClientPlugin`](https://content.comark.dev/reference/types/plugins#contentclientplugin)`<Methods>`, for [`createContentClient({ plugins })`](https://content.comark.dev/reference/client/create-content-client#options-plugins).

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

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

---

## 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](https://content.comark.dev/reference/content/hooks):

```ts [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 {
    name: 'word-count',
    setup(content) {
      // Hooks report the file key (`meta.key`), so key the cache by it too.
      content.hooks.hook('file:upsert', (_source, _key, file) => counts.set(file.meta.key, count(file)))
      content.hooks.hook('file:remove', (_source, key) => counts.delete(key))

      return {
        async wordCount(path: string) {
          const item = await content.get(path) // served from memory once parsed
          if (!item) return 0
          const cached = counts.get(item.meta.key)
          if (cached !== undefined) return cached
          const n = count(item)
          counts.set(item.meta.key, n)
          return n
        },
      }
    },
  }
})
```

### Expose server route

Register a [serve handler](https://content.comark.dev/reference/content/add-serve-handler) so `wordCount` is reachable under the `word-count` section, then forward the route to [`content.handler()`](https://content.comark.dev/reference/content/handler):

```ts [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](#definecontentclientpluginfactory) calls the `word-count` section. Registered on a [`createContentClient()`](https://content.comark.dev/reference/client/create-content-client) instance, it adds a typed `content.wordCount()` method to the browser client:

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

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

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

const words = await content.wordCount('/blog/hello')
//    ^? number — computed on the server, fetched over HTTP
```

::warning
Plugins run synchronously at construction time. Don't `await` long-running work inside `setup`: do it lazily inside the methods you expose.
::

## Timing and tracing

Two `ContentPluginContext` members hook plugins into the timing system:

- **`content.perf`** — instrument the methods your plugin exposes so they show up in debug timelines and OTel traces alongside the core spans. Wrap public methods in `content.perf.run()` and inner steps in `withSpan(content.perf, …)` — both are free no-ops unless the user installs a [tracing plugin](https://content.comark.dev/plugins/built-in/tracing). See [custom spans](https://content.comark.dev/reference/content/perf#custom-spans) for the pattern (the built-in [`sqlQuery`](https://content.comark.dev/plugins/built-in/sql-query) and [full-text search](https://content.comark.dev/plugins/built-in/full-text-search) plugins do exactly this).
- **`content.setPerf(recorder)`** — install the recorder *behind* `content.perf`. This is how the tracing plugins themselves are built; use it to author your own recorder (for example forwarding spans to another APM). The first installed recorder wins — later calls warn and are ignored. See [custom recorders](https://content.comark.dev/plugins/built-in/tracing#custom-recorders).

## Hooks

The [`content.hooks`](https://content.comark.dev/reference/content/hooks) bus (typed as [`ContentHooks`](https://content.comark.dev/reference/types/content#contenthooks)) fires on every content change. Subscribe with `content.hooks.hook(name, callback)`; handlers run in registration order.

| Hook                | When                                                                                                                                                                             | Payload                                                                                                                             |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `read:after`        | After a public read resolved (`get`, `list`, `navigation`, `query`, `search`, or your own)                                                                                       | [`ContentReadContext`](https://content.comark.dev/reference/types/content#contentreadcontext) with deterministic `tags`             |
| `file:parsed`       | After a file parser assembles a `ContentFile`                                                                                                                                    | [`FileParsedContext`](https://content.comark.dev/reference/types/file-parsed#fileparsedcontext) — mutate or null `file`             |
| `typegen:field`     | While generating source types                                                                                                                                                    | [`TypegenFieldContext`](https://content.comark.dev/reference/types/typegen#typegenfieldcontext) — override `type`, register imports |
| `file:upsert`       | After [`content.get()`](https://content.comark.dev/reference/content/get) parses, [`content.update()`](https://content.comark.dev/reference/content/update), or a watcher update | `(source, key, file)`                                                                                                               |
| `file:remove`       | After [`content.remove()`](https://content.comark.dev/reference/content/remove) or a watcher removal                                                                             | `(source, key)`                                                                                                                     |
| `file:bulk:load`    | After a source finishes parsing a batch                                                                                                                                          | `(source, files)`                                                                                                                   |
| `watch:file:update` | Raw watcher event before bookkeeping                                                                                                                                             | `(source, key, file)`                                                                                                               |
| `watch:file:remove` | Raw watcher event before bookkeeping                                                                                                                                             | `(source, key)`                                                                                                                     |
| `watch:error`       | A watched file failed to parse or validate                                                                                                                                       | `(source, key, error)`                                                                                                              |

See [`content.hooks`](https://content.comark.dev/reference/content/hooks) for the full event reference.

### Read events

A plugin that exposes a read method emits `read:after` for it, so a page cached from that read gets dependency tags like the built-in reads. Call `content.callReadHook(operation, input, result)` after the read resolved, never on failure:

```ts [related.ts]
setup(content) {
  return {
    related: async (path: string) => {
      const items = await findRelated(path)
      await content.callReadHook('related', { sources: [content.name] }, items)
      return items
    },
  }
}
```

The tags come out as `content`, `content:related`, and `content:source:<name>` for every source in `input.sources` and every `meta.source` (or top-level `source`) found in the result. A client plugin does the same through `client.callReadHook()` after its fetch resolves. The built-in [`sqlQuery`](https://content.comark.dev/plugins/built-in/sql-query) and [full-text search](https://content.comark.dev/plugins/built-in/full-text-search) plugins emit `query` and `search` this way.

## Lifecycle

Two registration methods on the plugin context tie your plugin into the instance's lifecycle:

- **`content.onDispose(fn)`**: runs when the instance is [disposed](https://content.comark.dev/reference/content/dispose). Release timers, connections, and subscriptions here. Callbacks run in reverse setup order.
- **`content.onClean(fn)`**: runs when the instance's persisted data is [deleted](https://content.comark.dev/reference/content/clean), before the cache namespace is cleared. Drop the state your plugin keeps in shared storage for this instance, scoped by `content.key` so other refs' state survives.

```ts [lifecycle.ts]
setup(content) {
  const table = content.key ? `__words_${content.key}` : '__words'
  const timer = setInterval(recount, 60_000)
  content.onDispose(() => clearInterval(timer))
  content.onClean(() => db.execute(`DROP TABLE IF EXISTS ${table}`))
}
```

The built-in [`sqlQuery`](https://content.comark.dev/plugins/built-in/sql-query) plugin drops its key-scoped tables on clean; [full-text search](https://content.comark.dev/plugins/built-in/full-text-search) deletes its rows from the shared table.


## Sitemap

See the full [sitemap](https://content.comark.dev/sitemap.md) for all pages.
