Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/content/4.plugins/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ These plugins are **enabled by default** whenever you call `parseMarkdown()` or
::
::

::callout{icon="i-lucide-lightbulb"}
Default plugins run in the order shown above. Plugins you pass through `plugins` run afterward in the order you provide them. If an explicit plugin has the same `name` as a default plugin, it replaces that default and runs in its explicit position. If you provide the same explicit plugin name more than once, only the first instance runs.
::

### Disable default plugins

Turn them all off with `registerDefaultPlugins: false`:
Expand All @@ -64,7 +68,7 @@ const result = await parseMarkdown(content, {
})
```

See also the [`registerDefaultPlugins` option](/reference/parse#options) on the Parse API.
See the [`registerDefaultPlugins` and `plugins` options](/reference/parse#options) on the Parse API.

## Plugins

Expand Down
4 changes: 2 additions & 2 deletions docs/content/5.reference/1.parse.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,8 @@ Both `parseMarkdown()` and `createMarkdownParser()` accept the same `ParserOptio
| `html` | `boolean` | `true` | **Deprecated** (warns). Prefer `registerDefaultPlugins: false` and register `html()` explicitly. `html: false` still skips the default html plugin. |
| `linkify` | `boolean` | `true` | Auto-convert URL-like text into links. Set `false` to disable |
| `headingIds` | `boolean` | `true` | Auto-generate `id` attributes for `h1`–`h6` headings. Set `false` to disable |
| `registerDefaultPlugins` | `boolean` | `true` | Register the built-in default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`). Set `false` to disable. Also can be used to configure default plugins like `components` in conjunction with `plugins` |
| `plugins` | `ComarkPlugin[]` | `[]` | Array of plugins to apply |
| `registerDefaultPlugins` | `boolean` | `true` | Register the built-in default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`). Set `false` to disable them. |
| `plugins` | `ComarkPlugin[]` | `[]` | Ordered plugins to run after the defaults. A same-name plugin replaces its default; duplicate explicit names keep the first instance. See [Default plugins](/plugins#default-plugins). |
| `tracer` | `ComarkTracer` | `undefined` | Timing recorder for the parse pipeline — see [Timing the parse](#timing-the-parse) |

### Timing the parse
Expand Down
3 changes: 1 addition & 2 deletions packages/comark/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ export function createMarkdownParser<const TPlugins extends readonly ComarkPlugi
)
}

// User plugins first so same-name entries override defaults via dedupePlugins.
const defaultPlugins =
options.registerDefaultPlugins !== false
? [
Expand All @@ -92,7 +91,7 @@ export function createMarkdownParser<const TPlugins extends readonly ComarkPlugi
]
: []

const plugins = dedupePlugins([...userPlugins, ...defaultPlugins])
const plugins = dedupePlugins(defaultPlugins, userPlugins)
const hasPlugin = (name: string) => plugins.some((plugin) => plugin.name === name)

