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; };