Streaming API

Render incomplete Markdown correctly during streaming. autoCloseMarkdown() closes unterminated syntax so AI output displays at every frame.

autoCloseMarkdown(source, options?)

Automatically closes unclosed markdown inline syntax and Comark components. Built for streaming scenarios where content arrives incrementally and may be incomplete at any point. CommonMark/GFM healing follows the behavioral SPEC in packages/comark/SPEC/auto-close.md.

Parameters:

  • source - The markdown content (potentially partial/incomplete)
  • options (optional) - Feature flags and Comark settings:

    • Comark: frontmatter: true completes an unclosed leading frontmatter block; syntax: false skips component-fence closing; attributes controls {...} attribute scopes (defaults to syntax)
    • Links / images: linkMode: 'protocol' | 'text-only' (default 'protocol'), incompleteLinkPlaceholder (default comark:incomplete-link), incompleteImagePlaceholder (default comark:incomplete-image)
    • Math: inline $…$ and block $$…$$ off by default on bare autoCloseMarkdown; pass math: true (or use parseMarkdown, which enables it)
    • Streaming: dropTrailingOpeners: true drops a trailing opener after whitespace (hello *hello) so half-typed markers do not flash. Enabled automatically when parsing with streaming: true.

Returns: string, the source with all unclosed syntax closed

Example:

import { autoCloseMarkdown } from 'comark'

autoCloseMarkdown('**bold text')
// '**bold text**'

autoCloseMarkdown('[partial link')
// '[partial link](comark:incomplete-link)'

autoCloseMarkdown('$x = 5')
// '$x = 5'

autoCloseMarkdown('$x = 5', { math: true })
// '$x = 5$'

autoCloseMarkdown('hello *', { dropTrailingOpeners: true })
// 'hello'
autoCloseMarkdown is also available as a parse option: set autoClose: true (default) in parseMarkdown() or createMarkdownParser() to apply it automatically.

Parser integration

autoClose is enabled by default in parseMarkdown() and createMarkdownParser(). You can disable it if you want to handle incomplete syntax yourself:

import { parseMarkdown } from 'comark'

const result = await parseMarkdown(content, {
  autoClose: true // default
})

Supported syntax

Inline Markdown

