Skip to content

fix(markdown): 嵌套/未闭合代码块里的 \(...\) 被当成公式改写 - #516

Open
hsqbyte wants to merge 1 commit into
xintaofei:mainfrom
hsqbyte:fix/math-delimiters-nested-fence
Open

fix(markdown): 嵌套/未闭合代码块里的 \(...\) 被当成公式改写#516
hsqbyte wants to merge 1 commit into
xintaofei:mainfrom
hsqbyte:fix/math-delimiters-nested-fence

Conversation

@hsqbyte

@hsqbyte hsqbyte commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

前几天在预览一篇讲 LaTeX 的 markdown 笔记时发现代码块里的内容被改了,排查下来是 normalizeMathDelimiters 屏蔽代码块的正则有问题,顺手修一下。

问题

屏蔽代码块用的是 `{3,}[\s\S]*?`{3,},它把「任意 3+ 个反引号」和「下一撮 3+ 个反引号」配成一对,没有遵守 CommonMark 的规则——围栏只能被同种、且不短于开启围栏的反引号关闭。于是两种情况会漏:

  1. 嵌套围栏:用 md 包一个 ```js ```` 代码块时(写教程展示代码块常见),外层四反引号围栏被里面的三反引号错误地「关闭」了,导致中间真正的代码没被屏蔽,里面的 \(x\) 被改写成 `$$x$$`。
  2. 未闭合围栏:流式输出时,代码块的结尾围栏还没到,此时整段是「未闭合」的,正则匹配不到收尾,代码内容同样被改写 —— 表现为代码块里的公式在流式过程中一直闪。

复现

新建一个 .md 文件,内容:

````md
```js
const x = 1 // \(x\)
```
````

在文件预览里打开,代码块里的 \(x\) 会被渲染成 $$x$$(居中的斜体公式),而它本该原样显示。AI 回复里出现同样的嵌套结构,以及任意代码块在流式输出途中,都会命中。

修复

把开启围栏用捕获组存下来,要求收尾围栏是「同种字符、不短于开启围栏」的一撮(\1`* / \2~*),或者直接到字符串结尾(对应未闭合的情况)。两个真实围栏之间的正文公式仍然照常规范化,行为不变。

测试

math-delimiters.parse.test.ts 里补了 3 个用例(嵌套围栏、未闭合围栏、两围栏之间的正文公式),pnpm test 全绿(4296 passed),pnpm eslint 通过。

…ences

The code-masking regex in normalizeMathDelimiters paired any run of 3+
backticks with the *next* run of 3+ backticks. That ignores CommonMark's
rule that a fence only closes on a same-char run at least as long as its
opener, so:

- a ````md block wrapping an inner ```js block was mis-split at the inner
  ``` fence, leaving the code between the two inner fences unmasked. Any
  \(x\) / \[x\] in that code got rewritten to $$x$$.
- an unclosed fence (which every code block is, mid-stream) matched
  nothing and was left entirely unmasked, so its contents were rewritten
  and KaTeX flickered inside the code block until the closing fence arrived.

