Render Comark in Vue

Learn how to render Comark in a Vue.js application with custom components, plugins, and streaming support.

The @comark/vue package provides Vue components for rendering Comark content with full support for custom components, plugins, and streaming.

Installation

pnpm add @comark/vue

<Markdown>

The <Markdown> component is the simplest way to render markdown in Vue. It handles parsing and rendering automatically.

<Markdown> is an async component and must always be wrapped in <Suspense>
For server-side rendering, use the MarkdownDocument component combined with parsing on the server instead.
App.vue
<script setup lang="ts">
import { Markdown } from '@comark/vue'

const content = `# Hello World

This is **markdown** with Comark components.
`
</script>

<template>
  <Suspense>
    <Markdown>{{ content }}</Markdown>
  </Suspense>
</template>

Usage

Pass markdown content via the default slot or the value prop. value accepts a markdown string or a pre-parsed MarkdownDocument:

<template>
  <Markdown>{{ content }}</Markdown>
</template>
Passing a document to <Markdown> skips parsing at runtime, but the parser is still bundled because Markdown imports it. To keep the client bundle free of the parser, use <MarkdownDocument> instead.

Props

PropTypeDefaultDescription
valuestring | MarkdownDocumentundefinedMarkdown string or pre-parsed document (alternative to default slot)
optionsParserOptions{}Parser options (autoUnwrap, autoClose, etc.)
pluginsComarkPlugin[][]Array of plugins
unwrapboolean | string | string[]falseStrip wrapper tags (MDC unwrap) — true unwraps <p>; a comma/space-separated string or array peels tags sequentially, e.g. "ul li"
componentsRecord<string, Component>{}Custom Vue component mappings
componentsManifestComponentManifestundefinedDynamic component resolver
streamingbooleanfalseEnable streaming mode
summarybooleanfalseOnly render content before <!-- more -->
caretboolean | { class: string }falseAppend caret to last text node
dataRecord<string, unknown>{}Runtime values referenced from markdown via :prop="data.path"

options

See ParserOptions for available options.

App.vue
<script setup lang="ts">
import { Markdown } from '@comark/vue'
</script>

<template>
  <Markdown :options="{ autoUnwrap: true, autoClose: true }">
    {{ content }}
  </Markdown>
</template>

plugins

See ComarkPlugin for available plugins.

App.vue
<script setup lang="ts">
import { Markdown } from '@comark/vue'
import shiki from '@comark/vue/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'

const plugins = [
  shiki({
    themes: {
      light: githubLight,
      dark: githubDark
    }
  })
]
</script>

<template>
  <Suspense>
    <Markdown :plugins="plugins">{{ content }}</Markdown>
  </Suspense>
</template>

For math and mermaid plugins, also pass the companion components:

App.vue
<script setup lang="ts">
import { Markdown } from '@comark/vue'
import math, { Math } from '@comark/vue/plugins/math'
import mermaid, { Mermaid } from '@comark/vue/plugins/mermaid'
import 'katex/dist/katex.min.css'
</script>

<template>
  <Suspense>
    <Markdown
      :value="markdown"
      :components="{ math: Math, mermaid: Mermaid }"
      :plugins="[math(), mermaid()]"
    />
  </Suspense>
</template>

components

Use this prop to map custom Vue components to Comark elements and use them in your markdown.

Create a Vue component

Save a component such as components/Alert.vue:

components/Alert.vue
<script setup lang="ts">
defineProps<{
  type?: 'info' | 'warning' | 'error' | 'success'
}>()
</script>

<template>
  <div class="alert" :class="`alert-${type || 'info'}`" role="alert">
    <slot />
  </div>
</template>

Map the tag to your component

Pass the components prop to Markdown:

App.vue
<script setup lang="ts">
import { Markdown } from '@comark/vue'
import Alert from './components/Alert.vue'
import Card from './components/Card.vue'

const components = { alert: Alert, card: Card }
</script>

<template>
  <Markdown :components="components">{{ content }}</Markdown>
</template>
Components receive props from the markdown and render children via slots.

Use it in your Markdown content

::alert{type="warning"}
This is a warning message!
::
See Component Bindings for how props and slots map to your Vue component, or Component Syntax for the full Comark syntax API: nested components, inline syntax, and more.

componentsManifest

For lazy-loading components on demand. Components are resolved once and cached. Works with both <Markdown> and <MarkdownDocument>:

App.vue
<script setup lang="ts">
import { Markdown } from '@comark/vue'

