Skip to content
Open
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: 2 additions & 0 deletions docs/content/3.rendering/6.svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,8 @@ If no custom component matches, the tag renders as a native HTML element (via `<

Enable real-time rendering as content arrives, ideal for AI chat interfaces and live previews.

`Markdown` and `MarkdownAsync` reuse completed blocks while `streaming` is true. Set it to false when the stream ends to run a final full parse. Changes to `options`, `plugins`, or `unwrap` create a new parser. Heading tails and reference definitions use a full parse to preserve heading IDs and links.

Set `streaming` to `true` while content is being received, then `false` when done:

```svelte [components/AiChat.svelte]
Expand Down
2 changes: 1 addition & 1 deletion examples/2.vite/svelte/src/pages/Syntax.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ Text before the comment and text after the comment both render normally.
</script>

<Markdown
{markdown}
value={markdown}
plugins={[shiki({ languages: [python] })]}
components={{ Alert }}
{componentsManifest}
Expand Down
2 changes: 2 additions & 0 deletions packages/comark-svelte/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Heads up!

### Streaming

`Markdown` and `MarkdownAsync` reuse completed blocks while `streaming` is true. Set it to false when the stream ends to run a final full parse. Changes to `options`, `plugins`, or `unwrap` create a new parser. Heading tails and reference definitions use a full parse to preserve heading IDs and links.

```svelte
<Markdown value={content} streaming={isStreaming} caret />
```
Expand Down
16 changes: 11 additions & 5 deletions packages/comark-svelte/src/async/MarkdownAsync.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ and wrap this component in a `<svelte:boundary>` for pending/error states.
-->
<script lang="ts">
import type { MarkdownDocument as MarkdownDocumentType, ComarkPlugin, ComponentManifest } from 'comark'
import { parseMarkdown } from 'comark'
import { createSerializedMarkdownParser } from 'comark'
import { isMarkdownDocument } from 'comark/utils'
import MarkdownDocument from '../components/MarkdownDocument.svelte'
import ResolveAsync from './ResolveAsync.svelte'
Expand Down Expand Up @@ -59,13 +59,19 @@ and wrap this component in a `<svelte:boundary>` for pending/error states.
} = $props()

let content = $derived(typeof value === 'string' ? value.trim() : '')
// Compare config values before creating a parser when parent props are spread.
let parserOptions = $derived(options)
let parserPlugins = $derived(plugins)
let parserUnwrap = $derived(unwrap)
let parse = $derived(createSerializedMarkdownParser({
...parserOptions,
...(parserUnwrap ? { unwrap: parserUnwrap } : {}),
plugins: [...parserPlugins],
}))
let parsed = $derived(
isMarkdownDocument(value)
? value
: // `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 parse(content, { streaming }),
)
</script>

Expand Down
44 changes: 30 additions & 14 deletions packages/comark-svelte/src/components/Markdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ This is an alert component
-->
<script lang="ts">
import type { MarkdownDocument as MarkdownDocumentType, ComarkPlugin, ComponentManifest } from 'comark'
import { parseMarkdown } from 'comark'
import { createSerializedMarkdownParser } from 'comark'
import { isMarkdownDocument } from 'comark/utils'
import MarkdownDocument from './MarkdownDocument.svelte'

Expand Down Expand Up @@ -53,24 +53,40 @@ This is an alert component
class?: string
} = $props()

let parsed: MarkdownDocumentType | null = $state(null)
let parsedValue: MarkdownDocumentType | null = $state(null)
let parseError: unknown = $state(null)
let parsed = $derived.by(() => {
if (parseError) throw parseError
return parsedValue
})

let content = $derived(typeof value === 'string' ? value.trim() : '')
// Compare config values before creating a parser when parent props are spread.
let parserOptions = $derived(options)
let parserPlugins = $derived(plugins)
let parserUnwrap = $derived(unwrap)
let parse = $derived(createSerializedMarkdownParser({
...parserOptions,
...(parserUnwrap ? { unwrap: parserUnwrap } : {}),
plugins: [...parserPlugins],
}))

