03 · Markdown 管线
1. 两个解析器,各司其职
| 编辑解析器 | 语义解析器 | |
|---|---|---|
| 库 | @lezer/markdown | remark / mdast(unified 生态) |
| 运行时机 | 每次按键(增量) | 导出、大纲、搜索索引、插件分析(按需) |
| 输出 | Lezer 语法树(位置 + 节点类型) | mdast AST(语义结构) |
| 要求 | 极致增量、容错、能给出精确字符偏移 | 语义完整、生态丰富、易于做转换 |
为什么不合并成一个:增量解析器要的是「位置精确 + 容错 + 快」,语义解析器要的是 「结构完整 + 生态」,两者优化目标不同。硬合并要么牺牲输入延迟,要么牺牲导出质量。 决策记录见 ADR-0003。
风险与对策:两者可能对同一段文本给出不同解读(尤其在嵌套列表、惰性延续、 HTML 块这些 CommonMark 的阴暗角落)。对策是一致性测试:
- CommonMark 官方 spec 测试集(650+ 用例)同时跑两个解析器,比对块级结构是否一致;
- GFM 扩展用例同样处理;
- 任何不一致要么修,要么在
docs/known-divergences.md里显式记录并加回归用例。✅ 已落地。652 例官方语料,2 例不一致(0.31%),都登记在那个文件里。 做法与踩到的坑见 07 §1.1。
- 这套测试进 CI 必跑,见 07 质量基线。
2. 语法扩展:三件套契约
每个语法扩展必须同时提供三样东西,缺一不可。这是插件 API 的核心约束 (也是为什么内置功能自己也要走这套 API)。
export interface SyntaxExtension {
id: string // 'math', 'footnote', 'wikilink'…
/** 1. 编辑侧:Lezer markdown 扩展,负责增量解析出节点 */
lezer: MarkdownConfig
/** 2. 语义侧:把 Lezer 节点桥接成 mdast 节点 —— 或提供 remark 插件 */
mdast: {
fromLezer?(node: SyntaxNodeRef, ctx: BridgeCtx): MdastNode | null
remarkPlugin?: Plugin
}
/** 3. 序列化:mdast 节点 → Markdown 文本(导出与格式化时用) */
serialize(node: MdastNode, ctx: SerializeCtx): string
/** 可选:编辑器里的呈现规则(见 02 §3) */
decorate?: DecorationRule
/** 可选:导出为 HTML 时的渲染 */
toHtml?(node: MdastNode, ctx: HtmlCtx): HtmlNode
}注册顺序即优先级;冲突(两个扩展抢同一段语法)在注册时报错,不静默覆盖。
3. 内置支持的语法
基线:CommonMark 全集。 在此之上:
| 组 | 语法 | 状态 |
|---|---|---|
| GFM | 表格、任务列表、删除线、自动链接、脚注 | ✅ M2 |
| 扩展 | YAML front matter | ✅ M3 |
| 扩展 | 数学:$…$ / $$…$$(KaTeX) | ✅ M3 |
| 扩展 | 图表:```mermaid | ✅ M3 |
| 扩展 | 行内 HTML(封闭标签集,无属性) | ✅ M4.5,见 02 §5.1 |
| 扩展 | 目录占位符 [TOC] | ❌ 原排 M4,没做 |
| 扩展 | 高亮 ==x==、上下标(可开关) | ❌ 原排 M4,没做 |
| 扩展 | 通用指令语法 :::note / ::icon[](remark-directive 风格) | M5 |
| 插件位 | Wiki 链接 [[x]]、标签 #tag | 由插件提供,不内置 |
打 ❌ 的两项排在 M4,而 M4 实际做的是主题引擎与导出 —— 它们被挤掉了,不是做了 一半。[TOC] 在 06 §1 的导出管线图里还列着一个转换阶段,那一步同样没有实现; 导出目前不处理 [TOC],它会按普通文本原样输出(符合原则 P2)。
上下标 / 高亮那条有个具体的副作用值得记:上游的 markdownLanguage 里本来就带 下标、上标、emoji 短代码三样,我们刻意没用它、而是拿 commonmarkLanguage + 显式 GFM 自己拼(packages/markdown/src/language.ts)。用现成的等于提前把三个没设计过 呈现规则的语法偷偷打开 —— ~x~ 会突然变成下标,用户既不知道为什么,也关不掉。
所有非 CommonMark 语法默认开关状态需明示,且关闭时必须退化为纯文本原样保留。
3.1 当前实际支持到哪一步
上面那张表说的是「支持哪些语法」。这一节说的是每一种在编辑器里长什么样 —— 两者混在一起,很容易让人以为「支持」就等于「标记会藏起来」。
解析层:CommonMark 全集 + GFM(表格、任务列表、删除线、自动链接)
- 自研的脚注扩展。
dialect默认gfm;commonmark严格模式仍然保留, 两个方言各跑一遍 652 个官方 spec 用例的结构不变量。
GFM 是显式拼出来的(commonmarkLanguage + GFM 扩展), 而不是直接用上游的 markdownLanguage。后者还捆了下标、上标、emoji 短代码 三样 —— 那三样排在 M4 且要求可开关,用它就等于提前偷偷打开: ~x~ 会突然变成下标,用户既不知道为什么也关不掉。
呈现层分三类:
① 有专门的呈现规则
标题、粗体、斜体、删除线、行内代码、引用、无序列表、嵌套列表、分隔线、 行内链接、引用式链接、自动链接(含 GFM 的裸链接)、图片、 表格、任务列表、脚注、转义字符、HTML 实体。
② 有样式,但源码保留可见 —— 这是有意的
| 语法 | 现状 | 为什么不藏 |
|---|---|---|
| Setext 标题 | 字号生效,=== 可见 | 藏了会留下一个空行,观感更糟 |
| 围栏代码块 | 等宽 / 底色 / 高亮 / 不折行;围栏在光标离开后藏起来 | 光标进块即显形,用户仍然删得掉 |
| 缩进代码块 | 同上 | 本来就没有可藏的标记 |
| 有序列表 | 编号原样显示 | 编号是内容的一部分,不是纯标记 |
| 链接引用定义 | 原样 | 它本来就是写给人看的定义行 |
| 脚注定义行 | [^1]: 原样,整行弱化 | 标签是内容(「第几条」),不是纯标记 |
| GFM 自动链接 | 原样加下划线 | URL 文本本身就是要显示的内容 |
| 硬换行 | 原样 | 源码里本来就是换行,无需处理 |
③ 真实缺口 —— 还没做,不是有意为之
| 语法 | 现在 | 应该 | 状态 |
|---|---|---|---|
行内 HTML <b>x</b> | ✅ 渲染 | M4.5 做完,见 02 §5.1 | |
HTML 块 <div>…</div> | 显示原文 | 渲染 | 不做,理由见下 |
| 表格网格编辑 | ✅ Tab 跳格、增删行列、设对齐、整理;拖列宽没有 | 拖列宽 | 换了做法,见 02 §6.4 |
行内 HTML 已经做了,而且绕开了原本让它被搁置的那个理由。原本的顾虑是: 架构 01 §6 要求内嵌 HTML 必须先经消毒,而在 Electron 里消毒漏一个 on* 属性 就是 XSS→RCE。落地的解法不是「把消毒器写好」,是一个字节的 HTML 都不进 DOM —— 渲染效果全部由 mark 装饰 + 类名达成,写进 DOM 的只有一个封闭集合里的类名。 完整推理见 02 §5.1。
块级 HTML 决定不做,这不是排期问题。<div class="warning"> 这类东西的意义 几乎全在属性里,而属性正是上面那套「只写类名」的做法覆盖不到的地方 —— 要渲染它就得回到「解析 + 消毒」那条路,把刚绕开的风险原样请回来。 收益(渲染一个 div)跟代价(在 Electron 里维护一个消毒器)不成比例。
表格网格编辑换了做法:没有做 widget 网格,而是把每条命令实现成一次纯文本 变换。撤销、脏标记、外部改文件因此全都自动正确。代价是拖列宽做不了 (那需要一份持久的列宽状态,而列宽在 Markdown 文本里无处安放)。见 02 §6.4。
3.2 脚注:为什么得自己写
@lezer/markdown 的 GFM 包只有表格、任务列表、删除线、自动链接四样, 不含脚注,而 GitHub 自己是支持的。所以 packages/markdown/src/footnote.ts 是三件套契约(§2)里 Lezer 那一件的第一个真实实例 —— 内置功能也走插件 API 的同一条路,不开后门。
实现上踩到一个值得记下来的坑:[^1]: 内容 在形状上完全符合链接引用定义 (标签 [^1]、目标 内容),上游的 LinkReference 叶子解析器同样会盯上它。 单行定义时没事(finishLeaf 按顺序取第一个成功的,我们排在前面); 但只要定义换行续写,LinkReference 会在第二行的 nextLine 里直接结块, 根本走不到 finish 阶段,脚注就被吃成了链接引用。
nextLine 是唯一能抢在它之前插手的位置,因此在那里截断解析器数组。 代价写在测试里钉住了:一个 [^x]: 开头的块不能再被解析成 Setext 标题或表格。
两条已知限制(有意为之):
- 标签不允许含空白和
]。放开之后[^ ]、[^a] b]的归属会变得难以预测, 而它们在真实文档里几乎不出现。匹配不上就退化成普通文本。 - 定义只吃到空行为止,不支持跨空行的多段落脚注(GitHub 靠 4 空格缩进续写)。 用叶子块实现换来惰性延续与「下一条定义自动断开」两件事白送, 改成 composite block 才能支持多段落,成本不划算。
4. 格式保真:怎么做到零损耗
目标 G2 的实现来自架构本身(原则 P1):保存 = 把缓冲区写回磁盘, 不存在「序列化」这一步,所以天然逐字节一致。
但仍有三处需要显式设计:
4.1 编码与换行
打开文件时记录:BOM 有无、编码、主导换行符(LF / CRLF)。保存时按原样还原。
已实现范围(M0):UTF-8、UTF-8 with BOM、带 BOM 的 UTF-16 LE/BE。 无法按这些编码解码的内容(二进制、GBK 等无 BOM 的遗留编码)直接拒绝打开, 而不是用替换字符糊过去 —— 那样用户一保存就把文件损坏了。 GBK/Big5 等中文遗留编码的嗅探留到后续里程碑。
已知损耗:混合换行。 缓冲区内统一用 \n,保存时按主导换行符统一还原, 因此原本混合 CRLF/LF 的文件保存后会被统一。要真正保留混合状态,需要按行记录 原始换行符并在编辑中维护这份映射,成本远高于收益(这类文件本身就是历史事故)。 当前做法:TextFileMeta.mixedEol 标记出来,打开时当面提示用户, 而不是等保存完才让他在 git diff 里发现。
4.2 尾部换行
不自动增删文件末尾的换行符。可在设置里开启「保存时确保末尾换行」,默认关。
4.3 结构化编辑产生的文本
表格网格编辑器、列表重排等操作会生成文本,这里必须有明确风格策略:
- 表格:默认保持原表格的对齐方式与是否补空格;新建表格用「管道对齐 + 单空格填充」。
- 列表标记:沿用该列表已有的标记字符(
-/*/+),不强行改成偏好设置里的那个。 - 缩进:沿用文件已有的缩进宽度(嗅探),新文件用设置值。
原则:编辑器可以决定新内容的风格,但不得改写用户既有内容的风格。
4.4 可选的格式化命令
提供显式的「格式化文档」命令(走 mdast → 序列化,类似 Prettier)。这是用户主动触发 的破坏性操作,不是保存时的隐式行为。
5. 大纲与文档模型派生
大纲、字数统计、链接检查这些都从 Lezer 树直接派生(不需要 mdast,省一次解析):
const outline = new StateField<OutlineItem[]>(...) // 依赖 syntaxTree大纲更新做防抖(150ms),且只在标题节点集合变化时触发 UI 重渲染。
6. 性能预算
| 操作 | 预算 |
|---|---|
| 单次按键后的解析 + 装饰重建(1k 行文档) | < 4ms |
| 打开 10k 行文档到可编辑 | < 300ms |
| 全量 mdast 解析(10k 行,导出时) | < 500ms,且在 utility 进程里做,不阻塞 UI |
基准测试放 benchmarks/,CI 每次跑并对比基线,回退超过 20% 则失败(见 07)。
7. 围栏代码块的语言高亮
给 markdown() 传 codeLanguages。Lezer 支持混合语言解析, 会把 ```ts 的内容交给 TypeScript 解析器,产出的 token 直接落进 现有的 HighlightStyle,不需要第二套渲染路径,也不需要第二个编辑器实例。
语言从哪来:@codemirror/language-data 提供约百种语言的 LanguageDescription,每种都是动态 import,用到才加载。 Vite 会把它们切成独立 chunk,主 bundle 不受影响。
体积实测:Linux AppImage 114.8MB(预算 180MB,07 §2),主 bundle 601KB, 112 个语言解析器按需加载。余量充足,无需收窄语言清单。
7.1 匹配规则只能有一份
codeLanguages 传的是函数而不是数组:
codeLanguages: (info) => matchCodeLanguage(info, languages)传数组的话,上游会用它自己的 LanguageDescription.matchLanguageName, 而语言选择器(M2 加的那个下拉框)要在编辑器侧判断「当前是什么语言」。 两边各自匹配一次,规则一旦分家,界面上就会出现自相矛盾的状态: 下拉框显示「纯文本」、代码却是彩色的;更糟的是用户一碰那个下拉框, 本来好好的语言标注就被改掉了。
matchCodeLanguage 在上游规则之上补了一条扩展名兜底。原因是实测出来的: 上游只认「名字 + 别名」,而 py、rb、kt 是扩展名不是别名 —— 于是 ```py 一直是不高亮的,而它恰恰是最常见的写法之一。 (上游的 fuzzy 选项也救不了:它做的是「信息串里包含某个长度大于 2 的别名」, py 并不包含 python。顺带一提,这条 fuzzy 规则会让 brainfuck-x 归到 Brainfuck —— 行为不是我们定的,但两边一致,且有测试钉住。)
匹配不上时退化为纯文本,不报错、不吞内容(原则 P2)。
7.2 语言选择器
围栏折叠之后语言名不再可见,所以在代码块右上角放一个 <select>:
- 绝对定位,不占行内空间 —— 一旦参与布局,代码首行就被顶得往右缩一截;
- 不持有状态 —— 选完立刻把规范名写回围栏标注,走一次普通 transaction, 因此撤销、脏标记、将来的协同全都自动正确(docs/design/02 §6 的铁律);
- 文档里写的语言若不在清单里(拼错、或我们不认识),它自己也会作为一个 选项存在 —— 否则
<select>会显示成别的值,等于悄悄改了用户的字。
用原生 <select> 而不是自绘下拉:一百多种语言,原生控件自带键盘导航、 首字母跳转、各平台一致的滚动行为。自绘要把这些重做一遍才能追平。
8. 粘贴 HTML → Markdown
跟导出正好是反方向的一条路:导出是 mdast → hast → HTML,粘贴是 HTML → hast → mdast。同一套 unified 生态,概念只有一份,落在 @typo/import。
8.1 为什么它值得单独做
从网页、Word、Google 文档里复制内容,剪贴板里同时躺着 text/plain 和 text/html。默认粘贴只取前者 —— 标题、列表、表格、链接全部变成裸文字。 对一个 Markdown 编辑器来说,这是日常损耗最大的一处。
8.2 转换是同步的
粘贴必须当场完成。异步的话就得记住「粘到哪儿」,而用户在这几毫秒里完全 可能继续打字 —— 那是 images.ts 里靠映射锚点解决的一整类问题(位置失效、 选区替换错、撤销分组乱掉)。同步转换让这类问题根本不存在。
代价是 parse5 与 unified 会被静态打进编辑器包,不像 KaTeX / Mermaid 那样可以 懒加载。这是明知道的取舍:粘贴的手感与正确性比几百 KB 更值钱。
8.3 清洗规则针对具体来源,不做「通用清洗」
真实剪贴板里的 HTML 跟教科书里的不是一个东西。与其写一套哪儿都不太对的通用 清洗,不如为每个已知的坏来源写一条说得清楚的规则:
| 来源 | 症状 | 规则 |
|---|---|---|
| 网页 / Word | <style> 的文本内容被当成正文 | 整个元素丢掉 |
| Google 文档 | 全文被包进 <b style="font-weight:normal"> | 认出假粗体外壳并拆掉 |
| Word / Google 文档 | 粗体写成 <span style="font-weight:700"> | 行内样式还原成语义标签 |
| Word | <o:p>、mso- 类名、<p> </p> | 丢元素、丢空段落、 归一成空格 |
| 各处 | 零宽字符破坏分隔符识别 | 清掉 |
行内样式的还原只认 span / font:整块 div 被设成粗体通常是标题样式 或整页样式,照着转会得到一整篇加粗的文档。
8.4 没有结构就不转
判据是「转换换不来任何结构」时直接退回纯文本。典型来源是代码编辑器: 从 VS Code 复制一段代码,text/html 是一堆带行内配色的 <div><span>, 转出来是若干互不相干的段落 —— 而 text/plain 里躺着的正是原封不动的代码。
判据只看结构(有没有标题 / 列表 / 表格 / 链接 / 强调 / 代码 / 图片), 不看内容。<p> 与 <div> 不算结构:只有段落的 HTML 转出来跟纯文本没区别, 走转换反而引入转义。
8.5 让路规则
这个处理器很容易变成「什么粘贴都归我管」,那会踩坏另外三件已经对了的事:
- 剪贴板里有图片文件 → 交给图片插入。截图粘贴时剪贴板里往往同时有
text/html(一个<img>),抢过来会插一段没用的 HTML 源码而不是存图。 - 没有可转的结构 → 交给默认的纯文本粘贴(见 8.4)。
- 只有
text/plain→ 完全不插手。菜单里的「粘贴为纯文本」正是靠这条生效。
8.6 链接协议走白名单
粘进来的链接会留在用户的文档里,之后可能被点开、被导出、被分享。 javascript: 只是最出名的那一个,黑名单永远列不全。被拦下的链接只丢掉可点的 入口,文字照留 —— 内容一个字不少(原则 P2)。
图片的 data: URI 反而要保留:丢掉它就等于丢内容。
8.7 已知的降级
失败模式全部是良性的 —— 转得不够好,用户得到的是有点丑的 Markdown, 然后手改一下;不会丢内容,也不会损坏文档。
- 合并单元格(
colspan/rowspan)摊平成普通单元格,GFM 表达不了; - 嵌套表格摊平;
- 单元格里的块级内容(多个段落)被压平成一行,段落边界丢失;
- 超过 4MB 的 HTML 直接退回纯文本 —— 在主线程上啃几 MB 会让界面卡住;
<br>转成硬换行(行尾反斜杠)。看着有点噪声,但纯文本里的\n是 软换行,含义不一样,改过去等于悄悄改了用户的排版。