const manifest = (name: string) => {
  return import(`./components/prose/${name}.vue`)
}
</script>

<template>
  <Markdown :components-manifest="manifest">{{ content }}</Markdown>
</template>

data

Expose runtime values to markdown authors. Any prop written with a : prefix is resolved against the render context { frontmatter, meta, data, props } when its value isn't valid JSON. See Data Binding for the full scope.

App.vue
<script setup lang="ts">
import { Markdown } from '@comark/vue'

const user = { name: 'Ada', role: 'admin' }
const content = `Hello, :badge{:label="data.user.name"}!`
</script>

<template>
  <Suspense>
    <Markdown :value="content" :data="{ user }" />
  </Suspense>
</template>

defineMarkdownComponent

Creates a pre-configured <Markdown> component with default options, plugins, and components baked in.

Usage

Expose your configured component

markdown.ts
import { defineMarkdownComponent } from '@comark/vue'
import shiki from '@comark/vue/plugins/shiki'
import math, { Math } from '@comark/vue/plugins/math'
import mermaid, { Mermaid } from '@comark/vue/plugins/mermaid'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
import CustomAlert from './components/CustomAlert.vue'

export const AppMarkdown = defineMarkdownComponent({
  name: 'AppMarkdown',

  plugins: [
    math(),
    mermaid(),
    shiki({
      themes: {
        light: githubLight,
        dark: githubDark
      },
    }),
  ],

  components: {
    Math,
    Mermaid,
    alert: CustomAlert,
  },
})

Use it in your templates

App.vue
<script setup lang="ts">
import { AppMarkdown } from './markdown'
</script>

<template>
  <!-- All configuration is already included -->
  <AppMarkdown>{{ content }}</AppMarkdown>

  <!-- Can still override per-instance -->
  <AppMarkdown :components="{ alert: DifferentAlert }">{{ content }}</AppMarkdown>
</template>

Options

OptionTypeDefaultDescription
extendsReturnType<typeof defineMarkdownComponent>undefinedInherit plugins and components from another component
namestringundefinedComponent name for debugging
autoUnwrapbooleantrueAutomatically unwrap single block elements
autoClosebooleantrueAuto-close incomplete markdown syntax
linkifybooleantrueAuto-convert URL-like text into links
registerDefaultPluginsbooleantrueRegister default plugins (frontmatter, html, alert, task-list, components, attributes)
pluginsComarkPlugin[][]Array of plugins
componentsRecord<string, Component>{}Custom Vue component mappings
classstringundefinedAdditional CSS classes for the wrapper div

extends

Inherit plugins and components from another component, then layer your own on top:

markdown.ts
import { defineMarkdownComponent } from '@comark/vue'
import shiki from '@comark/vue/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
import toc from '@comark/vue/plugins/toc'
import math, { Math } from '@comark/vue/plugins/math'
import ProsePre from './components/ProsePre.vue'

// Base: highlight + prose components, used everywhere
export const BaseMarkdown = defineMarkdownComponent({
  name: 'BaseMarkdown',
  plugins: [
    shiki({ themes: { light: githubLight, dark: githubDark } }),
  ],
  components: { ProsePre },
})

// Article: extends Base, adds TOC and math
export const ArticleMarkdown = defineMarkdownComponent({
  name: 'ArticleMarkdown',
  extends: BaseMarkdown,
  plugins: [toc({ depth: 3 }), math()],
  components: { Math },
})

// Comment: extends Base only, no TOC, no math
export const CommentMarkdown = defineMarkdownComponent({
  name: 'CommentMarkdown',
  extends: BaseMarkdown,
})

Merging behavior

  • plugins: Arrays are concatenated (config plugins + prop plugins)
  • components: Component mappings override global configuration
  • Other options: Component options override global configuration

<MarkdownDocument>

Renders a pre-parsed MarkdownDocument without any parsing. Use it when you parse on the server, in a build step, or via an API, so no parser or plugin code is shipped to the browser.

Parsing

Parse on the server

server.ts
import { createMarkdownParser } from 'comark'
import { readFile } from 'node:fs/promises'

const parse = createMarkdownParser()

// In your server handler
export async function getContentDocument(slug: string) {
  const markdown = await readFile(`content/${slug}.md`, 'utf-8')
  return parse(markdown)
}

Render with MarkdownDocument

ContentPage.vue
<script setup lang="ts">
import { MarkdownDocument } from '@comark/vue'
import Alert from './components/Alert.vue'

