Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions benchmarks/comark-parser-reuse.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) {
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()
4 changes: 3 additions & 1 deletion docs/content/5.reference/1.parse.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`)
Expand Down Expand Up @@ -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
Expand Down
64 changes: 61 additions & 3 deletions packages/comark/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MarkdownExitPlugin, ExitNode>
}

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.
*
Expand Down Expand Up @@ -94,14 +151,15 @@ export function createMarkdownParser<const TPlugins extends readonly ComarkPlugi
const plugins = dedupePlugins(defaultPlugins, userPlugins)
const hasPlugin = (name: string) => 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

Expand Down
143 changes: 143 additions & 0 deletions packages/comark/test/markdown-exit-memo.test.ts
Original file line number Diff line number Diff line change
@@ -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',
'',
'<strong class="bold">html</strong>',
'',
].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)
}
})
})
2 changes: 1 addition & 1 deletion test/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
}
`)
})
Expand Down
Loading