diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 164ad847bb..c8de225e9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: '@xterm/xterm': specifier: 6.1.0-beta.141 version: 6.1.0-beta.141 + katex: + specifier: ^0.16.25 + version: 0.16.28 lucide-react: specifier: ^0.560.0 version: 0.560.0(react@18.3.1) @@ -341,9 +344,15 @@ importers: react-syntax-highlighter: specifier: ^15.6.6 version: 15.6.6(react@18.3.1) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 socket.io-client: specifier: 4.8.3 version: 4.8.3 diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 38d1075671..e10573be2c 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -35,12 +35,15 @@ "@openbitfun/ui": "workspace:^", "@xterm/addon-fit": "0.12.0-beta.141", "@xterm/xterm": "6.1.0-beta.141", + "katex": "^0.16.25", "lucide-react": "^0.560.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.6", + "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "socket.io-client": "4.8.3", "zustand": "^5.0.10" }, diff --git a/src/mobile-web/src/components/ChatMarkdown.tsx b/src/mobile-web/src/components/ChatMarkdown.tsx index 08229cff9e..8bb9c4b430 100644 --- a/src/mobile-web/src/components/ChatMarkdown.tsx +++ b/src/mobile-web/src/components/ChatMarkdown.tsx @@ -1,6 +1,7 @@ import { Check as LucideCheck, Copy as LucideCopy, FileText as LucideFileText } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; +import type { Components, UrlTransform } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; @@ -30,6 +31,8 @@ import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml'; import { MobileButton, MobileIconButton, MobileLink } from '@openbitfun/ui/mobile'; import { useI18n } from '../i18n'; import { useTheme } from '../theme'; +import { mayContainMarkdownMath, normalizeMathDelimiters } from './chatMarkdownMath'; +import type { MarkdownMathContentProps } from './MarkdownMathContent'; import { RemoteArtifactImage } from './RemoteArtifactImage'; const SYNTAX_LANGUAGES = { bash, c, cpp, csharp, css, diff, go, java, javascript, json, jsx, kotlin, markdown, markup, php, python, ruby, rust, sql, swift, tsx, typescript, yaml }; @@ -359,6 +362,7 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) ); }; + interface MarkdownContentProps { content: string; onFileDownload?: (path: string, onProgress?: (downloaded: number, total: number) => void) => Promise; @@ -368,12 +372,37 @@ interface MarkdownContentProps { export const MarkdownContent: React.FC = ({ content, onFileDownload, onGetFileInfo }) => { const { isDark } = useTheme(); const syntaxTheme = isDark ? vscDarkPlus : vs; + const hasMath = useMemo(() => mayContainMarkdownMath(content), [content]); + const normalizedContent = useMemo( + () => (hasMath ? normalizeMathDelimiters(content) : content), + [content, hasMath], + ); + const [MathRenderer, setMathRenderer] = useState | null>(null); + + // KaTeX is only downloaded for chats that actually contain math. If the + // optional chunk cannot load, keep the base renderer instead of breaking the + // transcript. + useEffect(() => { + if (!hasMath || MathRenderer) return; + let cancelled = false; + import('./MarkdownMathContent') + .then((module) => { + if (!cancelled) setMathRenderer(() => module.MarkdownMathContent); + }) + .catch((error) => { + console.error('[ChatMarkdown] math renderer failed to load', error); + }); + return () => { + cancelled = true; + }; + }, [hasMath, MathRenderer]); + const fileReferences = useMemo( () => onFileDownload && onGetFileInfo ? projectFileReferences(content) : [], [content, onFileDownload, onGetFileInfo], ); - const components: React.ComponentProps['components'] = useMemo(() => ({ + const components: Components = useMemo(() => ({ code({ className, children, ...props }: any) { const match = /language-(\w+)/.exec(className || ''); const codeStr = String(children).replace(/\n$/, ''); @@ -477,26 +506,36 @@ export const MarkdownContent: React.FC = ({ content, onFil }, }), [syntaxTheme, isDark, onFileDownload, onGetFileInfo]); + const markdownUrlTransform = useCallback((url, key) => { + if (key === 'src' && /^data:image\/(?:png|jpeg|gif|webp|bmp|svg\+xml|avif);base64,/i.test(url)) return url; + if (/^[A-Za-z]:[\\/]/.test(url)) return url; + if (url.startsWith('computer://') || /^openbitfun:\/\/(?:runtime|current-session)\//.test(url)) return url; + if (/^(https?|mailto|tel|file):/i.test(url) || url.startsWith('#') || url.startsWith('/')) { + return url; + } + // Preserve relative paths without a protocol (e.g. "report.pptx", + // "./output.pdf"). Unknown schemes remain blocked. + if (!url.includes(':')) return url; + return ''; + }, []); + return ( <> - { - if (key === 'src' && /^data:image\/(?:png|jpeg|gif|webp|bmp|svg\+xml|avif);base64,/i.test(url)) return url; - if (/^[A-Za-z]:[\\/]/.test(url)) return url; - if (url.startsWith('computer://') || /^openbitfun:\/\/(?:runtime|current-session)\//.test(url)) return url; - if (/^(https?|mailto|tel|file):/i.test(url) || url.startsWith('#') || url.startsWith('/')) { - return url; - } - // Preserve relative paths without a protocol (e.g. "report.pptx", - // "./output.pdf"). Unknown schemes remain blocked. - if (!url.includes(':')) return url; - return ''; - }} - > - {content} - + {hasMath && MathRenderer ? ( + + ) : ( + + {content} + + )} {fileReferences.length > 0 && onGetFileInfo && onFileDownload && (
{fileReferences.map((reference) => ( diff --git a/src/mobile-web/src/components/MarkdownMathContent.tsx b/src/mobile-web/src/components/MarkdownMathContent.tsx new file mode 100644 index 0000000000..1b5555e4fb --- /dev/null +++ b/src/mobile-web/src/components/MarkdownMathContent.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import type { Components, UrlTransform } from 'react-markdown'; +import rehypeKatex from 'rehype-katex'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import 'katex/dist/katex.min.css'; + +export interface MarkdownMathContentProps { + content: string; + components: Components; + urlTransform: UrlTransform; +} + +/** + * KaTeX-enabled Markdown renderer. It is imported lazily so chats without math + * do not download the KaTeX runtime; the base renderer stays in ChatMarkdown. + */ +export const MarkdownMathContent: React.FC = ({ + content, + components, + urlTransform, +}) => ( + + {content} + +); + +export default MarkdownMathContent; diff --git a/src/mobile-web/src/components/chatMarkdownMath.ts b/src/mobile-web/src/components/chatMarkdownMath.ts new file mode 100644 index 0000000000..4d99b83181 --- /dev/null +++ b/src/mobile-web/src/components/chatMarkdownMath.ts @@ -0,0 +1,202 @@ +/** + * Mobile chat math activation and delimiter normalization. + * + * Math is rendered from a lazily loaded module, so this detection decides + * whether the browser downloads KaTeX at all. Keep the single-dollar boundary + * checks aligned with desktop MarkdownRenderer.mayContainMarkdownMath: a + * closing dollar directly followed by a digit is ordinary currency text + * (`$5 and $10`), not math. + */ +export function mayContainMarkdownMath(content: string): boolean { + if (!content) return false; + if (content.includes('$$') || content.includes('\\(') || content.includes('\\[')) { + return true; + } + return /(^|[^\\$])\$[^$\n]{1,240}\$(?!\d)/.test(content); +} + +const CODE_FENCE = + /(^|\n)((?:[ \t]*>[ \t]?)*[ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n(?:(?:[ \t]*>[ \t]?)*[ \t]*)\3[^\n]*(?=\n|$)|$)/g; +const INLINE_CODE = /(`+)((?:[^\n`]|(?!\1)`)*?)\1/g; +const PROPER_DISPLAY_MATH = /(^|\n)([ \t]{0,3}\$\$[^\n]*\n[\s\S]*?\n[ \t]{0,3}\$\$[^\n]*)(?=\n|$)/g; +const SINGLE_LINE_DISPLAY_MATH = /(^|[^\\])\$\$([^\n]+?)\$\$/g; +const BRACKET_DISPLAY_MATH = /(^|[^\\])\\\[([\s\S]*?)\\\]/g; +const BRACKET_INLINE_MATH = /(^|[^\\])\\\(([\s\S]*?)\\\)/g; + +type Span = { key: string; content: string }; + +function placeholder(spans: Span[], kind: string, content: string): string { + const key = `@@MATH_${kind}_${spans.length}@@`; + spans.push({ key, content }); + return key; +} + +/** True when the delimiter owns a top-level line; indented/container math stays inline. */ +function startsLine(text: string, index: number): boolean { + const before = text.slice(0, index); + return before === '' || before.endsWith('\n'); +} + +/** + * A bare identifier of one or two characters is commonly a formula (`$x$`, + * `$n$th`). Longer shell-style names, brace expansions, and text containing + * whitespace without operators stay literal. + */ +function looksLikeInlineMath(raw: string): boolean { + const value = raw.trim(); + if (!value) return false; + if (/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(value)) return false; + if (/[\\^_=+*/<>|{}()[\]]/.test(value)) return true; + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) return value.length <= 2; + if (/\s/.test(value)) return false; + return /^[A-Za-z0-9.,'+-]+$/.test(value); +} + +/** + * `remark-math` is configured with `singleDollarTextMath: false`, so a lone + * `$` always stays literal. Recognized inline formulas are widened to + * `$$...$$`, which `remark-math` treats as inline math, and every other dollar + * pair (currency, shell variables, paths) is left alone. + */ +function convertInlineDollarMath(text: string, spans: Span[]): string { + let output = ''; + let index = 0; + while (index < text.length) { + const character = text[index]; + if (character === '\\') { + output += text.slice(index, index + 2); + index += 2; + continue; + } + if (character !== '$') { + output += character; + index += 1; + continue; + } + // Proper `$$` fences are already placeholders; keep any stray pair intact + // rather than pairing it with a later single dollar. + if (text[index + 1] === '$') { + output += '$$'; + index += 2; + continue; + } + // `${VAR}` is a shell expansion, not a math delimiter. + if (text[index + 1] === '{') { + output += '$'; + index += 1; + continue; + } + let cursor = index + 1; + let closing = -1; + while (cursor < text.length) { + const next = text[cursor]; + if (next === '\n') break; + if (next === '\\') { + cursor += 2; + continue; + } + if (next === '$') { + closing = cursor; + break; + } + cursor += 1; + } + if (closing > index + 1) { + const content = text.slice(index + 1, closing); + if (looksLikeInlineMath(content)) { + output += placeholder(spans, 'INLINE', `$$${content.trim()}$$`); + index = closing + 1; + continue; + } + } + output += '$'; + index += 1; + } + return output; +} + +/** + * Convert the LaTeX delimiter styles that common models emit into the + * `$$...$$` forms remark-math parses. Code fences and inline code are replaced + * with placeholders first, so literal `\[`/`$$` examples survive. + * + * Display delimiters only become a block when they own their line. Inside a + * list item, table row, or blockquote they stay inline so the surrounding + * container is not torn apart. + */ +export function normalizeMathDelimiters(content: string): string { + if (!content) return content; + const hasDisplayBracket = content.includes('\\['); + const hasInlineBracket = content.includes('\\('); + const hasDollar = content.includes('$'); + if (!hasDisplayBracket && !hasInlineBracket && !hasDollar) { + return content; + } + + const spans: Span[] = []; + + // Protect code fences (including blockquoted and list-indented fences). + let transformed = content.replace(CODE_FENCE, (match) => + placeholder(spans, 'CODE_BLOCK', match), + ); + + // Protect inline code spans. + transformed = transformed.replace(INLINE_CODE, (match) => + placeholder(spans, 'CODE_INLINE', match), + ); + + // Leave well-formed block math untouched; it already has the shape + // remark-math expects, and its body must not be scanned for `$`. + transformed = transformed.replace(PROPER_DISPLAY_MATH, (match) => + placeholder(spans, 'DISPLAY', match), + ); + + // remark-math only treats `$$` as a display fence when it owns its line. + // A single-line `$$...$$` is a display fence at line start and inline math + // everywhere else. + transformed = transformed.replace( + SINGLE_LINE_DISPLAY_MATH, + (match, prefix: string, math: string, offset: number) => { + const display = startsLine(transformed, offset + prefix.length); + const rendered = display + ? `\n\n$$\n${math.trim()}\n$$\n\n` + : `$$${math.trim()}$$`; + return prefix + placeholder(spans, display ? 'BLOCK' : 'INLINE', rendered); + }, + ); + + // \[ ... \] is display math when it owns its line, inline math otherwise. + transformed = transformed.replace( + BRACKET_DISPLAY_MATH, + (match, prefix: string, math: string, offset: number) => { + const delimiterIndex = offset + prefix.length; + // `C:\[dir\]\file` is a Windows path, not display math. + if (prefix === ':' && /[A-Za-z]/.test(transformed[delimiterIndex - 2] ?? '')) { + return match; + } + const display = startsLine(transformed, delimiterIndex); + const rendered = display + ? `\n\n$$\n${math.trim()}\n$$\n\n` + : `$$${math.trim()}$$`; + return prefix + placeholder(spans, display ? 'BLOCK' : 'INLINE', rendered); + }, + ); + + // \( ... \) is inline math. + transformed = transformed.replace(BRACKET_INLINE_MATH, (match, prefix: string, math: string) => + prefix + placeholder(spans, 'INLINE', `$$${math.trim()}$$`), + ); + + // Remaining single-dollar pairs are only math when they clearly are. + transformed = convertInlineDollarMath(transformed, spans); + + // Restore protected content. Math spans are restored before code spans so a + // code placeholder inside a formula still resolves; reverse order also keeps + // nested placeholders stable. + for (let index = spans.length - 1; index >= 0; index -= 1) { + const { key, content: value } = spans[index]; + transformed = transformed.replace(key, () => value); + } + + return transformed; +} diff --git a/src/mobile-web/src/styles/components/markdown.scss b/src/mobile-web/src/styles/components/markdown.scss index 5f20cb1b5d..e8db4eaf78 100644 --- a/src/mobile-web/src/styles/components/markdown.scss +++ b/src/mobile-web/src/styles/components/markdown.scss @@ -482,6 +482,39 @@ } } +// ─── Math (KaTeX) ───────────────────────────────────────────────────────────── + +// Assistant markdown, thinking previews, and background-receipt prompts can all +// render formulas, so the mobile containment rules are shared by the page. +.chat-page { + .katex-display { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + max-width: 100%; + margin: 0.5rem 0; + padding: var(--openbitfun-space-1) 0; + + > .katex { + display: inline-block; + min-width: 0; + text-align: initial; + } + } + + .katex { + font-size: 1.05em; + line-height: 1.2; + text-indent: 0; + } + + // KaTeX hard-codes #cc0000 for parse errors; keep it readable on the dark + // mobile theme. + .katex-error { + color: var(--openbitfun-color-status-danger-content); + } +} + // Output bytes can be larger than the viewport. Keep their aspect ratio in // compact, foldable and wide transcript hosts. .markdown-output-image { diff --git a/src/mobile-web/tests/fixtures/host-queue.tsx b/src/mobile-web/tests/fixtures/host-queue.tsx index 6a48c1960f..fc92816828 100644 --- a/src/mobile-web/tests/fixtures/host-queue.tsx +++ b/src/mobile-web/tests/fixtures/host-queue.tsx @@ -39,7 +39,7 @@ export function mountHostQueueFixture({ count = 1, expanded = true } = {}) { }, []); return
= 900 ? ' chat-page--wide' : ''}`} style={{ '--chat-composer-height': `${height}px` } as React.CSSProperties}>
项目介绍
-
+
} cancelling={false} containerRef={ref} expanded={expanded} imageAnalyzing={false} sending={false} input="继续检查" inputRef={null} modelControls={null} onActivate={noop} onAttach={noop} onCancel={() => calls.push('stop')} diff --git a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs index e73433c7a1..160baf63c5 100644 --- a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs +++ b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs @@ -43,6 +43,21 @@ test('mobile running composer keeps send and stop independently available alongs window.queueFixture=mountHostQueueFixture(); }); await page.waitForSelector('.host-message-queue li'); + // The math renderer is a lazy chunk; wait for it before asserting its DOM. + await page.waitForSelector('.chat-msg__assistant-content .katex'); + assert.ok(await page.$eval('.chat-msg__assistant-content .katex', el => !!el), 'katex renders inside assistant message'); + assert.ok(await page.$eval('.chat-msg__assistant-content .katex-display', el => !!el), 'katex block display renders'); + const currency = await page.$$eval('.chat-msg__assistant-content p', paragraphs => { + const paragraph = paragraphs.find(el => el.textContent?.includes('价格')); + return paragraph + ? { text: paragraph.textContent, katex: paragraph.querySelectorAll('.katex').length } + : null; + }); + assert.ok(currency, 'currency paragraph stays in the transcript'); + assert.match(currency.text, /\$5 和 \$10/); + assert.match(currency.text, /\$HOME 和 \$PATH/); + assert.equal(currency.katex, 0, 'currency and shell variables must not become math'); + assert.ok((await page.$eval('.code-block-wrapper', el => el.textContent)).includes('echo $$ $1 $&'), 'code blocks preserve special dollar patterns'); assert.ok(await page.$eval('.host-message-queue',el=>el.getBoundingClientRect().height<=100),'one queued message stays compact'); const actions=await page.$$eval('.chat-page__send-btn', buttons=>buttons.map(button=>({disabled:button.disabled,stop:button.classList.contains('is-stop')}))); assert.deepEqual(actions,[{disabled:false,stop:false},{disabled:false,stop:true}]); diff --git a/src/mobile-web/tests/mobile-ui-components.test.mjs b/src/mobile-web/tests/mobile-ui-components.test.mjs index 4c336cbae0..4375130437 100644 --- a/src/mobile-web/tests/mobile-ui-components.test.mjs +++ b/src/mobile-web/tests/mobile-ui-components.test.mjs @@ -322,6 +322,100 @@ test('mobile transcript keeps one user bubble and projects file cards outside ma assert.match(markdownStyles, /\.file-card\s*\{[\s\S]*?inline-size:\s*100%;/); }); +test('mobile chat markdown lazily loads katex plugins and keeps mobile display styles', async () => { + const markdown = await readFile(path.join(sourceDirectory, 'components/ChatMarkdown.tsx'), 'utf8'); + const math = await readFile(path.join(sourceDirectory, 'components/MarkdownMathContent.tsx'), 'utf8'); + const mathDelimiters = await readFile(path.join(sourceDirectory, 'components/chatMarkdownMath.ts'), 'utf8'); + const markdownStyles = await readFile(path.join(sourceDirectory, 'styles/components/markdown.scss'), 'utf8'); + + // KaTeX stays out of the chat chunk: only the math-capable renderer imports + // its plugins and styles, and ChatMarkdown loads it on demand. + assert.match(math, /import rehypeKatex from 'rehype-katex';/); + assert.match(math, /import remarkMath from 'remark-math';/); + assert.match(math, /import 'katex\/dist\/katex\.min\.css';/); + assert.match(math, /remarkPlugins=\{\[remarkGfm,\s*\[remarkMath,\s*\{\s*singleDollarTextMath:\s*false\s*\}\]\]\}/); + assert.match(math, /rehypePlugins=\{\[\[rehypeKatex,\s*\{\s*strict:\s*false,\s*throwOnError:\s*false\s*\}\]\]\}/); + assert.match(markdown, /import\('\.\/MarkdownMathContent'\)/); + assert.match(markdown, /mayContainMarkdownMath\(content\)/); + assert.match(markdown, /hasMath && MathRenderer/); + assert.doesNotMatch(markdown, /from 'remark-math'|from 'rehype-katex'|katex\.min\.css/); + assert.match(mathDelimiters, /export function normalizeMathDelimiters/); + assert.match(mathDelimiters, /export function mayContainMarkdownMath/); + + assert.match(markdownStyles, /\.katex-display\s*\{[\s\S]*?overflow-x:\s*auto;/); + assert.match(markdownStyles, /\.katex-display\s*\{[\s\S]*?-webkit-overflow-scrolling:\s*touch;/); + assert.match(markdownStyles, /\.katex\s*\{[\s\S]*?font-size:\s*1\.05em;/); + assert.match(markdownStyles, /\.katex-error\s*\{[\s\S]*?color:\s*var\(--openbitfun-color-status-danger-content\);/); +}); + +test('math activation ignores currency-only dollar pairs', async () => { + const mathDelimiters = await readFile(path.join(sourceDirectory, 'components/chatMarkdownMath.ts'), 'utf8'); + const code = ts.transpileModule(mathDelimiters, { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; + const { mayContainMarkdownMath: mayContain } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`); + + assert.equal(mayContain(''), false); + assert.equal(mayContain('plain text without math'), false); + assert.equal(mayContain('Cost is $5 and $10.'), false, 'currency amounts must not activate math rendering'); + assert.equal(mayContain('Inline $x$'), true); + assert.equal(mayContain('Display $$E=mc^2$$'), true); + assert.equal(mayContain('Bracket \\(x\\)'), true); + assert.equal(mayContain('Bracket \\[x\\]'), true); +}); + +test('normalizeMathDelimiters protects code blocks and normalizes LaTeX delimiters', async () => { + const mathDelimiters = await readFile(path.join(sourceDirectory, 'components/chatMarkdownMath.ts'), 'utf8'); + const code = ts.transpileModule(mathDelimiters, { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; + const { normalizeMathDelimiters: normalize } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`); + + // Empty or plain text without math brackets or raw dollar blocks + assert.equal(normalize(''), ''); + assert.equal(normalize('plain text with $10 and $20'), 'plain text with $10 and $20'); + + // Inline bracket \( ... \) becomes inline $$ ... $$ math. + assert.equal(normalize('Inline \\( a + b = c \\) math'), 'Inline $$a + b = c$$ math'); + + // A display bracket that owns its line becomes a block; inside a container + // it stays inline so the list, table, or quote is not torn apart. + assert.match(normalize('\\[\\frac{1}{2}\\]'), /\$\$\s*\n\\frac\{1\}\{2\}\n\s*\$\$/); + assert.equal(normalize('Block \\[\\frac{1}{2}\\] math'), 'Block $$\\frac{1}{2}$$ math'); + assert.equal(normalize('- Energy: $$E=mc^2$$'), '- Energy: $$E=mc^2$$'); + assert.equal(normalize('| \\[x^2\\] | 2 |'), '| $$x^2$$ | 2 |'); + assert.equal(normalize('> \\[x^2\\]'), '> $$x^2$$'); + + // Single line $$...$$ is a block only at a top-level line start. + assert.match(normalize('$$E=mc^2$$'), /\$\$\s*\nE=mc\^2\n\s*\$\$/); + assert.equal(normalize('Formula: $$E=mc^2$$'), 'Formula: $$E=mc^2$$'); + + // Single-dollar math is widened to $$ so remark-math can parse it with + // singleDollarTextMath disabled; currency, shell variables, and paths stay text. + assert.equal(normalize('Inline $x$ and $y$'), 'Inline $$x$$ and $$y$$'); + assert.equal(normalize('The $n$th term'), 'The $$n$$th term'); + assert.equal(normalize('Cost $5; formula \\(x\\)'), 'Cost $5; formula $$x$$'); + assert.equal(normalize('Cost is $5 and $10.'), 'Cost is $5 and $10.'); + assert.equal(normalize('Use $HOME and $PATH'), 'Use $HOME and $PATH'); + assert.equal(normalize('Use ${HOME} and ${PATH}'), 'Use ${HOME} and ${PATH}'); + assert.equal(normalize('C:\\[data\\]\\file.txt'), 'C:\\[data\\]\\file.txt'); + + // Escaped delimiters are literal text, not math. + assert.equal(normalize('Escaped \\\\[index\\\\] and \\\\(x\\\\)'), 'Escaped \\\\[index\\\\] and \\\\(x\\\\)'); + + // Code fence with special replacement patterns ($$, $&, $1) and escaped characters must not be corrupted + const codeFenceSample = 'Formula \\(x\\)\n```bash\necho $$ $1 $&\n```\nDone'; + const codeFenceNormalized = normalize(codeFenceSample); + assert.ok(codeFenceNormalized.includes('echo $$ $1 $&'), 'code blocks must preserve literal $$ and $& without replace corruption'); + assert.ok(codeFenceNormalized.includes('$$x$$'), 'formula outside code fence must be normalized'); + + // Blockquoted fences are code too. + const quotedFence = '> ```bash\n> echo \\(x\\)\n> ```'; + assert.equal(normalize(quotedFence), quotedFence); + + // Multi-backtick inline code with internal backtick or brackets + const multiBacktickSample = 'Look at ``code ` with \\[index\\] inside`` and \\(y\\)'; + const multiBacktickNormalized = normalize(multiBacktickSample); + assert.ok(multiBacktickNormalized.includes('``code ` with \\[index\\] inside``'), 'multi-backtick inline code must be preserved verbatim'); + assert.ok(multiBacktickNormalized.includes('$y$'), 'formula outside inline code must be normalized'); +}); + test('mobile file card failures stay readable and repeatable', async () => { const markdown = await readFile(path.join(sourceDirectory, 'components/ChatMarkdown.tsx'), 'utf8'); const markdownStyles = await readFile(path.join(sourceDirectory, 'styles/components/markdown.scss'), 'utf8');