diff --git a/docs/content/4.plugins/index.md b/docs/content/4.plugins/index.md index 0d739c9b..819c76fe 100644 --- a/docs/content/4.plugins/index.md +++ b/docs/content/4.plugins/index.md @@ -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`: @@ -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 diff --git a/docs/content/5.reference/1.parse.md b/docs/content/5.reference/1.parse.md index e7a577dc..299214b8 100644 --- a/docs/content/5.reference/1.parse.md +++ b/docs/content/5.reference/1.parse.md @@ -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 diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index 9fda91e9..c9f6cdd8 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -78,7 +78,6 @@ export function createMarkdownParser plugins.some((plugin) => plugin.name === name) const parser = new MarkdownExit({ linkify: options.linkify ?? true }).enable(['table', 'strikethrough']) diff --git a/packages/comark/src/types.ts b/packages/comark/src/types.ts index 1810282e..12b8e2bb 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -499,7 +499,9 @@ export interface ParserOptions[ 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 diff --git a/packages/comark/src/utils/helpers.ts b/packages/comark/src/utils/helpers.ts index 83cc72ca..4f06893c 100644 --- a/packages/comark/src/utils/helpers.ts +++ b/packages/comark/src/utils/helpers.ts @@ -15,21 +15,32 @@ export function createSerializedTask( } /** - * 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[]): ComarkPlugin[] { - const seen = new Set() - const result: ComarkPlugin[] = [] - - for (const plugin of plugins) { - if (seen.has(plugin.name)) { - continue - } - seen.add(plugin.name) - result.push(plugin) +export function dedupePlugins( + defaultPlugins: readonly ComarkPlugin[], + userPlugins: readonly ComarkPlugin[] +): ComarkPlugin[] { + const plugins = new Map>() + + for (const plugin of defaultPlugins) { + plugins.set(plugin.name, plugin) + } + + const seenUserPlugins = new Set() + 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 diff --git a/packages/comark/test/plugins/default-plugins.test.ts b/packages/comark/test/plugins/default-plugins.test.ts index 2db2dbaf..d47605e5 100644 --- a/packages/comark/test/plugins/default-plugins.test.ts +++ b/packages/comark/test/plugins/default-plugins.test.ts @@ -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' @@ -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', { diff --git a/test/bundle.test.ts b/test/bundle.test.ts index efc3a4ca..dd140bb3 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -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)", } `) })