diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index 7869b489..f62bf7c2 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -32,6 +32,13 @@ export { parseFrontmatter } from './internal/frontmatter.ts' // Re-export plugin utilities export { defineComarkPlugin } from './utils/helpers.ts' +// Constructing a `MarkdownExit` instance is expensive because `LinkifyIt` +// compiles its regexes in the constructor, and a configured instance holds no +// per-parse state, so parsers built from the same options share one. +let nextPluginId = 0 +const pluginIds = new WeakMap() +const sharedParsers = new Map() + /** * Creates a parser function for Comark content. * @@ -94,14 +101,26 @@ 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 linkify = options.linkify ?? true + const key = [ + linkify, + ...mdPlugins.map((fn) => pluginIds.get(fn) ?? (pluginIds.set(fn, nextPluginId), nextPluginId++)), + ].join(',') + + let parser = sharedParsers.get(key) + if (!parser) { + parser = new MarkdownExit({ linkify }).enable(['table', 'strikethrough']) + for (const fn of mdPlugins) parser.use(fn) + sharedParsers.set(key, parser) + } + let lastOutput: MarkdownDocument | null = null let lastInput: string | null = null diff --git a/packages/comark/test/parser-sharing.test.ts b/packages/comark/test/parser-sharing.test.ts new file mode 100644 index 00000000..19a968a2 --- /dev/null +++ b/packages/comark/test/parser-sharing.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { createMarkdownParser, defineComarkPlugin } from 'comark' +import type { MarkdownItPlugin } from 'comark' + +// The shared instance is internal, so it is observed through the public API: a +// markdown-it plugin function runs once per instance, so counting how often it +// is registered counts the instances that were built. +let stableUses = 0 +const stableMdPlugin = (() => { + stableUses++ +}) as unknown as MarkdownItPlugin + +const stablePlugin = defineComarkPlugin(() => ({ + name: 'sharing-stable', + markdownItPlugins: [stableMdPlugin], +})) + +let closureUses = 0 +const closurePlugin = defineComarkPlugin(() => ({ + name: 'sharing-closure', + markdownItPlugins: [ + (() => { + closureUses++ + }) as unknown as MarkdownItPlugin, + ], +})) + +describe('parser sharing', () => { + it('builds one 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"') + }) + + 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) + }) +}) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index a6534737..7e0e7029 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/react": "37.7k (76 files)", "@comark/svelte": "44.9k (84 files)", "@comark/vue": "56.0k (80 files)", - "comark": "368k (158 files)", + "comark": "369k (158 files)", } `) })