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 text

Without 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 yet

The 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 italic

Single asterisks or underscores are completed automatically.

Bold Italic (***text)

***This is bold and italic

Triple asterisks for combined formatting are handled.

Inline Code (`code)

`const foo = "bar

Incomplete inline code blocks are closed with a backtick.

Strikethrough (~~text)

~~This text is being crossed out

Strikethrough formatting is completed automatically.

Streamdown handles several link scenarios:

Incomplete link text:

[Click here

Completes to: [Click here](streamdown:incomplete-link)

Incomplete URL:

[Click here](https://exampl

Completes 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 hereClick here (plain text)
  • [Click here](https://examplClick 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:

![Alt text that's incomplete

This prevents visual clutter during streaming.

Mathematical Expressions

Block-level KaTeX expressions are completed:

$$
E = mc^2

The parser adds the closing $$ to ensure proper math rendering.

Configuration

Within Streamdown

Unterminated block parsing is enabled by default in Streamdown. You can disable the remend preprocessor if needed:

import { Streamdown } from 'streamdown';

export default function Page() {
  return (
    <Streamdown parseIncompleteMarkdown={false}>
      {markdown}
    </Streamdown>
  );
}

However, disabling this feature will result in incomplete Markdown syntax being displayed literally, which is generally not desirable for user-facing applications.

Using Remend Standalone

You can use remend independently in your own markdown rendering pipeline:

import remend from 'remend';

const partialMarkdown = "This is **incomplete bold";
const completed = remend(partialMarkdown);
// Result: "This is **incomplete bold**"

For links, you can use the linkMode option to control how incomplete links are handled:

import remend from 'remend';

// Default behavior: use placeholder URL
remend("[Click here](http://exampl");
// 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 handler
  • handle - Transform function that receives text and returns modified text
  • priority - Optional execution order (lower runs first, default: 100)

Execution Order

Built-in handlers use priorities 0-70:

HandlerPriority
setextHeadings0
links10
boldItalic20
bold30
italic40-42
inlineCode50
strikethrough60
katex70

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 only

This 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*world

This 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:

  1. **This → Renders nothing (too short)
  2. **This is bol → Renders as This is bol
  3. **This is bold** → Renders as This is bold

With default linkMode: 'protocol':

  1. [Cli → Renders nothing
  2. [Click here → Renders as Click here
  3. [Click here](https:// → Renders as Click here
  4. [Click here](https://example.com) → Renders as Click here

With linkMode: 'text-only':

  1. [Cli → Renders nothing
  2. [Click here → Renders as plain text: Click here
  3. [Click here](https:// → Renders as plain text: Click here
  4. [Click here](https://example.com) → Renders as Click here

Code Streaming

  1. `const → Renders as const
  2. `const foo = → Renders as const foo =
  3. `const foo = "bar"` → Renders as const foo = "bar"