fix(web): disable single-dollar inline math in remark-math (#805)

* fix(web/markdown): disable single-dollar inline math in remark-math

Default remark-math configuration treats $...$ as inline LaTeX, which
turns any prose containing two currency amounts (e.g. "save $400 vs the
$200 plan") into a KaTeX block — paragraphs collapse, whitespace is
stripped, the running text is re-rendered as math symbols.

Pass `singleDollarTextMath: false` to remarkMath so single $ is plain
text. Block math `$$...$$` (on its own line) still renders, matching
GitHub-flavored markdown semantics.

Single source of truth: MARKDOWN_PLUGINS is shared by MarkdownText,
Reasoning, and MarkdownRenderer — fix lands in all three surfaces.

Adds 3 regression tests that drive the unified pipeline end-to-end:
prose with multiple "$N" amounts produces no `class="katex"` and no
`<math>` element; `$$...$$` block math still does.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(web): declare unified/remark-parse/remark-rehype/hast-util-to-html

The new markdown-text regression test imports these directly to drive the
unified pipeline end-to-end. They were resolving via transitive deps from
remark-math and rehype-katex, which is fragile — a future dep upgrade can
remove the transitives and break the test.

Declare them explicitly under devDependencies. No code change; lockfile
records the same versions that were already installed transitively
(unified@11.0.5, remark-parse@11.0.0, remark-rehype@11.1.2,
hast-util-to-html@9.0.5).

Addresses the HAPI Bot review finding on PR #805.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-08 13:26:41 +08:00
committed by GitHub
co-authored by Cursor
parent 66ba312117
commit c87f88f02f
4 changed files with 71 additions and 2 deletions
+6
View File
@@ -139,10 +139,14 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"hast-util-to-html": "^9.0.5",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"unified": "^11.0.5",
"vite": "^7.3.0",
"vitest": "^4.0.16",
},
@@ -1070,6 +1074,8 @@
"@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.0", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-hmdAxSgsAxeLfRFn57u13xLc7jHGxKJR/HZUwKkFKe6vcTkGRWsSJToVhGZrUSCT+giYo8g0YQpyOWI/i8cBLA=="],
"@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-1GWfncMeaZvBIfSB0RY4UI4ywiKUtOAi41nRHxqUI/VdWS9Rw3syCRa4bH2gFJzrdRtDdi0kfSib9YRHs1uQgg=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
+4
View File
@@ -58,10 +58,14 @@
"@tailwindcss/postcss": "^4.1.18",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"hast-util-to-html": "^9.0.5",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"unified": "^11.0.5",
"vite": "^7.3.0",
"vitest": "^4.0.16"
}
@@ -1,8 +1,16 @@
import { describe, expect, it } from 'vitest'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { toHtml } from 'hast-util-to-html'
import remarkBreaks from 'remark-breaks'
import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink'
import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink'
import { MARKDOWN_PLUGINS, MARKDOWN_PLUGINS_WITH_BREAKS } from '@/components/assistant-ui/markdown-text'
import {
MARKDOWN_PLUGINS,
MARKDOWN_PLUGINS_WITH_BREAKS,
MARKDOWN_REHYPE_PLUGINS,
} from '@/components/assistant-ui/markdown-text'
describe('MARKDOWN_PLUGINS integration', () => {
it('includes remarkNonHttpsAutolink', () => {
@@ -21,3 +29,47 @@ describe('MARKDOWN_PLUGINS integration', () => {
expect(MARKDOWN_PLUGINS_WITH_BREAKS).toContain(remarkBreaks)
})
})
function render(markdown: string): string {
const processor = unified()
.use(remarkParse)
.use(MARKDOWN_PLUGINS)
.use(remarkRehype)
.use(MARKDOWN_REHYPE_PLUGINS)
const tree = processor.runSync(processor.parse(markdown))
return toHtml(tree as never)
}
describe('MARKDOWN_PLUGINS — currency prose vs KaTeX', () => {
// Regression: prose with multiple "$N" amounts must NOT be eaten by KaTeX.
// remarkMath is configured with `singleDollarTextMath: false` so single
// dollar signs are treated as literal text, matching GitHub markdown.
it('does not render single-$ currency amounts as KaTeX math', () => {
const md = "The plan is $200/mo and the bill is $80 — total $400 saved."
const html = render(md)
expect(html).not.toContain('class="katex"')
expect(html).not.toContain('<math')
expect(html).toContain('$200')
expect(html).toContain('$80')
expect(html).toContain('$400')
})
it('does not render the reported real-world prose as KaTeX', () => {
// Lifted (paraphrased) from the bug report: paragraph with multiple
// "$N" amounts and apostrophes that previously collapsed into a single
// KaTeX block and stripped whitespace from the running text.
const md = "Cursor's UI quotes the ratio: at least $400 of API usage on a $200 plan. That's 2:1."
const html = render(md)
expect(html).not.toContain('class="katex"')
expect(html).not.toContain('<math')
expect(html).toContain('$400')
expect(html).toContain('$200')
})
it('still renders block math with $$...$$ on its own lines', () => {
const md = "Before\n\n$$\nE = mc^2\n$$\n\nAfter"
const html = render(md)
expect(html).toContain('class="katex"')
})
})
@@ -34,10 +34,17 @@ import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
// from them. Both must come before remarkMath (to avoid treating TeX as URI).
// remarkFilePathLinks runs last to convert file paths → links after all other
// transforms have settled.
//
// remarkMath is configured with `singleDollarTextMath: false` so that prose
// containing currency amounts (e.g. "$200/mo ... $80 bill") is not garbled
// into KaTeX output. Block math `$$...$$` (on its own line) still works.
// This matches GitHub-flavored markdown behavior. The option lives on the
// shared TAIL so both MARKDOWN_PLUGINS (default) and MARKDOWN_PLUGINS_WITH_BREAKS
// (user-prompt rendering with hard breaks) inherit the fix.
const MARKDOWN_PLUGIN_TAIL = [
remarkNonHttpsAutolink,
remarkStripCjkAutolink,
remarkMath,
[remarkMath, { singleDollarTextMath: false }],
remarkDisableIndentedCode,
remarkFilePathLinks, // upstream — file path → link conversion, runs last
] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>