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()→parseMarkdown()· factory:createMarkdownParser()(sync, no await) - AST: object tree → compact tuples
['tag', props, ...children] - Result:
result.body/result.data→document.nodes/document.frontmatter - Renderer:
<MDCRenderer :body :data>→<MarkdownDocument :value> - All-in-one:
<MDC :value>→<Markdown :value> - Slots:
<MDCSlot />→ native<slot /> - Plugins: global
nuxt.config→ per-componentdefineMarkdownComponent({ 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, as 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 { parseMarkdown } from 'comark'
const document = await parseMarkdown(markdown)Reusable parser
import { createMarkdownParser } from '@nuxtjs/mdc/runtime'
const parser = await createMarkdownParser(options)
const result = await parser(markdown)import { createMarkdownParser } from 'comark'
const parse = createMarkdownParser(options) // synchronous, no await needed
const document = await parse(markdown)createMarkdownParser 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(document)renderMarkdown includes frontmatter automatically: it reads document.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
}// MarkdownDocument
{
nodes: Node[] // the AST
frontmatter: Record<string, any> // frontmatter (all keys)
meta: {
toc?: Toc // requires toc plugin
summary?: Node[] // requires summary plugin
[key: string]: any
}
}Quick reference:
| MDC | Comark |
|---|---|
result.body | document.nodes |
result.data | document.frontmatter |
result.data.title | document.frontmatter.title |
result.toc | document.meta.toc (requires toc plugin) |
result.excerpt | document.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 { createMarkdownParser } from 'comark'
import shiki from 'comark/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
const parse = createMarkdownParser({
plugins: [
shiki({
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 { parseMarkdown } from 'comark'
import toc from 'comark/plugins/toc'
const document = await parseMarkdown(md, { plugins: [toc({ depth: 3 })] })
const tableOfContents = document.meta.tocExcerpt / Summary
const result = await parseMarkdown(md)
const excerpt = result.excerpt // MDCRoot | undefinedimport { parseMarkdown } from 'comark'
import summary from 'comark/plugins/summary'
const document = await parseMarkdown(md, { plugins: [summary()] })
const summaryNodes = document.meta.summary // Node[] | undefinedEmoji
// enabled by default via remark-emoji
// mdc: { remarkPlugins: { 'remark-emoji': {} } }import { parseMarkdown } from 'comark'
import emoji from 'comark/plugins/emoji'
const document = await parseMarkdown(md, { plugins: [emoji()] })Parse Options
// MDCOptions
{
remark: { plugins: { /* record */ } },
rehype: { options: {...}, plugins: { /* record */ } },
highlight: { theme: '...', langs: [...] } | false,
toc: { depth: 3, searchDepth: 2 } | false,
keepComments: false,
keepPosition: false,
}// ParserOptions
{
plugins: ComarkPlugin[], // ordered array, not a record
autoUnwrap: true, // removes <p> from single-paragraph containers
autoClose: true, // completes incomplete syntax (useful for streaming)
// HTML, components, attributes, alerts, task-list, frontmatter are on by default
}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 Markdown, MarkdownDocument, defineMarkdownComponent, and defineMarkdownDocumentComponent. 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" /><MarkdownDocument :value="document" :components="components" />Props that changed:
MDC <MDCRenderer> | Comark <MarkdownDocument> | Notes |
|---|---|---|
body (MDCRoot) | value (MarkdownDocument) | Different shape, see AST section |
data | — | document.frontmatter contains frontmatter, not a separate prop |
tag | — | Wrapper is always <div class="comark-content"> |
prose | — | Prose* component resolution is automatic |
unwrap | unwrap prop / parse option | <Markdown unwrap /> or :options="{ unwrap: 'p' }" — see inline rendering |
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 <Markdown>.
<MDC :value="markdown" :parser-options="options" />
<MDC :value="result" /> <!-- pre-parsed MDCParserResult --><Markdown :value="markdown" :options="options" />
<!-- or default slot -->
<Markdown>{{ markdown }}</Markdown>For a pre-parsed document, use <MarkdownDocument> directly instead of passing it to the all-in-one component.
defineMarkdownComponent
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 defineMarkdownComponent and defineMarkdownDocumentComponent, 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 { defineMarkdownComponent, defineMarkdownDocumentComponent } from '@comark/vue'
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'
import CustomAlert from '~/components/Alert.vue'
import ProsePre from '~/components/ProsePre.vue'
// Full-featured component for articles
export const ArticleMarkdown = defineMarkdownComponent({
name: 'ArticleMarkdown',
plugins: [
shiki({ 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 CommentMarkdown = defineMarkdownComponent({
name: 'CommentMarkdown',
})<script setup lang="ts">
import { ArticleMarkdown, CommentMarkdown } from '~/composables/markdown'
</script>
<template>
<!-- Full plugins for the article body -->
<ArticleMarkdown>{{ article.content }}</ArticleMarkdown>
<!-- Lightweight for each comment -->
<CommentMarkdown v-for="c in comments" :key="c.id">{{ c.body }}</CommentMarkdown>
</template>The same pattern applies to defineMarkdownDocumentComponent when you pre-parse on the server:
export const ArticleMarkdownDocument = defineMarkdownDocumentComponent({
name: 'ArticleMarkdownDocument',
components: {
alert: CustomAlert,
ProsePre,
},
})<script setup lang="ts">
import { ArticleMarkdownDocument } from '~/composables/markdown'
const { data: document } = await useFetch(`/api/article/${slug}`)
</script>
<template>
<ArticleMarkdownDocument :value="document" />
</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" /><Markdown summary>{{ markdown }}</Markdown>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.
ProsePre components that relied on a code prop (or meta.lines) for copy buttons or collapse thresholds need a small update: after highlighting, Comark does not expose the raw source as a prop. Declare __node and reconstruct the text with textContent() from comark/utils. See Custom Pre.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 | — | defineMarkdownComponent({ plugins, components }) |
| Reusable configured renderer | — | defineMarkdownDocumentComponent({ components }) |
| Parse | parseMarkdown(md) | parseMarkdown(md) |
| Render to Markdown | stringifyMarkdown(body, data) | renderMarkdown(document) |
| Reusable parser | await createMarkdownParser(opts) | createMarkdownParser(opts) |
| AST root | result.body (MDCRoot object) | document.nodes (tuple array) |
| Frontmatter | result.data | document.frontmatter |
| TOC | result.toc (built-in) | document.meta.toc (toc plugin) |
| Excerpt | result.excerpt (built-in) | document.meta.summary (summary plugin) |
| Renderer | <MDCRenderer :body :data> | <MarkdownDocument :value> |
| All-in-one | <MDC :value> | <Markdown :value> |
| Highlight | mdc.highlight in nuxt.config | shiki() plugin in defineMarkdownComponent |
| 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 |
Why Comark?
Comark gives JavaScript applications one Markdown pipeline for standards-based parsing, serializable documents, multiple renderers, plugins, and streaming.
Migration from MDX
Map every MDX concept (components, props, expressions, imports and exports) to its Comark equivalent, with a step-by-step migration checklist.