const parser = new MarkdownExit({ linkify: options.linkify ?? true }).enable(['table', 'strikethrough'])
Expand Down
4 changes: 3 additions & 1 deletion packages/comark/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,9 @@ export interface ParserOptions<TPlugins extends readonly ComarkPlugin<any, any>[
registerDefaultPlugins?: boolean

/**
* Additional plugins to use
* Additional plugins to use. A plugin with the same name as a default plugin
* replaces that default and runs, in user-defined order, after the remaining defaults.
* Duplicate user plugins keep their first occurrence.
* @default []
*/
plugins?: TPlugins
Expand Down
35 changes: 23 additions & 12 deletions packages/comark/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,32 @@ export function createSerializedTask<TArgs extends unknown[], TResult>(
}

/**
* Remove duplicate plugins by name, keeping the first occurrence.
* Merge default and user plugins, deduplicating by name.
*
* User plugins replace same-name defaults and run after the remaining defaults.
* The default list is expected to contain unique names. The first user plugin
* with a given name wins.
*/
export function dedupePlugins(plugins: ComarkPlugin<any, any>[]): ComarkPlugin<any, any>[] {
const seen = new Set<string>()
const result: ComarkPlugin<any, any>[] = []

for (const plugin of plugins) {
if (seen.has(plugin.name)) {
continue
}
seen.add(plugin.name)
result.push(plugin)
export function dedupePlugins(
defaultPlugins: readonly ComarkPlugin<any, any>[],
userPlugins: readonly ComarkPlugin<any, any>[]
): ComarkPlugin<any, any>[] {
const plugins = new Map<string, ComarkPlugin<any, any>>()

for (const plugin of defaultPlugins) {
plugins.set(plugin.name, plugin)
}

const seenUserPlugins = new Set<string>()
for (const plugin of userPlugins) {
if (seenUserPlugins.has(plugin.name)) continue
seenUserPlugins.add(plugin.name)
// Reinsert overrides so they move from the default order to the user order.
plugins.delete(plugin.name)
plugins.set(plugin.name, plugin)
}

return result
return [...plugins.values()]
}

// #region define plugin
Expand Down
54 changes: 54 additions & 0 deletions packages/comark/test/plugins/default-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import type { ComarkPlugin } from '../../src/types'
import { parseMarkdown } from '../../src/parse'
import attributes from '../../src/plugins/attributes'
import components from '../../src/plugins/components'
Expand Down Expand Up @@ -121,6 +122,59 @@ describe('default plugin options', () => {
})
})

describe('post hook ordering', () => {
it('runs default normalizer post hooks before user post hooks', async () => {
let seen: unknown
const probe: ComarkPlugin = {
name: 'probe',
post(state) {
seen = structuredClone(state.tree.nodes)
},
}
await parseMarkdown('> [!NOTE]\n> hi', { plugins: [probe] })
// `alert` has already rewritten the blockquote when the user post hook runs.
expect(seen).toEqual([['blockquote', { as: 'note' }, 'hi']])
})

it('runs a user override in explicit plugin order after the remaining defaults', async () => {
const order: string[] = []
const probe: ComarkPlugin = { name: 'probe', post: () => void order.push('probe') }
const alertOverride: ComarkPlugin = { name: 'alert', post: () => void order.push('alert-override') }
await parseMarkdown('> [!NOTE]\n> hi', { plugins: [probe, alertOverride] })
expect(order).toEqual(['probe', 'alert-override'])
})

it('extracts frontmatter before user pre hooks run', async () => {
let seenMarkdown = ''
let seenFrontmatter: unknown
const probe: ComarkPlugin = {
name: 'probe',
pre(state) {
seenMarkdown = state.markdown
seenFrontmatter = { ...state.frontmatter }
},
}
const tree = await parseMarkdown('---\ntitle: Hello\n---\n\n# Hi', { plugins: [probe] })
// User pre hooks see the stripped body and the parsed frontmatter.
expect(seenMarkdown).not.toContain('title: Hello')
expect(seenMarkdown).toContain('# Hi')
expect(seenFrontmatter).toEqual({ title: 'Hello' })
expect(tree.frontmatter).toEqual({ title: 'Hello' })
})

it('preserves explicit registration order when registerDefaultPlugins is false', async () => {
const order: string[] = []
const probe: ComarkPlugin = { name: 'probe', post: () => void order.push('probe') }
const userAlert: ComarkPlugin = { name: 'alert', post: () => void order.push('alert') }
await parseMarkdown('> [!NOTE]\n> hi', {
registerDefaultPlugins: false,
plugins: [probe, userAlert],
})
// No defaults registered, so nothing is hoisted — the user's order rules.
expect(order).toEqual(['probe', 'alert'])
})
})

describe('user plugin override', () => {
it('keeps an explicit components plugin active with registerDefaultPlugins: false', async () => {
const tree = await parseMarkdown('::alert\nContent', {
Expand Down
2 changes: 1 addition & 1 deletion test/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => {
"@comark/react": "43.6k (74 files)",
"@comark/svelte": "43.9k (82 files)",
"@comark/vue": "60.5k (78 files)",
"comark": "422k (156 files)",
"comark": "423k (156 files)",
}
`)
})
Expand Down