Custom

Plugin API

Type-safe API for creating Comark plugins with pre/post lifecycle hooks.

The comark/parse module exports defineComarkPlugin — a typed factory wrapper for building plugins that extend the parser.

defineComarkPlugin(factory)

Wraps a plugin factory function to provide type safety for both options and the returned plugin.

Parameters:

  • factory - A function (options?: O) => ComarkPlugin where O is an optional options type. When the plugin is instantiated, options receives the values passed by the caller. See ComarkPlugin for the full shape of the returned object.

Type parameters:

ParameterDefaultDescription
OptionsunknownShape of the options the factory accepts.
TMeta{}Keys this plugin contributes to tree.meta. Surfaces on the parse result and constrains writes inside post.
TFrontmatter{}Keys this plugin contributes to tree.frontmatter. This is experimental and may change in future versions.

Returns: A typed plugin factory (options?: O) => ComarkPlugin<TMeta, TFrontmatter>

Example:

import { defineComarkPlugin } from 'comark/parse'

export default defineComarkPlugin(() => ({
  name: 'my-plugin',
  pre(state) {},
  post(state) {},
}))

With typed options:

import { defineComarkPlugin } from 'comark/parse'

interface MyOptions {
  prefix?: string
}

export default defineComarkPlugin((options: MyOptions = {}) => ({
  name: 'my-plugin',
  post(state) {
    // options.prefix is typed
  },
}))

Typed contributions

Plugins that write to tree.meta can declare what they contribute via the second and third type parameters of defineComarkPlugin. Two things follow from that declaration:

  1. Inside post(state), state.tree.meta and state.tree.frontmatter are typed as the declared shape, so writes are checked against it.
  2. At the parse callsite, the contribution is intersected into the resulting tree.meta / tree.frontmatterresult.meta.toc reads back as the declared type instead of any.

Declaring a contribution

import { defineComarkPlugin } from 'comark/parse'

interface TocTree {
  title: string
  depth: number
  links: { id: string; text: string }[]
}

export default defineComarkPlugin<{}, { toc: TocTree }>(() => ({
  name: 'toc',
  post(state) {
    state.tree.meta.toc = { title: 'Contents', depth: 2, links: [] }

    // @ts-expect-error — wrong value type for the declared key
    state.tree.meta.toc = 123

    // @ts-expect-error — `summary` was not declared in the contribution
    state.tree.meta.summary = []
  },
}))

A plugin that contributes more than one key lists them all in the same object literal:

defineComarkPlugin<HeadingsOptions, { title?: string; description?: string }>(() => ({
  name: 'headings',
  post(state) {
    state.tree.meta.title = 'Hello'
    state.tree.meta.description = undefined
  },
}))

Inference at the parse callsite

parse and createParse infer the plugins tuple and intersect each plugin's contribution into the result type. No explicit generic argument is needed:

import { parse } from 'comark'
import toc from 'comark/plugins/toc'
import summary from 'comark/plugins/summary'

const tree = await parse(content, { plugins: [toc(), summary()] })

tree.meta.toc       // Toc
tree.meta.summary   // ComarkNode[]
tree.meta.something // unknown — undeclared keys are typed as unknown

Back-compat for unannotated plugins

Plugins that omit the meta/frontmatter type parameters keep the pre-typed free-form behavior — state.tree.meta is Record<string, any> and any key can be written. The result type on the parse callsite also stays Record<string, any> when no plugin declared a contribution (or when plugins is passed as a widened ComarkPlugin[] variable rather than an inline array).

// Old style — still works, no narrowing
defineComarkPlugin(() => ({
  name: 'wordcount',
  post(state) {
    state.tree.meta.wordCount = 42 // ok — no declared contribution
  },
}))

Lifecycle

Plugins hook into two phases of the parsing pipeline:

Markdown string


┌──────────┐
│   pre()  │  ← Modify raw markdown before parsing
└──────────┘


  Parse & Build ComarkTree


┌──────────┐
│  post()  │  ← Transform the AST after parsing
└──────────┘


  ComarkTree

pre(state)

Called before markdown is tokenized. Use it to transform the raw markdown string.

import { defineComarkPlugin } from 'comark/parse'

export default defineComarkPlugin(() => ({
  name: 'strip-comments',
  pre(state) {
    state.markdown = state.markdown.replace(/<!--[\s\S]*?-->/g, '')
  },
}))

ComarkParsePreState:

FieldTypeDescription
markdownstringThe raw markdown — modify to change parser input
optionsParseOptionsThe parser configuration

post(state)

Called after the AST is built. Use it to traverse nodes or populate meta with extracted data.

import { defineComarkPlugin } from 'comark/parse'
import { visit } from 'comark/utils'

export default defineComarkPlugin(() => ({
  name: 'word-count',
  post(state) {
    let count = 0
    visit(state.tree,
      (node) => typeof node === 'string',
      (node) => { count += (node as string).split(/\s+/).filter(Boolean).length }
    )
    state.tree.meta.wordCount = count
  },
}))

ComarkParsePostState<TMeta, TFrontmatter>:

FieldTypeDescription
markdownstringThe original markdown input
treeComarkTree<TMeta, TFrontmatter>The parsed AST — modify to transform output. tree.meta / tree.frontmatter are typed against the plugin's declared contribution.
optionsParseOptionsThe parser configuration
tokensunknown[]The raw markdown-it tokens
To traverse and transform tree nodes, see the AST API page for the visit() utility.

ComarkPlugin<TMeta, TFrontmatter>

PropertyTypeDescription
namestringA unique identifier for the plugin
markdownItPluginsMarkdownItPlugin[]markdown-it plugins to register on the parser — see Markdown-it Plugins
pre(state: ComarkParsePreState) => voidHook called before parsing
post(state: ComarkParsePostState<TMeta, TFrontmatter>) => voidHook called after the AST is built. state.tree.meta / state.tree.frontmatter are typed as the declared contribution — see Typed contributions.

Usage

Pass plugins to parse() or the <Comark> component:

import { parse } from 'comark'
import myPlugin from './my-plugin'

const tree = await parse(content, {
  plugins: [myPlugin()]
})