Knowledge Base

Migrating from MDC

Step-by-step guide for moving from @nuxtjs/mdc to Comark.

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/mdccomark (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.datatree.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-component defineComarkComponent({ plugins })
  • Markdown files: no changes needed

Agent Skill

To give coding agents the MDC-to-Comark migration skill, install it from production:

Terminal
npx skills add https://comark.dev/skills/migrate-mdc-to-comark

Package Changes

If you only use the programmatic API (parse functions, plugins, AST manipulation), install comark alone:

npm uninstall @nuxtjs/mdc
npm install comark

If 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/nuxt

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

Reusable parser

import { createMarkdownParser } from '@nuxtjs/mdc/runtime'
const parser = await createMarkdownParser(options)
const result = await parser(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.
See the Parse API for the full list of options and return types.

Render Functions

Serialize back to Markdown

import { stringifyMarkdown } from '@nuxtjs/mdc/runtime'
const markdown = stringifyMarkdown(result.body, result.data)

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
}

Quick reference:

MDCComark
result.bodytree.nodes
result.datatree.frontmatter
result.data.titletree.frontmatter.title
result.toctree.meta.toc (requires toc plugin)
result.excerpttree.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' }
  ]
}

Mapping:

MDCComark
node.type === 'element'Array.isArray(node)
node.type === 'text'typeof node === 'string'
node.tagnode[0]
node.propsnode[1]
node.childrennode.slice(2)

If you traverse the AST manually, update your node-walking code accordingly.

See the Comark AST reference for the full node type documentation and traversal helpers.

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({ /* ... */ }),
        },
      },
    },
  },
})

Table of Contents

import { parseMarkdown } from '@nuxtjs/mdc/runtime'
const result = await parseMarkdown(md, { toc: { depth: 3 } })
const toc = result.toc

Excerpt / Summary

const result = await parseMarkdown(md)
const excerpt = result.excerpt  // MDCRoot | undefined

Emoji

// enabled by default via remark-emoji
// mdc: { remarkPlugins: { 'remark-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,
}

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 the Plugins documentation for the full list of available plugins and how to write your own.

Nuxt Module

Configuration

export default defineNuxtConfig({
  modules: ['@nuxtjs/mdc'],
  mdc: { highlight: { ... }, remarkPlugins: { ... } },
})

@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" />

Props that changed:

MDC <MDCRenderer>Comark <ComarkRenderer>Notes
body (MDCRoot)tree (ComarkTree)Different shape — see AST section
datatree.frontmatter contains frontmatter, not a separate prop
tagWrapper is always <div class="comark-content">
proseProse* component resolution is automatic
unwrapUse autoUnwrap in parse options instead
classStyle the wrapper via CSS
componentscomponentsSame purpose, same shape
componentsManifestNew: dynamic async component resolver
streamingNew: streaming mode
caretNew: 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 -->

For a pre-parsed tree, use <ComarkRenderer> directly instead of passing it to the all-in-one component.

See Render Comark in Nuxt for the full component API, streaming setup, and SSR examples.

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.

composables/comark.ts
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',
})
pages/article/[slug].vue
<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:

composables/comark.ts
export const ArticleRenderer = defineComarkRendererComponent({
  name: 'ArticleRenderer',
  components: {
    alert: CustomAlert,
    ProsePre,
  },
})
pages/article/[slug].vue
<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>

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

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

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:

ElementMDC component nameComark component name
<p>ProsePProseP
<h1>ProseH1ProseH1
<a>ProseAProseA
<pre>ProsePreProsePre
<code>ProseCodeProseCode

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.
:::
See the Component Syntax reference for blocks, inline elements, props, slots, and nesting.

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 equivalent
These features may be added in a future release. If your project relies on binding syntax or data passing, please create a feature request on GitHub so we can prioritize accordingly.

Quick Reference

TaskMDCComark
Install (core only)@nuxtjs/mdccomark
Install (Nuxt)@nuxtjs/mdc@comark/nuxt
Nuxt module@nuxtjs/mdc@comark/nuxt
Global plugin configmdc: { ... } in nuxt.config— (no global config)
Reusable configured componentdefineComarkComponent({ plugins, components })
Reusable configured rendererdefineComarkRendererComponent({ components })
ParseparseMarkdown(md)parse(md)
Render to MarkdownstringifyMarkdown(body, data)renderMarkdown(tree)
Reusable parserawait createMarkdownParser(opts)createParse(opts)
AST rootresult.body (MDCRoot object)tree.nodes (tuple array)
Frontmatterresult.datatree.frontmatter
TOCresult.toc (built-in)tree.meta.toc (toc plugin)
Excerptresult.excerpt (built-in)tree.meta.summary (summary plugin)
Renderer<MDCRenderer :body :data><ComarkRenderer :tree>
All-in-one<MDC :value><Comark :markdown>
Highlightmdc.highlight in nuxt.confighighlight() plugin in defineComarkComponent
Prose componentscomponents/prose/Prose*.vuecomponents/prose/Prose*.vue
Render slot<MDCSlot /><slot />
Unwrap slot<MDCSlot unwrap="p" /> or <slot mdc-unwrap="p" /><slot unwrap="p" />
StreamingNot supportedstreaming prop + autoClose