From 09a2e120f3c5a6f300f3d7a3999a6dd3c3f0edd4 Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 5 Aug 2026 15:11:47 +0800 Subject: [PATCH] test(ui): strengthen TeX parser boundaries --- .../src/markdown/remarkMathCompatibility.ts | 9 +++- .../ui-primitives/tests/markdown.spec.tsx | 52 ++++++++++++++----- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts index c9dfc9a5d3..dcd8c32362 100644 --- a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts +++ b/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts @@ -1,3 +1,5 @@ +/** Extend upstream dollar-only math syntax with TeX delimiters while reusing its token vocabulary. */ + import { factorySpace } from 'micromark-factory-space' import type {} from 'micromark-extension-math' import { markdownLineEnding } from 'micromark-util-character' @@ -11,8 +13,11 @@ interface RemarkProcessor { } const previousBackslash: Previous = function (code) { - /* v8 ignore next -- micromark calls previous after an event has been emitted. */ - return code !== codes.backslash || this.events.at(-1)?.[1].type === types.characterEscape + if (code !== codes.backslash) return true + const tail = this.events.at(-1) + /* v8 ignore next -- a previous code necessarily has a preceding event. */ + if (tail === undefined) return false + return tail[1].type === types.characterEscape } const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 057e807b1d..7a858c1199 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -203,6 +203,12 @@ describe('MarkdownText', () => { math: 1, display: 0, }, + { + source: String.raw`\\\(x\)`, + math: 1, + display: 0, + value: 'x', + }, { source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)', math: 1, @@ -238,31 +244,51 @@ describe('MarkdownText', () => { rendered.unmount() } - const literal = render() - expect(literal.container.querySelectorAll('.katex')).toHaveLength(1) + const literal = render() + expect(literal.container.querySelectorAll('.katex')).toHaveLength(0) expect(literal.container.querySelector('.katex-display')).toBeNull() expect(literal.container.textContent).toContain('[x') - expect(literal.container.textContent).toContain('xxx trailing') }) it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => { - const sources = [ - '$$\n\\theta\n$$', - '$$$\\theta$$$', - '$$a$b\nc', - ' \\[\n \\theta\n \\]', - '\\(\\theta', - '\\[\n\\[', - '> \\[\nnot a quoted continuation\n\\]', + const cases = [ + { source: '$$\n\\theta\n$$', math: 1, display: 1 }, + { source: '$$$\\theta$$$', math: 1, display: 0 }, + { source: '$$a$b\nc', math: 0, display: 0 }, + { source: ' \\[\n \\theta\n \\]', math: 1, display: 1 }, + { source: '\\(\\theta', math: 0, display: 0 }, + { source: String.raw`\(a\\)`, math: 0, display: 0 }, + { source: '\\[\n\\[', math: 0, display: 0 }, + { source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 }, ] - for (const source of sources) { - const rendered = render() + for (const item of cases) { + const rendered = render() + expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math) + expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display) expect(rendered.container.querySelector('.katex-error')).toBeNull() rendered.unmount() } }) + it('lets display math interrupt an open paragraph', () => { + for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) { + const rendered = render() + expect(rendered.container.querySelectorAll('p')).toHaveLength(1) + expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1) + rendered.unmount() + } + }) + + it('leaves a dollar block with trailing text to upstream inline math', () => { + const { container } = render() + + expect(container.querySelectorAll('.katex')).toHaveLength(1) + expect(container.querySelector('.katex-display')).toBeNull() + expect(container.querySelector('annotation')?.textContent).toBe('x') + expect(container.textContent).toContain('trailing') + }) + it('renders escaped dollars and even backslash pairs before closing fences', () => { const source = [ String.raw`$$100\$$$`,