Skip to main content
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

npm i remend

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

OptionTypeDefaultDescription
boldbooleantrueComplete bold formatting — **text**text**
boldItalicbooleantrueComplete bold-italic formatting — ***text***text***
italicbooleantrueComplete italic formatting — *text*text* and _text_text_
inlineCodebooleantrueComplete inline code — `code`code`
strikethroughbooleantrueComplete strikethrough — ~~text~~text~~
linksbooleantrueComplete incomplete links
imagesbooleantrueHandle incomplete images (removes them)
katexbooleantrueComplete block KaTeX math — $$formula$$formula$$
inlineKatexbooleanfalseComplete inline KaTeX math — $formula$formula$. Disabled by default to avoid ambiguity with currency symbols.
singleTildebooleantrueEscape single ~ between word characters — 20~2520\~25
setextHeadingsbooleantrueHandle incomplete setext headings to prevent misinterpretation
comparisonOperatorsbooleantrueEscape > as comparison operators in list items — - > 25- \> 25
htmlTagsbooleantrueStrip incomplete HTML tags at end of text — text <customtext
linkMode'protocol' | 'text-only''protocol'How to render incomplete links. 'protocol' uses a streamdown:incomplete-link placeholder; 'text-only' displays only the link text.
handlersRemendHandler[][]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.
HandlerPriority
singleTilde0
comparisonOperators5
htmlTags10
setextHeadings15
links20
boldItalic30
bold35
italic (double underscore)40
italic (single asterisk)41
italic (single underscore)42
inlineCode50
strikethrough60
katex70
inlineKatex75
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';
UtilitySignatureDescription
isWithinCodeBlock(text: string, index: number) => booleanReturns true if the character at index is inside a fenced code block
isWithinMathBlock(text: string, index: number) => booleanReturns true if the character at index is inside a $$ math block
isWithinLinkOrImageUrl(text: string, index: number) => booleanReturns true if the character at index is inside a link or image URL segment
isWordChar(char: string) => booleanReturns 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;
  },
};

Usage with remark

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.
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:
  1. Strips a single trailing space (unless it is a hard-line-break double space)
  2. Builds an ordered list of enabled handlers sorted by priority
  3. Runs each handler in order, passing the accumulated result string
  4. Returns early if the links handler emits the incomplete-link marker (no further processing needed)
  5. 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.

Build docs developers (and LLMs) love