Parse API
parseMarkdown(source, options?)
Parses Markdown from a string and returns a complete MarkdownDocument. Default plugins add frontmatter, alerts, task lists, HTML, components, and attributes to the standard Markdown parser.
Parameters:
source- The Markdown content as a stringoptions?- Parser options including plugins
Returns: MarkdownDocument object containing:
nodes- The parsed Markdown AST nodesfrontmatter- Frontmatter data parsed from YAMLmeta- Additional metadata from plugins (for example,toc,summary)
Example:
import { parseMarkdown } from 'comark'
const content = `---
title: Hello World
---
This is a simple example
`
const result = await parseMarkdown(content)
console.log(result){
"nodes": [
["p", {}, "This is a simple example"]
],
"frontmatter": {
"title": "Hello World"
},
"meta": {}
}Frontmatter
The parse function automatically extracts and parses YAML frontmatter:
const content = `---
title: My Document
tags:
- javascript
- markdown
author:
name: John Doe
email: john@example.com
---
# Content here
`
const result = await parseMarkdown(content)
console.log(result.frontmatter){
"title": "My Document",
"tags": ["javascript", "markdown"],
"author": { "name": "John Doe", "email": "john@example.com" }
}Table of contents
Register the toc plugin to generate a table of contents based on headings:
import toc from 'comark/plugins/toc'
const content = `# Main Title
## Section 1
Some content here.
### Subsection 1.1
More content.
## Section 2
Final content.
`
const result = await parseMarkdown(content, { plugins: [toc()] })
console.log(result.meta.toc){
"title": "Main Title",
"depth": 2,
"searchDepth": 2,
"links": [
{ "id": "section-1", "text": "Section 1", "depth": 2, "children": [
{ "id": "subsection-11", "text": "Subsection 1.1", "depth": 3 }
]},
{ "id": "section-2", "text": "Section 2", "depth": 2 }
]
}HTML parsing
HTML tags embedded in Comark content are parsed into AST nodes by default and can be mixed freely with Comark components and markdown syntax.
const content = `
<div class="note">
::alert{type="info"}
Hello <strong class="text-red-500">world</strong>
::
</div>
`
const result = await parseMarkdown(content)
console.log(result.nodes)[
["div", { "class": "note" },
["alert", { "type": "info" },
"Hello ",
["strong", { "class": "text-red-500" }, "world"]
]
]
]HTML parsing is provided by the built-in html plugin.
Summary
Content before the <!-- more --> comment is extracted as a summary when using the summary plugin:
import { parseMarkdown } from 'comark'
import summary from 'comark/plugins/summary'
const content = `# Article Title
This is the introduction paragraph that will be used as a summary.
<!-- more -->
This is the full article content that won't appear in the summary.
`
const result = await parseMarkdown(content, {
plugins: [summary()]
})
console.log(result.meta.summary)
// Node[] with only the content before <!-- more -->createMarkdownParser(options?)
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 asparseMarkdown())
Returns: An async parser function (source: string) => Promise<MarkdownDocument>
Example:
import { createMarkdownParser } from 'comark'
import shiki from 'comark/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
import emoji from 'comark/plugins/emoji'
import toc from 'comark/plugins/toc'
// Create a parser with specific configuration
const parse = createMarkdownParser({
autoUnwrap: true,
autoClose: true,
plugins: [
shiki({
themes: { light: githubLight, dark: githubDark }
}),
emoji(),
toc()
]
})
// Reuse the parser for multiple documents
const doc1 = await parse('# Document 1\n\nContent...')
const doc2 = await parse('# Document 2\n\nMore content...')
const doc3 = await parse('# Document 3\n\nEven more...')Use cases
Here are some use cases for createMarkdownParser():
Static site generator
Use Promise.all to parse all files in parallel. Since createMarkdownParser() initializes the parser once, the returned function is safe to call concurrently.
import { createMarkdownParser } from 'comark'
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { renderHtmlFromDocument } from '@comark/html'
import shiki from 'comark/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
import toc from 'comark/plugins/toc'
import emoji from 'comark/plugins/emoji'
async function buildSite(contentDir: string, outDir: string) {
// Create parser once with all desired plugins
const parse = createMarkdownParser({
plugins: [
shiki({
themes: { light: githubLight, dark: githubDark }
}),
toc({ depth: 3 }),
emoji()
]
})
const files = await readdir(contentDir)
const mdFiles = files.filter(f => f.endsWith('.md'))
// Parse all files in parallel with the same parser instance
await Promise.all(
mdFiles.map(async (file) => {
const content = await readFile(join(contentDir, file), 'utf-8')
const doc = await parse(content)
const html = await renderHtmlFromDocument(doc)
await writeFile(join(outDir, file.replace('.md', '.html')), html)
})
)
console.log(`Built ${mdFiles.length} pages`)
}
await buildSite('./content', './dist')API server
import { createMarkdownParser } from 'comark'
import security from 'comark/plugins/security'
// Create parser once when server starts
const parse = createMarkdownParser({
plugins: [
security() // Sanitize user-generated content
]
})
// Reuse parser for every request
app.post('/api/markdown', async (req, res) => {
try {
const tree = await parse(req.body.content)
res.json({ success: true, tree })
} catch (error) {
res.status(400).json({ error: 'Invalid markdown' })
}
})Benchmark
Using createMarkdownParser() has several benefits over calling parseMarkdown() multiple times:
- 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
import { parseMarkdown, createMarkdownParser } from 'comark'
import shiki from 'comark/plugins/shiki'
const content = '```js\nconsole.log("hello")\n```'
// ❌ Slow: Creates new parser + highlighter for each parse
console.time('parse x1000')
for (let i = 0; i < 1000; i++) {
await parseMarkdown(content, {
plugins: [shiki()]
})
}
console.timeEnd('parse x1000')
// → ~8000ms (parser + highlighter recreated 1000 times)
// ✅ Fast: Reuses same parser + highlighter instance
console.time('createMarkdownParser x1000')
const parse = createMarkdownParser({
plugins: [shiki()]
})
for (let i = 0; i < 1000; i++) {
await parse(content)
}
console.timeEnd('createMarkdownParser x1000')
// → ~800ms (10x faster! parser + highlighter created once)Options
Both parseMarkdown() and createMarkdownParser() accept the same ParserOptions:
| Option | Type | Default | Description |
|---|---|---|---|
autoUnwrap | boolean | true | Remove unnecessary <p> wrappers from single-element containers |
autoClose | boolean | (markdown: string) => string | true | Auto-close incomplete markdown syntax, or use a custom completion function |
unwrap | boolean | string | string[] | false | Remove wrapper tags from the tree, hoisting their children (MDC unwrap behaviour). true unwraps p; a comma/whitespace-separated string or array unwraps the listed tags; '*' matches any tag. Tags apply sequentially (each descends one level), and adjacent text is merged into a single string. |
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 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. |
tracer | ComarkTracer | undefined | Timing recorder for the parse pipeline — see Timing the parse |
Timing the parse
Pass a tracer to time each phase of the pipeline and every plugin hook, so you can see where parse time goes (for example, a slow post shiki hook). The contract is a structural subset of OpenTelemetry Tracer — startSpan and startActiveSpan — so a real OTel tracer works as-is:
import { trace } from '@opentelemetry/api'
import shiki from 'comark/plugins/shiki'
const parse = createMarkdownParser({
// Uses the OpenTelemetry provider registered by your app or hosting platform.
tracer: trace.getTracer('comark'),
plugins: [shiki()],
})@opentelemetry/api defines the tracing API but does not export spans by itself. trace.getTracer() uses the globally registered OpenTelemetry provider; without one, it returns a no-op tracer. Hosting platforms often register and configure that provider for you. Otherwise, initialize an OpenTelemetry SDK and exporter before creating the parser.See the complete Node.js example, including an OTLP/HTTP exporter and commands for viewing traces locally with otel-front.
Or a minimal recorder:
const spans: { name: string, duration: number }[] = []
const tracer = {
startSpan(name) {
const start = performance.now()
return { end: () => spans.push({ name, duration: performance.now() - start }) }
},
startActiveSpan(name, optionsOrFn, maybeFn) {
const fn = typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn
const span = tracer.startSpan(name)
// Nested startActiveSpan/startSpan calls become children via your context/stack.
return fn(span) // caller (comark) ends the span
},
}
const parse = createMarkdownParser({ tracer, plugins: [shiki()] })
await parse(markdown)
// spans → comark:parse
// ├─ comark:autoclose
// ├─ comark:pre:frontmatter
// ├─ comark:tokenize
// ├─ comark:nodes
// ├─ comark:post:alert
// └─ comark:post:shiki
// …Recorded spans: a root comark:parse active span enclosing comark:autoclose, comark:tokenize (markdown parsing), comark:nodes (token → AST conversion and unwrapping), and comark:pre:<name> / comark:post:<name> for each plugin hook. Nested startActiveSpan calls form the parent → child hierarchy (OTel active context, or a stack in a simple recorder). There is no timing overhead when tracer is omitted, and no Node-specific API is used — it works in the browser too.
Inline rendering
Use unwrap: 'p' (or unwrap: true) to render markdown without the
wrapping <p>, which is handy for buttons, badges, and other inline hosts.
Adjacent text is merged into a single string, matching MDC's unwrap:
await parseMarkdown('Hello **world**', { unwrap: 'p' })
// nodes: ['Hello ', ['strong', {}, 'world']]
await parseMarkdown('a\n\nb', { unwrap: true })
// nodes: ['ab'] (paragraphs merged, no separator)Tags are applied sequentially, each descending one level into the result of
the previous one — so a space-separated (or comma-separated) list peels nested
wrappers. '*' matches any tag:
// Unwrap the <ul>, then the <li> inside it
await parseMarkdown('- Buy milk', { unwrap: 'ul li' })
// nodes: ['Buy milk']On the framework components this is exposed as an unwrap shorthand prop:
<UButton><Markdown :value="text" unwrap /></UButton>
<UButton><Markdown :value="text" unwrap="ul li" /></UButton>
<UButton><Markdown :value="text" :options="{ unwrap: 'p' }" /></UButton>