Merge pull request #7629 from deepseek-harness/fix/revert-4484-display-dollar

revert(ui-primitives): 撤回 #4484 的多行美元公式解析
This commit is contained in:
Yudong Han
2026-10-30 17:43:58 +08:00
committed by GitHub
14 changed files with 25 additions and 711 deletions
@@ -1,22 +0,0 @@
# Bilingual-pair consistency record for 2026-09-17-multiline-display-dollar-math.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# 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
/agent-note-multi-line-display-dollar-math:
en: 8f57c77aa43fbd6c
zh: ef5192184e4923d5
/agent-note-multi-line-display-dollar-math/problem:
en: e4b73644a982844c
zh: 48f86358242d6ccc
/agent-note-multi-line-display-dollar-math/decision:
en: d6b136b1f62285b7
zh: aba323bfdb800162
/agent-note-multi-line-display-dollar-math/alternatives-considered:
en: e0833b5674e648e7
zh: b0472020b07099b9
/agent-note-multi-line-display-dollar-math/consequences:
en: 70f16597bd640e93
zh: 0e0c9005f331e514
/agent-note-multi-line-display-dollar-math/testing:
en: f17a1453df91ce88
zh: 4f15be0139e8b694
@@ -1,66 +0,0 @@
# 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) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node.
A `$$` sequence that fails to close ends the candidate: immediately on a later line, and at that line's ending on the opening line (`$$x$$ trailing` fails there). Within the opening line the scan continues, so a later `$$` there still closes (`$$\verb*a$$a$$`). Scanning past either point would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of a later line closes nothing when the comment holds text before it. A `%` whose text before the fence is empty or whitespace (`95%$$`, `95 %$$`) holds no comment text, and a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. That state and its gate on the closing check belong to the dollar flow. The characters right after `\verb` or `\verb*` are that command's delimiter rather than a fence, so the delimiter and, when the region is empty, the `$` closing it are content; that tail is cleared at a line ending and after an escaped backslash (`\\verb` is a line break followed by letters), and a comment holds no commands. Otherwise verbatim regions are not parsed, which keeps `\verb`-bearing formulas on the same value path as every other input.
Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there.
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) registers `mathCompatibility()` after `math()`: micromark dispatches the extension registered last first, so the dollar flow above takes precedence over upstream's `mathFlow` while `math()` still owns 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.
**Continue the dollar flow after a failed close.** That is what a plain `multiline` flag produces: `$$x$$ trailing\n\n$$y$$` then scans to the second block's `$$` and renders one math node holding both lines' text, where upstream inline math renders `x`, the prose, and `y` separately. Existing behavior for `$$x$$ trailing` already pinned the fallback, so the flow declines the candidate instead. Declining on any `$` rather than a full `$$` sequence also broke formulas that use a solitary `$` inside `\text{}`.
**End the attempt as soon as one line resists parsing.** Declining every failed same-line close swallows nothing but turns `$$a$b` lines into a quadratic scan and drops the solitary-`$` formulas above. The condition is the full `$$` sequence, not the character.
**Claim the paragraph interruption in the check.** Returning success there, as upstream's flow does for the fences it accepts, prevents the lazy bookkeeping that broke the list case, but it also ends the paragraph for candidates that later fail (`**before\n$$a\$b\nafter**` split in two and exposed its `**`). Declining instead lets upstream's own check decide.
**Apply the interruption guard to the backslash construct too.** The guard exists to protect the construct tried after this one; `\[`/`\(` have no second construct, and an unconditional guard dropped their multi-line blocks (`Prose line` followed by `\[`).
## 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 value is `asciimath`, which upstream's flow treats as fence meta and drops. That spelling has no user in this repository, and the renderer never read `meta` anyway.
Six inputs still reach upstream's flow or stay in the surrounding paragraph, because the compatibility flow declines them. Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) resolves through upstream inline math, as the existing `markdown.client.spec.tsx` case pins — including when a later `$$` block follows. A `$$` sequence the line does not end after declines the block: immediately on a later line, and at that line's ending on the opening line. A `$$` block left open runs to the next lone `$$` or to EOF exactly as it did before this change, because the upstream flow closes at EOF.
The fourth is a multi-line block whose opening line carries a solitary `$` and that must interrupt a paragraph, with no blank line before it. Both flows decline the interruption there — this one by design, upstream because its fence meta stops at the `$` — so the block stays inside the paragraph and renders as inline math or not at all. Separating the block from the paragraph with a blank line makes this flow own it and render the display block.
The fifth follows from the comment gate: a block whose only closing fence sits behind a comment that holds text keeps scanning instead of closing there, so when nothing else closes it the block reaches the upstream flow as the shapes above do. A fence behind a whitespace-only comment closes the block there, which leaves a following lone `$$` as its own empty block where the flow it replaced kept one node.
The sixth is a multi-line block inside a container whose continuation line falls back to the container's indentation. `- Define:` followed by `$$f(x)=x` at the item's indentation and then a de-indented continuation line gives upstream's flow a fence meta and no value, so the reader sees an empty display block and the continuation as prose — the opening line is gone. Keeping the continuation at the same indentation, or moving the block out of the list, renders it normally. Both flow behaviors match the grammar this replaced; only the multi-line blocks this flow owns are new.
One further limitation is not a fallback: the flow reads the six characters before a fence and does not parse verbatim regions, so a fence inside an unterminated region (`\verb|$$`) closes the block there, and a region whose text ends in `\verb` (`\verb**\verb*$$`) has its fence read as that text's delimiter. The six characters before either fence are locally identical, so no rule that reads only that tail separates them.
Repeated unclosed candidates keep linear parser work: an open candidate declines at the next line that starts with a full `$$` it cannot close, rather than scanning each candidate to EOF, which would make 9.6 KB of `$$a$b` lines take ~4 s instead of ~16 ms. 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` adds the multi-line case — opening-line math closed on a content line, closed by a lone fence, after a paragraph, inside a list item, and at column zero after a list item — and that case fails without the change. The same file also pins a multi-line formula that uses a solitary `$`, which like the case above renders only with this change. Further cases pin one condition each: a trailing-text candidate ahead of a later block (the flow must decline a full `$$` the line does not end after), a solitary `$` inside a single-line display formula (the flow must not decline on a single `$`), an escaped-dollar candidate that leaves its paragraph intact (the interruption check must not claim the paragraph), an unclosed `\[` candidate that leaves its paragraph intact (the guard must not apply to the backslash construct), a candidate ending in an odd backslash (the escape run must end at the line ending), a closing fence inside a TeX comment, a `%` the fence follows immediately or after whitespace, a second `%` inside an open comment, a `%` inside `\verb|…|`, the delimiter right after `\verb` and `\verb*` together with its empty region, an escaped backslash before `verb`, a verb tail that crosses no line ending, a comment ending in `verb`, a later-line trailing fence whose following prose upstream takes, a failed fence inside a comment, and bounded-work cases for repeated candidates with that backslash, without it, and ending in a verb tail.
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.
@@ -1,66 +0,0 @@
# 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) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。
闭合失败的 `$$` 序列会结束该候选:出现在后续行时立即结束;出现在开围栏行时在该行行尾结束(`$$x$$ trailing` 即在此放弃)。在开围栏行内扫描继续,因此同一行更靠后的 `$$` 仍能闭合(`$$\verb*a$$a$$`)。越过这两点继续扫描都会吞掉更靠后一个块的闭合围栏。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 `$$` 不闭合任何东西;而围栏之前只有空白或什么都没有的 `%`(`95%$$`、`95 %$$`)不含注释文本,且在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。该状态及其对闭合判定的门控属于 dollar flow。`\verb` 或 `\verb*` 之后的那个字符是该命令的分隔符而不是围栏,因此该分隔符、以及区域为空时紧随其后的那个 `$` 都属于内容;这段尾部状态在换行处、以及在被转义的反斜杠之后清空(`\\verb` 是换行加字母,不是该命令),注释里也不识别命令。除此之外不解析原样文本区域,使含 `\verb` 的公式与其它输入走同一条 value 路径。
它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) 把 `mathCompatibility()` 注册在 `math()` 之后:micromark 先派发最后注册的扩展,上面的 dollar flow 因此优先于上游 `mathFlow`,而行内 `$…$` 仍由 `math()` 负责。
## Alternatives considered
**兼容层维持只处理同一行 `$$…$$`。** 多行写法是 GitHub、KaTeX auto-render、VS Code、Typora 都接受的形状,也是模型写多分支公式时实际采用的形状;不解析它意味着公式位置显示为红色错误块,并连带吞掉后文。
**解析后修补语法树。** 被吞掉的正文已经进入 `mathFlowValue`,与数学源码无法区分;`meta` 和 `value` 无法还原成原意的块,而且后面的块根本没有被解析。
**解析前用文本替换改写 `$$` 围栏。** 兼容层的设计就是语法扩展;文本替换需要自己定位代码 span、围栏和转义,而且改的是作者原文而不是解析它。
**闭合失败后继续扫描。** 这正是单纯打开 `multiline` 会得到的行为:`$$x$$ trailing\n\n$$y$$` 会一路扫到第二个块的 `$$`,渲染成一个同时含着两行文本的 math 节点;而上游行内数学会把 `x`、正文和 `y` 分开渲染。`$$x$$ trailing` 的既有行为已经固定了这条回退,因此该构造放弃这个候选。对任意一个 `$`(而不是完整的 `$$` 序列)就放弃,还会破坏在 `\text{}` 内使用单个 `$` 的公式。
**只要有一行解析不下去就结束尝试。** 对每次同一行闭合失败都放弃虽然不再吞文,却会让 `$$a$b` 这类行变成平方级扫描,并丢掉上面那些含单个 `$` 的公式。判据是完整的 `$$` 序列,不是那个字符。
**在检查阶段认领段落打断。** 像上游 flow 那样返回成功可以避免破坏列表用例的 lazy 记账,但也会让最终失败的候选提前结束段落(`**before\n$$a\$b\nafter**` 被拆成两段并暴露 `**`)。改为不认领,把决定交给上游自己的检查。
**把打断守卫也用在反斜杠构造上。** 这个守卫是为了保护随后被尝试的构造;`\[`/`\(` 没有第二个构造,无条件套用会丢掉它们的跨行块(`Prose line` 后接 `\[`)。
## Consequences
三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。`$$` 围栏 meta 不再生效:`$$asciimath$$` 会解析成 value 为 `asciimath` 的公式,而上游 flow 会把该段当作 fence meta 丢弃。这种写法在本仓库没有使用者,且渲染器本来就从不读 `meta`。
有六种输入仍会走到上游 flow,或留在所属段落里,因为兼容 flow 主动放弃它们。闭合 `$$` 之后还有文本时(`$$x$$ trailing`,或用中文引号收尾的散文行)走上游行内数学,这一点由 `markdown.client.spec.tsx` 中已有用例固定,后面还有别的 `$$` 块时同样如此。「后面不是行尾」的 `$$` 序列会让块放弃:出现在后续行时立即放弃,出现在开围栏行时在该行行尾放弃。未闭合的 `$$` 块仍像本次改动之前那样延伸到下一个独占行 `$$` 或文件结尾,因为上游 flow 在 EOF 处闭合。
第四种是「开围栏行含孤立 `$`、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 `$` 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。
第五种来自注释门控:当块唯一的闭合围栏位于「带文本的注释」之后时,块继续扫描而不是在那里闭合,若再没有别的围栏闭合它,就会像上面几种一样走到上游 flow。若围栏之前只有空白,则块在该处闭合,此后单独一行的 `$$` 会成为它自己的空块——而被替换掉的语法那时只给出一个节点。
第六种是容器内续行退回容器缩进的多行块。`- Define:` 之后以列表项缩进写 `$$f(x)=x`,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「本 flow 拥有的多行块」。
另有一项不属于回退的限制:flow 只读取围栏之前的六个字符、不解析原样文本区域,因此未闭合区域内部的围栏(`\verb|$$`)会在那里闭合块,而内容以文本 `\verb` 结尾的区域(`\verb**\verb*$$`)其围栏会被读成该文本的分隔符。两种情况下围栏前的六个字符在局部完全相同,仅凭这段尾部无法区分。
重复的未闭合候选保持线性解析开销:未闭合的候选会在下一行遇到无法闭合的完整 `$$` 时放弃,而不是把每个候选都扫到 EOF——否则 9.6 KB 的 `$$a$b` 行会花约 4 秒而不是约 16 毫秒。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。
## Testing
`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。同一文件还固定了「使用单个 `$` 的多行公式」,它与上面的多行用例一样只有本次改动才会渲染成 display。另有一组用例各自对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 `$$`)、单行显示公式内的单个 `$`(不得因为一个 `$` 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 `\[` 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束)、TeX 注释内的闭合围栏、紧邻围栏或只隔空白的 `%`、已开启注释内的第二个 `%`、`\verb|…|` 内的 `%`、`\verb` 与 `\verb*` 之后的分隔符及其空区域、`verb` 之前的转义反斜杠、不跨行的 verb 尾部、以 `verb` 结尾的注释、后续行尾随文本围栏(其后正文由上游接管)、注释内失败的围栏,以及带该反斜杠、不带该反斜杠、以 verb 尾部结尾三种重复候选的有界解析开销。
DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。
@@ -19,7 +19,6 @@
- text: .
- math: π 4 < θ < π 2
- math: θ ∈ ( π 4 , π 2 ) . (1)
- math: α + β γ
- table:
- rowgroup:
- row "Symbol Value":
+2 -5
View File
@@ -56,9 +56,6 @@ function mathFixture(): string {
'',
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
'',
'$$\\alpha+\\beta',
'\\gamma$$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
@@ -122,8 +119,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(7)
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(3)
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)
expect(await page.locator('.katex-error').count()).toBe(0)
const fonts = await page.evaluate(async () => {
const faces = [...document.fonts].filter(face => face.family.includes('KaTeX_'))
@@ -54,8 +54,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work:
en: 2f2db9565903b4e6
zh: ca9d8fed836d857c
en: 6b4d21406db53e7c
zh: aa130102a8f7cd13
/deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
-2
View File
@@ -238,8 +238,6 @@ These limits define how the atoms behave at the edges; they are current package
- **Product icons need the ui-theme sprite** — each icon only references its symbol, so without ui-theme, or inside another document such as an iframe, the icon renders nothing.
- **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, error, and idle.
- **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`; each feature must supply complete localized labels through the primitive's typed props ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
- **Display math keeps fallback shapes** — a `$$x$$ trailing` on the opening line resolves through upstream inline math and leaves the surrounding prose in place, while the same trailing text on a later line declines the block and upstream takes the prose up to the next lone `$$` or EOF; a block left open ends at the next lone `$$` or at EOF; a later fence behind a `%` comment that holds text closes nothing, so a block nothing else closes reaches the same fallback; a multi-line block whose opening line carries a solitary `$` stays in its paragraph unless a blank line separates them; and a container continuation line that leaves the item indentation hands the block to upstream, which drops the opening line. `$$` fence meta is no longer honored ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md)).
- **A fence after a `\verb` delimiter is content** — the tokenizer reads the six characters before a fence, so the `$` right after `\verb` or `\verb*` is that command's delimiter rather than a closing fence, and a region whose text ends in `\verb` (`\verb**\verb*$$`) reads the same way. A fence inside an unterminated region (`\verb|$$`) still closes the block there, because the region's content is not parsed.
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR colors, carriage return, backspace, erase-in-line, tab stops, and character width are honored; absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped.
<a id="dev-note"></a>
@@ -238,8 +238,6 @@ kind: "package-library"
- **产品图标依赖 ui-theme 的 sprite**:每个图标只引用自己的 symbol,因此没有 ui-theme 时,或在 iframe 等另一个文档中,图标不渲染任何内容。
- **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing、error 和 idle。
- **面向用户的文案必须由渲染点提供**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`;各功能必须通过原子组件的带类型 prop 提供完整本地化 label(见[决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。
- **显示数学保留若干回退形状**:开围栏行上的 `$$x$$ trailing` 走上游行内数学,前后正文保持不变;同样形态出现在后续行时块被放弃,随后由上游把正文吞到下一个独占行 `$$` 或文件结尾;未闭合的块在下一个独占行 `$$` 或文件结尾结束;后续行的围栏若位于**含文本**的 `%` 注释之后则不闭合,若无其他围栏闭合,块走同一条回退;开围栏行含孤立 `$` 的多行块会留在段落内,除非与前文空一行;容器内续行若离开条目缩进,块交由上游 flow 处理并丢弃开围栏行。`$$` 围栏 meta 不再生效(见[决策](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md))。
- **紧跟 `\verb` 分隔符的围栏属于内容**:解析器只读取围栏之前的六个字符,因此紧跟 `\verb` 或 `\verb*` 的 `$` 是该命令的分隔符而不是闭合围栏,内容以 `\verb` 结尾的区域(`\verb**\verb*$$`)同理。未闭合区域内部的围栏(`\verb|$$`)仍会在该处闭合块,因为区域内容不被解析。
- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色、回车、退格、行内擦除、制表位与字符宽度会被遵循;绝对光标定位、清屏与备用屏幕序列会被剥离。
<a id="dev-note"></a>
@@ -1,26 +1,13 @@
/**
* Extend upstream dollar-only math syntax with TeX delimiters while reusing its
* token vocabulary. The dollar construct is dispatched before upstream's
* `mathFlow`, so the shapes it accepts keep the opening line's text as math
* instead of fence meta, which the renderer does not read. A `$$` candidate it
* declines — a `$$` sequence the line does not end after, or a block left open
* — is left to that upstream flow.
*/
/** 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, markdownSpace } from 'micromark-util-character'
import { markdownLineEnding } from 'micromark-util-character'
import { codes, constants, types } from 'micromark-util-symbol'
import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark-util-types'
// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback.
/** Prefixes whose following character is a `\verb` delimiter, not a fence. */
const VERB_DELIMITER_PREFIXES = ['\\verb', '\\verb*'] as const
/** Longest prefix above, in code units. */
const VERB_DELIMITER_TAIL = 6
const previousBackslash: Previous = function (code) {
if (code !== codes.backslash) return true
const tail = this.events.at(-1)
@@ -133,16 +120,10 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
}
}
function createMathFlow(marker: number, openMarker: number, closeMarker: number): Construct {
function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct {
const tokenize: Tokenizer = function (effects, ok, nok) {
const self = this
let oddBackslashRun = false
let inComment = false
let commentHasContent = false
let crossedLineEnding = false
let sawClosingFenceSequence = false
let consumedTail = ''
let failedClose = false
const tail = self.events.at(-1)
const initialSize = tail?.[1].type === types.linePrefix
? tail[2].sliceSerialize(tail[1], true).length
@@ -174,13 +155,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
function content(code: number | null): State | undefined {
if (code === codes.eof) return nok(code)
if (code === marker && marker === codes.dollarSign && !inComment && verbDelimiterAhead()) {
// The `$` here delimits a `\verb…` region rather than closing the block,
// so it is content, and a `$` right after it closes an empty region and
// is content too. A comment holds no commands.
return verbatimDelimiter(code)
}
if (code === marker && closingFenceAllowed()) {
if (code === marker && (marker !== codes.dollarSign || !oddBackslashRun)) {
return effects.attempt(
{ partial: true, tokenize: tokenizeClosingFence },
closed,
@@ -188,83 +163,17 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
)(code)
}
if (markdownLineEnding(code)) {
// A close that failed earlier on this candidate ends it at the line
// ending, which is what the grammar this flow replaced did: a failed
// `$$x$$ trailing` never reaches the next line.
if (marker === codes.dollarSign && failedClose) return nok(code)
// A trailing backslash escapes the line ending, not the next line's
// first character, so the escape run ends here, and a TeX comment ends
// with the line. The comment exception covers only a closing fence on a
// later line, which is where the multi-line block can hide one; the
// same-line form keeps closing as it did before this flow spanned lines.
oddBackslashRun = false
inComment = false
commentHasContent = false
consumedTail = ''
if (marker === codes.dollarSign) crossedLineEnding = true
// A paragraph-interruption check on the dollar flow must not consume the
// line ending: that keeps the paragraph open across the line boundary,
// and the document layer then counts the next line lazy for every
// construct tried afterwards. Declining leaves the interruption decision
// to the upstream flow, whose own check short-circuits without consuming
// and claims only the fences it accepts. The backslash construct has no
// upstream counterpart and keeps consuming here.
if (self.interrupt && marker === codes.dollarSign) return nok(code)
return effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
return multiline
? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
: nok(code)
}
return valueStart(code)
}
/**
* Whether the marker under the scanner may close the block. The dollar flow
* keeps an escaped `$` and a `%` comment that holds text from closing.
*/
function closingFenceAllowed(): boolean {
if (marker !== codes.dollarSign) return true
return !oddBackslashRun && !(inComment && commentHasContent && crossedLineEnding)
}
/** Whether the marker under the scanner is a `\verb` region's delimiter. */
function verbDelimiterAhead(): boolean {
return VERB_DELIMITER_PREFIXES.some(prefix => consumedTail.endsWith(prefix))
}
function verbatimDelimiter(code: number | null): State | undefined {
effects.enter('mathFlowValue')
oddBackslashRun = false
rememberConsumed(code)
effects.consume(code)
return verbatimDelimiterClose
}
function verbatimDelimiterClose(code: number | null): State | undefined {
if (code !== marker) return value(code)
rememberConsumed(code)
effects.consume(code)
return value
}
/** Record one consumed code unit so a fence can be told from a delimiter. */
function rememberConsumed(code: number | null): void {
/* v8 ignore next 2 -- every call site passes a code unit, never EOF. */
if (code === null) return
consumedTail = `${consumedTail}${String.fromCharCode(code)}`.slice(-VERB_DELIMITER_TAIL)
}
function afterClosingFenceAttempt(code: number | null): State | undefined {
if (marker === codes.backslash) {
return effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code)
}
// A full `$$` that the line does not end after closes nothing there. On
// the opening line the scan continues, so `$$x$$ trailing` declines at
// that line's ending while a later `$$` on the same line still closes; on
// any later line it ends the candidate outright, so a following block is
// not swallowed. A solitary `$` is content, not a failed fence.
if (sawClosingFenceSequence) {
if (crossedLineEnding) return nok(code)
failedClose = true
}
return markerValueStart(code)
return marker === codes.backslash
? effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code)
: markerValueStart(code)
}
function afterContinuation(code: number | null): State | undefined {
@@ -279,17 +188,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
function valueStart(code: number | null): State | undefined {
effects.enter('mathFlowValue')
// An unescaped `%` opens a TeX comment wherever it is the first character
// of a value run, such as a line that starts inside the block. The
// backslash construct reads neither this state nor the escape run.
oddBackslashRun = code === codes.backslash
if (code === codes.percentSign && marker === codes.dollarSign && !inComment) {
inComment = true
commentHasContent = false
} else if (inComment && !markdownSpace(code)) {
commentHasContent = true
}
rememberConsumed(code)
effects.consume(code)
return value
}
@@ -297,15 +196,12 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
function markerValueStart(code: number | null): State | undefined {
effects.enter('mathFlowValue')
oddBackslashRun = false
if (inComment) commentHasContent = true
rememberConsumed(code)
effects.consume(code)
return valueAfterMarker
}
function valueAfterMarker(code: number | null): State | undefined {
if (code === marker) {
rememberConsumed(code)
effects.consume(code)
return value
}
@@ -317,25 +213,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
effects.exit('mathFlowValue')
return content(code)
}
const escaped = oddBackslashRun
const doubleBackslash = code === codes.backslash && escaped
oddBackslashRun = code === codes.backslash ? !escaped : false
// An unescaped `%` starts a TeX comment that runs to the line ending, so
// a `$$` inside it closes nothing. A `%` the fence follows immediately
// holds no comment text, which is how writers use it mid-formula
// (`95%$$`, and `95 %$$` alike); only a comment holding a non-whitespace
// character suppresses the close, and a `%` inside an open comment is
// comment text like any other character.
if (!escaped && code === codes.percentSign && marker === codes.dollarSign && !inComment) {
inComment = true
commentHasContent = false
} else if (inComment && !markdownSpace(code)) {
commentHasContent = true
}
// `\\verb` is a line break followed by letters, not the command, so the
// pair's second backslash never reaches the tail the command match reads.
if (doubleBackslash) consumedTail = ''
else rememberConsumed(code)
oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false
effects.consume(code)
return value
}
@@ -350,7 +228,6 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
closeOk: State,
closeNok: State,
): State {
sawClosingFenceSequence = false
return factorySpace(closeEffects, sequenceStart, types.linePrefix, constants.tabSize)
function sequenceStart(code: number | null): State | undefined {
@@ -365,7 +242,6 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
if (code !== closeMarker) return closeNok(code)
closeEffects.consume(code)
closeEffects.exit('mathFlowFenceSequence')
sawClosingFenceSequence = true
return factorySpace(closeEffects, after, types.whitespace)
}
@@ -402,7 +278,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number)
return {
concrete: true,
name: marker === codes.dollarSign ? 'dollarMathFlow' : 'backslashMathFlow',
name: marker === codes.dollarSign ? 'sameLineDollarMathFlow' : 'backslashMathFlow',
tokenize,
}
}
@@ -443,29 +319,29 @@ const backslashMathFlow = createMathFlow(
codes.backslash,
codes.leftSquareBracket,
codes.rightSquareBracket,
true,
)
const dollarMathFlow = createMathFlow(
const sameLineDollarMathFlow = createMathFlow(
codes.dollarSign,
codes.dollarSign,
codes.dollarSign,
false,
)
const backslashMath: Extension = {
flow: {
[codes.backslash]: backslashMathFlow,
[codes.dollarSign]: dollarMathFlow,
[codes.dollarSign]: sameLineDollarMathFlow,
},
text: { [codes.backslash]: backslashMathText },
}
/**
* 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 `$$<math>$$` on one line and a `$$<math>` block
* closed by a trailing `$$` parse as one math node, and a closing `$$` may sit
* at the end of a content line.
* 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.
* @returns The micromark syntax extension.
*/
export function mathCompatibility(): Extension {
@@ -43,10 +43,7 @@ export function parseGfm(text: string): Root {
*/
export function parseGfmWithMath(text: string): Root {
return recoverLocalImages(fromMarkdown(text, {
// `mathCompatibility()` MUST stay last: micromark dispatches the extension
// registered last first, so its dollar flow takes precedence over `math()`
// and keeps the opening line's text as math instead of upstream's fence meta.
extensions: [gfm(gfmOptions), cjkFriendlyStrong(), math(), mathCompatibility()],
extensions: [gfm(gfmOptions), cjkFriendlyStrong(), mathCompatibility(), math()],
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown(), windowsPathDestinations()],
}), text)
}
@@ -1,89 +0,0 @@
<div class="_markdown_404681">
<p>
#text "Opening line carries math:"
<span class="katex-display">
<span class="katex">
<span class="katex-mathml">
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mi>
#text "a"
<mo>
#text "+"
<mi>
#text "b"
<mi>
#text "c"
<mo>
#text "+"
<mi>
#text "d"
<annotation encoding="application/x-tex">
#text "a+b\nc+d"
<span aria-hidden="true" class="katex-html">
<span class="katex-base">
<span class="katex-strut" style="height: 0.6667em; vertical-align: -0.0833em;">
<span class="mord mathnormal">
#text "a"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="mbin">
#text "+"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="katex-base">
<span class="katex-strut" style="height: 0.7778em; vertical-align: -0.0833em;">
<span class="mord mathnormal">
#text "b"
<span class="mord mathnormal">
#text "c"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="mbin">
#text "+"
<span class="mspace" style="margin-right: 0.2222em;">
<span class="katex-base">
<span class="katex-strut" style="height: 0.6944em;">
<span class="mord mathnormal">
#text "d"
<p>
#text "Closing fence alone on its own line:"
<span class="katex-display">
<span class="katex">
<span class="katex-mathml">
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mi>
#text "θ"
<mi>
#text "φ"
<annotation encoding="application/x-tex">
#text "\\theta\n\\varphi"
<span aria-hidden="true" class="katex-html">
<span class="katex-base">
<span class="katex-strut" style="height: 0.8889em; vertical-align: -0.1944em;">
<span class="mord mathnormal" style="margin-right: 0.0278em;">
#text "θ"
<span class="mord mathnormal">
#text "φ"
<ul>
<li>
#text "list item keeps column-zero display math outside the item"
<span class="katex-display">
<span class="katex">
<span class="katex-mathml">
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML">
<semantics>
<mrow>
<mi>
#text "ψ"
<mi>
#text "ω"
<annotation encoding="application/x-tex">
#text "\\psi\n\\omega"
<span aria-hidden="true" class="katex-html">
<span class="katex-base">
<span class="katex-strut" style="height: 0.8889em; vertical-align: -0.1944em;">
<span class="mord mathnormal" style="margin-right: 0.0359em;">
#text "ψ"
<span class="mord mathnormal" style="margin-right: 0.0359em;">
#text "ω"
@@ -1,8 +0,0 @@
<div class="_markdown_404681">
<p>
#text "Opening line carries math:\n$$a+b\nc+d$$"
<p>
#text "Closing fence alone on its own line:\n$$\\theta\n\\varphi\n$$"
<ul>
<li>
#text "list item keeps column-zero display math outside the item\n$$\\psi\n\\omega$$"
@@ -243,20 +243,6 @@ const CORPUS: Record<string, string> = {
'\\sqrt{2}',
'```',
].join('\n'),
'math-multiline-display': [
'Opening line carries math:',
'$$a+b',
'c+d$$',
'',
'Closing fence alone on its own line:',
'$$\\theta',
'\\varphi',
'$$',
'',
'- list item keeps column-zero display math outside the item',
'$$\\psi',
'\\omega$$',
].join('\n'),
'gfm-strikethrough-and-literals': 'Mixed ~~gone~~ text with www.example.com literal and user@example.com email.',
'cjk-strong-and-inline-code-url': [
'**注意:**内容在标点后直接闭合。',
@@ -5,9 +5,6 @@ import { JsonBlock, MarkdownText } from './markdown-test-components.tsx'
import { LinkIconMedium, MarkdownDelegateProvider } from '../src/index.ts'
import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
import { parseGfmWithMath } from '../src/markdown/parse.ts'
// The node-type augmentation the `math` assertions below read.
import type {} from 'mdast-util-math'
afterEach(cleanup)
@@ -517,289 +514,6 @@ describe('MarkdownText', () => {
}
})
it('renders a display-dollar block whose opening line carries math across lines', () => {
const cases = [
{ source: '$$a+b\nc+d$$', value: 'a+b\nc+d' },
{ source: '$$a+b\nc+d\n$$', value: 'a+b\nc+d' },
{ source: 'Prose line\n$$a+b\nc+d$$', value: 'a+b\nc+d' },
{ source: '- item\n $$a+b\n c+d$$', value: 'a+b\nc+d' },
{ source: '- item\n$$\na+b\n$$', value: 'a+b' },
]
for (const item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(1)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
rendered.unmount()
}
const followed = render(<MarkdownText text={'$$a+b\nc+d$$\n\nAfter'} />)
expect(followed.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(followed.container.querySelectorAll('p')).toHaveLength(1)
expect(followed.container.querySelector('p')?.textContent).toBe('After')
followed.unmount()
})
it('leaves a dollar candidate with trailing text to inline math ahead of a later block', () => {
const { container } = render(<MarkdownText text={'$$x$$ trailing\n\n$$y$$'} />)
expect(container.querySelectorAll('.katex')).toHaveLength(2)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('y')
expect(container.querySelector('.katex-error')).toBeNull()
expect(container.textContent).toContain('trailing')
})
it('keeps an unclosed backslash candidate inside one paragraph', () => {
const { container } = render(<MarkdownText text={'**before\n\\[literal]\nafter**'} />)
expect(container.querySelectorAll('p')).toHaveLength(1)
expect(container.querySelector('strong')).not.toBeNull()
expect(container.textContent).not.toContain('**')
})
it('bounds parser work for repeated unclosed dollar candidates', () => {
const source = '$$a$b\n'.repeat(1_600)
const startedAt = performance.now()
const tree = parseGfmWithMath(source)
expect(performance.now() - startedAt).toBeLessThan(3_000)
expect(tree.children.every(node => node.type !== 'math')).toBe(true)
})
it('keeps a solitary dollar inside a display formula as content', () => {
for (const source of [
'$$P = \\begin{cases} 1 & \\text{if $x>0$} \\end{cases}$$',
'$$\\text{a $x$ b}\\tag{1}$$',
]) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
rendered.unmount()
}
})
it('renders a multi-line display formula that uses a solitary dollar', () => {
const source = '$$P = \\begin{cases} 1 & \\text{if $x>0$} \\\\\n0 & \\text{otherwise} \\end{cases}$$\n\nAfter'
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-error')).toBeNull()
expect(container.querySelectorAll('p')).toHaveLength(1)
expect(container.querySelector('p')?.textContent).toBe('After')
})
it('ignores a closing fence inside a TeX comment on a later line', () => {
const commented = render(<MarkdownText text={'$$\n\\frac{1}{ % keep $$\n2}\n$$\n\nAfter'} />)
expect(commented.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(commented.container.querySelector('.katex-display annotation')?.textContent)
.toBe('\\frac{1}{ % keep $$\n2}')
expect(commented.container.querySelector('.katex-error')).toBeNull()
expect(commented.container.querySelector('p')?.textContent).toBe('After')
commented.unmount()
for (const source of ['$$\n% keep $$\n\\frac12\n$$\n\nAfter']) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
expect(rendered.container.querySelector('p')?.textContent).toBe('After')
rendered.unmount()
}
})
it('closes a multi-line block whose last line ends in a bare percent', () => {
for (const source of ['$$\\text{acc} =\n95%$$', '$$\\text{acc} =\n95 %$$']) {
const rendered = render(<MarkdownText text={source} />)
const annotation = rendered.container.querySelector('.katex-display annotation')?.textContent ?? ''
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
expect(annotation).toContain('95')
expect(annotation).toMatch(/^\\text\{acc\} =/)
rendered.unmount()
}
})
it('keeps an escaped percent inside its formula', () => {
const { container } = render(<MarkdownText text={'$$a\\%b$$'} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('a\\%b')
})
it('leaves verbatim forms to the value scanner', () => {
const percent = render(<MarkdownText text={'$$\\verb|%|$$\n\nAfter\n\n$$\ny\n$$'} />)
expect(percent.container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(percent.container.querySelector('.katex-error')).toBeNull()
expect(percent.container.querySelector('p')?.textContent).toBe('After')
percent.unmount()
const controlWord = render(<MarkdownText text={'$$\\verbatim + y$$'} />)
expect(controlWord.container.querySelector('.katex-display annotation')?.textContent).toBe('\\verbatim + y')
controlWord.unmount()
const starred = render(<MarkdownText text={'$$\\verb*a$$a$$'} />)
expect(starred.container.querySelector('.katex-display annotation')?.textContent).toBe('\\verb*a$$a')
starred.unmount()
for (const source of ['$$\\verb**abc*$$\n\nAfter\n\n$$\ny\n$$', '$$x % \\verb$abc$$\n\nAfter\n\n$$\ny\n$$']) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
expect(rendered.container.querySelector('p')?.textContent).toBe('After')
rendered.unmount()
}
})
it('keeps a verb delimiter and its empty region inside the formula', () => {
for (const source of ['$$\\verb$$$$', '$$\\verb$x$ y$$']) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
rendered.unmount()
}
// A region that holds text leaves its closing `$` to the fence logic, so the
// value keeps `\verb$abc` and KaTeX reports the unterminated command.
const unterminated = render(<MarkdownText text={'$$\\verb$abc$$'} />)
expect(unterminated.container.querySelector('.katex-error')?.textContent).toContain('\\verb$abc')
unterminated.unmount()
})
it('leaves a later-line trailing fence to upstream, which takes the following prose', () => {
const { container } = render(<MarkdownText text={'$$a+b\nc+d$$ trailing\n\nAfter'} />)
expect(container.querySelectorAll('p')).toHaveLength(0)
expect(container.querySelector('.katex-error')?.textContent).toContain('c+d$$ trailing')
expect(container.querySelector('.katex-error')?.textContent).toContain('After')
})
it('does not read a comment ending in verb as a verbatim delimiter', () => {
const { container } = render(<MarkdownText text={'$$x % \\verb$$\n\nAfter\n\n$$\ny\n$$'} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('x % \\verb')
expect(container.querySelector('.katex-error')).toBeNull()
expect(container.querySelector('p')?.textContent).toBe('After')
})
it('bounds parser work for repeated candidates ending in a verb tail', () => {
const source = '$$a$b\\verb\n\n'.repeat(1_600)
const startedAt = performance.now()
const tree = parseGfmWithMath(source)
expect(performance.now() - startedAt).toBeLessThan(3_000)
expect(tree.children.every(node => node.type !== 'math')).toBe(true)
})
it('does not carry the verb delimiter across a line ending', () => {
const { container } = render(<MarkdownText text={'$$a$b\\verb\n\n$$y$$'} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('y')
})
it('does not read an escaped backslash as the verb command', () => {
for (const source of ['$$\\\\verb$$\n\nAfter', '$$\\\\verb*$$\n\nAfter']) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(rendered.container.querySelector('p')?.textContent).toBe('After')
rendered.unmount()
}
})
it('reads the character after verb as that command delimiter, not a fence', () => {
for (const source of ['$$\n\\verb$$\nx\n$$\n\nAfter', '$$\n\\verb*$$\nx\n$$\n\nAfter']) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(rendered.container.querySelector('p')?.textContent).toBe('After')
rendered.unmount()
}
})
it('treats a percent inside a comment as comment text', () => {
for (const source of [
'$$\n% 100%$$\n$$',
'$$\n\\frac{1}{ % accuracy 95%$$\n2}\n$$\n\nAfter',
]) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
rendered.unmount()
}
})
it('keeps a lone dollar on a later line inside the block', () => {
const { container } = render(<MarkdownText text={'$$a+b\nc$d\n$$'} />)
expect(container.querySelector('.katex-error')?.textContent).toBe('a+b\nc$d')
})
it('renders a display formula whose later line uses a solitary dollar', () => {
const { container } = render(
<MarkdownText text={'$$f(x) = \\begin{cases}\n1 & \\text{if $x > 0$} \\\\\n0 & \\text{otherwise}\n\\end{cases}$$\n\nAfter'} />,
)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-error')).toBeNull()
expect(container.querySelector('p')?.textContent).toBe('After')
})
it('keeps a fence that fails inside a comment and a trailing lone dollar parseable', () => {
const commentFail = render(<MarkdownText text={'$$x%$$ y'} />)
expect(commentFail.container.querySelector('.katex-display')).toBeNull()
expect(commentFail.container.querySelector('annotation')?.textContent).toBe('x%')
expect(commentFail.container.textContent).toContain('y')
commentFail.unmount()
const lone = render(<MarkdownText text={'$$a$'} />)
expect(lone.container.querySelectorAll('.katex')).toHaveLength(0)
expect(lone.container.querySelector('p')?.textContent).toBe('$$a$')
lone.unmount()
})
it('closes a block at a fence behind a whitespace-only comment', () => {
const { container } = render(<MarkdownText text={'$$\na = x% $$\n$$'} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('a = x% ')
expect(container.querySelector('.katex-error')).toBeNull()
})
it('ends the backslash escape run at a line ending', () => {
const { container } = render(<MarkdownText text={'$$a$b\\\n\n$$y$$'} />)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toBe('y')
expect(container.querySelector('.katex-error')).toBeNull()
})
it('bounds parser work for repeated candidates ending in an odd backslash', () => {
const source = '$$a$b\\\n\n'.repeat(3_200)
const startedAt = performance.now()
const tree = parseGfmWithMath(source)
expect(performance.now() - startedAt).toBeLessThan(3_000)
expect(tree.children.every(node => node.type !== 'math')).toBe(true)
})
it('keeps an escaped-dollar display candidate inside its paragraph', () => {
const { container } = render(<MarkdownText text={'**before\n$$a\\$b\nafter**'} />)
expect(container.querySelectorAll('p')).toHaveLength(1)
expect(container.querySelector('strong')).not.toBeNull()
expect(container.textContent).not.toContain('**')
})
it('lets display math interrupt an open paragraph', () => {
for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) {
const rendered = render(<MarkdownText text={source} />)