From 1a12d127f3c2066e005f2df47d787bddee732c81 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Thu, 10 Sep 2026 12:35:34 +0200 Subject: [PATCH 01/13] perf: share one parser across callers with equivalent options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building a parser runs six default plugin factories and registers them on a fresh markdown-it instance. On a page with 176 short documents that is 38ms of the 46ms total, and every `` instance paid it: Vue built one per instance in `setup`, and React and Svelte built one per parse. `getMarkdownParser(options)` returns a parser shared by every caller with equivalent options, keyed structurally on primitives and by identity on `plugins`, `autoClose`, `tracer` and `cache`. `parseMarkdown` goes through it, which is what fixes React and Svelte with no framework changes. `createMarkdownParser` is unchanged and still builds a fresh parser. 176 short documents time memory parser per document 38.07ms 9.17mb shared parser 1.65ms 5.24mb shared parser, cached 15.08µs 57.34kb Adds an opt-in `cache` option, off by default because a parser usually outlives a request on the server and retained documents would be invisible to the caller. It keys on the source alone, holds the promise so concurrent callers share one parse, never caches a streaming parse, and evicts LRU rather than clearing. Every framework component now gives a streaming instance its own parser and shares one otherwise. Streaming keeps incremental state on the parser and every non-streaming parse resets it, so sharing would both let two streams collide and let any non-streaming parse silently defeat incremental reuse. Each also takes a `parser` prop for callers who want to own it. `createSerializedTask` no longer swallows rejections. A failed parse resolved to `null`, which rendered an empty document with nothing in the console. --- AGENTS.md | 3 +- benchmarks/comark-parser-reuse.ts | 31 +++++ docs/content/3.rendering/3.vue.md | 6 + docs/content/3.rendering/4.nuxt.md | 1 + docs/content/3.rendering/5.react.md | 2 + docs/content/3.rendering/6.svelte.md | 1 + docs/content/3.rendering/7.angular.md | 1 + docs/content/5.reference/1.parse.md | 59 +++++++++- .../src/components/markdown.component.ts | 32 ++++-- .../comark-react/src/components/Markdown.tsx | 12 +- .../src/components/MarkdownClient.tsx | 31 ++++- .../src/async/MarkdownAsync.svelte | 16 ++- .../src/components/Markdown.svelte | 15 ++- packages/comark-svelte/src/types.ts | 7 +- .../comark-vue/src/components/Markdown.ts | 61 +++++++--- packages/comark-vue/test/parser-reuse.test.ts | 81 +++++++++++++ packages/comark/src/internal/parse/cache.ts | 63 +++++++++++ .../comark/src/internal/parse/parser-key.ts | 47 ++++++++ packages/comark/src/parse.ts | 77 ++++++++++++- packages/comark/src/types.ts | 29 +++++ packages/comark/src/utils/helpers.ts | 9 +- packages/comark/test/parse-cache.test.ts | 106 ++++++++++++++++++ packages/comark/test/parser-registry.test.ts | 87 ++++++++++++++ packages/comark/test/serialized-task.test.ts | 36 ++++++ test/bundle.test.ts | 10 +- 25 files changed, 774 insertions(+), 49 deletions(-) create mode 100644 benchmarks/comark-parser-reuse.ts create mode 100644 packages/comark-vue/test/parser-reuse.test.ts create mode 100644 packages/comark/src/internal/parse/cache.ts create mode 100644 packages/comark/src/internal/parse/parser-key.ts create mode 100644 packages/comark/test/parse-cache.test.ts create mode 100644 packages/comark/test/parser-registry.test.ts create mode 100644 packages/comark/test/serialized-task.test.ts diff --git a/AGENTS.md b/AGENTS.md index f4c80c7d..c499c9a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -382,7 +382,7 @@ import mermaid, { Mermaid } from '@comark/angular/plugins/mermaid' ```typescript // Core parsing -import { parseMarkdown, autoCloseMarkdown } from 'comark' +import { parseMarkdown, getMarkdownParser, createMarkdownParser, autoCloseMarkdown } from 'comark' // HTML rendering (parse + render in one step) import { createHtmlRenderer, renderHtml, renderHtmlFromDocument } from '@comark/html' @@ -520,6 +520,7 @@ const result = await parseMarkdown(markdownContent, { autoClose: true, // Auto-close incomplete syntax; also accepts (markdown) => string unwrap: 'p', // Strip top-level wrapper tags (MDC unwrap); merges paragraphs registerDefaultPlugins: true, // frontmatter, html, alert, task-list, components, attributes; false to disable + cache: false, // memoize by source per parser; true is a bounded LRU of 200 }) result.nodes // Node[] diff --git a/benchmarks/comark-parser-reuse.ts b/benchmarks/comark-parser-reuse.ts new file mode 100644 index 00000000..544639de --- /dev/null +++ b/benchmarks/comark-parser-reuse.ts @@ -0,0 +1,31 @@ +import { barplot, bench, group, run } from 'mitata' +import { createMarkdownParser, getMarkdownParser } from '../packages/comark/src/parse.ts' + +// Shaped after a component documentation page: many short documents, each one a +// prop or slot description, rendered by its own component instance. +const DOCUMENTS = Array.from( + { length: 176 }, + (_, i) => `Some **description** with \`code\` and a [link](https://example.dev) #${i}` +) + +async function parseAll(parse: (markdown: string) => Promise) { + for (const document of DOCUMENTS) await parse(document) +} + +barplot(() => { + group('176 short documents', () => { + bench('parser per document', async () => { + for (const document of DOCUMENTS) await createMarkdownParser()(document) + }) + + bench('shared parser', async () => { + await parseAll(getMarkdownParser()) + }) + + bench('shared parser, cached', async () => { + await parseAll(getMarkdownParser({ cache: true })) + }) + }) +}) + +await run() diff --git a/docs/content/3.rendering/3.vue.md b/docs/content/3.rendering/3.vue.md index c8426cd9..e5c8aeb1 100644 --- a/docs/content/3.rendering/3.vue.md +++ b/docs/content/3.rendering/3.vue.md @@ -111,6 +111,7 @@ Passing a document to `` skips parsing at runtime, but the **parser is | `value` | `string \| MarkdownDocument` | `undefined` | Markdown string or pre-parsed document (alternative to default slot) | | [`options`](#code-markdown-props-code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom Vue component mappings | | [`componentsManifest`](#code-markdown-props-code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver | @@ -351,6 +352,7 @@ import { AppMarkdown } from './markdown' | `linkify` | `boolean` | `true` | Auto-convert URL-like text into links | | `registerDefaultPlugins` | `boolean` | `true` | Register default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom Vue component mappings | | `class` | `string` | `undefined` | Additional CSS classes for the wrapper div | @@ -399,6 +401,10 @@ export const CommentMarkdown = defineMarkdownComponent({ --- +::note +Many `` on one page share a parser automatically when their configuration matches, so you do not need to hoist parsing out yourself for performance. Pass plugin instances from a stable reference rather than creating them inline, or each instance gets its own parser. See [`getMarkdownParser()`](/reference/parse#code-getmarkdownparseroptions-code). +:: + ## `` Renders a pre-parsed `MarkdownDocument` without any parsing. Use it when you parse on the server, in a build step, or via an API, so no parser or plugin code is shipped to the browser. diff --git a/docs/content/3.rendering/4.nuxt.md b/docs/content/3.rendering/4.nuxt.md index c86cb635..d671df9a 100644 --- a/docs/content/3.rendering/4.nuxt.md +++ b/docs/content/3.rendering/4.nuxt.md @@ -99,6 +99,7 @@ Pass markdown via the default slot or the `value` prop: | `value` | `string \| MarkdownDocument` | `undefined` | Markdown string or pre-parsed document (alternative to default slot) | | [`options`](#code-markdown-props-code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom Vue component mappings | | [`componentsManifest`](#code-markdown-props-code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver | diff --git a/docs/content/3.rendering/5.react.md b/docs/content/3.rendering/5.react.md index 9ec2deb1..a4fc9fb0 100644 --- a/docs/content/3.rendering/5.react.md +++ b/docs/content/3.rendering/5.react.md @@ -98,6 +98,7 @@ Passing a document to `` skips parsing at runtime, but the **parser is | `value` | `string \| MarkdownDocument` | `''` | Markdown string or pre-parsed document (alternative to children) | | [`options`](#code-markdown-props-code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom React component mappings | | [`componentsManifest`](#code-markdown-props-code-componentsmanifest) | `(name: string) => Promise` | `undefined` | Dynamic component resolver | @@ -312,6 +313,7 @@ export default function App() { | `linkify` | `boolean` | `true` | Auto-convert URL-like text into links | | `registerDefaultPlugins` | `boolean` | `true` | Register default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom React component mappings | | `className` | `string` | `undefined` | Additional CSS classes for the wrapper div | diff --git a/docs/content/3.rendering/6.svelte.md b/docs/content/3.rendering/6.svelte.md index a09a9102..e1c9a421 100644 --- a/docs/content/3.rendering/6.svelte.md +++ b/docs/content/3.rendering/6.svelte.md @@ -90,6 +90,7 @@ Passing a document to `` skips parsing at runtime, but the **parser is | `value` | `string \| MarkdownDocument` | `''` | Markdown string or pre-parsed document | | [`options`](#code-markdown-props-code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom Svelte component mappings | | [`componentsManifest`](#code-markdown-props-code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver | diff --git a/docs/content/3.rendering/7.angular.md b/docs/content/3.rendering/7.angular.md index 5ffa3d4e..0aed09ea 100644 --- a/docs/content/3.rendering/7.angular.md +++ b/docs/content/3.rendering/7.angular.md @@ -85,6 +85,7 @@ Passing a document to `` skips parsing at runtime, but the **pa | `value` | `string \| MarkdownDocument` | `''` | Markdown string or pre-parsed document | | [`options`](#code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#components) | `Record>` | `{}` | Custom Angular component mappings | | [`streaming`](#streaming) | `boolean` | `false` | Enable streaming mode | diff --git a/docs/content/5.reference/1.parse.md b/docs/content/5.reference/1.parse.md index c1b4bb6e..8d377af5 100644 --- a/docs/content/5.reference/1.parse.md +++ b/docs/content/5.reference/1.parse.md @@ -201,9 +201,39 @@ console.log(result.meta.summary) --- +## `getMarkdownParser(options?)`{lang="ts"} + +Returns a parser shared by every caller with equivalent options, building it on first use. Building a parser runs every plugin factory and registers them on a fresh markdown-it instance, and that dominates the cost of parsing short documents, so sharing one is the difference between a page that renders in milliseconds and one that does not. + +`parseMarkdown()` goes through this, so most callers get the reuse for free. + +**Parameters:** + +- `options?` - Parser options (same as `parseMarkdown()`) + +**Returns:** An async parser function `(source: string, opts?: { streaming?: boolean }) => Promise` + +Equivalence is structural for primitive options and by identity for `plugins`, `autoClose`, `tracer` and `cache`. Create plugin instances once rather than inline on every render, or every call gets its own parser: + +```typescript +import { getMarkdownParser } from 'comark' +import shiki from 'comark/plugins/shiki' + +// Shared across every call +const plugins = [shiki()] +const tree = await getMarkdownParser({ plugins })(source) + +// A different parser every time +const other = await getMarkdownParser({ plugins: [shiki()] })(source) +``` + +::warning +Shared parsers are for non-streaming parses. Streaming keeps incremental state on the parser and every non-streaming parse resets it, so a streaming consumer must own its parser. Use `createMarkdownParser()` or `createSerializedMarkdownParser()` there. The framework components already do this for you. +:: + ## `createMarkdownParser(options?)`{lang="ts"} -Creates a reusable parser function with pre-configured options. Unlike `parseMarkdown()` which creates a new parser instance on each call, `createMarkdownParser()` returns a parser function that can be called multiple times with the same configuration. +Creates a reusable parser function with pre-configured options. Unlike `getMarkdownParser()`, this always builds a fresh parser, so use it when you need one nobody else holds: a streaming consumer, or an isolated cache. **Parameters:** @@ -314,9 +344,33 @@ app.post('/api/markdown', async (req, res) => { }) ``` +### Caching parsed documents + +Set `cache` to memoize results by source string. It is off by default, because a parser usually outlives a request on the server and retained documents would be invisible to the caller: + +```typescript +const parse = getMarkdownParser({ cache: true }) // bounded LRU of 200 +const parse = getMarkdownParser({ cache: 500 }) // your own bound +const parse = getMarkdownParser({ cache: store }) // any Map-compatible store +``` + +The cache holds the promise, so callers asking for the same source in one tick share a single parse rather than racing. Streaming parses are never served from it and never fill it, and a failed parse is not cached. + +Measured on 176 short documents, the shape of a component documentation page: + +| | Time | Memory | +|---|---|---| +| Parser per document | 38.07 ms | 9.17 mb | +| Shared parser | 1.65 ms | 5.24 mb | +| Shared parser, cached | 15.08 µs | 57.34 kb | + +::warning +Documents from a cached parser are shared between callers, so treat them as immutable. +:: + ### Benchmark -Using `createMarkdownParser()` has several benefits over calling `parseMarkdown()` multiple times: +Using a reusable parser has several benefits over building one per parse: - **Performance**: Parser and plugins are initialized once, not on every parse - **Consistency**: All documents parsed with the same configuration @@ -366,6 +420,7 @@ Both `parseMarkdown()` and `createMarkdownParser()` accept the same `ParserOptio | `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 them. | +| `cache` | `boolean \| number \| ComarkDocumentCache` | `false` | Memoize parse results by source string, per parser. `true` uses a bounded LRU of 200. Streaming parses are never cached. | | `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) | diff --git a/packages/comark-angular/src/components/markdown.component.ts b/packages/comark-angular/src/components/markdown.component.ts index 6fdfb310..efe3108c 100644 --- a/packages/comark-angular/src/components/markdown.component.ts +++ b/packages/comark-angular/src/components/markdown.component.ts @@ -8,8 +8,8 @@ import { Type, inject, } from '@angular/core' -import { createSerializedMarkdownParser } from 'comark' -import type { ParserOptions, MarkdownDocument as MarkdownDocumentType } from 'comark' +import { createSerializedMarkdownParser, getMarkdownParser } from 'comark' +import type { ParserOptions, ComarkParseFn, MarkdownDocument as MarkdownDocumentType } from 'comark' import { isMarkdownDocument } from 'comark/utils' import { MarkdownDocument } from './markdown-document.component.ts' @@ -71,19 +71,33 @@ export class Markdown implements OnChanges { /** Additional data to pass to the renderer for :binding resolution */ @Input() data: Record = {} + /** Parser to use instead of one resolved from `options` and `plugins` */ + @Input() parser?: ComarkParseFn + document: MarkdownDocumentType | null = null - private serializedParse = createSerializedMarkdownParser({}) + private serializedParse: ComarkParseFn = getMarkdownParser({}) private cdr = inject(ChangeDetectorRef) + /** + * Streaming keeps incremental state inside the parser closure, and every + * non-streaming parse resets it, so a streaming instance must own its parser. + * Non-streaming instances share one, which is where the win is. + */ + private resolveParser(): ComarkParseFn { + if (this.parser) return this.parser + const parseOptions = { + ...this.options, + ...(this.unwrap ? { unwrap: this.unwrap } : {}), + plugins: this.plugins, + } + return this.streaming ? createSerializedMarkdownParser(parseOptions) : getMarkdownParser(parseOptions) + } + ngOnChanges(changes: SimpleChanges): void { - if (changes['options'] || changes['plugins'] || changes['unwrap']) { - this.serializedParse = createSerializedMarkdownParser({ - ...this.options, - ...(this.unwrap ? { unwrap: this.unwrap } : {}), - plugins: this.plugins, - }) + if (changes['options'] || changes['plugins'] || changes['unwrap'] || changes['streaming'] || changes['parser']) { + this.serializedParse = this.resolveParser() } if ( changes['value'] || diff --git a/packages/comark-react/src/components/Markdown.tsx b/packages/comark-react/src/components/Markdown.tsx index 6b54f797..2e40b999 100644 --- a/packages/comark-react/src/components/Markdown.tsx +++ b/packages/comark-react/src/components/Markdown.tsx @@ -1,5 +1,6 @@ import React from 'react' -import { parseMarkdown } from 'comark' +import { getMarkdownParser } from 'comark' +import type { ComarkParseFn } from 'comark' import type { MarkdownDocument as MarkdownDocumentType, ParserOptions } from 'comark' import { isMarkdownDocument } from 'comark/utils' import { MarkdownDocument } from './MarkdownDocument.tsx' @@ -21,6 +22,11 @@ export interface MarkdownProps { */ options?: Exclude + /** + * Parser to use instead of one resolved from `options` and `plugins` + */ + parser?: ComarkParseFn + /** * Additional plugins to use */ @@ -107,6 +113,7 @@ export async function Markdown({ value, options = {}, plugins = [], + parser, unwrap = false, components: customComponents = {}, componentsManifest, @@ -141,6 +148,7 @@ export async function Markdown({ value={source} options={parseOptions} plugins={plugins} + parser={parser} components={customComponents} componentsManifest={componentsManifest} streaming={streaming} @@ -151,7 +159,7 @@ export async function Markdown({ ) } - const parsed = await parseMarkdown(source, { ...parseOptions, plugins }) + const parsed = await (parser ?? getMarkdownParser({ ...parseOptions, plugins }))(source) return ( + parser ?? + (streaming + ? createSerializedMarkdownParser({ ...options, plugins }) + : getMarkdownParser({ ...options, plugins })), + [parser, streaming] + ) + // Re-creates the promise only when content changes. // Note: options/plugins should be stable references (defined outside render or memoized). - // Pre-parsed documents resolve immediately without calling parseMarkdown(). + // Pre-parsed documents resolve immediately without parsing. const parsePromise = useMemo( - () => (isMarkdownDocument(content) ? Promise.resolve(content) : parseMarkdown(content, { ...options, plugins })), - [content] + () => (isMarkdownDocument(content) ? Promise.resolve(content) : parse(content)), + [content, parse] ) // Keep showing the previous parsed result while a new parse is pending — @@ -58,6 +78,7 @@ export function MarkdownClient({ children, value, options = {}, plugins = [], .. diff --git a/packages/comark-svelte/src/async/MarkdownAsync.svelte b/packages/comark-svelte/src/async/MarkdownAsync.svelte index 090dcdd5..4d323bb5 100644 --- a/packages/comark-svelte/src/async/MarkdownAsync.svelte +++ b/packages/comark-svelte/src/async/MarkdownAsync.svelte @@ -29,7 +29,8 @@ and wrap this component in a `` for pending/error states. --> diff --git a/packages/comark-svelte/src/components/Markdown.svelte b/packages/comark-svelte/src/components/Markdown.svelte index fdcffadf..b1aeb8c4 100644 --- a/packages/comark-svelte/src/components/Markdown.svelte +++ b/packages/comark-svelte/src/components/Markdown.svelte @@ -25,7 +25,8 @@ This is an alert component --> {#if parsed} diff --git a/packages/comark-svelte/src/components/Markdown.svelte b/packages/comark-svelte/src/components/Markdown.svelte index b1aeb8c4..fa0e1431 100644 --- a/packages/comark-svelte/src/components/Markdown.svelte +++ b/packages/comark-svelte/src/components/Markdown.svelte @@ -46,6 +46,7 @@ This is an alert component value?: string | MarkdownDocumentType options?: Record plugins?: ComarkPlugin[] + parser?: ComarkParseFn unwrap?: boolean | string | string[] components?: Record componentsManifest?: ComponentManifest @@ -59,24 +60,26 @@ This is an alert component let content = $derived(typeof value === 'string' ? value.trim() : '') + // `parse` directly mutates `plugins` which creates an infinite effect loop + // so we copy it before passing it in so it gets a regular JS array and we get to still + // track dependencies from an external perspective + let parseOptions = $derived({ ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] }) + // Streaming keeps incremental state inside the parser closure, and every // non-streaming parse resets it, so a streaming instance must own its parser. - // Non-streaming instances share one, which is where the win is. - function resolveParser() { - if (parser) return parser - const parseOptions = { ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] } - return streaming ? createSerializedMarkdownParser(parseOptions) : getMarkdownParser(parseOptions) - } + // Non-streaming instances share one, which is where the win is. Derived from + // the configuration alone so a streaming instance keeps the same parser, and + // its incremental state, across every chunk of content. + let parse = $derived( + parser ?? (streaming ? createSerializedMarkdownParser(parseOptions) : getMarkdownParser(parseOptions)), + ) let requestVersion = 0 let appliedVersion = 0 $effect(() => { if (isMarkdownDocument(value)) return const currentVersion = ++requestVersion - // `parse` directly mutates `plugins` which creates an infinite effect loop - // so we copy it before passing it in so it gets a regular JS array and we get to still - // track dependencies from an external perspective - resolveParser()(content).then((result) => { + parse(content).then((result) => { if (currentVersion > appliedVersion) { appliedVersion = currentVersion parsed = result From e328c4ebc23d43115d303b633241b044d4808cef Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 11 Sep 2026 10:40:35 +0200 Subject: [PATCH 06/13] fix(parse): make the parser key unambiguous for arrays --- .../comark/src/internal/parse/parser-key.ts | 6 +----- packages/comark/test/parser-registry.test.ts | 18 ++++-------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/packages/comark/src/internal/parse/parser-key.ts b/packages/comark/src/internal/parse/parser-key.ts index 8029f658..67c5b5bd 100644 --- a/packages/comark/src/internal/parse/parser-key.ts +++ b/packages/comark/src/internal/parse/parser-key.ts @@ -37,11 +37,7 @@ export function parserKey(options: Record): string { const value = options[name] if (value === undefined) continue key += ` ${name}:` - if (Array.isArray(value)) { - for (const item of value) key += `${encode(item)},` - } else { - key += encode(value) - } + key += Array.isArray(value) ? `[${value.map(encode)}]` : encode(value) } return key } diff --git a/packages/comark/test/parser-registry.test.ts b/packages/comark/test/parser-registry.test.ts index 8059b592..f4a7f7ba 100644 --- a/packages/comark/test/parser-registry.test.ts +++ b/packages/comark/test/parser-registry.test.ts @@ -10,30 +10,20 @@ describe('getMarkdownParser', () => { expect(getMarkdownParser({ linkify: false })).toBe(getMarkdownParser({ linkify: false })) }) - it('ignores key order', () => { - expect(getMarkdownParser({ autoUnwrap: true, linkify: true })).toBe( - getMarkdownParser({ linkify: true, autoUnwrap: true }) - ) - }) - - it('treats an explicit undefined as absent', () => { - expect(getMarkdownParser({ autoUnwrap: undefined })).toBe(getMarkdownParser()) - }) - it('returns different parsers for different options', () => { expect(getMarkdownParser({ linkify: false })).not.toBe(getMarkdownParser({ linkify: true })) expect(getMarkdownParser({ unwrap: 'p' })).not.toBe(getMarkdownParser({ unwrap: 'div' })) }) + it('does not confuse an array with the string its items join to', () => { + expect(getMarkdownParser({ unwrap: ['p,div'] })).not.toBe(getMarkdownParser({ unwrap: 'p,div,' })) + }) + it('matches a fresh array holding the same plugin instances', () => { const plugin = noop() expect(getMarkdownParser({ plugins: [plugin] })).toBe(getMarkdownParser({ plugins: [plugin] })) }) - it('matches a fresh array of primitives', () => { - expect(getMarkdownParser({ unwrap: ['p'] })).toBe(getMarkdownParser({ unwrap: ['p'] })) - }) - it('does not match two instances from the same factory', () => { expect(getMarkdownParser({ plugins: [noop()] })).not.toBe(getMarkdownParser({ plugins: [noop()] })) }) From 12aced430fa2b0ba237892be68d854f752c61019 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 11 Sep 2026 10:40:39 +0200 Subject: [PATCH 07/13] docs: note that parser overrides options and plugins --- docs/content/3.rendering/3.vue.md | 2 +- docs/content/3.rendering/4.nuxt.md | 2 +- docs/content/3.rendering/5.react.md | 2 +- docs/content/3.rendering/6.svelte.md | 2 +- docs/content/3.rendering/7.angular.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/content/3.rendering/3.vue.md b/docs/content/3.rendering/3.vue.md index e5c8aeb1..4ab23c9f 100644 --- a/docs/content/3.rendering/3.vue.md +++ b/docs/content/3.rendering/3.vue.md @@ -111,7 +111,7 @@ Passing a document to `` skips parsing at runtime, but the **parser is | `value` | `string \| MarkdownDocument` | `undefined` | Markdown string or pre-parsed document (alternative to default slot) | | [`options`](#code-markdown-props-code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | -| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins`. When set, `options`, `plugins` and `unwrap` are ignored | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom Vue component mappings | | [`componentsManifest`](#code-markdown-props-code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver | diff --git a/docs/content/3.rendering/4.nuxt.md b/docs/content/3.rendering/4.nuxt.md index d671df9a..f5c4390a 100644 --- a/docs/content/3.rendering/4.nuxt.md +++ b/docs/content/3.rendering/4.nuxt.md @@ -99,7 +99,7 @@ Pass markdown via the default slot or the `value` prop: | `value` | `string \| MarkdownDocument` | `undefined` | Markdown string or pre-parsed document (alternative to default slot) | | [`options`](#code-markdown-props-code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | -| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins`. When set, `options`, `plugins` and `unwrap` are ignored | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom Vue component mappings | | [`componentsManifest`](#code-markdown-props-code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver | diff --git a/docs/content/3.rendering/5.react.md b/docs/content/3.rendering/5.react.md index a4fc9fb0..6a473e2c 100644 --- a/docs/content/3.rendering/5.react.md +++ b/docs/content/3.rendering/5.react.md @@ -98,7 +98,7 @@ Passing a document to `` skips parsing at runtime, but the **parser is | `value` | `string \| MarkdownDocument` | `''` | Markdown string or pre-parsed document (alternative to children) | | [`options`](#code-markdown-props-code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | -| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins`. When set, `options`, `plugins` and `unwrap` are ignored | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom React component mappings | | [`componentsManifest`](#code-markdown-props-code-componentsmanifest) | `(name: string) => Promise` | `undefined` | Dynamic component resolver | diff --git a/docs/content/3.rendering/6.svelte.md b/docs/content/3.rendering/6.svelte.md index e1c9a421..ce00842f 100644 --- a/docs/content/3.rendering/6.svelte.md +++ b/docs/content/3.rendering/6.svelte.md @@ -90,7 +90,7 @@ Passing a document to `` skips parsing at runtime, but the **parser is | `value` | `string \| MarkdownDocument` | `''` | Markdown string or pre-parsed document | | [`options`](#code-markdown-props-code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | -| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins`. When set, `options`, `plugins` and `unwrap` are ignored | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#code-markdown-props-code-components) | `Record` | `{}` | Custom Svelte component mappings | | [`componentsManifest`](#code-markdown-props-code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver | diff --git a/docs/content/3.rendering/7.angular.md b/docs/content/3.rendering/7.angular.md index 0aed09ea..d10a32ac 100644 --- a/docs/content/3.rendering/7.angular.md +++ b/docs/content/3.rendering/7.angular.md @@ -85,7 +85,7 @@ Passing a document to `` skips parsing at runtime, but the **pa | `value` | `string \| MarkdownDocument` | `''` | Markdown string or pre-parsed document | | [`options`](#code-options) | `ParserOptions` | `{}` | Parser options (autoUnwrap, autoClose, etc.) | | [`plugins`](#code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins | -| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins` | +| `parser` | `ComarkParseFn` | `undefined` | Parser to use instead of one resolved from `options` and `plugins`. When set, `options`, `plugins` and `unwrap` are ignored | | `unwrap` | `boolean \| string \| string[]` | `false` | Strip wrapper tags (MDC `unwrap`) — `true` unwraps `

`; a comma/space-separated string or array peels tags sequentially, for example `"ul li"` | | [`components`](#components) | `Record>` | `{}` | Custom Angular component mappings | | [`streaming`](#streaming) | `boolean` | `false` | Enable streaming mode | From babf0cd63d3802632bd7cceb5dca987516ddd640 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 11 Sep 2026 10:41:49 +0200 Subject: [PATCH 08/13] chore: refresh the bundle size snapshot --- test/bundle.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 6513ed58..1310362d 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -60,14 +60,14 @@ describe('package bundle size', { timeout: 60_000 }, () => { expect(report).toMatchInlineSnapshot(` { - "@comark/angular": "54.0k (70 files)", + "@comark/angular": "54.8k (70 files)", "@comark/ansi": "36.6k (98 files)", "@comark/html": "15.7k (58 files)", "@comark/nuxt": "11.8k (58 files)", - "@comark/react": "36.8k (74 files)", - "@comark/svelte": "43.9k (82 files)", - "@comark/vue": "54.7k (78 files)", - "comark": "364k (158 files)", + "@comark/react": "37.3k (74 files)", + "@comark/svelte": "45.4k (82 files)", + "@comark/vue": "55.6k (78 files)", + "comark": "368k (160 files)", } `) }) From 119133c43f7c9ddd482ceb8ac4b10afe01964917 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 11 Sep 2026 12:09:58 +0200 Subject: [PATCH 09/13] perf(parse): share the configured markdown-it instance between parsers --- .../comark/src/internal/parse/parser-key.ts | 43 ----- packages/comark/src/parse.ts | 136 +++++++-------- .../comark/test/markdown-exit-memo.test.ts | 159 ++++++++++++++++++ packages/comark/test/parser-registry.test.ts | 77 --------- 4 files changed, 223 insertions(+), 192 deletions(-) delete mode 100644 packages/comark/src/internal/parse/parser-key.ts create mode 100644 packages/comark/test/markdown-exit-memo.test.ts delete mode 100644 packages/comark/test/parser-registry.test.ts diff --git a/packages/comark/src/internal/parse/parser-key.ts b/packages/comark/src/internal/parse/parser-key.ts deleted file mode 100644 index 67c5b5bd..00000000 --- a/packages/comark/src/internal/parse/parser-key.ts +++ /dev/null @@ -1,43 +0,0 @@ -const refIds = new WeakMap() -let nextRefId = 0 - -function refId(value: object): number { - let id = refIds.get(value) - if (id === undefined) { - id = ++nextRefId - refIds.set(value, id) - } - return id -} - -function encode(value: unknown): string { - if (value !== null && (typeof value === 'object' || typeof value === 'function')) { - return `#${refId(value as object)}` - } - return `${typeof value}:${String(value)}` -} - -/** - * Structural key for a set of parser options. - * - * Primitives, and arrays of them such as `unwrap: ['p']`, are serialised by - * value. Objects and functions (`plugins`, `autoClose`, `tracer`) are - * serialised by interned identity, so callers should create plugin instances - * once rather than inline on every render. - * - * Keys are walked in sorted order so `{ a, b }` and `{ b, a }` collapse to the - * same parser, and `undefined` values are skipped so `{ autoUnwrap: undefined }` - * matches `{}`. The field list comes from the object itself rather than a - * hand-maintained one, so a new `ParserOptions` field can never be silently - * left out of the key. - */ -export function parserKey(options: Record): string { - let key = '' - for (const name of Object.keys(options).sort()) { - const value = options[name] - if (value === undefined) continue - key += ` ${name}:` - key += Array.isArray(value) ? `[${value.map(encode)}]` : encode(value) - } - return key -} diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index be9dfd22..7e47e894 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -23,7 +23,6 @@ import { applyUnwrap, resolveUnwrapTags } from './internal/parse/unwrap.ts' import { marmdownItTokensToMarkdownDocument } from './internal/parse/token-processor.ts' import { autoCloseMarkdown } from './internal/parse/auto-close/index.ts' import { extractReusableNodes } from './internal/parse/incremental.ts' -import { parserKey } from './internal/parse/parser-key.ts' import { createSerializedTask, dedupePlugins } from './utils/helpers.ts' import { noopTracer, withSpan } from './utils/trace.ts' @@ -33,6 +32,63 @@ export { parseFrontmatter } from './internal/frontmatter.ts' // Re-export plugin utilities export { defineComarkPlugin } from './utils/helpers.ts' +/** + * A configured `MarkdownExit` instance, shared by every parser built from the + * same options. + * + * Constructing `MarkdownExit` costs roughly 213 µs, and 96% of that is the + * `LinkifyIt` instance it declares as a class field: `LinkifyIt` compiles + * eleven regexes of about 20k characters each, and it does so even when + * `linkify` is false. The plugin factories, `.enable()` and `.use()` together + * account for 2%. Rendering many small documents therefore spends nearly all + * of its time building parsers, so the instance is shared instead. + * + * Sharing is safe because a configured instance is immutable after + * construction. comark only calls `parser.parse()`, per-parse state lives on + * markdown-it's own state object and on the fresh `env` handed to each parse, + * and the construction-time mutations (`md.set({ html: true })` in the html + * plugin, the `md.parse` wrap in attributes) run once per instance. comark's + * own closure, including the incremental `lastOutput` and `lastInput`, still + * belongs to each parser, so nothing per-parse is shared and streaming stays + * per parser. + * + * The key is the `linkify` flag plus the ordered list of markdown-it plugin + * functions, held as a trie of `WeakMap`s so entries die with the plugin + * closures instead of growing without bound. A plugin factory that builds a + * fresh function on every call misses the cache, which is correct: two + * closures can configure markdown-it differently, so they must not share an + * instance. Create plugin instances once to get the hit. + */ +interface ExitNode { + md?: MarkdownExit + next: WeakMap +} + +const exitRoots: Record<'true' | 'false', ExitNode> = { + true: { next: new WeakMap() }, + false: { next: new WeakMap() }, +} + +function getMarkdownExit(linkify: boolean, mdPlugins: MarkdownExitPlugin[]): MarkdownExit { + let node = exitRoots[String(linkify) as 'true' | 'false'] + for (const fn of mdPlugins) { + let next = node.next.get(fn) + if (!next) { + node.next.set(fn, (next = { next: new WeakMap() })) + } + node = next + } + + if (!node.md) { + node.md = new MarkdownExit({ linkify }).enable(['table', 'strikethrough']) + for (const fn of mdPlugins) { + node.md.use(fn) + } + } + + return node.md +} + /** * Creates a parser function for Comark content. * @@ -95,14 +151,15 @@ export function createMarkdownParser plugins.some((plugin) => plugin.name === name) - const parser = new MarkdownExit({ linkify: options.linkify ?? true }).enable(['table', 'strikethrough']) - + const mdPlugins: MarkdownExitPlugin[] = [] for (const plugin of plugins) { for (const markdownItPlugin of plugin.markdownItPlugins || []) { - parser.use(markdownItPlugin as unknown as MarkdownExitPlugin) + mdPlugins.push(markdownItPlugin as unknown as MarkdownExitPlugin) } } + const parser = getMarkdownExit(options.linkify ?? true, mdPlugins) + let lastOutput: MarkdownDocument | null = null let lastInput: string | null = null @@ -225,73 +282,6 @@ export function createMarkdownParser } -/** Bound on how many distinct configurations keep a shared parser alive. */ -const MAX_SHARED_PARSERS = 32 -const sharedParsers = new Map() - -function warnOnSharedStreaming(parse: ComarkParseFn): ComarkParseFn { - let warned = false - return (markdown, opts) => { - if (opts?.streaming && !warned) { - warned = true - console.warn( - '[comark] streaming parse on a parser from `getMarkdownParser()`. Shared parsers hold one ' + - 'incremental state, so another consumer can corrupt or reset it. Use `createMarkdownParser()` ' + - 'or `createSerializedMarkdownParser()` for streaming.' - ) - } - return parse(markdown, opts) - } -} - -/** - * Returns a parser shared by every caller with equivalent options, building it - * on first use. Prefer this over `createMarkdownParser()` when many callers - * parse with the same configuration: building a parser runs every plugin - * factory and registers them on a fresh markdown-it instance, which dominates - * the cost of parsing short documents. - * - * Equivalence is structural for primitive options and by identity for - * `plugins`, `autoClose` and `tracer`, so create plugin instances once - * rather than inline on every render. - * - * Shared parsers are for non-streaming parses. Streaming keeps incremental - * state on the parser, and every non-streaming parse resets it, so a streaming - * consumer must own its parser. - * - * @example - * ```typescript - * import { getMarkdownParser } from 'comark' - * - * const tree = await getMarkdownParser({ plugins })(markdown) - * ``` - */ -export function getMarkdownParser[] = []>( - options: ParserOptions = {} as ParserOptions -): ComarkParseFn>, ResolvedFrontmatter>> { - const key = parserKey(options as Record) - let parser = sharedParsers.get(key) - - if (parser) { - // Touch, so the least recently used configuration is the one evicted. - sharedParsers.delete(key) - sharedParsers.set(key, parser) - } else { - // Copied: the parser closes over `options` and exposes it to plugins as - // `state.options`, so a caller must not be able to mutate it afterwards. - parser = warnOnSharedStreaming(createMarkdownParser({ ...options } as ParserOptions)) - sharedParsers.set(key, parser) - if (sharedParsers.size > MAX_SHARED_PARSERS) { - sharedParsers.delete(sharedParsers.keys().next().value!) - } - } - - return parser as ComarkParseFn< - ResolvedMeta>, - ResolvedFrontmatter> - > -} - /** * Parse Comark content from a string * @@ -331,7 +321,9 @@ export async function parseMarkdown>, ResolvedFrontmatter>> > { - return await getMarkdownParser(options)(markdown) + const parser = createMarkdownParser(options) + + return await parser(markdown) } /** diff --git a/packages/comark/test/markdown-exit-memo.test.ts b/packages/comark/test/markdown-exit-memo.test.ts new file mode 100644 index 00000000..6e6a1665 --- /dev/null +++ b/packages/comark/test/markdown-exit-memo.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { createMarkdownParser, defineComarkPlugin } from 'comark' +import type { MarkdownItPlugin } from 'comark' + +// `getMarkdownExit()` is internal, so the memo is observed through the public +// API: a markdown-it plugin function only runs once per shared instance, so +// counting its registrations counts the instances that were built. +let stableUses = 0 +const stableMdPlugin = (() => { + stableUses++ +}) as unknown as MarkdownItPlugin + +const stablePlugin = defineComarkPlugin(() => ({ + name: 'memo-stable', + markdownItPlugins: [stableMdPlugin], +})) + +let closureUses = 0 +const closurePlugin = defineComarkPlugin(() => ({ + name: 'memo-closure', + markdownItPlugins: [ + (() => { + closureUses++ + }) as unknown as MarkdownItPlugin, + ], +})) + +describe('markdown-exit instance sharing', () => { + it('still returns a fresh parser function on every call', () => { + expect(createMarkdownParser()).not.toBe(createMarkdownParser()) + }) + + it('builds one markdown-it instance for parsers with the same plugin functions', () => { + const plugin = stablePlugin() + const before = stableUses + + for (let i = 0; i < 20; i++) { + createMarkdownParser({ plugins: [plugin] }) + } + + expect(stableUses - before).toBe(1) + }) + + it('builds one instance per closure when a factory returns a fresh function', () => { + const before = closureUses + + for (let i = 0; i < 5; i++) { + createMarkdownParser({ plugins: [closurePlugin()] }) + } + + expect(closureUses - before).toBe(5) + }) + + it('builds parsers cheaply once the instance is shared', () => { + // Warm the instance and the JIT, then time constructions that all hit the + // memo. A fresh `MarkdownExit` costs around 200 µs, so 200 of them would + // take about 40ms. The bound is deliberately loose to stay stable in CI. + for (let i = 0; i < 20; i++) { + createMarkdownParser() + } + + const start = performance.now() + for (let i = 0; i < 200; i++) { + createMarkdownParser() + } + + expect(performance.now() - start).toBeLessThan(20) + }) + + it('does not share an instance between linkify settings', async () => { + const withLinkify = await createMarkdownParser({ linkify: true })('See https://comark.dev for more') + const withoutLinkify = await createMarkdownParser({ linkify: false })('See https://comark.dev for more') + + expect(JSON.stringify(withLinkify.nodes)).toContain('"a"') + expect(JSON.stringify(withoutLinkify.nodes)).not.toContain('"a"') + }) +}) + +describe('per-parser state', () => { + it('keeps streaming state on the parser across another parser use', async () => { + const streaming = createMarkdownParser() + const other = createMarkdownParser() + + await streaming('# Title\n\nFirst paragraph.\n', { streaming: true }) + const second = await streaming('# Title\n\nFirst paragraph.\n\nSecond paragraph.\n', { streaming: true }) + + await other('Unrelated **document**') + + const third = await streaming('# Title\n\nFirst paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n', { + streaming: true, + }) + + // Reused nodes are carried over by reference from the previous output. + expect(third.nodes[0]).toBe(second.nodes[0]) + expect(third.nodes[1]).toBe(second.nodes[1]) + expect(third.nodes).toHaveLength(4) + }) + + it('does not leak frontmatter between two streaming parsers', async () => { + const a = createMarkdownParser() + const b = createMarkdownParser() + + await a('---\ntitle: A\n---\n\nAlpha\n', { streaming: true }) + await b('---\ntitle: B\n---\n\nBeta\n', { streaming: true }) + + const resultA = await a('---\ntitle: A\n---\n\nAlpha one.\n', { streaming: true }) + const resultB = await b('---\ntitle: B\n---\n\nBeta one.\n', { streaming: true }) + + expect(resultA.frontmatter).toEqual({ title: 'A' }) + expect(resultB.frontmatter).toEqual({ title: 'B' }) + }) + + it('does not carry a link reference definition into another parser', async () => { + // Reference definitions live in markdown-it's `env`, which is fresh on + // every parse. The components plugin claims the `[docs]` syntax, so this + // runs without the default plugins. + const definer = createMarkdownParser({ registerDefaultPlugins: false }) + const consumer = createMarkdownParser({ registerDefaultPlugins: false }) + + const defined = await definer('[docs]: https://comark.dev\n\nRead the [docs].\n') + expect(JSON.stringify(defined.nodes)).toContain('https://comark.dev') + + const withoutDefinition = await consumer('Read the [docs].\n') + expect(JSON.stringify(withoutDefinition.nodes)).not.toContain('https://comark.dev') + }) + + it('parses the same document identically across 50 concurrent parsers', async () => { + const source = [ + '---', + 'title: Concurrency', + '---', + '', + '# Hello **world**', + '', + 'Some `code` and a [link](https://comark.dev).', + '', + '::alert{type="info"}', + 'Careful.', + '::', + '', + '| a | b |', + '| - | - |', + '| 1 | 2 |', + '', + '- [ ] todo', + '- [x] done', + '', + 'html', + '', + ].join('\n') + + const baseline = await createMarkdownParser()(source) + const results = await Promise.all(Array.from({ length: 50 }, () => createMarkdownParser()(source))) + + for (const result of results) { + expect(result).toEqual(baseline) + } + }) +}) diff --git a/packages/comark/test/parser-registry.test.ts b/packages/comark/test/parser-registry.test.ts deleted file mode 100644 index f4a7f7ba..00000000 --- a/packages/comark/test/parser-registry.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createMarkdownParser, getMarkdownParser, parseMarkdown } from '../src/parse.ts' -import { defineComarkPlugin } from '../src/utils/helpers.ts' - -const noop = defineComarkPlugin(() => ({ name: 'noop' })) - -describe('getMarkdownParser', () => { - it('returns the same parser for equivalent options', () => { - expect(getMarkdownParser()).toBe(getMarkdownParser()) - expect(getMarkdownParser({ linkify: false })).toBe(getMarkdownParser({ linkify: false })) - }) - - it('returns different parsers for different options', () => { - expect(getMarkdownParser({ linkify: false })).not.toBe(getMarkdownParser({ linkify: true })) - expect(getMarkdownParser({ unwrap: 'p' })).not.toBe(getMarkdownParser({ unwrap: 'div' })) - }) - - it('does not confuse an array with the string its items join to', () => { - expect(getMarkdownParser({ unwrap: ['p,div'] })).not.toBe(getMarkdownParser({ unwrap: 'p,div,' })) - }) - - it('matches a fresh array holding the same plugin instances', () => { - const plugin = noop() - expect(getMarkdownParser({ plugins: [plugin] })).toBe(getMarkdownParser({ plugins: [plugin] })) - }) - - it('does not match two instances from the same factory', () => { - expect(getMarkdownParser({ plugins: [noop()] })).not.toBe(getMarkdownParser({ plugins: [noop()] })) - }) - - it('distinguishes plugin order', () => { - const a = noop() - const b = defineComarkPlugin(() => ({ name: 'other' }))() - expect(getMarkdownParser({ plugins: [a, b] })).not.toBe(getMarkdownParser({ plugins: [b, a] })) - }) - - it('evicts the least recently used configuration past the bound', () => { - const first = getMarkdownParser({ unwrap: 'lru-probe' }) - // The registry holds 32 parsers; 40 distinct configurations push it out. - for (let i = 0; i < 40; i++) getMarkdownParser({ unwrap: `lru-filler-${i}` }) - expect(getMarkdownParser({ unwrap: 'lru-probe' })).not.toBe(first) - }) - - it('warns once when used for a streaming parse', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const parse = getMarkdownParser({ unwrap: 'streaming-warn-probe' }) - - await parse('a', { streaming: true }) - await parse('a b', { streaming: true }) - - expect(warn).toHaveBeenCalledTimes(1) - expect(String(warn.mock.calls[0]?.[0])).toContain('getMarkdownParser') - warn.mockRestore() - }) - - it('parses concurrently on one shared parser', async () => { - const parse = getMarkdownParser() - const source = 'Hello **world** and `code`' - const baseline = await parse(source) - - const results = await Promise.all(Array.from({ length: 50 }, () => parse(source))) - for (const result of results) expect(result.nodes).toEqual(baseline.nodes) - }) -}) - -describe('createMarkdownParser', () => { - it('still returns a fresh parser every call', () => { - expect(createMarkdownParser()).not.toBe(createMarkdownParser()) - }) -}) - -describe('parseMarkdown', () => { - it('still parses correctly through the shared registry', async () => { - const tree = await parseMarkdown('Hello **world**') - expect(tree.nodes).toEqual([['p', {}, 'Hello ', ['strong', {}, 'world']]]) - }) -}) From 64bf0c9bb9053e5695badbabc48ee41ef4a22d3d Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 11 Sep 2026 12:10:06 +0200 Subject: [PATCH 10/13] revert(vue,react,svelte,angular): drop the parser registry integration --- .../src/components/markdown.component.ts | 37 +++------ .../comark-react/src/components/Markdown.tsx | 12 +-- .../src/components/MarkdownClient.tsx | 31 ++----- .../src/async/MarkdownAsync.svelte | 28 +++---- .../src/components/Markdown.svelte | 24 ++---- packages/comark-svelte/src/types.ts | 7 +- .../comark-vue/src/components/Markdown.ts | 61 +++----------- packages/comark-vue/test/parser-reuse.test.ts | 81 ------------------- 8 files changed, 46 insertions(+), 235 deletions(-) delete mode 100644 packages/comark-vue/test/parser-reuse.test.ts diff --git a/packages/comark-angular/src/components/markdown.component.ts b/packages/comark-angular/src/components/markdown.component.ts index 0b0aec62..c15571b2 100644 --- a/packages/comark-angular/src/components/markdown.component.ts +++ b/packages/comark-angular/src/components/markdown.component.ts @@ -8,8 +8,8 @@ import { Type, inject, } from '@angular/core' -import { createSerializedMarkdownParser, getMarkdownParser } from 'comark' -import type { ParserOptions, ComarkParseFn, MarkdownDocument as MarkdownDocumentType } from 'comark' +import { createSerializedMarkdownParser } from 'comark' +import type { ParserOptions, MarkdownDocument as MarkdownDocumentType } from 'comark' import { isMarkdownDocument } from 'comark/utils' import { MarkdownDocument } from './markdown-document.component.ts' @@ -71,33 +71,19 @@ export class Markdown implements OnChanges { /** Additional data to pass to the renderer for :binding resolution */ @Input() data: Record = {} - /** Parser to use instead of one resolved from `options` and `plugins` */ - @Input() parser?: ComarkParseFn - document: MarkdownDocumentType | null = null - private parse: ComarkParseFn = getMarkdownParser({}) + private serializedParse = createSerializedMarkdownParser({}) private cdr = inject(ChangeDetectorRef) - /** - * Streaming keeps incremental state inside the parser closure, and every - * non-streaming parse resets it, so a streaming instance must own its parser. - * Non-streaming instances share one, which is where the win is. - */ - private resolveParser(): ComarkParseFn { - if (this.parser) return this.parser - const parseOptions = { - ...this.options, - ...(this.unwrap ? { unwrap: this.unwrap } : {}), - plugins: this.plugins, - } - return this.streaming ? createSerializedMarkdownParser(parseOptions) : getMarkdownParser(parseOptions) - } - ngOnChanges(changes: SimpleChanges): void { - if (changes['options'] || changes['plugins'] || changes['unwrap'] || changes['streaming'] || changes['parser']) { - this.parse = this.resolveParser() + if (changes['options'] || changes['plugins'] || changes['unwrap']) { + this.serializedParse = createSerializedMarkdownParser({ + ...this.options, + ...(this.unwrap ? { unwrap: this.unwrap } : {}), + plugins: this.plugins, + }) } if ( changes['value'] || @@ -105,8 +91,7 @@ export class Markdown implements OnChanges { changes['plugins'] || changes['unwrap'] || changes['streaming'] || - changes['summary'] || - changes['parser'] + changes['summary'] ) { this.parseMarkdown() } @@ -126,7 +111,7 @@ export class Markdown implements OnChanges { } source = source.trim() - this.parse(source, { streaming: this.streaming }).then((result) => { + this.serializedParse(source, { streaming: this.streaming }).then((result) => { this.document = result this.cdr.markForCheck() }) diff --git a/packages/comark-react/src/components/Markdown.tsx b/packages/comark-react/src/components/Markdown.tsx index 1f843bde..aecbd5fd 100644 --- a/packages/comark-react/src/components/Markdown.tsx +++ b/packages/comark-react/src/components/Markdown.tsx @@ -1,6 +1,5 @@ import React from 'react' -import { getMarkdownParser } from 'comark' -import type { ComarkParseFn } from 'comark' +import { parseMarkdown } from 'comark' import type { MarkdownDocument as MarkdownDocumentType, ParserOptions } from 'comark' import { isMarkdownDocument } from 'comark/utils' import { MarkdownDocument } from './MarkdownDocument.tsx' @@ -22,11 +21,6 @@ export interface MarkdownProps { */ options?: Omit - /** - * Parser to use instead of one resolved from `options` and `plugins` - */ - parser?: ComarkParseFn - /** * Additional plugins to use */ @@ -113,7 +107,6 @@ export async function Markdown({ value, options = {}, plugins = [], - parser, unwrap = false, components: customComponents = {}, componentsManifest, @@ -148,7 +141,6 @@ export async function Markdown({ value={source} options={parseOptions} plugins={plugins} - parser={parser} components={customComponents} componentsManifest={componentsManifest} streaming={streaming} @@ -159,7 +151,7 @@ export async function Markdown({ ) } - const parsed = await (parser ?? getMarkdownParser({ ...parseOptions, plugins }))(source) + const parsed = await parseMarkdown(source, { ...parseOptions, plugins }) return ( - parser ?? - (streaming - ? createSerializedMarkdownParser({ ...options, plugins }) - : getMarkdownParser({ ...options, plugins })), - [parser, streaming] - ) - // Re-creates the promise only when content changes. // Note: options/plugins should be stable references (defined outside render or memoized). - // Pre-parsed documents resolve immediately without parsing. + // Pre-parsed documents resolve immediately without calling parseMarkdown(). const parsePromise = useMemo( - () => (isMarkdownDocument(content) ? Promise.resolve(content) : parse(content)), - [content, parse] + () => (isMarkdownDocument(content) ? Promise.resolve(content) : parseMarkdown(content, { ...options, plugins })), + [content] ) // Keep showing the previous parsed result while a new parse is pending — @@ -78,7 +58,6 @@ export function MarkdownClient({ diff --git a/packages/comark-svelte/src/async/MarkdownAsync.svelte b/packages/comark-svelte/src/async/MarkdownAsync.svelte index fe0e5b07..090dcdd5 100644 --- a/packages/comark-svelte/src/async/MarkdownAsync.svelte +++ b/packages/comark-svelte/src/async/MarkdownAsync.svelte @@ -29,8 +29,7 @@ and wrap this component in a `` for pending/error states. --> {#if parsed} diff --git a/packages/comark-svelte/src/components/Markdown.svelte b/packages/comark-svelte/src/components/Markdown.svelte index fa0e1431..fdcffadf 100644 --- a/packages/comark-svelte/src/components/Markdown.svelte +++ b/packages/comark-svelte/src/components/Markdown.svelte @@ -25,8 +25,7 @@ This is an alert component -->