Rendering

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

<Comark>

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

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

const content = `# Hello World

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

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

Usage

Pass markdown content via the default slot or the markdown prop:

<template>
  <Comark>{{ content }}</Comark>
</template>

Props

PropTypeDefaultDescription
markdownstringundefinedAlternative to default slot
optionsParseOptions{}Parser options (autoUnwrap, autoClose, etc.)
pluginsComarkPlugin[][]Array of plugins
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 ParseOptions for available options.

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

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

plugins

See ComarkPlugin for available plugins.

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

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

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

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

App.vue
<script setup lang="ts">
import { Comark } 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>
    <Comark
      :markdown="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 Comark:

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

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

<template>
  <Comark :components="components">{{ content }}</Comark>
</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 <Comark> and <ComarkRenderer>:

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

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

<template>
  <Comark :components-manifest="manifest">{{ content }}</Comark>
</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 { Comark } from '@comark/vue'

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

<template>
  <Suspense>
    <Comark :markdown="content" :data="{ user }" />
  </Suspense>
</template>

defineComarkComponent

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

Usage

Expose your configured component

comark.ts
import { defineComarkComponent } from '@comark/vue'
import highlight from '@comark/vue/plugins/highlight'
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 AppComark = defineComarkComponent({
  name: 'AppComark',

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

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

Use it in your templates

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

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

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

Options

OptionTypeDefaultDescription
extendsReturnType<typeof defineComarkComponent>undefinedInherit plugins and components from another component
namestringundefinedComponent name for debugging
autoUnwrapbooleantrueAutomatically unwrap single block elements
autoClosebooleantrueAuto-close incomplete markdown syntax
htmlbooleantrueParse embedded HTML tags into AST nodes
linkifybooleantrueAuto-convert URL-like text into links
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:

comark.ts
import { defineComarkComponent } from '@comark/vue'
import highlight from '@comark/vue/plugins/highlight'
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 BaseComark = defineComarkComponent({
  name: 'BaseComark',
  plugins: [
    highlight({ themes: { light: githubLight, dark: githubDark } }),
  ],
  components: { ProsePre },
})

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

// Comment: extends Base only, no TOC, no math
export const CommentComark = defineComarkComponent({
  name: 'CommentComark',
  extends: BaseComark,
})

Merging behavior

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

<ComarkRenderer>

Renders a pre-parsed ComarkTree 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 { createParse } from 'comark'
import { readFile } from 'node:fs/promises'

const parse = createParse()

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

Render with ComarkRenderer

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

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

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

<template>
  <ComarkRenderer :tree="tree" :components="{ alert: Alert }" />
</template>

Renderer Props

PropTypeDefaultDescription
treeComarkTree—Required. The parsed tree returned by parse()
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"

defineComarkRendererComponent

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

Setup

Expose your configured renderer

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

export const ArticleRenderer = defineComarkRendererComponent({
  name: 'ArticleRenderer',
  components: {
    alert: CustomAlert,
    ProsePre,
  },
})

Use it in your templates

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

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

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

<template>
  <ArticleRenderer :tree="tree" />
</template>

Renderer Options

OptionTypeDefaultDescription
extendsReturnType<typeof defineComarkRendererComponent>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:

comark.ts
import { defineComarkRendererComponent } 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 BaseRenderer = defineComarkRendererComponent({
  name: 'BaseRenderer',
  components: { ProsePre, ProseA },
})

export const ArticleRenderer = defineComarkRendererComponent({
  name: 'ArticleRenderer',
  extends: BaseRenderer,
  components: { alert: CustomAlert },
})

export const CommentRenderer = defineComarkRendererComponent({
  name: 'CommentRenderer',
  extends: BaseRenderer,
  components: { alert: CommentAlert },
})

Live Documents

ComarkRenderer 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 tree's own meta.key (set by a plugin) or the comark-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>
  <ComarkRenderer comark-key="page" :tree="tree" />
</template>

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

import { createComarkContext, parse } from 'comark'

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

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

A path is a node-index path into tree.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 Comark and ComarkRenderer components.

Create overridden version

components/Heading.vue
<script setup lang="ts">
const props = defineProps<{
  __node?: ComarkElement
  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>
  <Comark :components="{ h1: Heading, h2: Heading, h3: Heading }">
    {{ content }}
  </Comark>
</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 { Comark } 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>
  <Comark :streaming="isStreaming" caret>
    {{ content }}
  </Comark>
</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 -->
<Comark :streaming="isStreaming" caret>{{ content }}</Comark>

<!-- Custom caret class -->
<Comark :streaming="isStreaming" :caret="{ class: 'my-caret' }">{{ content }}</Comark>
.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 ComarkElement 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 { Comark } from '@comark/vue'

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

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

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

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