diff --git a/.changeset/hot-dryers-rescue.md b/.changeset/hot-dryers-rescue.md
new file mode 100644
index 00000000..aa4e0d45
--- /dev/null
+++ b/.changeset/hot-dryers-rescue.md
@@ -0,0 +1,5 @@
+---
+"streamdown": minor
+---
+
+Add new option `extraIncompleteHandles` that allows users to configure how unterminated Markdown blocks are handled.
diff --git a/apps/website/app/components/props.tsx b/apps/website/app/components/props.tsx
index 67daa253..8534f620 100644
--- a/apps/website/app/components/props.tsx
+++ b/apps/website/app/components/props.tsx
@@ -38,6 +38,13 @@ const props = [
description:
"Array of remark plugins to use for processing markdown. Includes GitHub Flavored Markdown and math support by default. You can import defaultRemarkPlugins to access individual default plugins when overriding.",
},
+ {
+ name: "extraIncompleteHandles",
+ type: "array",
+ default: "[handleIncompleteStrikethrough, handleIncompleteBlockKatex]",
+ description:
+ "The unterminated Markdown blocks handles based on the syntax supported by the remarkPlugins.",
+ },
{
name: "shikiTheme",
type: "[BundledTheme, BundledTheme] (from Shiki)",
diff --git a/packages/streamdown/README.md b/packages/streamdown/README.md
index 2b28abb7..a5c307bf 100644
--- a/packages/streamdown/README.md
+++ b/packages/streamdown/README.md
@@ -206,6 +206,7 @@ Streamdown accepts all the same props as react-markdown, plus additional streami
| `components` | `object` | - | Custom component overrides |
| `rehypePlugins` | `array` | `[[harden, { allowedImagePrefixes: ["*"], allowedLinkPrefixes: ["*"], defaultOrigin: undefined }], rehypeRaw, [rehypeKatex, { errorColor: "var(--color-muted-foreground)" }]]` | Rehype plugins to use. Includes rehype-harden for security, rehype-raw for HTML support, and rehype-katex for math rendering by default |
| `remarkPlugins` | `array` | `[[remarkGfm, {}], [remarkMath, { singleDollarTextMath: false }]]` | Remark plugins to use. Includes GitHub Flavored Markdown and math support by default |
+| `extraIncompleteHandles` | `array` | `[handleIncompleteStrikethrough, handleIncompleteBlockKatex]` | The unterminated Markdown blocks handles based on the syntax supported by the remarkPlugins. |
| `shikiTheme` | `[BundledTheme, BundledTheme]` | `['github-light', 'github-dark']` | The light and dark themes to use for code blocks |
| `mermaidConfig` | `MermaidConfig` | - | Custom configuration for Mermaid diagrams (theme, colors, etc.) |
| `controls` | `boolean \| { table?: boolean, code?: boolean, mermaid?: boolean }` | `true` | Control visibility of copy/download buttons |
diff --git a/packages/streamdown/__tests__/dollar-sign.test.tsx b/packages/streamdown/__tests__/dollar-sign.test.tsx
index 08832845..c3f76bdd 100644
--- a/packages/streamdown/__tests__/dollar-sign.test.tsx
+++ b/packages/streamdown/__tests__/dollar-sign.test.tsx
@@ -1,6 +1,16 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
-import { Streamdown } from "../index";
+import remarkMath from "remark-math";
+import type { Pluggable } from "unified";
+import {
+ Streamdown,
+ defaultRemarkPlugins,
+} from "../index";
+import {
+ handleIncompleteStrikethrough,
+ handleIncompleteInlineKatex,
+ handleIncompleteBlockKatex,
+} from "../lib/parse-incomplete-markdown";
describe("Dollar sign handling", () => {
it("should not render dollar amounts as math", () => {
@@ -88,4 +98,25 @@ describe("Dollar sign handling", () => {
const text = container.textContent;
expect(text).toContain("$99.99");
});
+
+ it("should handle escaped dollar signs and normal single dollar inline math with extra handleIncompleteInlineKatex", () => {
+ const content = "The price is \\$50 and math is $E = mc^2";
+ const extraIncompleteHandles = [
+ handleIncompleteStrikethrough,
+ handleIncompleteInlineKatex,
+ handleIncompleteBlockKatex,
+ ];
+ const remarkPlugins: Pluggable[] = [
+ defaultRemarkPlugins.gfm,
+ [remarkMath, { singleDollarTextMath: true }],
+ ];
+ const { container } = render({content});
+
+ const katexElements = container.querySelectorAll(".katex");
+ expect(katexElements.length).toBe(1);
+
+ // Check that dollar amounts are preserved
+ const text = container.textContent;
+ expect(text).toContain("$50");
+ });
});
diff --git a/packages/streamdown/__tests__/parse-incomplete-markdown.test.ts b/packages/streamdown/__tests__/parse-incomplete-markdown.test.ts
index 3fb6141e..0489c996 100644
--- a/packages/streamdown/__tests__/parse-incomplete-markdown.test.ts
+++ b/packages/streamdown/__tests__/parse-incomplete-markdown.test.ts
@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
-import { parseIncompleteMarkdown } from "../lib/parse-incomplete-markdown";
+import {
+ type IncompleteHandle,
+ parseIncompleteMarkdown as parseIncompleteMarkdownWithoutDefaultExtraIncompleteHandles,
+ handleIncompleteStrikethrough,
+ handleIncompleteInlineKatex,
+ handleIncompleteBlockKatex,
+} from "../lib/parse-incomplete-markdown";
+
+const parseIncompleteMarkdown = (text: string, extraIncompleteHandles: IncompleteHandle[] = []): string => {
+ return parseIncompleteMarkdownWithoutDefaultExtraIncompleteHandles(text, [handleIncompleteStrikethrough, handleIncompleteBlockKatex, ...extraIncompleteHandles]);
+}
describe("parseIncompleteMarkdown", () => {
describe("basic input handling", () => {
@@ -355,15 +365,24 @@ describe("parseIncompleteMarkdown", () => {
expect(parseIncompleteMarkdown("$incomplete")).toBe("$incomplete");
});
+ it("should complete single dollar signs with extra handleIncompleteInlineKatex", () => {
+ expect(parseIncompleteMarkdown("Text with $formula", [handleIncompleteInlineKatex])).toBe(
+ "Text with $formula$"
+ );
+ expect(parseIncompleteMarkdown("$incomplete", [handleIncompleteInlineKatex])).toBe("$incomplete$");
+ });
+
it("should keep text with paired dollar signs unchanged", () => {
// Even paired dollar signs are preserved but not treated as math
const text = "Text with $x^2 + y^2 = z^2$";
expect(parseIncompleteMarkdown(text)).toBe(text);
+ expect(parseIncompleteMarkdown(text, [handleIncompleteInlineKatex])).toBe(text);
});
it("should handle multiple inline KaTeX sections", () => {
const text = "$a = 1$ and $b = 2$";
expect(parseIncompleteMarkdown(text)).toBe(text);
+ expect(parseIncompleteMarkdown(text, [handleIncompleteInlineKatex])).toBe(text);
});
it("should NOT complete odd number of dollar signs", () => {
@@ -385,11 +404,19 @@ describe("parseIncompleteMarkdown", () => {
expect(parseIncompleteMarkdown("$x + y = z")).toBe("$x + y = z");
});
+ it("should complete dollar sign at start of text with extra handleIncompleteInlineKatex", () => {
+ expect(parseIncompleteMarkdown("$x + y = z", [handleIncompleteInlineKatex])).toBe("$x + y = z$");
+ });
+
it("should handle escaped dollar signs", () => {
const text = "Price is \\$100";
expect(parseIncompleteMarkdown(text)).toBe(text);
});
+ it("should NOT complete escaped single dollar signs with extra handleIncompleteInlineKatex", () => {
+ expect(parseIncompleteMarkdown("\\$3 dollar", [handleIncompleteInlineKatex])).toBe("\\$3 dollar");
+ });
+
it("should handle multiple consecutive dollar signs correctly", () => {
expect(parseIncompleteMarkdown("$$$")).toBe("$$$$$");
expect(parseIncompleteMarkdown("$$$$")).toBe("$$$$");
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index d95054f4..8921f60d 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -13,7 +13,12 @@ import { harden } from "rehype-harden";
import type { Pluggable } from "unified";
import { components as defaultComponents } from "./lib/components";
import { parseMarkdownIntoBlocks } from "./lib/parse-blocks";
-import { parseIncompleteMarkdown } from "./lib/parse-incomplete-markdown";
+import {
+ type IncompleteHandle,
+ parseIncompleteMarkdown,
+ handleIncompleteStrikethrough,
+ handleIncompleteBlockKatex,
+} from "./lib/parse-incomplete-markdown";
import { cn } from "./lib/utils";
export type { MermaidConfig } from "mermaid";
@@ -30,6 +35,7 @@ export type StreamdownProps = Options & {
parseIncompleteMarkdown?: boolean;
className?: string;
shikiTheme?: [BundledTheme, BundledTheme];
+ extraIncompleteHandles?: IncompleteHandle[];
mermaidConfig?: MermaidConfig;
controls?: ControlsConfig;
isAnimating?: boolean;
@@ -54,6 +60,11 @@ export const defaultRemarkPlugins: Record = {
math: [remarkMath, { singleDollarTextMath: false }],
} as const;
+const defaultExtraIncompleteHandles = [
+ handleIncompleteStrikethrough,
+ handleIncompleteBlockKatex,
+];
+
export const ShikiThemeContext = createContext<[BundledTheme, BundledTheme]>([
"github-light" as BundledTheme,
"github-dark" as BundledTheme,
@@ -76,17 +87,18 @@ export const StreamdownRuntimeContext =
type BlockProps = Options & {
content: string;
+ extraIncompleteHandles: IncompleteHandle[];
shouldParseIncompleteMarkdown: boolean;
};
const Block = memo(
- ({ content, shouldParseIncompleteMarkdown, ...props }: BlockProps) => {
+ ({ content, shouldParseIncompleteMarkdown, extraIncompleteHandles, ...props }: BlockProps) => {
const parsedContent = useMemo(
() =>
typeof content === "string" && shouldParseIncompleteMarkdown
- ? parseIncompleteMarkdown(content.trim())
+ ? parseIncompleteMarkdown(content.trim(), extraIncompleteHandles)
: content,
- [content, shouldParseIncompleteMarkdown]
+ [content, shouldParseIncompleteMarkdown, extraIncompleteHandles]
);
return {parsedContent};
@@ -103,6 +115,7 @@ export const Streamdown = memo(
components,
rehypePlugins = Object.values(defaultRehypePlugins),
remarkPlugins = Object.values(defaultRemarkPlugins),
+ extraIncompleteHandles = defaultExtraIncompleteHandles,
className,
shikiTheme = ["github-light", "github-dark"],
mermaidConfig,
@@ -138,6 +151,7 @@ export const Streamdown = memo(
shouldParseIncompleteMarkdown={
shouldParseIncompleteMarkdown
}
+ extraIncompleteHandles={extraIncompleteHandles}
{...props}
/>
))}
diff --git a/packages/streamdown/lib/parse-incomplete-markdown.ts b/packages/streamdown/lib/parse-incomplete-markdown.ts
index 0c24af01..4273615e 100644
--- a/packages/streamdown/lib/parse-incomplete-markdown.ts
+++ b/packages/streamdown/lib/parse-incomplete-markdown.ts
@@ -6,6 +6,9 @@ const singleAsteriskPattern = /(\*)([^*]*?)$/;
const singleUnderscorePattern = /(_)([^_]*?)$/;
const inlineCodePattern = /(`)([^`]*?)$/;
const strikethroughPattern = /(~~)([^~]*?)$/;
+const inlineKatexPattern = /(\$)([^$]*?)$/;
+
+export type IncompleteHandle = (text: string) => string;
// Helper function to check if we have a complete code block
const hasCompleteCodeBlock = (text: string): boolean => {
@@ -450,7 +453,7 @@ const handleIncompleteInlineCode = (text: string): string => {
};
// Completes incomplete strikethrough formatting (~~)
-const handleIncompleteStrikethrough = (text: string): string => {
+export const handleIncompleteStrikethrough = (text: string): string => {
const strikethroughMatch = text.match(strikethroughPattern);
if (strikethroughMatch) {
@@ -472,7 +475,7 @@ const handleIncompleteStrikethrough = (text: string): string => {
};
// Counts single dollar signs that are not part of double dollar signs and not escaped
-const _countSingleDollarSigns = (text: string): number => {
+const countSingleDollarSigns = (text: string): number => {
return text.split("").reduce((acc, char, index) => {
if (char === "$") {
const prevChar = text[index - 1];
@@ -489,8 +492,26 @@ const _countSingleDollarSigns = (text: string): number => {
}, 0);
};
+export const handleIncompleteInlineKatex = (text: string): string => {
+ // Don't process if inside a complete code block
+ if (hasCompleteCodeBlock(text)) {
+ return text;
+ }
+
+ const inlineKatexMatch = text.match(inlineKatexPattern);
+
+ if (inlineKatexMatch) {
+ const singleDollars = countSingleDollarSigns(text);
+ if (singleDollars % 2 === 1) {
+ return `${text}$`;
+ }
+ }
+
+ return text;
+};
+
// Completes incomplete block KaTeX formatting ($$)
-const handleIncompleteBlockKatex = (text: string): string => {
+export const handleIncompleteBlockKatex = (text: string): string => {
// Count all $$ pairs in the text
const dollarPairs = (text.match(/\$\$/g) || []).length;
@@ -565,7 +586,7 @@ const handleIncompleteBoldItalic = (text: string): string => {
};
// Parses markdown text and removes incomplete tokens to prevent partial rendering
-export const parseIncompleteMarkdown = (text: string): string => {
+export const parseIncompleteMarkdown = (text: string, extraIncompleteHandles: IncompleteHandle[] = []): string => {
if (!text || typeof text !== "string") {
return text;
}
@@ -591,11 +612,10 @@ export const parseIncompleteMarkdown = (text: string): string => {
result = handleIncompleteSingleAsteriskItalic(result);
result = handleIncompleteSingleUnderscoreItalic(result);
result = handleIncompleteInlineCode(result);
- result = handleIncompleteStrikethrough(result);
- // Handle KaTeX formatting (only block math with $$)
- result = handleIncompleteBlockKatex(result);
- // Note: We don't handle inline KaTeX with single $ as they're likely currency symbols
+ extraIncompleteHandles.forEach(handle => {
+ result = handle(result);
+ })
return result;
};