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:

  1. autoClose - Automatically closes incomplete Markdown syntax (**bold becomes **bold**). Enabled by default — no configuration needed.
  2. streaming prop - 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')

Supported auto-close patterns:

SyntaxExampleAuto-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

Copyright © 2026