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 (e.g.,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
The parse function automatically generates a table of contents based on headings:
const content = `# Main Title
## Section 1
Some content here.
### Subsection 1.1
More content.
## Section 2
Final content.
`
const result = await parseMarkdown(content)
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.
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 parseMarkdown('# Document 1\n\nContent...')
const doc2 = await parseMarkdown('# Document 2\n\nMore content...')
const doc3 = await parseMarkdown('# 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 parseMarkdown(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 parseMarkdown(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: Parser and plugins are initialized once, not on every parse
- 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 parseMarkdown(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 | true | Auto-close incomplete markdown syntax |
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. Also can be used to configure default plugins like components in conjunction with plugins |
plugins | ComarkPlugin[] | [] | Array of plugins to apply |
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 (e.g. 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.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 just 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>