mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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=="],
|
||||
|
||||
@@ -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']>
|
||||
|
||||
Reference in New Issue
Block a user