SyntaxExampleAuto-closed
Bold**text**text**
Italic*text / _text*text* / _text_
Bold-italic***text***text***
Code`code`code`
Strikethrough~~text~~text~~
Link (protocol)[text](url[text](comark:incomplete-link)
Link (text-only)[texttext
Image![alt](url![alt](comark:incomplete-image)
Block math$$x$$x$$
Inline math$x$x$ (math: true)

Comark components

Block components are closed based on their marker count:

auto-close.ts
// Double marker (block component)
autoCloseMarkdown('::alert\nContent')
// '::alert\nContent\n::'

// Triple marker (nested component)
autoCloseMarkdown(':::card\nContent')
// ':::card\nContent\n:::'

// Nested components
autoCloseMarkdown('::::outer\n:::inner\n::component')
// '::::outer\n:::inner\n::component\n::\n:::\n::::'

Props and attributes

Components with props are handled correctly:

auto-close.ts
// Inline props
autoCloseMarkdown('::alert{type="info" title="Note"}')
// '::alert{type="info" title="Note"}\n::'

// YAML props
autoCloseMarkdown(`::component\n---\nkey: value\n---\nContent`)
// '::component\n---\nkey: value\n---\nContent\n::'

Use cases

AI streaming

When streaming AI-generated markdown, content arrives in chunks and may be incomplete at any point:

chat.ts
import { autoCloseMarkdown, parseMarkdown } from 'comark'

let accumulated = ''

socket.on('chunk', async (chunk) => {
  accumulated += chunk

  // Auto-close before parsing to ensure valid AST at every chunk
  const closed = autoCloseMarkdown(accumulated)
  const result = await parseMarkdown(closed)

  renderContent(result.nodes)
})

socket.on('end', async () => {
  const result = await parseMarkdown(accumulated)
  renderFinalContent(result.nodes)
})

AI SDK

Build a streaming AI chat with live Comark rendering in a few lines. The <Markdown> component applies autoCloseMarkdown on every render by default, so the AST stays valid at every chunk and you get smooth incremental rendering without any extra wiring.

Install dependencies

npm install ai @ai-sdk/vue

Create a server route

server/api/chat.post.ts
import { convertToModelMessages, streamText } from 'ai'

export default defineEventHandler(async (event) => {
  const { messages } = await readBody(event)

  const result = streamText({
    model: 'anthropic/claude-sonnet-4.6',
    system: 'You are a helpful assistant. Always respond using Comark syntax.',
    messages: await convertToModelMessages(messages),
  })

  return result.toUIMessageStreamResponse()
})

Render on the client

Use the Chat class from @ai-sdk/vue and the <Markdown> component to parse and render the content as it arrives. Use isPartStreaming(part) for per-part streaming detection:

app/pages/chat.vue
<script setup lang="ts">
import { Chat } from '@ai-sdk/vue'
import { isTextUIPart } from 'ai'
import { isPartStreaming } from '@nuxt/ui/utils/ai'

const chat = new Chat({})
const input = ref('')

function onSubmit() {
  chat.sendMessage({ text: input.value })
  input.value = ''
}
</script>

<template>
  <UChatMessages
    should-auto-scroll
    :messages="chat.messages"
    :status="chat.status"
  >
    <template #indicator>
      <UChatShimmer text="Thinking..." />
    </template>

    <template #content="{ message }">
      <template
        v-for="(part, index) in message.parts"
        :key="`${message.id}-${part.type}-${index}`"
      >
        <template v-if="isTextUIPart(part)">
          <p v-if="message.role === 'user'" class="whitespace-pre-wrap">
            {{ part.text }}
          </p>
          <Suspense v-else>
            <Markdown :value="part.text" :streaming="isPartStreaming(part)" caret />
          </Suspense>
        </template>
      </template>
    </template>
  </UChatMessages>

  <UChatPrompt v-model="input" placeholder="Ask something…" @submit="onSubmit">
    <UChatPromptSubmit
      :status="chat.status"
      @stop="chat.stop()"
      @reload="chat.regenerate()"
    />
  </UChatPrompt>
</template>
See the full working example with server route and chat UI wired together.

The caret prop on <Markdown> appends a blinking cursor to the last text node while streaming. See the Vue rendering docs for custom caret styling.

Real-time editor

Show a live preview while the user types:

editor.ts
import { autoCloseMarkdown, parseMarkdown } from 'comark'
import { renderHtmlFromDocument } from '@comark/html'

editor.addEventListener('input', async (e) => {
  const closed = autoCloseMarkdown(e.target.value)
  const result = await parseMarkdown(closed)
  preview.innerHTML = await renderHtmlFromDocument(result)
})

Incremental file upload

Parse content progressively as a file uploads:

upload.ts
import { autoCloseMarkdown, parseMarkdown } from 'comark'

async function uploadAndParse(file: File) {
  const chunkSize = 64 * 1024 // 64KB
  let offset = 0
  let accumulated = ''

  while (offset < file.size) {
    const chunk = file.slice(offset, offset + chunkSize)
    accumulated += await chunk.text()

    const closed = autoCloseMarkdown(accumulated)
    const result = await parseMarkdown(closed)

    updateProgress({
      percent: (offset / file.size) * 100,
      preview: result.nodes
    })

    offset += chunkSize
  }

  return parseMarkdown(accumulated)
}

Performance

Call autoCloseMarkdown once per chunk on the accumulated content, not on every character:

stream.ts
// ✅ Good: call once per chunk
for await (const chunk of stream) {
  accumulated += chunk
  const closed = autoCloseMarkdown(accumulated)
  render(await parseMarkdown(closed))
}

// ❌ Avoid: calling for every character
for (const char of text) {
  accumulated += char
  const closed = autoCloseMarkdown(accumulated) // too frequent
  render(await parseMarkdown(closed))
}