Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ describe('functionUnderTest', () => {
```typescript
const result = await parseMarkdown(markdownContent, {
autoUnwrap: true, // Remove <p> wrappers from single-paragraph containers
autoClose: true, // Auto-close incomplete syntax; also accepts (markdown) => string
autoClose: 'streaming', // Heal incomplete syntax while streaming; `true` heals every parse, also accepts (markdown) => string
unwrap: 'p', // Strip top-level wrapper tags (MDC unwrap); merges paragraphs
registerDefaultPlugins: true, // frontmatter, html, alert, task-list, components, attributes; false to disable
})
Expand Down
2 changes: 1 addition & 1 deletion docs/content/3.rendering/2.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ This is a **bold** statement with a [link](https://example.com).
| [`plugins`](#render-options-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins |
| [`components`](#render-options-components) | `Record<string, fn>` | `{}` | Custom component renderers |
| [`data`](#render-options-data) | `Record<string, any>` | `undefined` | Data passed to component renderers |
| `autoClose` | `boolean` | `true` | Close incomplete Markdown and components before parsing |
| `autoClose` | `boolean \| 'streaming' \| fn` | `'streaming'` | Close incomplete Markdown and components before parsing. `'streaming'` only closes on a streaming parse, so a plain render leaves input as written |
| `autoUnwrap` | `boolean` | `true` | Remove a single paragraph wrapper inside components |
| `linkify` | `boolean` | `true` | Convert URL-like text into links |
| `registerDefaultPlugins` | `boolean` | `true` | Register default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`) |
Expand Down
4 changes: 2 additions & 2 deletions docs/content/3.rendering/3.vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ import { AppMarkdown } from './markdown'
| [`extends`](#code-markdown-code-definemarkdowncomponent-code-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 |
| `autoClose` | `boolean \| 'streaming' \| fn` | `'streaming'` | Auto-close incomplete markdown syntax. Only closes on a streaming parse by default |
| `linkify` | `boolean` | `true` | Auto-convert URL-like text into links |
| `registerDefaultPlugins` | `boolean` | `true` | Register default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`) |
| [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins |
Expand Down Expand Up @@ -744,7 +744,7 @@ async function askAI(prompt: string) {
```

::callout{icon="i-lucide-info" color="info"}
`autoClose` is enabled by default: incomplete syntax like `**bold text` is automatically closed on every parse. Disable with `:options="{ autoClose: false }"`.
`autoClose` defaults to `'streaming'`: incomplete syntax like `**bold text` is closed when the `streaming` prop is set, and left as written otherwise. Set `:options="{ autoClose: true }"` to close incomplete syntax on every parse, or `:options="{ autoClose: false }"` to never close it.
::

### Caret
Expand Down
2 changes: 1 addition & 1 deletion docs/content/3.rendering/4.nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ async function askAI(prompt: string) {
```

::callout{icon="i-lucide-info" color="info"}
`autoClose` is enabled by default: incomplete syntax like `**bold text` is automatically closed on every parse. Disable with `:options="{ autoClose: false }"`.
`autoClose` defaults to `'streaming'`: incomplete syntax like `**bold text` is closed when the `streaming` prop is set, and left as written otherwise. Set `:options="{ autoClose: true }"` to close incomplete syntax on every parse, or `:options="{ autoClose: false }"` to never close it.
::

### Caret
Expand Down
4 changes: 2 additions & 2 deletions docs/content/3.rendering/5.react.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ export default function App() {
| [`extends`](#code-markdown-code-definemarkdowncomponent-code-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 |
| `autoClose` | `boolean \| 'streaming' \| fn` | `'streaming'` | Auto-close incomplete markdown syntax. Only closes on a streaming parse by default |
| `linkify` | `boolean` | `true` | Auto-convert URL-like text into links |
| `registerDefaultPlugins` | `boolean` | `true` | Register default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`) |
| [`plugins`](#code-markdown-props-code-plugins) | `ComarkPlugin[]` | `[]` | Array of plugins |
Expand Down Expand Up @@ -677,7 +677,7 @@ export default function AiChat() {
```

::callout{icon="i-lucide-info" color="info"}
`autoClose` is enabled by default: incomplete syntax like `**bold text` is automatically closed on every parse. Disable with `options={{ autoClose: false }}`.
`autoClose` defaults to `'streaming'`: incomplete syntax like `**bold text` is closed when the `streaming` prop is set, and left as written otherwise. Set `options={{ autoClose: true }}` to close incomplete syntax on every parse, or `options={{ autoClose: false }}` to never close it.
::

### Caret
Expand Down
2 changes: 1 addition & 1 deletion docs/content/3.rendering/6.svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ Set `streaming` to `true` while content is being received, then `false` when don
```

::callout{icon="i-lucide-info" color="info"}
`autoClose` is enabled by default: incomplete syntax like `**bold text` is automatically closed on every parse. Disable with `options={{ autoClose: false }}`.
`autoClose` defaults to `'streaming'`: incomplete syntax like `**bold text` is closed when the `streaming` prop is set, and left as written otherwise. Set `options={{ autoClose: true }}` to close incomplete syntax on every parse, or `options={{ autoClose: false }}` to never close it.
::

### Caret
Expand Down
2 changes: 1 addition & 1 deletion docs/content/3.rendering/7.angular.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ export class AiChatComponent {
```

::callout{icon="i-lucide-info" color="info"}
`autoClose` is enabled by default: incomplete syntax like `**bold text` is automatically closed on every parse. Disable with `[options]="{ autoClose: false }"`.
`autoClose` defaults to `'streaming'`: incomplete syntax like `**bold text` is closed when the `streaming` prop is set, and left as written otherwise. Set `[options]="{ autoClose: true }"` to close incomplete syntax on every parse, or `[options]="{ autoClose: false }"` to never close it.
::

### Caret
Expand Down
2 changes: 1 addition & 1 deletion docs/content/3.rendering/8.ansi.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ This is a bold statement with a link (https://example.com).
| [`data`](#render-options-data) | `Record<string, any>` | `undefined` | Data passed to component renderers |
| `colors` | `boolean` | `true`* | Emit ANSI escape codes |
| `width` | `number` | `80` | Terminal width for HR and code block headers |
| `autoClose` | `boolean` | `true` | Close incomplete Markdown and components before parsing |
| `autoClose` | `boolean \| 'streaming' \| fn` | `'streaming'` | Close incomplete Markdown and components before parsing. `'streaming'` only closes on a streaming parse, so a plain render leaves input as written |
| `autoUnwrap` | `boolean` | `true` | Remove a single paragraph wrapper inside components |
| `linkify` | `boolean` | `true` | Convert URL-like text into links |
| `registerDefaultPlugins` | `boolean` | `true` | Register default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`) |
Expand Down
7 changes: 4 additions & 3 deletions docs/content/5.reference/1.parse.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ links:
variant: soft
---

## `parseMarkdown(source, options?)`{lang="ts"}
## `parseMarkdown(source, options?, parseOptions?)`{lang="ts"}

Parses Markdown from a string and returns a complete `MarkdownDocument`. Default plugins add frontmatter, alerts, task lists, HTML, components, and attributes to the standard Markdown parser.

**Parameters:**

- `source` - The Markdown content as a string
- `options?` - Parser options including plugins
- `parseOptions?` - Per-call options, `{ streaming?: boolean }`. Pass `{ streaming: true }` for a chunk of a stream so the default `autoClose` heals incomplete syntax.

**Returns:** `MarkdownDocument` object containing:

Expand Down Expand Up @@ -224,7 +225,7 @@ import toc from 'comark/plugins/toc'
// Create a parser with specific configuration
const parse = createMarkdownParser({
autoUnwrap: true,
autoClose: true,
autoClose: 'streaming',
plugins: [
shiki({
themes: { light: githubLight, dark: githubDark }
Expand Down Expand Up @@ -360,7 +361,7 @@ Both `parseMarkdown()` and `createMarkdownParser()` accept the same `ParserOptio
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `autoUnwrap` | `boolean` | `true` | Remove unnecessary `<p>` wrappers from single-element containers |
| `autoClose` | `boolean \| (markdown: string) => string` | `true` | Auto-close incomplete markdown syntax, or use a custom completion function |
| `autoClose` | `boolean \| 'streaming' \| (markdown: string) => string` | `'streaming'` | Auto-close incomplete markdown syntax. `'streaming'` only heals a parse called with `{ streaming: true }`; `true` heals every parse |
| `unwrap` | `boolean \| string \| string[]` | `false` | Remove wrapper tags from the tree, hoisting their children (MDC `unwrap` behaviour). `true` unwraps `p`; a comma/whitespace-separated string or array unwraps the listed tags; `'*'` matches any tag. Tags apply sequentially (each descends one level), and adjacent text is merged into a single string. |
| `html` | `boolean` | `true` | **Deprecated** (warns). Prefer `registerDefaultPlugins: false` and register `html()` explicitly. `html: false` still skips the default html plugin. |
| `linkify` | `boolean` | `true` | Auto-convert URL-like text into links. Set `false` to disable |
Expand Down
25 changes: 21 additions & 4 deletions docs/content/5.reference/2.auto-close.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,36 @@ autoCloseMarkdown('hello *', { dropTrailingOpeners: true })
::

::tip
`autoCloseMarkdown` is also available as a parse option: set `autoClose: true` (default) in `parseMarkdown()` or `createMarkdownParser()` to apply it automatically.
`autoCloseMarkdown` is also available as a parse option: `autoClose` in `parseMarkdown()` and `createMarkdownParser()` applies it for you while streaming.
::

### Parser integration

`autoClose` is enabled by default in `parseMarkdown()` and `createMarkdownParser()`. You can disable it or provide a custom completion function:
`autoClose` defaults to `'streaming'`, so healing runs when you parse with `{ streaming: true }` and a plain parse follows CommonMark:

```typescript
import { createMarkdownParser, parseMarkdown } from 'comark'

await parseMarkdown('a _b')
// 'a _b' stays literal text

const parse = createMarkdownParser()
await parse('a _b', { streaming: true })
// ['em', {}, 'b']
```

::warning
Before this default, healing ran on every parse. If you parse a stored response that may have been cut off, set `autoClose: true` to keep closing it.
::

You can force it on, turn it off, or provide a custom completion function:

::code-group
```typescript [Enabled (default)]
```typescript [Always]
import { parseMarkdown } from 'comark'

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

Expand Down
4 changes: 2 additions & 2 deletions docs/content/5.reference/3.reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import shiki from 'comark/plugins/shiki'

const result = await parseMarkdown(source, {
autoUnwrap: true, // Remove <p> wrappers from single-paragraph containers
autoClose: true, // Auto-close incomplete syntax
autoClose: 'streaming', // Auto-close incomplete syntax while streaming
plugins: [shiki()] // HTML parsing is on by default via the html plugin
})

Expand Down Expand Up @@ -296,7 +296,7 @@ type Node =
```typescript
interface ParserOptions {
autoUnwrap?: boolean // default: true
autoClose?: boolean | AutoCloseFunction // default: true
autoClose?: boolean | 'streaming' | AutoCloseFunction // default: 'streaming'
unwrap?: boolean | string | string[] // strip top-level wrapper tags, e.g. 'p' (default: false)
/** @deprecated Prefer registerDefaultPlugins: false */
html?: boolean // default: true
Expand Down
4 changes: 2 additions & 2 deletions docs/content/7.kb/2.migration-from-mdc.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ const document = await parseMarkdown(md, { plugins: [emoji()] })
{
plugins: ComarkPlugin[], // ordered array, not a record
autoUnwrap: true, // removes <p> from single-paragraph containers
autoClose: true, // completes incomplete syntax (useful for streaming)
autoClose: 'streaming', // completes incomplete syntax while streaming
// HTML, components, attributes, alerts, task-list, frontmatter are on by default
}
```
Expand Down Expand Up @@ -687,4 +687,4 @@ These features may be added in a future release. If your project relies on bindi
| Prose components | `components/prose/Prose*.vue` | `components/prose/Prose*.vue` |
| Render slot | `<MDCSlot />` | `<slot />` |
| Unwrap slot | `<MDCSlot unwrap="p" />` or `<slot mdc-unwrap="p" />` | `<slot unwrap="p" />` |
| Streaming | Not supported | `streaming` prop + `autoClose` |
| Streaming | Not supported | `streaming` prop, which also turns on `autoClose` healing |
4 changes: 2 additions & 2 deletions docs/skills/comark/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A guide for using Comark in AI agent and LLM-powered applications where markdown

LLMs stream markdown token-by-token. Standard markdown parsers expect complete input. They fail or produce broken output on partial streams. Comark was built to handle exactly this:

- **`autoClose`** (default: `true`): incomplete syntax like `**bold text` is automatically closed on every parse, so partial tokens always render correctly
- **`autoClose`** (default: `'streaming'`): incomplete syntax like `**bold text` is closed when you parse with `{ streaming: true }`, so partial tokens always render correctly. A plain parse leaves it as written; set `autoClose: true` to close on every parse
- **Streaming mode**: re-renders efficiently as content arrives
- **Caret indicator**: shows a live cursor during generation
- **ANSI rendering**: styled terminal output for CLI agents
Expand Down Expand Up @@ -297,7 +297,7 @@ export const ChatMarkdown = defineMarkdownComponent({
shiki({ themes: { light: githubDark, dark: githubDark } }),
],
components: { Math, alert: Alert },
autoClose: true,
autoClose: 'streaming',
})
```

Expand Down
2 changes: 1 addition & 1 deletion docs/skills/comark/references/parsing-ast.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ interface MarkdownDocument {
```typescript
interface ParserOptions {
autoUnwrap?: boolean // Remove unnecessary <p> wrappers (default: true)
autoClose?: boolean // Auto-close unclosed syntax (default: true)
autoClose?: boolean | 'streaming' | ((markdown: string) => string) // Auto-close unclosed syntax (default: 'streaming')
plugins?: ComarkPlugin[] // Enable plugins (e.g., highlight, emoji, toc)
}
```
Expand Down
2 changes: 1 addition & 1 deletion docs/skills/comark/references/rendering-svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ The `caret` prop appends a blinking cursor indicator to the last text node durin
<Markdown value={content} streaming={isStreaming} caret={{ class: 'my-caret' }} />
```

`autoClose` is enabled by default: incomplete syntax like `**bold text` is automatically closed on every parse.
`autoClose` defaults to `'streaming'`: incomplete syntax like `**bold text` is closed when the `streaming` prop is set, and left as written otherwise.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/skills/migrate-mdc-to-comark/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ The migration has two parts: **Core Package** (programmatic API) and **Nuxt Modu
{
plugins: ComarkPlugin[], // ordered array, not a record
autoUnwrap: true, // removes <p> from single-paragraph containers
autoClose: true, // completes incomplete syntax (useful for streaming)
autoClose: 'streaming', // completes incomplete syntax while streaming
// HTML, components, attributes, alerts, task-list, frontmatter are on by default
}
```
Expand Down
10 changes: 9 additions & 1 deletion packages/comark-ansi/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,16 @@ describe('renderAnsi', () => {
})

it('passes parser and renderer options through', async () => {
const output = await renderAnsi('**bold', { autoClose: false, colors: false })
// `autoClose: false` matches the default for a non-streaming render, so force
// healing on to prove the option reaches the parser.
const output = await renderAnsi('**bold', { autoClose: true, colors: false })
expect(output).not.toContain('\x1B[')
expect(output).toContain('bold')
expect(output).not.toContain('\\*\\*bold')
})

it('leaves incomplete markdown alone by default', async () => {
const output = await renderAnsi('**bold', { colors: false })
expect(output).toContain('\\*\\*bold')
})

Expand Down
9 changes: 8 additions & 1 deletion packages/comark-html/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,14 @@ describe('renderHtml', () => {
})

it('passes parser options through', async () => {
const html = await renderHtml('**bold', { autoClose: false })
// `autoClose: false` matches the default for a non-streaming render, so force
// healing on to prove the option reaches the parser.
const html = await renderHtml('**bold', { autoClose: true })
expect(html).toContain('<strong>')
})

it('leaves incomplete markdown alone by default', async () => {
const html = await renderHtml('**bold')
expect(html).toContain('**bold')
expect(html).not.toContain('<strong>')
})
Expand Down
11 changes: 9 additions & 2 deletions packages/comark-react/src/components/MarkdownClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ function MarkdownContent({
}

export function MarkdownClient({ children, value, options = {}, plugins = [], ...rest }: MarkdownProps) {
const streaming = rest.streaming ?? false

const content = isMarkdownDocument(value)
? value
: children
Expand All @@ -46,8 +48,13 @@ export function MarkdownClient({ children, value, options = {}, plugins = [], ..
// Note: options/plugins should be stable references (defined outside render or memoized).
// Pre-parsed documents resolve immediately without calling parseMarkdown().
const parsePromise = useMemo(
() => (isMarkdownDocument(content) ? Promise.resolve(content) : parseMarkdown(content, { ...options, plugins })),
[content]
() =>
isMarkdownDocument(content)
? Promise.resolve(content)
: // `streaming` must reach the parser, not just the renderer: it drives
// auto-close healing and incremental node reuse.
parseMarkdown(content, { ...options, plugins }, { streaming }),
[content, streaming]
)

// Keep showing the previous parsed result while a new parse is pending —
Expand Down
31 changes: 31 additions & 0 deletions packages/comark-react/test/streaming.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import React from 'react'
import { renderToReadableStream } from 'react-dom/server'
import { Markdown } from '../src/index'

async function renderAsync(element: React.ReactElement): Promise<string> {
const stream = await renderToReadableStream(element)
await stream.allReady
return new Response(stream).text()
}

// The `streaming` prop drives the renderer (caret, stream components) but it also
// has to reach the parser: auto-close heals only on a streaming parse.
describe('<Markdown streaming>', () => {
it('heals incomplete markdown while streaming', async () => {
const html = await renderAsync(
<Markdown
value="Hello **wor"
streaming
/>
)
expect(html).toContain('<strong>')
expect(html).toContain('wor')
})

it('leaves incomplete markdown alone when not streaming', async () => {
const html = await renderAsync(<Markdown value="Hello **wor" />)
expect(html).not.toContain('<strong>')
expect(html).toContain('**wor')
})
})
2 changes: 1 addition & 1 deletion packages/comark-svelte/src/async/MarkdownAsync.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ and wrap this component in a `<svelte:boundary>` for pending/error states.
: // `parse` directly mutates `plugins` which creates an infinite effect loop
// so we copy it before passing it in so it gets a regular JS array and we get to still
// track dependencies from an external perspective
await parseMarkdown(content, { ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] }),
await parseMarkdown(content, { ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] }, { streaming }),
)
</script>

Expand Down
2 changes: 1 addition & 1 deletion packages/comark-svelte/src/components/Markdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ This is an alert component
// `parse` directly mutates `plugins` which creates an infinite effect loop
// so we copy it before passing it in so it gets a regular JS array and we get to still
// track dependencies from an external perspective
parseMarkdown(content, { ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] }).then((result) => {
parseMarkdown(content, { ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] }, { streaming }).then((result) => {
if (currentVersion > appliedVersion) {
appliedVersion = currentVersion
parsed = result
Expand Down
Loading
Loading