Parse API

Parse Markdown into a compact, serializable document with parseMarkdown() or createMarkdownParser(), on the server, in the browser, or from a stream.

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 string
  • options? - Parser options including plugins

Returns: MarkdownDocument object containing:

  • nodes - The parsed Markdown AST nodes
  • frontmatter - Frontmatter data parsed from YAML
  • meta - 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)
For the complete parsed document and node types, see the Document Model.

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)

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)

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)

HTML parsing is provided by the built-in html plugin.

Summary

Summary extraction requires the summary plugin.

Content before the <!-- more --> comment is extracted as a summary when using the summary plugin:

parse.ts
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 as parseMarkdown())

Returns: An async parser function (source: string) => Promise<MarkdownDocument>

Example:

parse.ts
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.

build.ts
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

server.ts
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
benchmark.ts
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:

OptionTypeDefaultDescription
autoUnwrapbooleantrueRemove unnecessary <p> wrappers from single-element containers
autoClosebooleantrueAuto-close incomplete markdown syntax
unwrapboolean | string | string[]falseRemove 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.
htmlbooleantrueDeprecated (warns). Prefer registerDefaultPlugins: false and register html() explicitly. html: false still skips the default html plugin.
linkifybooleantrueAuto-convert URL-like text into links. Set false to disable
headingIdsbooleantrueAuto-generate id attributes for h1h6 headings. Set false to disable
registerDefaultPluginsbooleantrueRegister 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
pluginsComarkPlugin[][]Array of plugins to apply
tracerComarkTracerundefinedTiming 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 TracerstartSpan 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 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>