const { slug } = defineProps<{ slug: string }>()

const res = await fetch(`/api/content/${slug}`)
const document = await res.json()
</script>

<template>
  <MarkdownDocument :value="document" :components="{ alert: Alert }" />
</template>

Renderer Props

PropTypeDefaultDescription
valueMarkdownDocumentRequired. The parsed document returned by parseMarkdown()
componentsRecord<string, Component>{}Custom Vue component mappings
componentsManifestComponentManifestundefinedDynamic component resolver for lazy-loaded components
streamingbooleanfalseEnable streaming mode
caretboolean | { class: string }falseAppend a blinking caret to the last text node
dataRecord<string, unknown>{}Runtime values referenced from markdown via :prop="data.path"

defineMarkdownDocumentComponent

Creates a pre-configured <MarkdownDocument> with baked-in component mappings.

Setup

Expose your configured renderer

markdown.ts
import { defineMarkdownDocumentComponent } from '@comark/vue'
import CustomAlert from './components/Alert.vue'
import ProsePre from './components/ProsePre.vue'

export const ArticleMarkdownDocument = defineMarkdownDocumentComponent({
  name: 'ArticleMarkdownDocument',
  components: {
    alert: CustomAlert,
    ProsePre,
  },
})

Use it in your templates

ArticlePage.vue
<script setup lang="ts">
import { ArticleMarkdownDocument } from './markdown'

const { slug } = defineProps<{ slug: string }>()

const res = await fetch(`/api/article/${slug}`)
const document = await res.json()
</script>

<template>
  <ArticleMarkdownDocument :value="document" />
</template>

Renderer Options

OptionTypeDefaultDescription
extendsReturnType<typeof defineMarkdownDocumentComponent>undefinedInherit component mappings from another renderer
namestringundefinedComponent name for debugging
componentsRecord<string, Component>{}Custom Vue component mappings
classstringundefinedAdditional CSS classes for the wrapper div

Inheritance

Inherit component mappings from another renderer, then layer your own on top:

markdown.ts
import { defineMarkdownDocumentComponent } from '@comark/vue'
import ProsePre from './components/ProsePre.vue'
import ProseA from './components/ProseA.vue'
import CustomAlert from './components/Alert.vue'
import CommentAlert from './components/CommentAlert.vue'

const BaseMarkdownDocument = defineMarkdownDocumentComponent({
  name: 'BaseMarkdownDocument',
  components: { ProsePre, ProseA },
})

export const ArticleMarkdownDocument = defineMarkdownDocumentComponent({
  name: 'ArticleMarkdownDocument',
  extends: BaseMarkdownDocument,
  components: { alert: CustomAlert },
})

export const CommentMarkdownDocument = defineMarkdownDocumentComponent({
  name: 'CommentMarkdownDocument',
  extends: BaseMarkdownDocument,
  components: { alert: CommentAlert },
})

Live Documents

MarkdownDocument can subscribe to an ambient context so external sources can drive a mounted renderer: an HMR signal, a collaboration socket, an agent editing the document while you chat with it, or devtools. The listen key is the document's own meta.key (set by a plugin) or the document-key prop, so a parsed document can carry its own identity. If globalThis.comarkContext exists the renderer listens for updates on that key and re-renders; on unmount it cleans up. With no context, the key is ignored at zero cost.

<template>
  <MarkdownDocument document-key="page" :value="document" />
</template>

A driver installs the context once and pushes updates by key with set() (replace the whole document) or patch() (surgical node edits, with structural sharing so only the changed branch re-renders):

import { createComarkContext, parseMarkdown } from 'comark'

const ctx = createComarkContext() // installs globalThis.comarkContext
const doc = ctx.get('page', await parseMarkdown('# Hello')) // seed on first access

doc.set(await parseMarkdown('# Replaced'))
doc.patch({ op: 'insert', path: [1], node: ['p', {}, 'inserted'] })

A path is a node-index path into document.nodes: the first segment indexes the top-level nodes, each later segment indexes into that element's children. Patch operations are replace, insert, remove (each takes a path), plus meta, frontmatter, and data merges. The same context API powers @comark/react, @comark/svelte, and @comark/angular: websocket handlers, agents, devtools, and HMR all drive it the same way.


Component Bindings

Comark automatically bridges the gap between Comark syntax and your component's interface.

Prop Binding

Attributes in Comark syntax are passed as props to your component. Use the : prefix to pass typed values:

