Markdown Preprocessing and Repair#
The react-streamdown package processes LLM-generated text through two sequential layers before rendering: a text normalization layer (preprocess.ts) and an incomplete-markdown repair layer (remend.ts). Both layers run on the full accumulated text each time the stream flushes.
Key files:
packages/react-streamdown/src/preprocess.ts— math delimiter normalization, currency escapingpackages/react-streamdown/src/remend.ts—tailBoundedRemend,findRemendWindowStart,scanBlockspackages/react-streamdown/src/primitives/StreamdownText.tsx— wires both layers into the streaming pipeline
Quick-reference table of public exports:
| Export | File | Purpose |
|---|---|---|
tailBoundedRemend | remend.ts | Incomplete-markdown repair, prose-segmented |
findRemendWindowStart | remend.ts | Last safe block boundary (wraps scanBlocks) |
normalizeMathDelimiters | preprocess.ts | LaTeX/custom-tag → $/$$ |
escapeCurrencyDollars | preprocess.ts | Escape $5-style prices |
rewriteLatexBracketDelimiters | preprocess.ts | \(...\) / [...] → $/$$ |
rewriteCustomMathTags | preprocess.ts | [/math]...[/math] → $$ |
preprocess.ts — Text Normalization#
preprocess.ts normalizes text before the Markdown parser receives it, converting alternative math delimiters that language models emit to forms remark-math recognizes and escaping currency amounts so single-dollar math does not consume prices. All rewrites skip verbatim regions — HTML blocks, code spans, and fences — so delimiters shown as code remain unchanged .
rewriteOutsideCode#
The internal walker rewriteOutsideCode applies a rewrite function only to text outside HTML blocks, code spans, and fences. It steps over \x escapes so an escaped backtick does not open a span . Backtick regions use backtickEnd, which delegates to either fenceEnd or codeSpanEnd depending on whether opensBacktickFence accepts the run.
Fence Detection#
A backtick run opens a fence when it contains ≥3 backticks, only blockquote or list markers precede it on the line, and the info string contains no backtick . Indentation is not capped at the three columns CommonMark allows because the cap is container-relative and the walker does not track containers . This trade-off means a run indented four columns at the root, where CommonMark reads an indented code block, opens a fence here .
A tilde run opens a fence when it contains ≥3 tildes and carries the same line prefix (blockquote or list markers) . Tilde runs never open code spans, so no info-string check is needed .
fenceEnd finds the closing run — at least as long as the opener and the only content on its line — accounting for blockquote depth . A quoted fence ends where its blockquote does if no closer appears .
codeSpanEnd closes on an exact-length backtick run; a blank line ends the search because spans cannot cross paragraph boundaries .
Math Delimiter Normalization#
Three functions convert alternative math delimiters to the $...$ / $$...$$ form remark-math parses:
-
rewriteLatexBracketDelimiters converts
\(...\)→$...$and[...]→$$...$$. A single or double leading backslash is accepted, since models emit both depending on escaping . -
rewriteCustomMathTags converts
[/math]...[/math]→$$...$$and[/inline]...[/inline]→$...$. -
normalizeMathDelimiters composes both .
emitDisplayMath is the helper for multiline display math. It emits $$body$$ on one line for single-line bodies and the fenced form for bodies spanning lines, with $$ markers on their own lines . Markers written at the root column would end the list item or blockquote the math was written inside, so they carry that container's prefix and the body is aligned to it .
escapeCurrencyDollars#
escapeCurrencyDollars escapes $ preceding a digit so remark-math does not consume prices as math delimiters . The decision is made on the delimiter pair: the text up to the next $ is inspected to determine if it's plausible math via isMathBody . This keeps wrong guesses local — an accepted span contains no $, so an inserted escape can never fall between a delimiter pair and shift every delimiter that follows .
The function uses rewriteOutsideHtml (not rewriteOutsideCode) because it has its own verbatim-run logic via endOfVerbatimRun.
remend.ts — Tail-Bounded Incomplete-Markdown Repair#
remend.ts wraps the remend library with a segmented strategy: it identifies protected regions (closed code fences, HTML blocks, and math blocks) and any construct still open at the end, then applies full remend repair only to the final unsettled block while running escape-only passes on earlier prose segments .
scanBlocks#
The scanBlocks function performs a single-pass line-by-line scan that returns four values:
- boundary: character index after the most recent blank line outside any open fence, HTML block, or
$$math block — the last top-level block boundary - protectedRanges: flat array of start/end pairs marking closed fences, HTML blocks,
$$blocks, and line-start inline math; these regions are copied raw without remend escaping - openStart: start index of a fence, HTML, or math block still open at the end, or
-1if none - katexCloses: whether remend's katex completion (a bare
$$line) can close an open$$block — true only when the block was opened with exactly two dollars, outside any blockquote, and outside the list item of its opening line
Protected ranges start at line boundaries because remend drops a trailing space from its input; a cut mid-line would lose characters .
Fence Tracking#
A fence opens at any indentation , since a marker indented four or more columns is either a fence nested in a list item or an indented code block . The opener's indentation is stored in fenceIndent . A fence closes only on a marker of the same character (backtick or tilde), at the same blockquote depth, indented at most three columns past the opener, with a run at least as long as the opener, followed only by whitespace .
Math Block Tracking#
Dollar-sign math follows remark-math conventions . A run of two or more $ characters at the start of a line's content (after list and blockquote markers) opens a $$ math block only if no other dollar appears on that line . The block closes on a line holding only a dollar run at least as long, at the correct blockquote depth and indentation . Math blocks are raw: fence markers inside them open nothing.
Inline math (two or more dollars not at block start, or mid-line) is protected only when it opens at line start and closes on the same line with a run of exactly the same length .
HTML Block Tracking#
HTML blocks follow all seven CommonMark kinds . An HTML block opens when a line's content (indented fewer than four columns past blockquote markers themselves indented fewer than four) starts one of the seven patterns micromark recognizes . The body is raw: fence or math markers inside it open nothing .
Blank-Line Boundary Tracking#
A blank line outside any open fence, HTML block, or math block commits the boundary . The boundary marks the end of the last settled block. A bare > line is blank inside a blockquote but opens a new block after a blank line .
List and Blockquote Context#
The scan uses skipListMarkers to track list and blockquote nesting. List markers and blockquote markers affect where fences, math blocks, and HTML blocks can open and close. A fence or math block opened after list markers ends with that list item at the first line indented fewer columns than the item's content .
findRemendWindowStart#
findRemendWindowStart is a one-line wrapper that returns scanBlocks(text).boundary. It preserves the same public signature as the previous implementation .
tailBoundedRemend#
The tailBoundedRemend function is the main export. It segments the message and applies different remend configurations to each segment :
-
Promote comparisonOperators to handler: If
comparisonOperators !== false, the function promotes it to a prose-only handler so comparison-operator escapes run only on prose segments, not on raw protected blocks . -
Fast path: If
boundary ≤ 0, no protected ranges exist, and no block is open (openStart === -1), the function callsremend(text, options)directly . -
Segmented processing: Otherwise, it walks through protected ranges and prose segments:
- For each protected range
[from, to]: runsremend(text.slice(cursor, from), prefixOptions)on prose before it, then copiestext.slice(from, to)raw - If a block is still open (
openStart !== -1): runs remend on prose before the open block withCOMPLETION_OFF, then either copies the open block raw (when katex cannot close it) or runs remend with katex-only options (whenkatexClosesis true) - Otherwise: runs remend on prose before the boundary with
COMPLETION_OFF, then applies full remend to the tail starting at the boundary
- For each protected range
COMPLETION_OFF#
The COMPLETION_OFF object disables all remend completion options. It is enforced with a satisfies constraint at compile time: a new remend completion option will fail the build instead of silently reaching settled text .
PrefixSafeOption#
The PrefixSafeOption type lists options safe for settled prefix text: singleTilde, comparisonOperators, handlers, and linkMode. These apply escapes rather than completions .
escapeComparisonOperators#
The escapeComparisonOperators function escapes list-item-like comparison operators (e.g., - > 25) so they are not misread as list items . When comparisonOperators !== false, tailBoundedRemend promotes it to a handler that runs as a prose-only handler rather than on raw text .
Integration in StreamdownTextPrimitive#
StreamdownTextPrimitive enables tailBoundedRemend when mode === "streaming", repair is not disabled, and no custom parseMarkdownIntoBlocksFn is provided. It sets parseIncompleteMarkdown={false} when forwarding to Streamdown to prevent double repair .
Design Decisions and Known Limitations#
The pipeline reflects tradeoffs between correctness, maintainability, and the constraints of line-based scanning without full container tracking.
Fence detection: preprocess.ts vs remend.ts#
Two separate fence detectors exist and intentionally differ in their indentation rules:
-
preprocess.ts
opensBacktickFence(packages/react-streamdown/src/preprocess.ts) omits the CommonMark three-column indentation cap because the cap is container-relative and the walker does not track containers. A run indented four or more columns at root opens a fence here, where CommonMark would read it as an indented code block. This is a deliberate tradeoff, documented in the function comment: "Indentation is not capped at the three columns CommonMark allows, because the cap is relative to the enclosing container and this walker does not track containers" . -
remend.ts
scanBlocksfence opener opens a fence at any indentation: "a marker indented four or more columns is either a fence nested in a list item or an indented code block, code either way" . The closer must sit at most three columns past the opener , matching howfenceEndin preprocess reads closers .
Both arrive at the same practical outcome—protecting code from rewriting—but for different reasons. The preprocess walker exists to normalize math delimiters outside code, while the remend scanner exists to exclude settled blocks from incomplete-markdown repair.
Indented code blocks not protected#
Root-level indented code blocks (e.g., four-space indented lines at root) still receive tilde and comparison-operator escapes. Tracking indented code blocks requires list container state, which the line scanner does not maintain . This is a known limitation: Intro\n\n lm(y~x) becomes y\~x escaped . The issue is tracked in open issues #6945 and #7449, referenced in and .
Mid-line $$ not treated as display-math opener#
remark-math opens display math only where a line's content starts with $$ (the blockStart position in the scan) . The scan now opens a $$ block only at blockStart, not at any mid-line position . This fixed a bug where Price: $$$ tier plus a later $$ block caused the block to lose protection, as the mid-line $$ kept the scanner inside math for the rest of the message .
Custom handlers receive prose segments separately#
Since tailBoundedRemend now segments the message, custom remend.handlers receive each run of prose as a separate call, not the full final block as one string . This is a behavioral change from the initial tail-bounded implementation.
Maintaining upstream remend compatibility#
The background note flags: "react-streamdown's tailBoundedRemend code-span scanner must preserve remend's lookup and rendering behavior as upstream streamdown/remend packages change" . The scanBlocks leaf rules are designed to match remend's own parsing: info-string check, exact run length, paragraph-bounded spans, and backslash parity for escapes . The remend.ts file notes: "upstream, vercel/streamdown#571 (open) moves remend to a region scanner that recognizes ~~~ fences at any indentation; its tilde escape still ignores math regions, so the $$ half is not covered there" .
PR History#
| PR | What changed |
|---|---|
| #4418 | Introduced tailBoundedRemend and findRemendWindowStart. Replaced whole-message remend on every flush with tail-only repair. |
| #6330 | Bumped streamdown to ^2.6.0 so fenced code updates when streamed content changes. |
| #6885 | Fixed prefix-escape regression: earlier paragraphs were losing singleTilde / comparisonOperators escapes. Introduced two-pass strategy (prefix with COMPLETION_OFF + tail with full options) and the PrefixSafeOption / COMPLETION_OFF compile-time guard. |
| #7053 | Introduced scanBlocks and protectedRanges so ~~~ fences and $$ blocks in the settled prefix are copied raw instead of receiving tilde escapes. Fixed the quadratic indexOf in the $$ search to stop at line end. |
| #7770 | Extended tailBoundedRemend to segment the entire message so fences and $$ blocks inside the final block are also protected. Fence opener now accepted at any indentation. |
| #7973 | Fixed mid-line $$ (e.g. Price: $$$ tier) being treated as a display-math opener. $$ now opens a block only at blockStart. |