mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(editor): keep currency dollars out of rich-editor math spans
The TipTap InlineMath markdown tokenizer treated every `$…$` pair as math, so USD prose like `$148+ → $19` and CJK clauses between two currency amounts were swallowed. Guard the tokenizer with Pandoc delimiters (and reject pure numeric `$100$`) so `$$…$$` and `$x$` still render while currency stays literal. Fixes #15100
This commit is contained in:
@@ -10,7 +10,8 @@ import { Table } from '@tiptap/extension-table'
|
||||
import { TableCell } from '@tiptap/extension-table-cell'
|
||||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { BlockMath, InlineMath } from '@tiptap/extension-mathematics'
|
||||
import { BlockMath } from '@tiptap/extension-mathematics'
|
||||
import { RichMarkdownInlineMath } from './rich-markdown-inline-math'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { createLowlight, common } from 'lowlight'
|
||||
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
|
||||
@@ -201,7 +202,7 @@ export function createRichMarkdownExtensions({
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
InlineMath.configure({
|
||||
RichMarkdownInlineMath.configure({
|
||||
katexOptions: {
|
||||
throwOnError: false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
|
||||
// STA-4635 / #15100 reporter string — two unguarded `$…$` spans swallow currency and CJK prose.
|
||||
const ISSUE_CURRENCY_PROSE =
|
||||
'月成本 **$148+ → $19**(省 **87%**,年省 ~$1,550);后续释放老 EIP 可再降到 $15.4/月'
|
||||
|
||||
function createEditor(content: string): Editor {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(content, codec),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
}
|
||||
|
||||
function parseMarkdown(content: string): {
|
||||
markdown: string
|
||||
text: string
|
||||
inlineMath: string[]
|
||||
blockMath: string[]
|
||||
} {
|
||||
const editor = createEditor(content)
|
||||
try {
|
||||
const inlineMath: string[] = []
|
||||
const blockMath: string[] = []
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'inlineMath') {
|
||||
inlineMath.push(String(node.attrs.latex ?? ''))
|
||||
}
|
||||
if (node.type.name === 'blockMath') {
|
||||
blockMath.push(String(node.attrs.latex ?? ''))
|
||||
}
|
||||
})
|
||||
return {
|
||||
markdown: editor.getMarkdown().trimEnd(),
|
||||
text: editor.state.doc.textContent,
|
||||
inlineMath,
|
||||
blockMath
|
||||
}
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
describe('rich markdown inline math currency guard', () => {
|
||||
it('round-trips the reporter currency string with prose and every amount intact', () => {
|
||||
const { markdown, inlineMath } = parseMarkdown(ISSUE_CURRENCY_PROSE)
|
||||
|
||||
expect(inlineMath).toEqual([])
|
||||
expect(markdown).toContain('$148')
|
||||
expect(markdown).toContain('$19')
|
||||
expect(markdown).toContain('$1,550')
|
||||
expect(markdown).toContain('$15.4')
|
||||
expect(markdown).toContain('月成本')
|
||||
expect(markdown).toContain('后续释放老 EIP')
|
||||
expect((markdown.match(/\$/g) ?? []).length).toBe(4)
|
||||
})
|
||||
|
||||
it('still parses $$…$$ as display math', () => {
|
||||
const { inlineMath, blockMath, markdown } = parseMarkdown('Before\n\n$$\nx + y\n$$\n\nAfter')
|
||||
|
||||
expect(inlineMath).toEqual([])
|
||||
expect(blockMath).toEqual(['x + y'])
|
||||
expect(markdown).toBe('Before\n\n$$\nx + y\n$$\n\nAfter')
|
||||
})
|
||||
|
||||
it('never treats a single $ adjacent to digits as math', () => {
|
||||
expect(parseMarkdown('The price is $19.').inlineMath).toEqual([])
|
||||
expect(parseMarkdown('$148 is the monthly cost').inlineMath).toEqual([])
|
||||
expect(parseMarkdown('$148+ → $19').inlineMath).toEqual([])
|
||||
expect(parseMarkdown('costs $100$ exactly').inlineMath).toEqual([])
|
||||
})
|
||||
|
||||
it('does not consume CJK prose between two currency $ signs', () => {
|
||||
const source = '~$1,550);后续释放老 EIP 可再降到 $15.4/月'
|
||||
const { markdown, inlineMath, text } = parseMarkdown(source)
|
||||
|
||||
expect(inlineMath).toEqual([])
|
||||
expect(markdown).toContain('$1,550')
|
||||
expect(markdown).toContain('$15.4')
|
||||
expect(text).toContain('后续释放老 EIP')
|
||||
})
|
||||
|
||||
it('still parses genuine Pandoc inline math', () => {
|
||||
expect(parseMarkdown('Einstein wrote $E=mc^2$ in 1905.').inlineMath).toEqual(['E=mc^2'])
|
||||
expect(parseMarkdown('The variable is $x$.').inlineMath).toEqual(['x'])
|
||||
expect(parseMarkdown('Arithmetic $2+2$ is math.').inlineMath).toEqual(['2+2'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MarkdownTokenizer } from '@tiptap/core'
|
||||
import { InlineMath } from '@tiptap/extension-mathematics'
|
||||
|
||||
const baseTokenizer = InlineMath.config.markdownTokenizer as MarkdownTokenizer
|
||||
|
||||
// Pandoc: no space after the opening `$`, none before the closer, no digit after it.
|
||||
const PANDOC_INLINE_MATH = /^\$(?!\s)([^$]*[^\s$])\$(?!\$)(?!\d)/
|
||||
// Why: `$100$` satisfies Pandoc delimiters but is still a USD amount, not a formula.
|
||||
const PURE_CURRENCY_AMOUNT = /^(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?$/
|
||||
|
||||
export const RichMarkdownInlineMath = InlineMath.extend({
|
||||
markdownTokenizer: {
|
||||
...baseTokenizer,
|
||||
tokenize(src, tokens, lexer) {
|
||||
const match = PANDOC_INLINE_MATH.exec(src)
|
||||
if (!match || PURE_CURRENCY_AMOUNT.test(match[1].trim())) {
|
||||
return undefined
|
||||
}
|
||||
return baseTokenizer.tokenize(src, tokens, lexer)
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user