Skip to content

Commit 69fb1e0

Browse files
fix single dollar sign text rendering as math (#64)
* Disable singleDollarTextMath * Create wet-islands-slide.md * Update tests * Update mathematics.tsx * Update wet-islands-slide.md * Revert "Update wet-islands-slide.md" This reverts commit 3e8f80a.
1 parent 593b021 commit 69fb1e0

File tree

6 files changed

+138
-42
lines changed

6 files changed

+138
-42
lines changed

.changeset/wet-islands-slide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": patch
3+
---
4+
5+
fix single dollar sign text rendering as math

apps/website/app/components/mathematics.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { Section } from './section';
22

33
const markdown = `## Inline Math
44
5-
The quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$ for solving $ax^2 + bx + c = 0$.
5+
The quadratic formula is $$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$ for solving $$ax^2 + bx + c = 0$$.
66
7-
Euler's identity: $e^{i\\pi} + 1 = 0$ combines five fundamental mathematical constants.
7+
Euler's identity: $$e^{i\\pi} + 1 = 0$$ combines five fundamental mathematical constants.
88
99
## Block Math
1010
@@ -16,9 +16,9 @@ $$
1616
1717
## Summations and Integrals
1818
19-
The sum of the first $n$ natural numbers: $\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$
19+
The sum of the first $$n$$ natural numbers: $$\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$$
2020
21-
Integration by parts: $\\int u \\, dv = uv - \\int v \\, du$
21+
Integration by parts: $$\\int u \\, dv = uv - \\int v \\, du$$
2222
`;
2323

2424
export const Mathematics = () => (
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { render } from '@testing-library/react';
2+
import React from 'react';
3+
import { describe, expect, it } from 'vitest';
4+
import { Streamdown } from '../index';
5+
6+
describe('Dollar sign handling', () => {
7+
it('should not render dollar amounts as math', () => {
8+
const content = '$20 is a sum that isn\'t larger than a few dollars';
9+
const { container } = render(<Streamdown>{content}</Streamdown>);
10+
11+
// Check if content is incorrectly wrapped in math elements
12+
const katexElements = container.querySelectorAll('.katex');
13+
14+
// The text should be rendered as plain text, not math
15+
expect(katexElements.length).toBe(0);
16+
17+
// Check that the dollar signs are preserved in the text
18+
const text = container.textContent;
19+
expect(text).toContain('$20');
20+
expect(text).toContain('dollars');
21+
});
22+
23+
it('should handle multiple dollar signs in text', () => {
24+
const content = 'The price is $50 and the discount is $10 off';
25+
const { container } = render(<Streamdown>{content}</Streamdown>);
26+
27+
const katexElements = container.querySelectorAll('.katex');
28+
expect(katexElements.length).toBe(0);
29+
30+
// Check that both dollar amounts are preserved
31+
const text = container.textContent;
32+
expect(text).toContain('$50');
33+
expect(text).toContain('$10');
34+
});
35+
36+
it('should handle single dollar sign at end of text', () => {
37+
const content = 'The cost is $';
38+
const { container } = render(<Streamdown>{content}</Streamdown>);
39+
40+
const katexElements = container.querySelectorAll('.katex');
41+
expect(katexElements.length).toBe(0);
42+
43+
// Check that the single dollar sign is preserved without adding a trailing one
44+
const text = container.textContent;
45+
expect(text).toBe('The cost is $');
46+
});
47+
48+
it('should handle text with dollar sign followed by non-numeric characters', () => {
49+
const content = 'Use $variable in the code';
50+
const { container } = render(<Streamdown>{content}</Streamdown>);
51+
52+
const katexElements = container.querySelectorAll('.katex');
53+
expect(katexElements.length).toBe(0);
54+
55+
const text = container.textContent;
56+
expect(text).toContain('$variable');
57+
});
58+
59+
it('should still render block math with double dollar signs', () => {
60+
const content = 'Display math: $$E = mc^2$$';
61+
const { container } = render(<Streamdown>{content}</Streamdown>);
62+
63+
const katexElements = container.querySelectorAll('.katex');
64+
// Block math should still work
65+
expect(katexElements.length).toBeGreaterThan(0);
66+
});
67+
68+
it('should not render inline math with single dollar signs', () => {
69+
const content = 'This $x = y$ should not be rendered as math';
70+
const { container } = render(<Streamdown>{content}</Streamdown>);
71+
72+
const katexElements = container.querySelectorAll('.katex');
73+
// With singleDollarTextMath: false, this should not render as math
74+
expect(katexElements.length).toBe(0);
75+
76+
const text = container.textContent;
77+
expect(text).toContain('$x = y$');
78+
});
79+
80+
it('should handle mixed content with both currency and block math', () => {
81+
const content = 'The price is $99.99 and the formula is $$x^2 + y^2 = z^2$$';
82+
const { container } = render(<Streamdown>{content}</Streamdown>);
83+
84+
const katexElements = container.querySelectorAll('.katex');
85+
// Only the block math should render
86+
expect(katexElements.length).toBe(1);
87+
88+
const text = container.textContent;
89+
expect(text).toContain('$99.99');
90+
});
91+
});

packages/streamdown/__tests__/parse-incomplete-markdown.test.ts

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -318,14 +318,16 @@ describe('parseIncompleteMarkdown', () => {
318318
});
319319

320320
describe('KaTeX inline formatting ($)', () => {
321-
it('should complete incomplete inline KaTeX', () => {
321+
it('should NOT complete single dollar signs (likely currency)', () => {
322+
// Single dollar signs are likely currency, not math
322323
expect(parseIncompleteMarkdown('Text with $formula')).toBe(
323-
'Text with $formula$'
324+
'Text with $formula'
324325
);
325-
expect(parseIncompleteMarkdown('$incomplete')).toBe('$incomplete$');
326+
expect(parseIncompleteMarkdown('$incomplete')).toBe('$incomplete');
326327
});
327328

328-
it('should keep complete inline KaTeX unchanged', () => {
329+
it('should keep text with paired dollar signs unchanged', () => {
330+
// Even paired dollar signs are preserved but not treated as math
329331
const text = 'Text with $x^2 + y^2 = z^2$';
330332
expect(parseIncompleteMarkdown(text)).toBe(text);
331333
});
@@ -335,20 +337,23 @@ describe('parseIncompleteMarkdown', () => {
335337
expect(parseIncompleteMarkdown(text)).toBe(text);
336338
});
337339

338-
it('should complete odd number of inline KaTeX markers', () => {
340+
it('should NOT complete odd number of dollar signs', () => {
341+
// We don't auto-complete dollar signs anymore
339342
expect(parseIncompleteMarkdown('$first$ and $second')).toBe(
340-
'$first$ and $second$'
343+
'$first$ and $second'
341344
);
342345
});
343346

344-
it('should not confuse single $ with block $$', () => {
347+
it('should not complete single $ but should complete block $$', () => {
348+
// Block math $$ is completed, single $ is not
345349
expect(parseIncompleteMarkdown('$$block$$ and $inline')).toBe(
346-
'$$block$$ and $inline$'
350+
'$$block$$ and $inline'
347351
);
348352
});
349353

350-
it('should handle inline KaTeX at start of text', () => {
351-
expect(parseIncompleteMarkdown('$x + y = z')).toBe('$x + y = z$');
354+
it('should NOT complete dollar sign at start of text', () => {
355+
// Single dollar sign is likely currency
356+
expect(parseIncompleteMarkdown('$x + y = z')).toBe('$x + y = z');
352357
});
353358

354359
it('should handle escaped dollar signs', () => {
@@ -542,10 +547,10 @@ describe('parseIncompleteMarkdown', () => {
542547
);
543548
});
544549

545-
it('should handle KaTeX inside other formatting', () => {
546-
// Bold gets closed first, then KaTeX
550+
it('should handle dollar sign inside other formatting', () => {
551+
// Bold gets closed, dollar sign stays as-is (likely currency)
547552
expect(parseIncompleteMarkdown('**bold with $x^2')).toBe(
548-
'**bold with $x^2**$'
553+
'**bold with $x^2**'
549554
);
550555
});
551556

@@ -610,11 +615,12 @@ describe('parseIncompleteMarkdown', () => {
610615
'The formula $E = mc^2$ shows',
611616
];
612617

618+
// Single dollar signs are not auto-completed (likely currency)
613619
expect(parseIncompleteMarkdown(chunks[0])).toBe(chunks[0]);
614-
expect(parseIncompleteMarkdown(chunks[1])).toBe('The formula $E$');
615-
expect(parseIncompleteMarkdown(chunks[2])).toBe('The formula $E = mc$');
620+
expect(parseIncompleteMarkdown(chunks[1])).toBe('The formula $E');
621+
expect(parseIncompleteMarkdown(chunks[2])).toBe('The formula $E = mc');
616622
expect(parseIncompleteMarkdown(chunks[3])).toBe(
617-
'The formula $E = mc^2$'
623+
'The formula $E = mc^2'
618624
);
619625
expect(parseIncompleteMarkdown(chunks[4])).toBe(chunks[4]);
620626
});
@@ -656,10 +662,10 @@ describe('parseIncompleteMarkdown', () => {
656662
});
657663

658664
it('should not add underscore when math block has incomplete underscore', () => {
659-
// Incomplete math blocks get completed by handleIncompleteInlineKatex
660-
// The underscore inside should not be treated as italic
665+
// We no longer auto-complete single dollar signs
666+
// The underscore inside is not treated as italic since it's likely part of a variable name
661667
const text = 'Math expression $x_';
662-
expect(parseIncompleteMarkdown(text)).toBe('Math expression $x_$');
668+
expect(parseIncompleteMarkdown(text)).toBe('Math expression $x_');
663669

664670
const text2 = '$$formula_';
665671
expect(parseIncompleteMarkdown(text2)).toBe('$$formula_$$');
@@ -791,7 +797,7 @@ describe('parseIncompleteMarkdown', () => {
791797
expect(parseIncompleteMarkdown('text**')).toBe('text****');
792798
expect(parseIncompleteMarkdown('text*')).toBe('text**');
793799
expect(parseIncompleteMarkdown('text`')).toBe('text``');
794-
expect(parseIncompleteMarkdown('text$')).toBe('text$$');
800+
expect(parseIncompleteMarkdown('text$')).toBe('text$'); // Single dollar not completed
795801
expect(parseIncompleteMarkdown('text~~')).toBe('text~~~~');
796802
});
797803

packages/streamdown/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ export const Streamdown = memo(
103103
// biome-ignore lint/suspicious/noArrayIndexKey: "required"
104104
key={`${generatedId}-block_${index}`}
105105
rehypePlugins={[rehypeKatexPlugin, ...(rehypePlugins ?? [])]}
106-
remarkPlugins={[remarkGfm, remarkMath, ...(remarkPlugins ?? [])]}
106+
remarkPlugins={[
107+
remarkGfm,
108+
[remarkMath, { singleDollarTextMath: false }],
109+
...(remarkPlugins ?? []),
110+
]}
107111
shouldParseIncompleteMarkdown={shouldParseIncompleteMarkdown}
108112
/>
109113
))}

packages/streamdown/lib/parse-incomplete-markdown.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const singleAsteriskPattern = /(\*)([^*]*?)$/;
66
const singleUnderscorePattern = /(_)([^_]*?)$/;
77
const inlineCodePattern = /(`)([^`]*?)$/;
88
const strikethroughPattern = /(~~)([^~]*?)$/;
9-
const inlineKatexPattern = /(\$)([^$]*?)$/;
9+
// Removed inlineKatexPattern - no longer processing single dollar signs
1010
const blockKatexPattern = /(\$\$)([^$]*?)$/;
1111

1212
// Helper function to check if we have a complete code block
@@ -300,21 +300,11 @@ const handleIncompleteBlockKatex = (text: string): string => {
300300
};
301301

302302
// Completes incomplete inline KaTeX formatting ($)
303+
// Note: Since we've disabled single dollar math delimiters in remarkMath,
304+
// we should not auto-complete single dollar signs as they're likely currency symbols
303305
const handleIncompleteInlineKatex = (text: string): string => {
304-
// Don't process if inside a complete code block
305-
if (hasCompleteCodeBlock(text)) {
306-
return text;
307-
}
308-
309-
const inlineKatexMatch = text.match(inlineKatexPattern);
310-
311-
if (inlineKatexMatch) {
312-
const singleDollars = countSingleDollarSigns(text);
313-
if (singleDollars % 2 === 1) {
314-
return `${text}$`;
315-
}
316-
}
317-
306+
// Don't process single dollar signs - they're likely currency symbols, not math
307+
// Only process block math ($$) which is handled separately
318308
return text;
319309
};
320310

@@ -381,9 +371,9 @@ export const parseIncompleteMarkdown = (text: string): string => {
381371
result = handleIncompleteInlineCode(result);
382372
result = handleIncompleteStrikethrough(result);
383373

384-
// Handle KaTeX formatting (block first, then inline)
374+
// Handle KaTeX formatting (only block math with $$)
385375
result = handleIncompleteBlockKatex(result);
386-
result = handleIncompleteInlineKatex(result);
376+
// Note: We don't handle inline KaTeX with single $ as they're likely currency symbols
387377

388378
return result;
389379
};

0 commit comments

Comments
 (0)