Plugins

Creating Plugins

Learn how to create custom Comark plugins, use existing markdown-it plugins, and transform the AST.

Comark plugins let you extend the parser with new syntax and transform the resulting AST. Every plugin is a simple object that conforms to the ComarkPlugin interface.

Plugin Interface

Use the defineComarkPlugin helper to create plugins with full type safety and editor autocomplete:

import { defineComarkPlugin } from 'comark/parse'export default defineComarkPlugin(() => ({  name: 'my-plugin',  markdownItPlugins: [],  // Optional: extend the markdown-it parser  pre(state) {},           // Optional: run before parsing  post(state) {},          // Optional: run after parsing}))
PropertyDescription
nameA unique identifier for the plugin
markdownItPluginsArray of markdown-it plugins to register on the parser
preHook called before markdown is tokenized. Receives ComarkParsePreState
postHook called after the AST is built. Receives ComarkParsePostState

defineComarkPlugin wraps the entire factory function, providing type safety for both the options and the returned plugin:

import { defineComarkPlugin } from 'comark/parse'export default defineComarkPlugin((options: MyOptions = {}) => ({  name: 'my-plugin',  // ...}))

Plugin Lifecycle

Plugins hook into two phases of the parsing pipeline:

Markdown string┌──────────┐│   pre()  │  ← Modify raw markdown before parsing└──────────┘  Parse & Build ComarkTree AST┌──────────┐│  post()  │  ← Transform the AST after parsing└──────────┘  ComarkTree

Pre Hook

The pre hook runs before tokenization. Use it to transform the raw markdown string.

import { defineComarkPlugin } from 'comark/parse'export default defineComarkPlugin(() => ({  name: 'remove-comments',  pre(state) {    // state.markdown — the raw markdown input    state.markdown = state.markdown.replace(/<!--[\s\S]*?-->/g, '')  },}))

ComarkParsePreState:

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

Post Hook

The post hook runs after the AST is built. Use it to traverse and transform nodes, or to 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:

FieldTypeDescription
markdownstringThe original markdown input
treeComarkTreeThe parsed AST — modify it to transform output
optionsParseOptionsThe parser configuration
tokensunknown[]The raw markdown-it tokens

Markdown-it Compatibility

Comark's default parser is built on top of markdown-it (via markdown-exit, a maintained fork). This means most markdown-it plugins work with Comark with minimal effort.

When you provide markdownItPlugins in a Comark plugin, they are registered directly on the underlying markdown-it instance. This gives them full access to the markdown-it API, including:

  • Inline rulesmd.inline.ruler
  • Block rulesmd.block.ruler
  • Core rulesmd.core.ruler
  • Renderer rulesmd.renderer.rules (limited use — Comark uses its own rendering pipeline)

Using Existing markdown-it Plugins

To use an existing markdown-it plugin, wrap it with defineComarkPlugin:

import { defineComarkPlugin } from 'comark/parse'import markdownItSub from 'markdown-it-sub'import markdownItSup from 'markdown-it-sup'const subscript = defineComarkPlugin(() => ({  name: 'subscript',  markdownItPlugins: [markdownItSub],}))const superscript = defineComarkPlugin(() => ({  name: 'superscript',  markdownItPlugins: [markdownItSup],}))

Then pass them to parse() or the <Comark> component:

import { parse } from 'comark'const tree = await parse('H~2~O is water, 2^10^ is 1024', {  plugins: [subscript(), superscript()]})

What Works

Most markdown-it plugins that add parsing rules (inline, block, or core) work out of the box:

  • Plugins that add new inline syntax (e.g., subscript, superscript, mark, insert)
  • Plugins that add new block syntax (e.g., containers, definition lists, footnotes)
  • Plugins that transform tokens via core rules (e.g., abbreviations, replacements)

What Doesn't Work

Plugins that rely on markdown-it's renderer will not work as expected, because Comark uses its own rendering pipeline instead of md.renderer:

  • Plugins that only modify md.renderer.rules to change HTML output — their rendering logic will be ignored
  • Plugins that depend on renderer state or environment variables set during rendering

If a plugin only customizes rendering, you can handle the rendering side in Comark using custom components:

<script setup lang="ts">import { Comark } from '@comark/vue'// Register a custom component for the element the plugin producesconst components = {  mark: (props, { slots }) => h('mark', { class: 'highlight' }, slots.default?.())}</script><template>  <Comark :components="components">==highlighted text==</Comark></template>

markdown-exit vs markdown-it

Comark uses markdown-exit (a fork of markdown-it) as its base parser. The fork maintains API compatibility with markdown-it, so plugins written for markdown-it work without modification. The type MarkdownItPlugin accepted by Comark is:

type MarkdownItPlugin = (md: MarkdownIt) => void

This is the standard markdown-it plugin signature — a function that receives the markdown-it instance and modifies it.

Creating a markdown-it Parser Plugin

To add new syntax, write a markdown-it plugin and include it in markdownItPlugins. Here's a complete example that adds ==highlighted text== syntax:

import type { MarkdownItPlugin } from 'comark'import { defineComarkPlugin } from 'comark/parse'const highlightRule = (state: any, silent: boolean) => {  const start = state.pos  const max = state.posMax  // Must start with '=='  if (start + 1 >= max) return false  if (state.src.charCodeAt(start) !== 0x3D /* = */) return false  if (state.src.charCodeAt(start + 1) !== 0x3D /* = */) return false  // Find closing '=='  let pos = start + 2  while (pos + 1 < max) {    if (state.src.charCodeAt(pos) === 0x3D && state.src.charCodeAt(pos + 1) === 0x3D) {      if (!silent) {        const token = state.push('mark_open', 'mark', 1)        token.markup = '=='        state.pos = start + 2        state.posMax = pos        state.md.inline.tokenize(state)        state.push('mark_close', 'mark', -1)        state.posMax = max      }      state.pos = pos + 2      return true    }    pos++  }  return false}const markdownItHighlight: MarkdownItPlugin = (md) => {  md.inline.ruler.before('emphasis', 'mark', highlightRule)}export default defineComarkPlugin(() => ({  name: 'highlight',  markdownItPlugins: [markdownItHighlight],}))

The markdown-it plugin adds a parsing rule, and Comark automatically converts the resulting tokens into AST nodes. In this case, ==hello== becomes:

["mark", {}, "hello"]

Creating an AST Transform Plugin

AST transform plugins use the post hook and the visit() utility to traverse and modify nodes.

The visit() Utility

import { visit } from 'comark/utils'visit(tree, checker, visitor)
ParameterTypeDescription
checker(node: ComarkNode) => booleanReturn true for nodes to process
visitor(node: ComarkNode) => ComarkNode | false | voidTransform the node

The visitor can:

  • Return nothing — leave the node unchanged
  • Return a new node — replace the node
  • Return false — remove the node from the tree

Example: Auto-linking URLs

import type { ComarkNode } from 'comark'import { defineComarkPlugin } from 'comark/parse'import { visit } from 'comark/utils'export default defineComarkPlugin(() => ({  name: 'auto-link',  post(state) {    const urlPattern = /https?:\/\/[^\s]+/g    visit(state.tree,      (node) => typeof node === 'string',      (node) => {        const text = node as string        if (!urlPattern.test(text)) return        // Replace text node with a link element        return ['a', { href: text }, text] as ComarkNode      }    )  },}))

Example: Adding Classes to Elements

import { defineComarkPlugin } from 'comark/parse'import { visit } from 'comark/utils'export default defineComarkPlugin(() => ({  name: 'styled-tables',  post(state) {    visit(state.tree,      (node) => Array.isArray(node) && node[0] === 'table',      (node) => {        const el = node as [string, Record<string, any>, ...any[]]        el[1].class = [el[1].class, 'styled-table'].filter(Boolean).join(' ')      }    )  },}))

The ComarkTree Structure

Understanding the AST is key to writing transform plugins.

interface ComarkTree {  nodes: ComarkNode[]                // The AST nodes  frontmatter: Record<string, any>   // Parsed YAML frontmatter  meta: Record<string, any>          // Plugin-contributed metadata}

Nodes come in three forms:

// Text node — a plain string"Hello world"// Element node — [tag, attributes, ...children]["strong", {}, "bold text"]// Comment node — [null, {}, content][null, {}, "this is a comment"]

Elements can be nested:

// <p>Hello <strong>world</strong></p>["p", {}, "Hello ", ["strong", {}, "world"]]

Using Your Plugin

import { parse } from 'comark'import myPlugin from './my-plugin'const tree = await parse(content, {  plugins: [myPlugin()]})

Or with framework components:

<script setup lang="ts">import { Comark } from '@comark/vue'import myPlugin from './my-plugin'</script><template>  <Comark :plugins="[myPlugin()]">{{ content }}</Comark></template>
Copyright © 2026