close

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 (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)
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

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)

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

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 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

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 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: 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 parse(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 (for example, 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 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>