Unterminated Block Parsing
Learn how Streamdown handles incomplete Markdown syntax during AI streaming with remend.
One of Streamdown's most powerful features is its ability to intelligently parse and style incomplete Markdown blocks using the remend package. This feature, called unterminated block parsing, ensures that your streaming content looks polished even before the AI finishes its response.
About Remend
Remend is a lightweight, standalone preprocessor that completes incomplete Markdown syntax. Streamdown integrates remend by default, but you can also use it independently in your own projects. See the remend documentation for standalone usage.
The Challenge
When AI models stream Markdown content token-by-token, the content often arrives incomplete:
**This is bold textWithout proper handling, this would either:
- Not render any formatting at all
- Display the raw Markdown syntax
- Break the layout
Streamdown solves this by detecting incomplete patterns and intelligently completing them for rendering purposes.
How It Works
Remend (Streamdown's preprocessing layer) analyzes the incoming Markdown and identifies common patterns that might be incomplete. When detected, it automatically adds the closing syntax so the content renders correctly, then seamlessly updates when the actual closing syntax arrives.
The preprocessing happens before the markdown is passed into the unified/remark pipeline, operating on the raw string level for maximum performance.
Supported Incomplete Patterns
Bold Text (**text)
**This is bold text that hasn't been closed yetThe parser detects the opening ** and adds a closing ** to ensure the text renders as bold.
Italic Text (*text or _text)
*This is italic text
_This is also italicSingle asterisks or underscores are completed automatically.
Bold Italic (***text)
***This is bold and italicTriple asterisks for combined formatting are handled.
Inline Code (`code)
`const foo = "barIncomplete inline code blocks are closed with a backtick.
Strikethrough (~~text)
~~This text is being crossed outStrikethrough formatting is completed automatically.
Links ([text](url))
Streamdown handles several link scenarios:
Incomplete link text:
[Click hereCompletes to: [Click here](streamdown:incomplete-link)
Incomplete URL:
[Click here](https://examplCompletes to: [Click here](streamdown:incomplete-link)
The special streamdown:incomplete-link URL ensures the link renders visually but doesn't navigate anywhere.
Text-only mode:
If you prefer to display just the link text without any link markup during streaming (for better compatibility with other markdown renderers like react-markdown), use the linkMode option:
<Streamdown remend={{ linkMode: 'text-only' }}>
{markdown}
</Streamdown>With linkMode: 'text-only':
[Click here→Click here(plain text)[Click here](https://exampl→Click here(plain text)
The link renders as a proper link once complete.
Custom component override:
For full control over how incomplete links render, you can override the a component. This approach lets you customize styling, add loading indicators, or implement custom behavior:
<Streamdown
components={{
a: ({ href, children, ...props }) => {
const isIncomplete = href === 'streamdown:incomplete-link';
if (isIncomplete) {
// Render as plain text with custom styling
return <span className="text-muted-foreground">{children}</span>;
}
return (
<a href={href} target="_blank" rel="noreferrer" {...props}>
{children}
</a>
);
},
}}
>
{markdown}
</Streamdown>For react-markdown users consuming the remend package directly:
import ReactMarkdown from 'react-markdown';
import remend from 'remend';
<ReactMarkdown
components={{
a: ({ href, children, ...props }) => {
if (href === 'streamdown:incomplete-link') {
return <>{children}</>;
}
return <a href={href} {...props}>{children}</a>;
},
}}
>
{remend(streamingText)}
</ReactMarkdown>Images
For incomplete images, Streamdown removes them entirely rather than showing broken image placeholders:
;
// Result: "[Click here](streamdown:incomplete-link)"
// Text-only mode: display plain text
remend("[Click here](http://exampl", { linkMode: 'text-only' });
// Result: "Click here"See the remend package for more details on standalone usage.
Custom Handlers
You can extend remend with custom handlers to complete your own markers during streaming. This is useful for domain-specific syntax like custom tags or markers that your AI might output.
import remend, { type RemendHandler } from 'remend';
const jokeHandler: RemendHandler = {
name: 'joke',
handle: (text) => {
// Complete <<<JOKE>>> marks that aren't closed
const match = text.match(/<<<JOKE>>>([^<]*)$/);
if (match && !text.endsWith('<<</JOKE>>>')) {
return `${text}<<</JOKE>>>`;
}
return text;
},
priority: 80, // Runs after most built-ins (0-70)
};
const result = remend(content, { handlers: [jokeHandler] });Handler Interface
Each handler has three properties:
name- Unique identifier for the handlerhandle- Transform function that receives text and returns modified textpriority- Optional execution order (lower runs first, default: 100)
Execution Order
Built-in handlers use priorities 0-70:
| Handler | Priority |
|---|---|
setextHeadings | 0 |
links | 10 |
boldItalic | 20 |
bold | 30 |
italic | 40-42 |
inlineCode | 50 |
strikethrough | 60 |
katex | 70 |
Custom handlers default to priority 100, running after all built-ins. Set a lower priority to run before specific built-ins.
Example: Running Before Bold Handling
To process content before bold formatting is completed (priority 30), set a lower priority:
const preprocessHandler: RemendHandler = {
name: 'preprocess',
handle: (text) => {
// Transform content before bold is completed
return text.replace(/\{\{highlight\}\}/g, '**');
},
priority: 25, // Runs before bold (30) and after links (10)
};Example: Running After All Built-ins
For post-processing, use the default priority or higher:
const postprocessHandler: RemendHandler = {
name: 'postprocess',
handle: (text) => {
// Clean up after all built-in handlers
return text.trim();
},
priority: 150, // Runs after all built-ins
};Context Utilities
Remend exports utilities to help custom handlers detect context:
import {
isWithinCodeBlock,
isWithinMathBlock,
isWithinLinkOrImageUrl,
isWordChar,
} from 'remend';
const handler: RemendHandler = {
name: 'custom',
handle: (text) => {
// Skip processing inside code blocks
if (isWithinCodeBlock(text, text.length - 1)) {
return text;
}
// Your completion logic here
return text;
},
};Smart Behavior
Remend includes intelligent rules to avoid false positives:
List Item Detection
The parser won't close formatting markers that appear at the start of list items:
- **
- Item with bold marker onlyThis prevents prematurely closing bold formatting that might be intentional list structure.
Code Block Awareness
Formatting within complete code blocks is left untouched:
```python
def foo():
# This **won't** be treated as incomplete bold
return "bar"
```Math Block Protection
Underscores within math blocks are not treated as italic markers:
$$
E = m \times c^2
x_i = y_j
$$Word-Internal Characters
Asterisks and underscores within words (like variable names or between alphanumeric characters) are preserved:
const user_name = "john_doe";
234234*123
hello*worldThis ensures that text like product codes or mathematical expressions with asterisks are not mistakenly interpreted as italic formatting.
Performance Considerations
Remend is highly optimized for streaming scenarios:
- Direct string iteration - Avoids regex splits and allocations
- ASCII fast-path - Optimized character checking for common cases
- Early returns - Stops processing when conditions aren't met
- Zero dependencies - Pure TypeScript implementation
- Block-Level Processing - Streamdown splits content into blocks for parallel processing
Examples
Bold Text Streaming
As content streams in:
**This→ Renders nothing (too short)**This is bol→ Renders as This is bol**This is bold**→ Renders as This is bold
Link Streaming
With default linkMode: 'protocol':
[Cli→ Renders nothing[Click here→ Renders as Click here[Click here](https://→ Renders as Click here[Click here](https://example.com)→ Renders as Click here
With linkMode: 'text-only':
[Cli→ Renders nothing[Click here→ Renders as plain text: Click here[Click here](https://→ Renders as plain text: Click here[Click here](https://example.com)→ Renders as Click here
Code Streaming
`const→ Renders asconst`const foo =→ Renders asconst foo =`const foo = "bar"`→ Renders asconst foo = "bar"