Remend is a lightweight, zero-dependency TypeScript package that completes incomplete Markdown syntax. It powers the unterminated block parsing inside Streamdown, but you can also use it standalone in any streaming Markdown pipeline.
Installation
Basic usage
import remend from 'remend';
// During streaming
const partialMarkdown = 'This is **bold text';
const completed = remend(partialMarkdown);
// Result: "This is **bold text**"
// With incomplete link
const partialLink = 'Check out [this link](https://exampl';
const completedLink = remend(partialLink);
// Result: "Check out [this link](streamdown:incomplete-link)"
Call remend on every new chunk received from the stream, immediately before passing the string to your Markdown renderer.
Configuration options
Pass an options object as the second argument to selectively disable completions. All options default to true unless noted otherwise.
import remend from 'remend';
const completed = remend(partialMarkdown, {
links: false,
katex: false,
});
Options reference
| Option | Type | Default | Description |
|---|
bold | boolean | true | Complete bold formatting — **text → **text** |
boldItalic | boolean | true | Complete bold-italic formatting — ***text → ***text*** |
italic | boolean | true | Complete italic formatting — *text → *text* and _text → _text_ |
inlineCode | boolean | true | Complete inline code — `code → `code` |
strikethrough | boolean | true | Complete strikethrough — ~~text → ~~text~~ |
links | boolean | true | Complete incomplete links |
images | boolean | true | Handle incomplete images (removes them) |
katex | boolean | true | Complete block KaTeX math — $$formula → $$formula$$ |
inlineKatex | boolean | false | Complete inline KaTeX math — $formula → $formula$. Disabled by default to avoid ambiguity with currency symbols. |
singleTilde | boolean | true | Escape single ~ between word characters — 20~25 → 20\~25 |
setextHeadings | boolean | true | Handle incomplete setext headings to prevent misinterpretation |
comparisonOperators | boolean | true | Escape > as comparison operators in list items — - > 25 → - \> 25 |
htmlTags | boolean | true | Strip incomplete HTML tags at end of text — text <custom → text |
linkMode | 'protocol' | 'text-only' | 'protocol' | How to render incomplete links. 'protocol' uses a streamdown:incomplete-link placeholder; 'text-only' displays only the link text. |
handlers | RemendHandler[] | [] | Custom handlers to extend remend |
inlineKatex is opt-in because a single $ is commonly used for currency (e.g. $50). Enable it only if your content uses inline math and you want automatic completion.
Custom handlers
You can extend remend with your own handlers to complete domain-specific syntax that your AI might produce.
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–75)
};
const result = remend(content, { handlers: [jokeHandler] });
Handler interface
interface RemendHandler {
/** Unique identifier for this handler */
name: string;
/** Transform function: receives the current text, returns modified text */
handle: (text: string) => string;
/** Execution order — lower values run first. Default: 100 */
priority?: number;
}
Handler priorities
Built-in handlers occupy priorities 0–75. Custom handlers default to priority 100, so they run after all built-ins unless you set a lower value.
| Handler | Priority |
|---|
singleTilde | 0 |
comparisonOperators | 5 |
htmlTags | 10 |
setextHeadings | 15 |
links | 20 |
boldItalic | 30 |
bold | 35 |
italic (double underscore) | 40 |
italic (single asterisk) | 41 |
italic (single underscore) | 42 |
inlineCode | 50 |
strikethrough | 60 |
katex | 70 |
inlineKatex | 75 |
| Custom (default) | 100 |
Running before a specific built-in:
const preprocessHandler: RemendHandler = {
name: 'preprocess',
handle: (text) => {
// Transform content before bold is completed (priority 35)
return text.replace(/\{\{highlight\}\}/g, '**');
},
priority: 25, // Runs after links (20) and before boldItalic (30)
};
Running after all built-ins:
const postprocessHandler: RemendHandler = {
name: 'postprocess',
handle: (text) => text.trim(),
priority: 150,
};
Exported utilities
Remend exports context-detection helpers for use inside custom handlers:
import {
isWithinCodeBlock,
isWithinMathBlock,
isWithinLinkOrImageUrl,
isWordChar,
} from 'remend';
| Utility | Signature | Description |
|---|
isWithinCodeBlock | (text: string, index: number) => boolean | Returns true if the character at index is inside a fenced code block |
isWithinMathBlock | (text: string, index: number) => boolean | Returns true if the character at index is inside a $$ math block |
isWithinLinkOrImageUrl | (text: string, index: number) => boolean | Returns true if the character at index is inside a link or image URL segment |
isWordChar | (char: string) => boolean | Returns true if the character is a word character (letter, digit, or underscore) |
Example — skipping processing inside code blocks:
import { isWithinCodeBlock, type RemendHandler } from 'remend';
const handler: RemendHandler = {
name: 'custom',
handle: (text) => {
// Skip if the end of the text is inside a code block
if (isWithinCodeBlock(text, text.length - 1)) {
return text;
}
// Your completion logic here
return text;
},
};
Remend is a string preprocessor — it must run on the raw Markdown before the unified/remark pipeline parses it into an AST.
import remend from 'remend';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
const streamedMarkdown = 'This is **incomplete bold';
// Step 1: complete incomplete syntax
const completedMarkdown = remend(streamedMarkdown);
// Step 2: process with unified as normal
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeStringify)
.process(completedMarkdown);
console.log(String(file));
// <p>This is <strong>incomplete bold</strong></p>
Do not run remend as a remark plugin or after remarkParse. Remend operates on raw strings; applying it to an AST node would have no effect.
Incomplete link handling with react-markdown
If you use remend with react-markdown directly, filter out the placeholder URL in your a component:
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>
Or use linkMode: 'text-only' to avoid the placeholder URL entirely:
<ReactMarkdown>{remend(streamingText, { linkMode: 'text-only' })}</ReactMarkdown>
How it works
On each call, remend:
- Strips a single trailing space (unless it is a hard-line-break double space)
- Builds an ordered list of enabled handlers sorted by priority
- Runs each handler in order, passing the accumulated result string
- Returns early if the links handler emits the incomplete-link marker (no further processing needed)
- Returns the final completed string
Each handler performs direct string iteration rather than regex splits, keeping allocations low. Handlers are context-aware: they check isWithinCodeBlock, isWithinMathBlock, and similar guards before making changes so they don’t corrupt content inside fences or math blocks.
The library has zero runtime dependencies and is published as pure TypeScript/ESM.