From 7c05ef3b8a4af40398081529c736d0b872b9a4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 26 Aug 2026 12:58:53 +0200 Subject: [PATCH 1/6] fix(comark): run default normalizer post hooks before user post hooks User plugin post hooks previously ran before the default plugins' post hooks, so tree-consuming plugins saw an un-normalized tree (e.g. GFM alerts were still plain blockquotes because `alert` had not rewritten them into `['blockquote', { as }]` yet). Post hooks now run defaults first, then user plugins. Registration order is unchanged: user plugins still come first so same-name entries override defaults via dedupePlugins, and a user override keeps the default's execution slot. `pre` hooks and `markdownItPlugins` keep their existing order. --- packages/comark/src/parse.ts | 14 ++++++++++- .../test/plugins/default-plugins.test.ts | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index 9fda91e9..f882d931 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -32,6 +32,9 @@ export { parseFrontmatter } from './internal/frontmatter.ts' // Re-export plugin utilities export { defineComarkPlugin } from './utils/helpers.ts' +/** Names of the default plugins registered by `registerDefaultPlugins` (see below). */ +const DEFAULT_PLUGIN_NAMES = new Set(['frontmatter', 'html', 'alert', 'task-list', 'components', 'attributes']) + /** * Creates a parser function for Comark content. * @@ -95,6 +98,15 @@ export function createMarkdownParser plugins.some((plugin) => plugin.name === name) + // Default normalizer `post` hooks run before user `post` hooks so user plugins always see + // the normalized tree (e.g. `alert` has rewritten `> [!note]` into `['blockquote', { as }]`). + // A user plugin that overrides a default by name keeps that default's slot. `pre` hooks and + // `markdownItPlugins` keep registration order: user plugins first. + const postPlugins = [ + ...plugins.filter((plugin) => DEFAULT_PLUGIN_NAMES.has(plugin.name)), + ...plugins.filter((plugin) => !DEFAULT_PLUGIN_NAMES.has(plugin.name)), + ] + const parser = new MarkdownExit({ linkify: options.linkify ?? true }).enable(['table', 'strikethrough']) for (const plugin of plugins) { @@ -206,7 +218,7 @@ export function createMarkdownParser plugin.post!(state as ComarkParsePostState)) } diff --git a/packages/comark/test/plugins/default-plugins.test.ts b/packages/comark/test/plugins/default-plugins.test.ts index 2db2dbaf..5a9f1739 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,29 @@ 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('keeps the default slot for a user plugin that overrides a default by name', 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(['alert-override', 'probe']) + }) + }) + describe('user plugin override', () => { it('keeps an explicit components plugin active with registerDefaultPlugins: false', async () => { const tree = await parseMarkdown('::alert\nContent', { From 8b29e6a1432f761e9a9e8a08f7beac47cff96416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 26 Aug 2026 16:03:16 +0200 Subject: [PATCH 2/6] refactor(comark): derive default post-hook names from defaultPlugins Deriving the hoisted names from the registered defaultPlugins array instead of a hardcoded set removes the duplicated list and makes the hoisting a natural no-op with registerDefaultPlugins: false, where the user's explicit registration order rules. Adds a test for that case. --- packages/comark/src/parse.ts | 13 ++++++------- .../comark/test/plugins/default-plugins.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index f882d931..ad9398d5 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -32,9 +32,6 @@ export { parseFrontmatter } from './internal/frontmatter.ts' // Re-export plugin utilities export { defineComarkPlugin } from './utils/helpers.ts' -/** Names of the default plugins registered by `registerDefaultPlugins` (see below). */ -const DEFAULT_PLUGIN_NAMES = new Set(['frontmatter', 'html', 'alert', 'task-list', 'components', 'attributes']) - /** * Creates a parser function for Comark content. * @@ -100,11 +97,13 @@ export function createMarkdownParser [!note]` into `['blockquote', { as }]`). - // A user plugin that overrides a default by name keeps that default's slot. `pre` hooks and - // `markdownItPlugins` keep registration order: user plugins first. + // A user plugin that overrides a default by name keeps that default's slot. With + // `registerDefaultPlugins: false` this is a no-op and explicit registration order rules. + // `pre` hooks and `markdownItPlugins` keep registration order: user plugins first. + const defaultPluginNames = new Set(defaultPlugins.map((plugin) => plugin.name)) const postPlugins = [ - ...plugins.filter((plugin) => DEFAULT_PLUGIN_NAMES.has(plugin.name)), - ...plugins.filter((plugin) => !DEFAULT_PLUGIN_NAMES.has(plugin.name)), + ...plugins.filter((plugin) => defaultPluginNames.has(plugin.name)), + ...plugins.filter((plugin) => !defaultPluginNames.has(plugin.name)), ] const parser = new MarkdownExit({ linkify: options.linkify ?? true }).enable(['table', 'strikethrough']) diff --git a/packages/comark/test/plugins/default-plugins.test.ts b/packages/comark/test/plugins/default-plugins.test.ts index 5a9f1739..08a6bea6 100644 --- a/packages/comark/test/plugins/default-plugins.test.ts +++ b/packages/comark/test/plugins/default-plugins.test.ts @@ -143,6 +143,18 @@ describe('default plugin options', () => { await parseMarkdown('> [!NOTE]\n> hi', { plugins: [probe, alertOverride] }) expect(order).toEqual(['alert-override', 'probe']) }) + + 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', () => { From 31fc1f52ec70ba64b11454dba64841f7c6a1544e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 26 Aug 2026 16:11:29 +0200 Subject: [PATCH 3/6] fix(comark): run default pre hooks before user pre hooks too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same rationale as the post-hook ordering: among the defaults only frontmatter has a pre hook, and running it first gives user pre hooks a normalized contract — state.markdown without the frontmatter block, state.frontmatter and state.frontmatterText already populated — instead of raw text a rewriting plugin could corrupt. One partitioned list now drives both lifecycle phases; markdownItPlugins keep registration order. --- packages/comark/src/parse.ts | 17 +++++++++-------- .../test/plugins/default-plugins.test.ts | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index ad9398d5..633daa7a 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -95,13 +95,14 @@ export function createMarkdownParser plugins.some((plugin) => plugin.name === name) - // Default normalizer `post` hooks run before user `post` hooks so user plugins always see - // the normalized tree (e.g. `alert` has rewritten `> [!note]` into `['blockquote', { as }]`). - // A user plugin that overrides a default by name keeps that default's slot. With - // `registerDefaultPlugins: false` this is a no-op and explicit registration order rules. - // `pre` hooks and `markdownItPlugins` keep registration order: user plugins first. + // Default normalizer hooks run before user hooks in every lifecycle phase, so user plugins + // always see normalized input: `frontmatter` has stripped and parsed the frontmatter block + // before user `pre` hooks, and `alert` has rewritten `> [!note]` into `['blockquote', { as }]` + // before user `post` hooks. A user plugin that overrides a default by name keeps that + // default's slot. With `registerDefaultPlugins: false` this is a no-op and explicit + // registration order rules. `markdownItPlugins` keep registration order: user plugins first. const defaultPluginNames = new Set(defaultPlugins.map((plugin) => plugin.name)) - const postPlugins = [ + const hookPlugins = [ ...plugins.filter((plugin) => defaultPluginNames.has(plugin.name)), ...plugins.filter((plugin) => !defaultPluginNames.has(plugin.name)), ] @@ -160,7 +161,7 @@ export function createMarkdownParser plugin.pre!(state)) } @@ -217,7 +218,7 @@ export function createMarkdownParser plugin.post!(state as ComarkParsePostState)) } diff --git a/packages/comark/test/plugins/default-plugins.test.ts b/packages/comark/test/plugins/default-plugins.test.ts index 08a6bea6..aeaea0fc 100644 --- a/packages/comark/test/plugins/default-plugins.test.ts +++ b/packages/comark/test/plugins/default-plugins.test.ts @@ -144,6 +144,24 @@ describe('default plugin options', () => { expect(order).toEqual(['alert-override', 'probe']) }) + 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') } From ebb1dd1f1f702025aee316d3b672078e1adf77ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 26 Aug 2026 16:52:31 +0200 Subject: [PATCH 4/6] chore: ready --- docs/content/4.plugins/index.md | 4 ++- docs/content/5.reference/1.parse.md | 4 +-- packages/comark/src/parse.ts | 19 ++-------- packages/comark/src/types.ts | 4 ++- packages/comark/src/utils/helpers.ts | 35 ++++++++++++------- .../test/plugins/default-plugins.test.ts | 4 +-- 6 files changed, 36 insertions(+), 34 deletions(-) diff --git a/docs/content/4.plugins/index.md b/docs/content/4.plugins/index.md index 0d739c9b..73488043 100644 --- a/docs/content/4.plugins/index.md +++ b/docs/content/4.plugins/index.md @@ -38,6 +38,8 @@ These plugins are **enabled by default** whenever you call `parseMarkdown()` or :: :: +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 +66,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 633daa7a..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) - // Default normalizer hooks run before user hooks in every lifecycle phase, so user plugins - // always see normalized input: `frontmatter` has stripped and parsed the frontmatter block - // before user `pre` hooks, and `alert` has rewritten `> [!note]` into `['blockquote', { as }]` - // before user `post` hooks. A user plugin that overrides a default by name keeps that - // default's slot. With `registerDefaultPlugins: false` this is a no-op and explicit - // registration order rules. `markdownItPlugins` keep registration order: user plugins first. - const defaultPluginNames = new Set(defaultPlugins.map((plugin) => plugin.name)) - const hookPlugins = [ - ...plugins.filter((plugin) => defaultPluginNames.has(plugin.name)), - ...plugins.filter((plugin) => !defaultPluginNames.has(plugin.name)), - ] - const parser = new MarkdownExit({ linkify: options.linkify ?? true }).enable(['table', 'strikethrough']) for (const plugin of plugins) { @@ -161,7 +148,7 @@ export function createMarkdownParser plugin.pre!(state)) } @@ -218,7 +205,7 @@ export function createMarkdownParser plugin.post!(state as ComarkParsePostState)) } 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 aeaea0fc..d47605e5 100644 --- a/packages/comark/test/plugins/default-plugins.test.ts +++ b/packages/comark/test/plugins/default-plugins.test.ts @@ -136,12 +136,12 @@ describe('default plugin options', () => { expect(seen).toEqual([['blockquote', { as: 'note' }, 'hi']]) }) - it('keeps the default slot for a user plugin that overrides a default by name', async () => { + 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(['alert-override', 'probe']) + expect(order).toEqual(['probe', 'alert-override']) }) it('extracts frontmatter before user pre hooks run', async () => { From d95e87104257f7ef4fac6399ea4857278438eb92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 26 Aug 2026 16:55:25 +0200 Subject: [PATCH 5/6] docs: callout is better --- docs/content/4.plugins/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/content/4.plugins/index.md b/docs/content/4.plugins/index.md index 73488043..819c76fe 100644 --- a/docs/content/4.plugins/index.md +++ b/docs/content/4.plugins/index.md @@ -38,7 +38,9 @@ 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 From 42d1d046ae1b1cfb4de57ee41ca29276242208bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:02:47 +0000 Subject: [PATCH 6/6] test: update bundle size snapshot --- test/bundle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)", } `) })