From dccd71965fdb85afe20c467bf1d1dda63a5c6f50 Mon Sep 17 00:00:00 2001 From: dvd233 <111864431+dvd233@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:54:33 -0700 Subject: [PATCH 1/2] fix(latex): support compact block math delimiters --- src/__tests__/plugins.test.ts | 34 +++++++++++++++++++++++++++- src/__tests__/streamingFixup.test.ts | 15 ++++++++++++ src/plugins/Latex/index.ts | 10 ++++---- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/__tests__/plugins.test.ts b/src/__tests__/plugins.test.ts index 8ff708b..f98a8a0 100644 --- a/src/__tests__/plugins.test.ts +++ b/src/__tests__/plugins.test.ts @@ -284,12 +284,44 @@ describe('Latex — tokenizer', () => { it('tokenizes block math $$...$$', async () => { const { default: Latex } = await import('../plugins/Latex/index.js'); const md = new XMarkdownMini({ extensions: [Latex()] }); - // Block math requires newline after $$ (matching the old @alipay/markdown-x-math pattern) + // Canonical delimiters on their own lines remain supported. const tokens = md.parse('$$\nx^2 + y^2 = z^2\n$$'); const types = tokens.map((t: any) => t.type); expect(types).toContain('blockKatex'); }); + it('keeps tokenizing newline-delimited $...$ as block math', async () => { + const { default: Latex } = await import('../plugins/Latex/index.js'); + const md = new XMarkdownMini({ extensions: [Latex()] }); + const tokens = md.parse('$\nx^2\n$'); + + expect(tokens.map((t: any) => t.type)).toEqual(['blockKatex']); + expect((tokens[0] as any).text).toBe('x^2'); + }); + + it('tokenizes same-line $$...$$ as block math', async () => { + const { default: Latex } = await import('../plugins/Latex/index.js'); + const md = new XMarkdownMini({ extensions: [Latex()] }); + const tokens = md.parse('$$x^2 + y^2 = z^2$$'); + + expect(tokens.map((t: any) => t.type)).toEqual(['blockKatex']); + expect((tokens[0] as any).text).toBe('x^2 + y^2 = z^2'); + expect((tokens[0] as any).displayMode).toBe(true); + }); + + it('tokenizes attached multiline $$ delimiters without consuming the following inline formula', async () => { + const { default: Latex } = await import('../plugins/Latex/index.js'); + const md = new XMarkdownMini({ extensions: [Latex()] }); + const tokens = md.parse( + '$$\\begin{cases}\na = 1 \\\\\nb = 2\n\\end{cases}$$\n\n$\\therefore a + b = 3$', + ); + + expect(tokens[0].type).toBe('blockKatex'); + expect((tokens[0] as any).text).toContain('\\begin{cases}'); + const paragraph = tokens.find((token: any) => token.type === 'paragraph') as any; + expect(paragraph.tokens.map((token: any) => token.type)).toContain('inlineKatex'); + }); + it('a `$` closing a line does not swallow inline math or split the paragraph', async () => { // The block `start` must verify blockRule before asking marked to cut a // paragraph — else a `$` ending a line (here an inline `$x=1$`) falsely diff --git a/src/__tests__/streamingFixup.test.ts b/src/__tests__/streamingFixup.test.ts index 66c8448..478829e 100644 --- a/src/__tests__/streamingFixup.test.ts +++ b/src/__tests__/streamingFixup.test.ts @@ -264,6 +264,21 @@ describe('XMarkdownMini — streamingFixup option', () => { expect(flat.some((n) => String(n.attrs?.class ?? '').includes('katex-display'))).toBe(true); }); + it('renders a complete compact block formula as display math while streaming', () => { + const md = new XMarkdownMini({ extensions: [Latex()] }); + const patches: MiniNode[][] = []; + + md.renderNodes({ + content: '$$x^2 + y^2 = z^2$$', + streaming: { hasNextChunk: true }, + onPatch: (nodes) => patches.push(nodes), + }); + + const flat = flatten(patches[patches.length - 1]); + expect(flat.some((n) => String(n.attrs?.class ?? '').includes('katex-display'))).toBe(true); + expect(flat.some((n) => String(n.attrs?.class ?? '').includes('katex-inline'))).toBe(false); + }); + it('semantic streaming config accepts chunkDelay for typewriter mode', () => { vi.useFakeTimers(); try { diff --git a/src/plugins/Latex/index.ts b/src/plugins/Latex/index.ts index 577b453..9f95660 100644 --- a/src/plugins/Latex/index.ts +++ b/src/plugins/Latex/index.ts @@ -94,8 +94,9 @@ function stampFontClasses(list: MiniNode[], inherited: string): void { // --- Tokenizers --- -const inlineRule = /^(?:\${1,2}([^\$\n]+?)\${1,2}|\\\((.+?)\\\))/; -const blockRule = /^(\${1,2})\n([\s\S]+?)\n\1(?:\n|$)|^\\\[((?:\\.|[^\\])+?)\\\]/; +const inlineRule = /^(?:\$(?!\$)([^$\n]+?)\$(?!\$)|\\\((.+?)\\\))/; +const blockRule = + /^(?:(\${1,2})\n([\s\S]+?)\n\1|\$\$([\s\S]+?)\$\$)(?:\n|$)|^\\\[((?:\\.|[^\\])+?)\\\]/; function inlineKatexTokenizer(this: any, src: string): Tokens.Generic | undefined { const match = src.match(inlineRule); @@ -112,7 +113,7 @@ function inlineKatexTokenizer(this: any, src: string): Tokens.Generic | undefine function blockKatexTokenizer(this: any, src: string): Tokens.Generic | undefined { const match = src.match(blockRule); if (!match) return undefined; - const text = replaceAlign((match[2] || match[3]).trim()); + const text = replaceAlign((match[2] || match[3] || match[4]).trim()); return { type: 'blockKatex', raw: match[0], @@ -138,7 +139,8 @@ function blockStart(src: string): number | undefined { const bracket = src.indexOf('\\[', from); if (dollar === -1 && bracket === -1) return undefined; const index = Math.min(dollar === -1 ? Infinity : dollar, bracket === -1 ? Infinity : bracket); - if (blockRule.test(src.slice(index))) return index; + const startsLine = index === 0 || src.charCodeAt(index - 1) === 10; + if (startsLine && blockRule.test(src.slice(index))) return index; from = index + 1; } return undefined; From f1c718ae0870f8e7f07d883b8cc8882a63ad116f Mon Sep 17 00:00:00 2001 From: dvd233 <111864431+dvd233@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:14:34 +0800 Subject: [PATCH 2/2] test(latex): exercise split streaming delimiters --- src/__tests__/streamingFixup.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/__tests__/streamingFixup.test.ts b/src/__tests__/streamingFixup.test.ts index 478829e..1beb6d7 100644 --- a/src/__tests__/streamingFixup.test.ts +++ b/src/__tests__/streamingFixup.test.ts @@ -264,16 +264,20 @@ describe('XMarkdownMini — streamingFixup option', () => { expect(flat.some((n) => String(n.attrs?.class ?? '').includes('katex-display'))).toBe(true); }); - it('renders a complete compact block formula as display math while streaming', () => { + it('renders compact block math across split opening and closing delimiters', () => { const md = new XMarkdownMini({ extensions: [Latex()] }); const patches: MiniNode[][] = []; - md.renderNodes({ - content: '$$x^2 + y^2 = z^2$$', - streaming: { hasNextChunk: true }, - onPatch: (nodes) => patches.push(nodes), + const updates = ['$', '$$x^2 + y^2 = z^2', '$$x^2 + y^2 = z^2$', '$$x^2 + y^2 = z^2$$']; + updates.forEach((content, index) => { + md.renderNodes({ + content, + streaming: { hasNextChunk: index < updates.length - 1 }, + onPatch: (nodes) => patches.push(nodes), + }); }); + expect(patches).toHaveLength(updates.length); const flat = flatten(patches[patches.length - 1]); expect(flat.some((n) => String(n.attrs?.class ?? '').includes('katex-display'))).toBe(true); expect(flat.some((n) => String(n.attrs?.class ?? '').includes('katex-inline'))).toBe(false);