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.
Quick Overview
- Package:
@nuxtjs/mdc→comark(core) or@comark/nuxt(Nuxt module) - Parse:
parseMarkdown()→parse()· factory:createParse()(sync, no await) - AST: object tree → compact tuples
['tag', props, ...children] - Result:
result.body/result.data→tree.nodes/tree.frontmatter - Renderer:
<MDCRenderer :body :data>→<ComarkRenderer :tree> - All-in-one:
<MDC :value>→<Comark :markdown> - Slots:
<MDCSlot />→ native<slot /> - Plugins: global
nuxt.config→ per-componentdefineComarkComponent({ plugins }) - Markdown files: no changes needed
Agent Skill
To give coding agents the MDC-to-Comark migration skill, install it from production:
npx skills add https://comark.dev/skills/migrate-mdc-to-comarkPackage Changes
If you only use the programmatic API (parse functions, plugins, AST manipulation), install comark alone:
npm uninstall @nuxtjs/mdc
npm install comarkpnpm remove @nuxtjs/mdc
pnpm add comarkyarn remove @nuxtjs/mdc
yarn add comarkbun remove @nuxtjs/mdc
bun add comarkIf you also use the Nuxt module (renderer components, auto-imports, prose components), install @comark/nuxt instead — it includes comark as a dependency:
npm uninstall @nuxtjs/mdc
npm install @comark/nuxtpnpm remove @nuxtjs/mdc
pnpm add @comark/nuxtyarn remove @nuxtjs/mdc
yarn add @comark/nuxtbun remove @nuxtjs/mdc
bun add @comark/nuxtCore Package
The comark package is the foundation all other packages build on. The changes below apply to all programmatic usage, regardless of whether you use the Nuxt module.
Parse Functions
One-shot parsing
import { parseMarkdown } from '@nuxtjs/mdc/runtime'
const result = await parseMarkdown(markdown)import { parse } from 'comark'
const tree = await parse(markdown)Reusable parser
import { createMarkdownParser } from '@nuxtjs/mdc/runtime'
const parser = await createMarkdownParser(options)
const result = await parser(markdown)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.Render Functions
Serialize back to Markdown
import { stringifyMarkdown } from '@nuxtjs/mdc/runtime'
const markdown = stringifyMarkdown(result.body, result.data)import { renderMarkdown } from 'comark/render'
const markdown = await renderMarkdown(tree)renderMarkdown includes frontmatter automatically — it reads tree.frontmatter and serializes it as YAML front matter. See the Render API for full options.
Return Type
The shape of the parsed result changed.
// MDCParserResult
{
body: MDCRoot // the AST
data: { // frontmatter
title: string
description: string
[key: string]: any
}
toc: Toc | undefined
excerpt: MDCRoot | undefined
}// 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.
// MDC object tree
{
type: 'element',
tag: 'p',
props: { class: 'intro' },
children: [
{ type: 'text', value: 'Hello world' }
]
}// 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.
Plugins
MDC used the unified/remark/rehype pipeline. Comark uses its own lighter plugin interface.
Syntax Highlighting
import { createMarkdownParser, rehypeHighlight, createShikiHighlighter } from '@nuxtjs/mdc/runtime'
const parser = await createMarkdownParser({
rehype: {
plugins: {
highlight: {
instance: rehypeHighlight,
options: {
theme: 'material-theme-palenight',
highlighter: createShikiHighlighter({ /* ... */ }),
},
},
},
},
})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 }
})
]
})Table of Contents
import { parseMarkdown } from '@nuxtjs/mdc/runtime'
const result = await parseMarkdown(md, { toc: { depth: 3 } })
const toc = result.tocimport { parse } from 'comark'
import toc from 'comark/plugins/toc'
const tree = await parse(md, { plugins: [toc({ depth: 3 })] })
const tableOfContents = tree.meta.tocExcerpt / Summary
const result = await parseMarkdown(md)
const excerpt = result.excerpt // MDCRoot | undefinedimport { parse } from 'comark'
import summary from 'comark/plugins/summary'
const tree = await parse(md, { plugins: [summary()] })
const summaryNodes = tree.meta.summary // ComarkNode[] | undefinedEmoji
// enabled by default via remark-emoji
// mdc: { remarkPlugins: { 'remark-emoji': {} } }import { parse } from 'comark'
import emoji from 'comark/plugins/emoji'
const tree = await parse(md, { plugins: [emoji()] })Parse Options
// MDCParseOptions
{
remark: { plugins: { /* record */ } },
rehype: { options: {...}, plugins: { /* record */ } },
highlight: { theme: '...', langs: [...] } | false,
toc: { depth: 3, searchDepth: 2 } | false,
keepComments: false,
keepPosition: false,
}// 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.
Nuxt Module
Configuration
export default defineNuxtConfig({
modules: ['@nuxtjs/mdc'],
mdc: { highlight: { ... }, remarkPlugins: { ... } },
})export default defineNuxtConfig({
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.
Renderer Component
<MDCRenderer :body="result.body" :data="result.data" :components="components" /><ComarkRenderer :tree="tree" :components="components" />Props that changed:
MDC <MDCRenderer> | Comark <ComarkRenderer> | Notes |
|---|---|---|
body (MDCRoot) | tree (ComarkTree) | Different shape — see AST section |
data | — | tree.frontmatter contains 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>.
<MDC :value="markdown" :parser-options="options" />
<MDC :value="result" /> <!-- pre-parsed MDCParserResult --><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.
defineComarkComponent
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>Slots in Custom Components
MDC required a special <MDCSlot> component inside your custom Vue components to render children. Comark uses the native Vue <slot> element instead.
Default slot
<template>
<div class="alert">
<MDCSlot />
</div>
</template><template>
<div class="alert">
<slot />
</div>
</template>Unwrapping children
MDC's <MDCSlot unwrap="p"> and the older <slot mdc-unwrap="p"> attribute both map to <slot unwrap="p">:
<!-- MDCSlot -->
<MDCSlot unwrap="p" />
<!-- older mdc-unwrap attribute -->
<slot mdc-unwrap="p" /><slot unwrap="p" />The unwrap attribute strips the specified wrapper tag(s) from the rendered children — useful when you want to remove the auto-inserted <p> from single-paragraph slot content.
Named slots
Named slots work the same as in MDC — use #slotName in the markdown and <slot name="slotName"> in the component:
<!-- components/Card.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header" />
</div>
<div class="card-body">
<slot unwrap="p" />
</div>
</div>
</template>::card
#header
Card Title
Body content here.
::Summary
In Vue, render only the summary with the summary prop:
<MDCRenderer :body="result.excerpt ?? result.body" :data="result.data" /><Comark summary>{{ markdown }}</Comark>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.
Nuxt UI
If your project uses Nuxt UI, @comark/nuxt registers Nuxt UI prose components automatically.
Nuxt UI also exposes convenience shorthand components for common callout patterns — instead of ::callout{icon="..." color="..."}, you can use:
::note
Informational note.
::
::tip
A helpful tip.
::
::warning
Something to watch out for.
::
::caution
A critical warning.
::These are equivalent to ::callout with the matching color and default icon. They are only available with Nuxt UI — without it, continue using ::callout{...} directly.
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.
:::Unsupported Features
Comark does not currently support the following MDC features:
Binding Syntax
MDC's binding syntax ({{ variable }}) for interpolating data inside markdown content is not supported in Comark. This means you cannot reference variables or expressions inline within your markdown text.
<!-- MDC — worked -->
Hello {{ user.name }}, you have {{ count }} messages.
<!-- Comark — not supported -->
<!-- The {{ }} syntax will be rendered as plain text -->Props Binding and Data Passing
Because Comark does not support binding syntax, dynamic props binding and data passing from a parent context into markdown content are also not supported. In MDC, you could pass data to the markdown parser and reference it inside the document — this pattern has no equivalent in Comark.
// MDC — data available inside markdown via {{ }}
const result = await parseMarkdown(md, { data: { user, count } })
// Comark — no equivalentQuick Reference
| Task | MDC | Comark |
|---|---|---|
| Install (core only) | @nuxtjs/mdc | comark |
| Install (Nuxt) | @nuxtjs/mdc | @comark/nuxt |
| 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) |
| Render to Markdown | stringifyMarkdown(body, data) | renderMarkdown(tree) |
| 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 |
| Render slot | <MDCSlot /> | <slot /> |
| Unwrap slot | <MDCSlot unwrap="p" /> or <slot mdc-unwrap="p" /> | <slot unwrap="p" /> |
| Streaming | Not supported | streaming prop + autoClose |