let requestVersion = 0
let appliedVersion = 0
let isDocument = $derived(isMarkdownDocument(value))
$effect(() => {
if (isMarkdownDocument(value)) return
const currentVersion = ++requestVersion
// `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) => {
if (currentVersion > appliedVersion) {
appliedVersion = currentVersion
parsed = result
}
if (isDocument) return
const parseMarkdown = parse
let active = true
$effect(() => {
parseMarkdown(content, { streaming }).then((result) => {
if (active) {
parseError = null
parsedValue = result
}
}).catch((error) => {
if (active) parseError = error
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
return () => { active = false }
})
</script>

Expand Down
173 changes: 173 additions & 0 deletions packages/comark-svelte/test/incremental-streaming.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { ComarkPlugin } from 'comark'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-svelte'
import Markdown from '../src/components/Markdown.svelte'
import MarkdownAsync from '../src/async/MarkdownAsync.svelte'
import MarkdownBoundary from './test-components/MarkdownBoundary.svelte'

for (const [name, component] of [
['Markdown', Markdown],
['MarkdownAsync', MarkdownAsync],
] as const) {
describe(`${name} incremental parsing`, () => {
it('parses only the open tail and reparses the full value when streaming ends', async () => {
const inputs: string[] = []
const plugin: ComarkPlugin = {
name: 'inputs',
pre: (state) => {
inputs.push(state.markdown)
},
}
const initial = 'First\n\nSecond\n\nThird'
const screen = await render(MarkdownBoundary, {
component,
value: initial,
plugins: [plugin],
streaming: true,
})
await expect.element(screen.getByText('Third')).toBeInTheDocument()

const value = `${initial} grows`
await screen.rerender({ value })
await expect.element(screen.getByText('Third grows')).toBeInTheDocument()
expect(inputs).toHaveLength(2)
expect(inputs[1]).not.toContain('First')
expect(inputs[1]!.length).toBeLessThan(value.length)
await expect.element(screen.getByText('First')).toBeInTheDocument()

await screen.rerender({ streaming: false })
await expect.poll(() => inputs.length).toBe(3)
expect(inputs[2]).toBe(value)

await screen.rerender({ value: 'Replacement' })
await expect.element(screen.getByText('Replacement')).toBeInTheDocument()
expect(screen.container.textContent).not.toContain('First')
})

it('recreates the parser when options, plugins, or unwrap change', async () => {
const value = 'https://example.com\n\nTail'
const screen = await render(MarkdownBoundary, {
component,
value,
streaming: true,
options: { linkify: false },
})
await expect.element(screen.getByText('Tail')).toBeInTheDocument()
expect(screen.container.querySelector('a')).toBeNull()

await screen.rerender({ options: { linkify: true } })
await expect.element(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com')

const plugin: ComarkPlugin = {
name: 'replace',
pre: (state) => {
state.markdown = state.markdown.replace('Tail', 'Changed')
},
}
await screen.rerender({ plugins: [plugin] })
await expect.element(screen.getByText('Changed')).toBeInTheDocument()

await screen.rerender({ unwrap: true })
await expect.poll(() => screen.container.querySelector('p')).toBeNull()
expect(screen.container.textContent).toContain('Changed')
})

it('ignores old plugin results after a configuration change', async () => {
const { promise: gate, resolve: release } = Promise.withResolvers<void>()
let finished = false
const screen = await render(MarkdownBoundary, {
component,
value: 'Old',
streaming: true,
plugins: [
{
name: 'slow',
async pre() {
await gate
finished = true
},
},
],
})
await screen.rerender({ value: 'New', plugins: [] })
await expect.element(screen.getByText('New')).toBeInTheDocument()
release()
await expect.poll(() => finished).toBe(true)
await expect.element(screen.getByText('New')).toBeInTheDocument()
expect(screen.container.textContent).not.toContain('Old')
})

it('serializes overlapping plugin work and applies the newest update', async () => {
const { promise: gate, resolve: release } = Promise.withResolvers<void>()
let active = 0
let maxActive = 0
const inputs: string[] = []
const plugin: ComarkPlugin = {
name: 'deferred',
async pre(state) {
active++
maxActive = Math.max(maxActive, active)
inputs.push(state.markdown)
if (state.markdown.includes('Slow')) await gate
active--
},
}
const screen = await render(MarkdownBoundary, { component, value: 'Ready', streaming: true, plugins: [plugin] })
await expect.element(screen.getByText('Ready')).toBeInTheDocument()
await screen.rerender({ value: 'Slow' })
await expect.poll(() => inputs).toContain('Slow')
await screen.rerender({ value: 'Latest' })
release()
await expect.element(screen.getByText('Latest')).toBeInTheDocument()
expect(maxActive).toBe(1)
expect(screen.container.textContent).not.toContain('Slow')
})
})
}

it('passes parser errors to the Svelte boundary', async () => {
const screen = await render(MarkdownBoundary, {
component: Markdown,
value: 'Failure',
streaming: true,
plugins: [
{
name: 'fail',
async pre() {
throw new Error('Plugin failed')
},
},
],
})
await expect.element(screen.getByRole('alert')).toHaveTextContent('Plugin failed')
})

it('shows completed updates while the next update is still parsing', async () => {
const { promise: first, resolve: releaseFirst } = Promise.withResolvers<void>()
const { promise: last, resolve: releaseLast } = Promise.withResolvers<void>()
const started: string[] = []
const screen = await render(MarkdownBoundary, {
component: Markdown,
value: 'Ready',
streaming: true,
plugins: [
{
name: 'slow',
async pre(state) {
started.push(state.markdown)
if (state.markdown === 'First') await first
if (state.markdown === 'Last') await last
},
} satisfies ComarkPlugin,
],
})
await expect.element(screen.getByText('Ready')).toBeInTheDocument()
await screen.rerender({ value: 'First' })
await expect.poll(() => started).toContain('First')
await screen.rerender({ value: 'Last' })
releaseFirst()
await expect.poll(() => started).toContain('Last')
await expect.element(screen.getByText('First')).toBeInTheDocument()
releaseLast()
await expect.element(screen.getByText('Last')).toBeInTheDocument()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts">
import type { ComponentProps } from 'svelte'
import type Markdown from '../../src/components/Markdown.svelte'

let { component: Component, ...props }: ComponentProps<typeof Markdown> & { component: typeof Markdown } = $props()
</script>

<svelte:boundary>
<Component {...props} />
{#snippet pending()}<p>Loading</p>{/snippet}
{#snippet failed(error)}<p role="alert">{error instanceof Error ? error.message : String(error)}</p>{/snippet}
</svelte:boundary>
7 changes: 4 additions & 3 deletions packages/comark/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,9 @@ export async function parseMarkdown<const TPlugins extends readonly ComarkPlugin
}

/**
* Creates a serialized parser function for Comark content.
* This is useful for parsing large files in a streaming manner.
* Creates a serialized parser that reuses stream state across calls.
* Overlapping parses run one at a time; each caller still receives its own
* result or rejection (plugin errors are not swallowed).
*
* @param options - Parser options
* @returns ComarkParseFn - The serialized parser function
Expand All @@ -288,7 +289,7 @@ export async function parseMarkdown<const TPlugins extends readonly ComarkPlugin
* import { createSerializedMarkdownParser } from 'comark'
*
* const parseMarkdown = createSerializedMarkdownParser()
* const tree = await parseMarkdown(content)
* const tree = await parseMarkdown(content, { streaming: true })
* console.log(tree.nodes)
*/
export function createSerializedMarkdownParser<const TPlugins extends readonly ComarkPlugin<any, any>[] = []>(
Expand Down
11 changes: 8 additions & 3 deletions packages/comark/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ import type { ComarkPlugin, ComarkPluginFactory } from '../types.ts'
/**
* Returns a function that invokes `fn` **strictly one at a time**: each call waits until the
* previous invocation has settled (resolved or rejected) before starting the next.
* Callers still receive the real result or rejection — failures are not swallowed.
*/
export function createSerializedTask<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => Promise<TResult>
): (...args: TArgs) => Promise<TResult> {
let chain: Promise<TResult> = Promise.resolve(null as TResult)
// Keep the queue moving after either settle so stream state stays ordered,
// but return the real result/rejection to callers (Vue/Svelte boundaries).
let pending: Promise<unknown> = Promise.resolve()
return (...args: TArgs) => {
chain = chain.then(() => fn(...args)).catch(() => null as TResult)
return chain
const run = () => fn(...args)
const result = pending.then(run, run)
pending = result
return result
}
}

Expand Down
Loading
Loading