Rendering
Streaming
Render Comark content in real-time as it arrives, with auto-close for incomplete syntax and caret indicators for AI chat interfaces.
Comark supports real-time incremental parsing, making it ideal for AI chat interfaces, live previews, and any scenario where content arrives in chunks.
Why Streaming?
When an LLM generates a response, text arrives token by token. Without streaming support, you'd need to wait for the full response before rendering. Comark solves this with two features:
autoClose- Automatically closes incomplete Markdown syntax (**boldbecomes**bold**). Enabled by default — no configuration needed.streamingprop - Tells the renderer the content is still arriving, preventing layout shifts
Vue Streaming
Enable streaming by setting the streaming prop to true while content is being received:
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. You can disable it with :options="{ autoClose: false }".React Streaming
components/AiChat.tsx
import { useState } from 'react'
import { Comark } from '@comark/react'
export default function AiChat() {
const [content, setContent] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
async function askAI(prompt: string) {
setContent('')
setIsStreaming(true)
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let accumulated = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
accumulated += decoder.decode(value, { stream: true })
setContent(accumulated)
}
setIsStreaming(false)
}
return (
<Comark streaming={isStreaming} caret>
{content}
</Comark>
)
}
Svelte Streaming
components/AiChat.svelte
<script lang="ts">
import { Comark } from '@comark/svelte'
let content = $state('')
let isStreaming = $state(false)
async function askAI(prompt: string) {
content = ''
isStreaming = 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 += decoder.decode(value, { stream: true })
}
isStreaming = false
}
</script>
<Comark markdown={content} streaming={isStreaming} caret />
Caret Indicator
The caret prop appends a blinking cursor to the last text node, giving visual feedback that content is still arriving:
App.vue
<Comark :streaming="isStreaming" caret>
{{ content }}
</Comark>
Customize the caret's CSS class:
App.vue
<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; }
}
The caret is only visible while
streaming is true. Once streaming completes, the caret disappears automatically.How Auto-Close Works
The autoCloseMarkdown function detects and closes unclosed syntax:
import { autoCloseMarkdown } from 'comark'
autoCloseMarkdown('**bold text')
autoCloseMarkdown('- Item 1\n- Item 2\n - Nested')
autoCloseMarkdown('::alert{type="info"}\nContent')
**bold text**
- Item 1\n- Item 2\n - Nested
::alert{type="info"}\nContent\n::
Supported auto-close patterns:
| Syntax | Example | Auto-closed |
|---|---|---|
| Bold | **bold | **bold** |
| Italic | *italic | *italic* |
| Strikethrough | ~~strike | ~~strike~~ |
| Inline code | `code | `code` |
| Code block | ```js\ncode | ```js\ncode\n``` |
| Block component | ::alert\nText | ::alert\nText\n:: |
Advanced: Manual Stream Parsing
For full control over the parsing pipeline, parse each chunk manually:
import { parse, autoCloseMarkdown } from 'comark'
let accumulated = ''
async function onChunk(chunk: string) {
accumulated += chunk
const closed = autoCloseMarkdown(accumulated)
const tree = await parse(closed, { autoClose: false })
render(tree)
}
async function onComplete() {
const tree = await parse(accumulated)
render(tree)
}
When calling
autoCloseMarkdown manually, pass autoClose: false to parse to avoid double-closing. For the final chunk, parse the raw accumulated content without autoClose: false to get the accurate AST.Next Steps
- Auto-Close API - Detailed auto-close function reference
- Vue Rendering - Full Vue component API
- React Rendering - Full React component API
- Svelte Rendering - Full Svelte component API
- Parse API - Parser options and configuration