From f723892c98f4da30fc1a5f23780cadf3be35e872 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 17 Sep 2026 22:49:01 +0800 Subject: [PATCH 001/213] fix(web): render multi-line display-dollar math A `$$math` block whose opening line carries math and whose closing `$$` sits at the end of the last math line parsed as an unclosed flow: micromark-extension-math reads the opening line's text as fence meta and accepts a closing fence only on a line whose content is exactly `$$`. The flow then consumed the following document, and KaTeX rendered the block plus that swallowed text as one error span. Fence meta was also silently dropped because the renderer reads only the math node's value. The compatibility layer's dollar flow now owns every `$$` fence, spans multiple lines, and stops its paragraph-interruption check before the line ending, as the upstream flow does; registering it after `math()` is what gives it that precedence in micromark. --- ...17-multiline-display-dollar-math.i18n.yaml | 6 ++ ...026-09-17-multiline-display-dollar-math.md | 44 +++++++++ ...-09-17-multiline-display-dollar-math.zh.md | 44 +++++++++ .../expected/math-rendering/ui.expected.md | 1 + apps/web/tests/math-rendering.e2e.ts | 7 +- .../src/markdown/mathCompatibility.ts | 36 +++++--- .../ui-primitives/src/markdown/parse.ts | 5 +- .../math-multiline-display.settled.txt | 89 +++++++++++++++++++ .../math-multiline-display.streaming.txt | 8 ++ .../tests/markdown-dom-parity.client.spec.tsx | 14 +++ .../tests/markdown.client.spec.tsx | 19 ++++ 11 files changed, 256 insertions(+), 17 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-multiline-display.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-multiline-display.streaming.txt diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml new file mode 100644 index 0000000000..906489029c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md +2026-09-17-multiline-display-dollar-math.md: 1fbeacae41e2b50599c27dd2d62252a9db1f27f7 +2026-09-17-multiline-display-dollar-math.zh.md: 4433f93e5adfb38fd52127df43217b20fad5ba24 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md new file mode 100644 index 0000000000..1fbeacae41 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md @@ -0,0 +1,44 @@ +# Agent Note: Multi-line display-dollar math + +Status: implemented + +English | [中文](2026-09-17-multiline-display-dollar-math.zh.md) + +## Problem + +Authors and models write `$$` display math in three shapes. The settled grammar rendered two of them: `$$math$$` on one line, and an opening `$$` alone on its line closed by `$$` alone on its line. The third shape — math on the opening line, a closing `$$` at the end of the last math line — did not: + +```markdown +$$f(x)=x +\text{continued}$$ +``` + +`micromark-extension-math` treats text after an opening `$$` as fence meta and accepts a closing fence only on a line whose content is exactly `$$` (after optional whitespace). The [markdown compatibility layer](../../archived/feature/2026-07-23-web-assistant-markdown.md) claimed only the same-line shape, and the settled renderer passes `node.value` to KaTeX while ignoring `node.meta`. The block therefore never closed: it consumed the document text that followed, `value` retained the literal `$$`, KaTeX raised `Can't use function '$' in math mode`, and [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)'s error arm painted the TeX source plus the swallowed prose in red. + +In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a single error span, and six note files lost every line after such a block (up to 500 lines) to one. A second defect shared the mechanism: when a later lone `$$` did close the flow, the opening line had already been assigned to `meta`, so the renderer silently dropped the first line of the formula. + +## Decision + +[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) owns every `$$` flow fence. Its dollar construct spans multiple lines, so an opening `$$` followed by math on the same line closes at a trailing `$$` on any later content line, and the opening line's text stays in `mathFlowValue` instead of becoming fence meta. Its paragraph-interruption check returns before the line ending, as `micromark-extension-math`'s own flow does, because consuming it there marks the next line lazy for the constructs tried afterwards and made the upstream flow end empty inside a list item. + +[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) registers `mathCompatibility()` after `math()`: micromark dispatches the extension registered last first, so that order is what lets the compatibility flow claim `$$` while leaving `math()` to own inline `$…$`. + +## Alternatives considered + +**Keep the compatibility layer at same-line `$$…$$`.** The multi-line shape is what GitHub, KaTeX's auto-render, VS Code, and Typora accept, and the form models emit for multi-case formulas; leaving it unparsed means a red error span in place of the formula plus the rest of the document. + +**Repair the parsed tree afterwards.** The flow consumes the swallowed text into `mathFlowValue`, where it is indistinguishable from math source; `meta` and `value` cannot be reassembled into the intended block, and the following blocks were never parsed at all. + +**Rewrite `$$` fences in the source text before parsing.** The compatibility layer is a grammar extension by design; a text pass would need to locate code spans, fences, and escapes itself, and would change author text rather than parse it. + +**Reorder without the interruption check.** Registering the compatibility flow first fixes the formula but made upstream's flow end empty inside a list item, because the failed interruption check advanced the parser's lazy table before the second construct ran. + +## Consequences + +The three shapes now produce the same math node, so formulas stop truncating at the opening line and stop swallowing the prose after them. `$$` fence meta is no longer honored: `$$asciimath` parses as a formula whose first line is `asciimath`, which upstream's flow would have split into meta plus body. That spelling has no user in this repository, and the renderer never read `meta` anyway. + +Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) still resolves through upstream inline math; the existing `markdown.client.spec.tsx` case pins that behavior. KaTeX-level failures in a formula's own source, such as `\arccot`, remain errors by design. + +## Testing + +`packages/client/ui-primitives/tests/markdown.client.spec.tsx` covers the six shapes (same-line, opening-line content with a content-line close, content-line close alone, paragraph interruption, list item, and column-zero fence after a list item) and fails without the change. The DOM compatibility corpus adds `math-multiline-display` with its `.settled.txt` and `.streaming.txt` fixtures, which also pin that a column-zero `$$` after a list item stays outside the list item. `apps/web/tests/math-rendering.e2e.ts` renders the multi-line shape in the browser lane. diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md new file mode 100644 index 0000000000..4433f93e5a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md @@ -0,0 +1,44 @@ +# Agent Note: Multi-line display-dollar math + +Status: implemented + +[English](2026-09-17-multiline-display-dollar-math.md) | 中文 + +## Problem + +作者和模型写 `$$` 行间公式时有三种写法。定稿语法只渲染其中两种:一行内开闭的 `$$math$$`,以及 `$$` 独占一行、由独占一行的 `$$` 闭合。第三种——开围栏同一行就带公式内容,闭合 `$$` 落在最后一行公式行行尾——不渲染: + +```markdown +$$f(x)=x +\text{continued}$$ +``` + +`micromark-extension-math` 把开围栏 `$$` 之后的同一行文本当作围栏 meta,并且只接受内容恰为 `$$`(后接可选空白)的行作为闭合围栏。[markdown 兼容层](../../archived/feature/2026-07-23-web-assistant-markdown.md)只认同一行开闭这一种形状,而定稿渲染器把 `node.value` 交给 KaTeX,不读 `node.meta`。于是该块始终不闭合:它吞掉后续正文,`value` 里残留字面 `$$`,KaTeX 抛出 `Can't use function '$' in math mode`,[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx) 的错误分支把 TeX 原文连同被吞掉的正文一起显示为红色。 + +在暴露该问题的会话中,一条聊天回复 55 行里有 34 行(第 22–55 行)变成同一个错误块,六个笔记文件从出错点起的所有行(最多约 500 行)同样变成一个错误块。同一机制还带出第二个缺陷:当更靠后的独占行 `$$` 恰好闭合了这个流时,开围栏那一行已经归给 `meta`,渲染器于是静默丢掉公式的第一行。 + +## Decision + +[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 拥有所有 `$$` flow 围栏。它的 dollar 构造跨多行:开围栏 `$$` 后同一行的公式内容可以延续到后续内容行,由行尾的 `$$` 闭合;开围栏那一行的文本留在 `mathFlowValue` 里,不再变成围栏 meta。它的段落打断检查在换行符之前返回,与 `micromark-extension-math` 自己的 flow 一致;因为在那里消耗换行会把下一行对随后尝试的构造标记为 lazy,使上游 flow 在列表项内以空块结束。 + +[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) 把 `mathCompatibility()` 注册在 `math()` 之后:micromark 先派发最后注册的扩展,这个顺序让兼容 flow 认领 `$$`,同时把行内 `$…$` 留给 `math()`。 + +## Alternatives considered + +**兼容层维持只处理同一行 `$$…$$`。** 多行写法是 GitHub、KaTeX auto-render、VS Code、Typora 都接受的形状,也是模型写多分支公式时实际采用的形状;不解析它意味着公式位置显示为红色错误块,并连带吞掉后文。 + +**解析后修补语法树。** 被吞掉的正文已经进入 `mathFlowValue`,与数学源码无法区分;`meta` 和 `value` 无法还原成原意的块,而且后面的块根本没有被解析。 + +**解析前用文本替换改写 `$$` 围栏。** 兼容层的设计就是语法扩展;文本替换需要自己定位代码 span、围栏和转义,而且改的是作者原文而不是解析它。 + +**只调整注册顺序、不加打断检查。** 让兼容 flow 优先能修好公式,但会让上游 flow 在列表项内以空块结束——失败的打字检查在被第二个构造尝试之前就把解析器的 lazy 表推进了。 + +## Consequences + +三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。`$$` 围栏 meta 不再生效:`$$asciimath` 会解析成首行为 `asciimath` 的公式,而上游 flow 会把它拆成 meta 加正文。这种写法在本仓库没有使用者,且渲染器本来就从不读 `meta`。 + +闭合 `$$` 之后还有文本的情况(`$$x$$ trailing`,或用中文引号收尾的散文行)仍走上游行内数学;`markdown.client.spec.tsx` 中已有的用例固定了该行为。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。 + +## Testing + +`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 覆盖六种形状(同一行开闭、开围栏行内容加内容行闭合、闭合围栏独占一行、打断段落、列表项内、列表项之后的零缩进围栏),去掉改动即失败。DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。 diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 8afbbbb723..cf8cd37591 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -20,6 +20,7 @@ - text: . - math: π 4 < θ < π 2 - math: θ ∈ ( π 4 , π 2 ) . (1) +- math: α + β γ - table: - rowgroup: - row "Symbol Value": diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index c826d5aff2..bfd26c27e5 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -57,6 +57,9 @@ function mathFixture(): string { '', '$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$', '', + '$$\\alpha+\\beta', + '\\gamma$$', + '', '| Symbol | Value |', '| --- | --- |', '| $\\theta$ | \\(\\frac{1}{5}\\) |', @@ -119,8 +122,8 @@ describe('web e2e: settled Markdown math rendering', () => { await sessionRow.click() await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) - await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) + await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(7) + await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(3) expect(await page.locator('.katex-error').count()).toBe(0) await expect.poll( () => page.getByText('1 turns 1 steps', { exact: false }).count(), diff --git a/packages/client/ui-primitives/src/markdown/mathCompatibility.ts b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts index 3edd9d1e63..c5d62cb6c1 100644 --- a/packages/client/ui-primitives/src/markdown/mathCompatibility.ts +++ b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts @@ -1,4 +1,9 @@ -/** Extend upstream dollar-only math syntax with TeX delimiters while reusing its token vocabulary. */ +/** + * Extend upstream dollar-only math syntax with TeX delimiters while reusing its + * token vocabulary. The dollar construct claims every `$$` flow fence, so text + * on the opening line is math content instead of the upstream construct's fence + * meta, which the renderer does not read. + */ import { factorySpace } from 'micromark-factory-space' import type {} from 'micromark-extension-math' @@ -120,7 +125,7 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { } } -function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct { +function createMathFlow(marker: number, openMarker: number, closeMarker: number): Construct { const tokenize: Tokenizer = function (effects, ok, nok) { const self = this let oddBackslashRun = false @@ -163,9 +168,12 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, )(code) } if (markdownLineEnding(code)) { - return multiline - ? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code) - : nok(code) + // A paragraph-interruption check must stop before the line ending, as + // `micromark-extension-math`'s flow does: consuming it here marks the + // next line lazy for every construct tried afterwards. + return self.interrupt + ? ok(code) + : effects.attempt(nonLazyContinuation, afterContinuation, nok)(code) } return valueStart(code) } @@ -278,7 +286,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, return { concrete: true, - name: marker === codes.dollarSign ? 'sameLineDollarMathFlow' : 'backslashMathFlow', + name: marker === codes.dollarSign ? 'dollarMathFlow' : 'backslashMathFlow', tokenize, } } @@ -319,29 +327,29 @@ const backslashMathFlow = createMathFlow( codes.backslash, codes.leftSquareBracket, codes.rightSquareBracket, - true, ) -const sameLineDollarMathFlow = createMathFlow( +const dollarMathFlow = createMathFlow( codes.dollarSign, codes.dollarSign, codes.dollarSign, - false, ) const backslashMath: Extension = { flow: { [codes.backslash]: backslashMathFlow, - [codes.dollarSign]: sameLineDollarMathFlow, + [codes.dollarSign]: dollarMathFlow, }, text: { [codes.backslash]: backslashMathText }, } /** - * TeX backslash delimiters and same-line display-dollar blocks as a micromark - * syntax extension reusing `micromark-extension-math`'s token vocabulary; the - * caller must also register `math()` on the same parse so the emitted tokens - * compile to standard math nodes. + * TeX backslash delimiters and display-dollar blocks as a micromark syntax + * extension reusing `micromark-extension-math`'s token vocabulary; the caller + * must also register `math()` on the same parse so the emitted tokens compile + * to standard math nodes. Both `$$$$` on one line and a `$$` block + * closed by a trailing `$$` parse as one math node, and a closing `$$` may sit + * at the end of a content line. * @returns The micromark syntax extension. */ export function mathCompatibility(): Extension { diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts index 50f8f179b9..13e8665580 100644 --- a/packages/client/ui-primitives/src/markdown/parse.ts +++ b/packages/client/ui-primitives/src/markdown/parse.ts @@ -38,7 +38,10 @@ export function parseGfm(text: string): Root { */ export function parseGfmWithMath(text: string): Root { return fromMarkdown(text, { - extensions: [gfm(), cjkFriendlyStrong(), mathCompatibility(), math()], + // `mathCompatibility()` MUST stay last: micromark dispatches the extension + // registered last first, and the dollar flow must own every `$$` fence so + // the opening line's text stays math instead of upstream's fence meta. + extensions: [gfm(), cjkFriendlyStrong(), math(), mathCompatibility()], mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()], }) } diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-multiline-display.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-multiline-display.settled.txt new file mode 100644 index 0000000000..6be1db2645 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-multiline-display.settled.txt @@ -0,0 +1,89 @@ +
+

+ #text "Opening line carries math:" + + + + + + + + #text "a" + + #text "+" + + #text "b" + + #text "c" + + #text "+" + + #text "d" + + #text "a+b\nc+d" +