Render Comark in Vue
The @comark/vue package provides Vue components for rendering Comark content with full support for custom components, plugins, and streaming.
Installation
pnpm add @comark/vuenpm install @comark/vueyarn add @comark/vuebun 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>MarkdownDocument component combined with parsing on the server instead.<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><template>
<Markdown :value="content" />
</template><script setup lang="ts">
import type { MarkdownDocument } from 'comark'
defineProps<{ document: MarkdownDocument }>()
</script>
<template>
<Markdown :value="document" />
</template><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
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | MarkdownDocument | undefined | Markdown string or pre-parsed document (alternative to default slot) |
options | ParserOptions | {} | Parser options (autoUnwrap, autoClose, etc.) |
plugins | ComarkPlugin[] | [] | Array of plugins |
unwrap | boolean | string | string[] | false | Strip wrapper tags (MDC unwrap) — true unwraps <p>; a comma/space-separated string or array peels tags sequentially, e.g. "ul li" |
components | Record<string, Component> | {} | Custom Vue component mappings |
componentsManifest | ComponentManifest | undefined | Dynamic component resolver |
streaming | boolean | false | Enable streaming mode |
summary | boolean | false | Only render content before <!-- more --> |
caret | boolean | { class: string } | false | Append caret to last text node |
data | Record<string, unknown> | {} | Runtime values referenced from markdown via :prop="data.path" |
options
See ParserOptions for available options.
<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.
<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:
<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:
<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:
<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>Use it in your Markdown content
::alert{type="warning"}
This is a warning message!
::componentsManifest
For lazy-loading components on demand. Components are resolved once and cached. Works with both <Markdown> and <MarkdownDocument>:
<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.
<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
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
<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
| Option | Type | Default | Description |
|---|---|---|---|
extends | ReturnType<typeof defineMarkdownComponent> | undefined | Inherit plugins and components from another component |
name | string | undefined | Component name for debugging |
autoUnwrap | boolean | true | Automatically unwrap single block elements |
autoClose | boolean | true | Auto-close incomplete markdown syntax |
linkify | boolean | true | Auto-convert URL-like text into links |
registerDefaultPlugins | boolean | true | Register default plugins (frontmatter, html, alert, task-list, components, attributes) |
plugins | ComarkPlugin[] | [] | Array of plugins |
components | Record<string, Component> | {} | Custom Vue component mappings |
class | string | undefined | Additional CSS classes for the wrapper div |
extends
Inherit plugins and components from another component, then layer your own on top:
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
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
<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
| Prop | Type | Default | Description |
|---|---|---|---|
value | MarkdownDocument | — | Required. The parsed document returned by parseMarkdown() |
components | Record<string, Component> | {} | Custom Vue component mappings |
componentsManifest | ComponentManifest | undefined | Dynamic component resolver for lazy-loaded components |
streaming | boolean | false | Enable streaming mode |
caret | boolean | { class: string } | false | Append a blinking caret to the last text node |
data | Record<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
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
<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
| Option | Type | Default | Description |
|---|---|---|---|
extends | ReturnType<typeof defineMarkdownDocumentComponent> | undefined | Inherit component mappings from another renderer |
name | string | undefined | Component name for debugging |
components | Record<string, Component> | {} | Custom Vue component mappings |
class | string | undefined | Additional CSS classes for the wrapper div |
Inheritance
Inherit component mappings from another renderer, then layer your own on top:
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:
| Markdown | Prop 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>::card{title="My Card"}
Default slot content.
#footer
Footer slot content.
::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
<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:
<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:
Prose{PascalTag}: e.g.,ProseH1forh1. Follows the Nuxt Content prose component convention.{PascalTag}: e.g.,Alertforalert. PascalCase version of the tag name.{tag}: e.g.,alert. Exact tag name as-is.- 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:
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
@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:
<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:
<!-- 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:
<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><script setup lang="ts">
import type { ElementNode } from 'comark'
defineProps<{
__node?: ElementNode
id?: string
}>()
</script>