Capture the opening fence and require the closer to be a run of the same
char at least as long (\1`* / \2~*), or end of input. Prose math between
two real fences still normalizes as before.
@xintaofei

Copy link
Copy Markdown
Owner

先谢谢这个 PR 🙏 —— 问题找得很准,根因分析(CommonMark 4.5:收尾围栏必须同种字符、且不短于开启围栏)是对的,三个用例也写得很清爽。我把它拉下来跑了一轮比较仔细的核对,结论是方向完全正确、这个 bug 确实该修,但建议再打磨一轮再合,下面是具体情况。

核对方法:拿 remark-parse 当 oracle(它才是最终真正解析这段文本的东西),对每个输入算出「哪些字节真的在 code / inlineCode 节点里」,再和正则实际屏蔽的区间做差,这样「屏蔽多了」和「屏蔽少了」都能量化,不靠肉眼。


一、Bug 属实,已复现

main 上的 `{3,}[\s\S]*?`{3,} 确实是把「任意 3+ 反引号」和「下一撮 3+」硬配对,两种情况都复现了:

  • 嵌套围栏:内层代码没被屏蔽,\(x\) 被改写成 $$x$$
  • 未闭合围栏(流式过程中很常见的临时状态):整段可能匹配不到收尾,代码内容照样被改写

这两个都是真实可见的损坏,值得修。


二、和 main 冲突了,而且改的位置已经搬家了

这个 PR 的 base 是 9d71687,之后 main 的 ad42a0a(修 Windows 路径分隔符那个)把这条正则抽到了 src/components/ai-elements/markdown-mask.ts 的导出常量 CODE_SPANSmessage.tsx 现在只是调 maskLiteralSpans(text),本地那份内联正则已经不在了:

git merge-tree --write-tree origin/main <pr-head>
# CONFLICT (content): Merge conflict in src/components/ai-elements/message.tsx

所以 rebase 之后,修复应该落到 CODE_SPANS 上。我试着把这个 PR 的同一条正则原样搬过去,pnpm test 是全绿的(317 files / 4344 tests = main 的 4341 + 本 PR 新增 3),所以搬家本身没有额外成本,纯粹是位置问题。


三、新正则引入了一个「同类」的损坏 case

这个是我觉得最需要处理的一点 —— 它和 PR 想修的是同一种病,只是触发输入不同:

````a```
````
\[x\]
```

remark-parse 的结论是:第一行的 ````a``` 不是合法的围栏开启行(反引号围栏的 info string 里不允许出现反引号),所以它是段落;真正的代码块从第二行的 ```` 开始,一直到 EOF。也就是说 \[x\]在代码块里面的。

  • main:屏蔽 [0,8][9,23] —— 正确
  • 本 PR:屏蔽 [0,13][20,23] —— 偏移 14 的 \[x\] 漏在外面,会被改写成 $$x$$

四、还引入了一个「真公式不渲染」的 case

正文里行内出现三反引号时(写教程时挺常见的),新正则的 |$ 分支会从那里一路吞到 EOF:

Use ``` to open a block. Then \(x\) is math.

remark-parse 把整行解析成一个 paragraph,也就是说这里的 \(x\) 是应该被规范化、最终渲染成公式的。main 不匹配(正确),新正则整段屏蔽,后面的公式就不渲染了。

这个方向是 fail-safe 的(只是不渲染,不会损坏内容),严重程度比第三点低,但确实比 main 差。


五、\1 反向引用带来了回溯开销

(`{3,})[\s\S]*?(?:\1`*|$) 里每次惰性展开都要做一次「和开启围栏等长」的比较,最坏情况随「开启围栏长度 × 文档长度」增长。用约 160KB、正文全是「差一个字符、永远闭合不上」的反引号串测(我这台机器 + Node 24,仅供看趋势):

开启围栏长度 main 本 PR
100 0.13 ms 3.4 ms
1000 0.09 ms 28 ms
20000 0.37 ms 504 ms
50000 0.16 ms 1178 ms

正常内容(普通 160KB 未闭合代码块)是 0.04ms → 0.44ms,完全无感。所以这不是日常路径的问题,但 normalizeMathDelimiters流式每个 token 都在 UI 线程上跑的,万一 agent 吐出一长串反引号就会卡住界面,main 没有这条路径,值得留意一下。


六、两个「踩过的坑」,分享给你省点时间

别用朴素的行首锚定。 我本来以为「把开启和收尾围栏都锚到行首」能把上面几点一起解决,写了一版试,结果它在下面这些 main 和本 PR 都处理正确的场景上反而更糟:

输入 main 本 PR 行首锚定版
- ```js 开头的列表内围栏 ❌ 损坏
> ```js 开头的引用内围栏 ❌ 损坏
跨行的多反引号行内代码 ❌ 损坏
\r 换行的文档 ⚠️ 压制

根因是:围栏可以开在容器前缀后面(- > ),此时根本没有「行首的围栏」可以锚,而正则又没法可靠地识别容器前缀。我也试过「补上 info string 不含反引号」这条规则,实测对第三点没有帮助。感觉这条正则路线离天花板已经不远了 —— 真正干净的解法可能是学 ad42a0a 修 Windows 路径的做法,在解析后的树上做而不是在源字符串上做,不过那显然超出这个 PR 的范围了。

现有测试覆盖不到这一类。 上面那个明显有问题的锚定版,pnpm vitest run src/components/ai-elements/ 199 个用例(含本 PR 新增的 3 个)全绿。所以容器内围栏、跨行多反引号行内代码、纯 \r 文档这几类目前是测试盲区,改这条正则的时候光看测试是绿的不太够。


净收益还是很正的

6 万份随机按行结构文档(语料是刻意对抗性的,绝对值没意义,只看比例):

损坏代码 压制真公式
main 25.61% 11.39%
本 PR 10.73% 14.97%
  • 只有本 PR 会损坏、main 没问题的:0.20%
  • 只有 main 会损坏、本 PR 修好的:15.09%

也就是修好的比新引入的多约 75 倍,方向是明确正确的。


小结

我的建议是:

  1. rebase 到最新 main,把修复挪到 markdown-mask.tsCODE_SPANS(这步基本无痛,我验证过测试全绿)
  2. 第三点那个新的损坏 case 处理掉 —— 它和这个 PR 要修的是同一种病,留着有点可惜
  3. 第四点和第五点如果不打算一并解决,至少在注释里写成 known limit,再补两个测试钉住,避免以后有人踩

另外提醒一个容易埋雷的点:新正则依赖「不带 m 标志时 $ 也会匹配末尾换行之前」这个 JS 语义。以后要是有人为了做锚定顺手加了 m$ 就会变成「每个行尾」,EOF 那条分支会静默失效 —— 值得在旁边写一句注释钉死。

再次感谢你把这个问题挖出来并且给了这么清楚的复现步骤 👍 上面这些主要是想帮你把它一次修到位,改完我们再跑一轮 review。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants