fix(markdown): 嵌套/未闭合代码块里的 \(...\) 被当成公式改写 - #516
Conversation
…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.
|
先谢谢这个 PR 🙏 —— 问题找得很准,根因分析(CommonMark 4.5:收尾围栏必须同种字符、且不短于开启围栏)是对的,三个用例也写得很清爽。我把它拉下来跑了一轮比较仔细的核对,结论是方向完全正确、这个 bug 确实该修,但建议再打磨一轮再合,下面是具体情况。 核对方法:拿 一、Bug 属实,已复现main 上的
这两个都是真实可见的损坏,值得修。 二、和 main 冲突了,而且改的位置已经搬家了这个 PR 的 base 是 9d71687,之后 main 的 ad42a0a(修 Windows 路径分隔符那个)把这条正则抽到了 所以 rebase 之后,修复应该落到 三、新正则引入了一个「同类」的损坏 case这个是我觉得最需要处理的一点 —— 它和 PR 想修的是同一种病,只是触发输入不同:
四、还引入了一个「真公式不渲染」的 case正文里行内出现三反引号时(写教程时挺常见的),新正则的
这个方向是 fail-safe 的(只是不渲染,不会损坏内容),严重程度比第三点低,但确实比 main 差。 五、
|
| 开启围栏长度 | 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 倍,方向是明确正确的。
小结
我的建议是:
- rebase 到最新 main,把修复挪到
markdown-mask.ts的CODE_SPANS(这步基本无痛,我验证过测试全绿) - 第三点那个新的损坏 case 处理掉 —— 它和这个 PR 要修的是同一种病,留着有点可惜
- 第四点和第五点如果不打算一并解决,至少在注释里写成 known limit,再补两个测试钉住,避免以后有人踩
另外提醒一个容易埋雷的点:新正则依赖「不带 m 标志时 $ 也会匹配末尾换行之前」这个 JS 语义。以后要是有人为了做锚定顺手加了 m,$ 就会变成「每个行尾」,EOF 那条分支会静默失效 —— 值得在旁边写一句注释钉死。
再次感谢你把这个问题挖出来并且给了这么清楚的复现步骤 👍 上面这些主要是想帮你把它一次修到位,改完我们再跑一轮 review。
前几天在预览一篇讲 LaTeX 的 markdown 笔记时发现代码块里的内容被改了,排查下来是
normalizeMathDelimiters屏蔽代码块的正则有问题,顺手修一下。问题
屏蔽代码块用的是
`{3,}[\s\S]*?`{3,},它把「任意 3+ 个反引号」和「下一撮 3+ 个反引号」配成一对,没有遵守 CommonMark 的规则——围栏只能被同种、且不短于开启围栏的反引号关闭。于是两种情况会漏:md包一个```js ```` 代码块时(写教程展示代码块常见),外层四反引号围栏被里面的三反引号错误地「关闭」了,导致中间真正的代码没被屏蔽,里面的\(x\)被改写成 `$$x$$`。复现
新建一个
.md文件,内容:在文件预览里打开,代码块里的
\(x\)会被渲染成$$x$$(居中的斜体公式),而它本该原样显示。AI 回复里出现同样的嵌套结构,以及任意代码块在流式输出途中,都会命中。修复
把开启围栏用捕获组存下来,要求收尾围栏是「同种字符、不短于开启围栏」的一撮(
\1`*/\2~*),或者直接到字符串结尾(对应未闭合的情况)。两个真实围栏之间的正文公式仍然照常规范化,行为不变。测试
在
math-delimiters.parse.test.ts里补了 3 个用例(嵌套围栏、未闭合围栏、两围栏之间的正文公式),pnpm test全绿(4296 passed),pnpm eslint通过。