Migrating from MDC
Comark is the successor to @nuxtjs/mdc. The markdown syntax is fully compatible — your .md files need no changes. What changes is the JavaScript API: package names, parse functions, the AST format, and how plugins work.
Package Changes
npm uninstall @nuxtjs/mdc
npm install comark @comark/vue # Vue
npm install comark @comark/react # React
npm install comark @comark/nuxt # Nuxt
pnpm remove @nuxtjs/mdc
pnpm add comark @comark/vue # Vue
pnpm add comark @comark/react # React
pnpm add comark @comark/nuxt # Nuxt
yarn remove @nuxtjs/mdc
yarn add comark @comark/vue # Vue
yarn add comark @comark/react # React
yarn add comark @comark/nuxt # Nuxt
Nuxt Module
export default defineNuxtConfig({
// Before
modules: ['@nuxtjs/mdc'],
mdc: { highlight: { ... }, remarkPlugins: { ... } },
// After
modules: ['@comark/nuxt'],
// No plugin config here — plugins are defined per-component (see below)
})
@comark/nuxt auto-imports Comark, ComarkRenderer, defineComarkComponent, and defineComarkRendererComponent. Plugin and component configuration moves out of nuxt.config.ts and into dedicated component definitions.
Parse Functions
One-shot parsing
// Before
import { parseMarkdown } from '@nuxtjs/mdc/runtime'
const result = await parseMarkdown(markdown)
// After
import { parse } from 'comark'
const tree = await parse(markdown)
Reusable parser
// Before
import { createMarkdownParser } from '@nuxtjs/mdc/runtime'
const parser = await createMarkdownParser(options)
const result = await parser(markdown)
// After
import { createParse } from 'comark'
const parse = createParse(options) // synchronous — no await needed
const tree = await parse(markdown)
createParse is synchronous. createMarkdownParser was async because it had to initialize the unified processor pipeline. Comark's plugin system is eagerly initialized, so the factory returns immediately.Return Type
The shape of the parsed result changed.
// Before — MDCParserResult
{
body: MDCRoot // the AST
data: { // frontmatter
title: string
description: string
[key: string]: any
}
toc: Toc | undefined
excerpt: MDCRoot | undefined
}
// After — ComarkTree
{
nodes: ComarkNode[] // the AST
frontmatter: Record<string, any> // frontmatter (all keys)
meta: {
toc?: Toc // requires toc plugin
summary?: ComarkNode[] // requires summary plugin
[key: string]: any
}
}
Quick reference:
| MDC | Comark |
|---|---|
result.body | tree.nodes |
result.data | tree.frontmatter |
result.data.title | tree.frontmatter.title |
result.toc | tree.meta.toc (requires toc plugin) |
result.excerpt | tree.meta.summary (requires summary plugin) |
AST Format
The node structure is fundamentally different. MDC uses objects; Comark uses compact tuples.
// Before — MDC object tree
{
type: 'element',
tag: 'p',
props: { class: 'intro' },
children: [
{ type: 'text', value: 'Hello world' }
]
}
// After — Comark tuple array
['p', { class: 'intro' }, 'Hello world']
// tag attributes children...
Mapping:
| MDC | Comark |
|---|---|
node.type === 'element' | Array.isArray(node) |
node.type === 'text' | typeof node === 'string' |
node.tag | node[0] |
node.props | node[1] |
node.children | node.slice(2) |
If you traverse the AST manually, update your node-walking code accordingly.
Renderer Component
Vue
<!-- Before -->
<MDCRenderer :body="result.body" :data="result.data" :components="components" />
<!-- After -->
<ComarkRenderer :tree="tree" :components="components" />
Props that changed:
MDC <MDCRenderer> | Comark <ComarkRenderer> | Notes |
|---|---|---|
body (MDCRoot) | tree (ComarkTree) | Different shape — see AST section |
data | — | Frontmatter is in tree.frontmatter, not a separate prop |
tag | — | Wrapper is always <div class="comark-content"> |
prose | — | Prose component resolution is automatic |
unwrap | — | Use autoUnwrap in parse options instead |
class | — | Style the wrapper via CSS |
components | components | Same purpose, same shape |
| — | componentsManifest | New: dynamic async component resolver |
| — | streaming | New: streaming mode |
| — | caret | New: animated caret for streaming |
All-in-one component
The <MDC> component maps to <Comark>.
<!-- Before -->
<MDC :value="markdown" :parser-options="options" />
<MDC :value="result" /> <!-- pre-parsed MDCParserResult -->
<!-- After -->
<Comark :markdown="markdown" :options="options" />
<!-- or default slot -->
<Comark>{{ markdown }}</Comark>
For a pre-parsed tree, use <ComarkRenderer> directly instead of passing it to the all-in-one component.
Plugins and Highlighting
MDC used the unified/remark/rehype pipeline. Comark uses its own lighter plugin interface.
Syntax Highlighting
// Before — rehype-based Shiki plugin via createMarkdownParser
import { createMarkdownParser, rehypeHighlight, createShikiHighlighter } from '@nuxtjs/mdc/runtime'
const parser = await createMarkdownParser({
rehype: {
plugins: {
highlight: {
instance: rehypeHighlight,
options: {
theme: 'material-theme-palenight',
highlighter: createShikiHighlighter({ /* ... */ }),
},
},
},
},
})
// After — Comark highlight plugin
import { createParse } from 'comark'
import highlight from 'comark/plugins/highlight'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
const parse = createParse({
plugins: [
highlight({
themes: { light: githubLight, dark: githubDark }
})
]
})
Reusable components instead of global config
In MDC, plugins were configured once in nuxt.config.ts and applied globally to every parse call. In Comark, you define one or more named components using defineComarkComponent and defineComarkRendererComponent, each with its own plugins. This means different parts of your app can use different configurations without any workarounds.
A typical app needs at least two configurations: a full one for articles (highlight, TOC, math…) and a lighter one for areas like comment sections that don't need all that.
import { defineComarkComponent, defineComarkRendererComponent } from '@comark/vue'
import highlight from 'comark/plugins/highlight'
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'
import CustomAlert from '~/components/Alert.vue'
import ProsePre from '~/components/ProsePre.vue'
// Full-featured component for articles
export const ArticleComark = defineComarkComponent({
name: 'ArticleComark',
plugins: [
highlight({ themes: { light: githubLight, dark: githubDark } }),
toc({ depth: 3 }),
emoji(),
],
components: {
alert: CustomAlert,
ProsePre,
},
})
// Lightweight component for comments — no TOC, no highlight, no emoji
export const CommentComark = defineComarkComponent({
name: 'CommentComark',
})
<script setup lang="ts">
import { ArticleComark, CommentComark } from '~/composables/comark'
</script>
<template>
<!-- Full plugins for the article body -->
<ArticleComark>{{ article.content }}</ArticleComark>
<!-- Lightweight for each comment -->
<CommentComark v-for="c in comments" :key="c.id">{{ c.body }}</CommentComark>
</template>
The same pattern applies to defineComarkRendererComponent when you pre-parse on the server:
export const ArticleRenderer = defineComarkRendererComponent({
name: 'ArticleRenderer',
components: {
alert: CustomAlert,
ProsePre,
},
})
<script setup lang="ts">
import { ArticleRenderer } from '~/composables/comark'
const { data: tree } = await useFetch(`/api/article/${slug}`)
</script>
<template>
<ArticleRenderer :tree="tree" />
</template>
Table of Contents
// Before — built into parseMarkdown
import { parseMarkdown } from '@nuxtjs/mdc/runtime'
const result = await parseMarkdown(md, { toc: { depth: 3 } })
const toc = result.toc
// After — requires toc plugin
import { parse } from 'comark'
import toc from 'comark/plugins/toc'
const tree = await parse(md, { plugins: [toc({ depth: 3 })] })
const tableOfContents = tree.meta.toc
Excerpt / Summary
// Before — built in, splits on <!-- more -->
const result = await parseMarkdown(md)
const excerpt = result.excerpt // MDCRoot | undefined
// After — requires summary plugin
import { parse } from 'comark'
import summary from 'comark/plugins/summary'
const tree = await parse(md, { plugins: [summary()] })
const summaryNodes = tree.meta.summary // ComarkNode[] | undefined
In Vue, render only the summary with the summary prop:
<!-- Before -->
<MDCRenderer :body="result.excerpt ?? result.body" :data="result.data" />
<!-- After -->
<Comark summary>{{ markdown }}</Comark>
Emoji
// Before — enabled by default via remark-emoji
// mdc: { remarkPlugins: { 'remark-emoji': {} } }
// After — opt-in plugin
import { parse } from 'comark'
import emoji from 'comark/plugins/emoji'
const tree = await parse(md, { plugins: [emoji()] })
Prose Components
Both MDC and Comark support dropping custom Vue components into components/prose/ to override how standard HTML elements render. The Nuxt module auto-registers them globally.
The resolution naming changed:
| Element | MDC component name | Comark component name |
|---|---|---|
<p> | ProseP | ProseP |
<h1> | ProseH1 | ProseH1 |
<a> | ProseA | ProseA |
<pre> | ProsePre | ProsePre |
<code> | ProseCode | ProseCode |
The names are the same. The internal resolution mechanism changed: MDC resolved prose-p (kebab-case) in the renderer, then looked up the registered ProseP component. Comark resolves directly by ProseP (PascalCase). If your prose components are in components/prose/ and follow the Prose*.vue naming convention, they work without changes.
Parse Options
// Before — MDCParseOptions
{
remark: { plugins: { /* record */ } },
rehype: { options: {...}, plugins: { /* record */ } },
highlight: { theme: '...', langs: [...] } | false,
toc: { depth: 3, searchDepth: 2 } | false,
keepComments: false,
keepPosition: false,
}
// After — ParseOptions
{
plugins: ComarkPlugin[], // ordered array, not a record
autoUnwrap: true, // removes <p> from single-paragraph containers
autoClose: true, // completes incomplete syntax (useful for streaming)
html: true, // parse embedded HTML tags
}
The mdc.config.ts unified pipeline hooks (pre, remark, rehype, post) have no equivalent because Comark doesn't use unified. Use the ComarkPlugin interface instead — see Plugins.
Component Syntax
The MDC block and inline component syntax is identical. No changes needed in your markdown files.
::alert{type="info"}
This works the same in both MDC and Comark.
::
:badge{label="New"}
:::card
#header
Card title
Body content.
:::
Quick Reference
| Task | MDC | Comark |
|---|---|---|
| Install | @nuxtjs/mdc | comark + @comark/vue |
| Nuxt module | @nuxtjs/mdc | @comark/nuxt |
| Global plugin config | mdc: { ... } in nuxt.config | — (no global config) |
| Reusable configured component | — | defineComarkComponent({ plugins, components }) |
| Reusable configured renderer | — | defineComarkRendererComponent({ components }) |
| Parse | parseMarkdown(md) | parse(md) |
| Reusable parser | await createMarkdownParser(opts) | createParse(opts) |
| AST root | result.body (MDCRoot object) | tree.nodes (tuple array) |
| Frontmatter | result.data | tree.frontmatter |
| TOC | result.toc (built-in) | tree.meta.toc (toc plugin) |
| Excerpt | result.excerpt (built-in) | tree.meta.summary (summary plugin) |
| Renderer | <MDCRenderer :body :data> | <ComarkRenderer :tree> |
| All-in-one | <MDC :value> | <Comark :markdown> |
| Highlight | mdc.highlight in nuxt.config | highlight() plugin in defineComarkComponent |
| Prose components | components/prose/Prose*.vue | components/prose/Prose*.vue |
| Streaming | Not supported | streaming prop + autoClose |
See Also
- Installation
- Parse API —
parseandcreateParseoptions - Vue Rendering —
<Comark>and<ComarkRenderer> - Plugins — highlight, toc, emoji, and more