diff --git a/benchmarks/comark-parser-reuse.ts b/benchmarks/comark-parser-reuse.ts new file mode 100644 index 00000000..94f1d1d6 --- /dev/null +++ b/benchmarks/comark-parser-reuse.ts @@ -0,0 +1,32 @@ +import { barplot, bench, group, run } from 'mitata' +import { createMarkdownParser } from '../packages/comark/src/parse.ts' + +// Building a parser is cheap because the configured markdown-it instance is +// shared between parsers built from the same plugin functions. The two arms +// should land close together: hoisting a parser out of a render loop is no +// longer worth doing for performance. +// +// 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('one shared parser', async () => { + await parseAll(createMarkdownParser()) + }) + }) +}) + +await run() diff --git a/docs/content/5.reference/1.parse.md b/docs/content/5.reference/1.parse.md index c1b4bb6e..d0e109ec 100644 --- a/docs/content/5.reference/1.parse.md +++ b/docs/content/5.reference/1.parse.md @@ -205,6 +205,8 @@ console.log(result.meta.summary) 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. +Building a parser is cheap. The configured markdown-it instance is shared between every parser built from the same plugin functions, so one parser per component or per call is fine and you do not need to hoist parsing out of a render loop for performance. Create plugin instances once anyway, so their `markdownItPlugins` functions stay stable and the instance really is shared, and so a plugin that builds something expensive like a shiki highlighter only builds it once. + **Parameters:** - `options?` - Parser options (same as `parseMarkdown()`) @@ -318,7 +320,7 @@ app.post('/api/markdown', async (req, res) => { Using `createMarkdownParser()` has several benefits over calling `parseMarkdown()` multiple times: -- **Performance**: Parser and plugins are initialized once, not on every parse +- **Performance**: Plugin instances are created once, not on every parse. That is where the cost is, since the markdown-it instance behind them is already shared - **Consistency**: All documents parsed with the same configuration - **Memory efficiency**: Single parser instance handles multiple documents - **Ideal for batch processing**: Perfect when parsing many files diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index fa55a422..7e47e894 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -32,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. * @@ -94,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 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..b119e90d --- /dev/null +++ b/packages/comark/test/markdown-exit-memo.test.ts @@ -0,0 +1,143 @@ +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('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/test/bundle.test.ts b/test/bundle.test.ts index 6513ed58..53611c0f 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/react": "36.8k (74 files)", "@comark/svelte": "43.9k (82 files)", "@comark/vue": "54.7k (78 files)", - "comark": "364k (158 files)", + "comark": "365k (158 files)", } `) })