MarkdownProp value
{type="warning"}"warning" (string)
{:count="5"}5 (number)
{:active="true"}true (boolean)
{:config='{"key":"val"}'}{ key: 'val' } (object)

Named Slots

Named slots in Comark (#slotname) map to Vue named slots:

  • Default slot<slot />
  • Named slots<slot name="slotname" /> (e.g., #footer<slot name="footer" />)
<script setup lang="ts">
defineProps<{
  title?: string
}>()
</script>

<template>
  <div class="card">
    <h3 v-if="title">{{ title }}</h3>
    <slot />
    <footer>
      <slot name="footer" />
    </footer>
  </div>
</template>

Overriding HTML Elements

Override how native HTML elements render by mapping a component to their tag name via the components prop in both the Markdown and MarkdownDocument components.

Create overridden version

components/Heading.vue
<script setup lang="ts">
const props = defineProps<{
  __node?: ElementNode
  id?: string
}>()
</script>

<template>
  <component :is="__node?.[0] || 'h2'" :id="id" class="heading">
    <a v-if="id" :href="`#${id}`" class="anchor">#</a>
    <slot />
  </component>
</template>

Map

Pass the component to the components prop:

App.vue
<template>
  <Markdown :components="{ h1: Heading, h2: Heading, h3: Heading }">
    {{ content }}
  </Markdown>
</template>

Resolution Order

When Comark encounters a tag, it looks for a matching Vue component in this order, stopping at the first match:

  1. Prose{PascalTag}: e.g., ProseH1 for h1. Follows the Nuxt Content prose component convention.
  2. {PascalTag}: e.g., Alert for alert. PascalCase version of the tag name.
  3. {tag}: e.g., alert. Exact tag name as-is.
  4. Global: any component registered via app.component().

If none match, the tag renders as a native HTML element.


Nuxt UI Integration

When @nuxt/ui is installed, Comark automatically uses Nuxt UI's prose components for enhanced styling.

Vite Setup

Enable prose components by setting prose: true in the Nuxt UI Vite plugin:

vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      prose: true
    })
  ]
})

CSS Setup

src/assets/css/main.css
@import "tailwindcss";
@import "@nuxt/ui";

Streaming

Enable real-time rendering as content arrives, ideal for AI chat interfaces and live previews.

Setup

Set streaming to true while content is being received, then false when done:

components/AiChat.vue
<script setup lang="ts">
import { ref } from 'vue'
import { Markdown } from '@comark/vue'

const content = ref('')
const isStreaming = ref(false)

async function askAI(prompt: string) {
  content.value = ''
  isStreaming.value = true

  const response = await fetch('/api/chat', {
    method: 'POST',
    body: JSON.stringify({ prompt }),
  })

  const reader = response.body!.getReader()
  const decoder = new TextDecoder()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    content.value += decoder.decode(value, { stream: true })
  }

  isStreaming.value = false
}
</script>

<template>
  <Markdown :streaming="isStreaming" caret>
    {{ content }}
  </Markdown>
</template>
autoClose is enabled by default: incomplete syntax like **bold text is automatically closed on every parse. Disable with :options="{ autoClose: false }".

Caret

The caret prop appends a blinking cursor to the last text node while streaming is true:

App.vue
<!-- Default caret -->
<Markdown :streaming="isStreaming" caret>{{ content }}</Markdown>

<!-- Custom caret class -->
<Markdown :streaming="isStreaming" :caret="{ class: 'my-caret' }">{{ content }}</Markdown>
.my-caret {
  display: inline-block;
  width: 2px;
  height: 1em;
  background: currentColor;
  animation: blink 1s step-end infinite;
  vertical-align: text-bottom;
}

@keyframes blink {
  50% { opacity: 0; }
}

TypeScript Support

Use ComarkPlugin from comark to type plugin arrays, and ElementNode to type the __node prop in components that override HTML elements:

ComarkWrapper.vue
<script setup lang="ts">
import type { Component } from 'vue'
import type { ComarkPlugin } from 'comark'
import { Markdown } from '@comark/vue'

interface Props {
  content: string
  components?: Record<string, Component>
  plugins?: ComarkPlugin[]
}

const props = defineProps<Props>()
</script>

<template>
  <Suspense>
    <Markdown :components="props.components" :plugins="props.plugins">
      {{ props.content }}
    </Markdown>
  </Suspense>
</template>
components/Heading.vue
<script setup lang="ts">
import type { ElementNode } from 'comark'

defineProps<{
  __node?: ElementNode
  id?: string
}>()
</script>