Skip to content

03 · Markdown 管线

1. 两个解析器,各司其职

编辑解析器语义解析器
@lezer/markdownremark / 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)。

ts
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 默认 gfmcommonmark 严格模式仍然保留, 两个方言各跑一遍 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,省一次解析):

ts
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 传的是函数而不是数组

ts
codeLanguages: (info) => matchCodeLanguage(info, languages)

传数组的话,上游会用它自己的 LanguageDescription.matchLanguageName, 而语言选择器(M2 加的那个下拉框)要在编辑器侧判断「当前是什么语言」。 两边各自匹配一次,规则一旦分家,界面上就会出现自相矛盾的状态: 下拉框显示「纯文本」、代码却是彩色的;更糟的是用户一碰那个下拉框, 本来好好的语言标注就被改掉了。

matchCodeLanguage 在上游规则之上补了一条扩展名兜底。原因是实测出来的: 上游只认「名字 + 别名」,而 pyrbkt 是扩展名不是别名 —— 于是 ```py 一直是不高亮的,而它恰恰是最常见的写法之一。 (上游的 fuzzy 选项也救不了:它做的是「信息串里包含某个长度大于 2 的别名」, py 并不包含 python。顺带一提,这条 fuzzy 规则会让 brainfuck-x 归到 Brainfuck —— 行为不是我们定的,但两边一致,且有测试钉住。)

匹配不上时退化为纯文本,不报错、不吞内容(原则 P2)。

扩展名兜底会撞车,所以有一条在它之前的短路(issue #3): text / plain / plaintext / txt 一律直接判成纯文本。理由是 @codemirror/language-data 里 sTeX 声明了 extensions: ['text', 'tex', …] —— 于是 ```text 在落到纯文本之前先命中了它,语言选择器显示「sTeX」, 而 \section{} 被按 TeX 命令着了色。

这几个词作为围栏语言的含义是明确的(「这段不要高亮」),跟「哪个语言用 这个文件扩展名」不是一回事。两者分歧时,前者说了算。

7.2 语言选择器

围栏折叠之后语言名不再可见,所以在代码块右上角放一个 <select>

  • 绝对定位,不占行内空间 —— 一旦参与布局,代码首行就被顶得往右缩一截;
  • 不持有状态 —— 选完立刻把规范名写回围栏标注,走一次普通 transaction, 因此撤销、脏标记、将来的协同全都自动正确(docs/design/02 §6 的铁律);
  • 文档里写的语言若不在清单里(拼错、或我们不认识),它自己也会作为一个 选项存在 —— 否则 <select> 会显示成别的值,等于悄悄改了用户的字。

用原生 <select> 而不是自绘下拉:一百多种语言,原生控件自带键盘导航、 首字母跳转、各平台一致的滚动行为。自绘要把这些重做一遍才能追平。

8. 粘贴 HTML → Markdown

跟导出正好是反方向的一条路:导出是 mdast → hast → HTML,粘贴是 HTML → hast → mdast。同一套 unified 生态,概念只有一份,落在 @mosu/import

8.1 为什么它值得单独做

从网页、Word、Google 文档里复制内容,剪贴板里同时躺着 text/plaintext/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>&nbsp;</p>丢元素、丢空段落、&nbsp; 归一成空格
各处零宽字符破坏分隔符识别清掉

行内样式的还原只认 span / font:整块 div 被设成粗体通常是标题样式 或整页样式,照着转会得到一整篇加粗的文档。

「针对具体来源」不等于「照着来源的名字猜」(issue #16)

上面这张表里有两条当初写歪了,歪的方式正好相反,值得并排记下来。

样式匹配要锚到声明边界。 /font-weight\s*:\s*normal/ 不加锚点的话, 任何以这个名字结尾的厂商私有属性都会命中 —— 而 Word 的 mso-bidi-font-weight 恰好是这个形状。后果是双向的:真的加粗被剥掉, 没加粗的凭空变粗;最糟的是 mso-bidi-font-weight:normal;font-weight:bold —— 真正的那条被前面那个厂商后缀盖掉。

按 class 丢弃要写显式名单,不能用前缀。 原来是 c.startsWith('mso'), 两头都不对:Word 用 class=msoIns 标记修订里插入的正文,前缀一刀切把 用户真正想粘的字整段销毁了;而它注释里声称要挡的那些(MsoNormalMsoListParagraph,大写 MstartsWith('mso') 恒为 false,从来没生效过。 一条规则同时做到了误伤和失效。

强调节点要归一化,否则产出的是字面星号

<b>Hello </b><b>world</b> 直接序列化会得到 **Hello ****world** —— 渲染出来是 **Hello **<strong>world</strong>:用户粘进来的是一句加粗的话, 得到的是正文里可见的星号加上一半丢了加粗。

两条规则缺一不可:

  1. 首尾空白挤到包裹层外面。 CommonMark 的 flanking 规则不允许标记贴着空白 (跟 02 §7.2 是同一条规则的另一个入口);
  2. 相邻的同类包裹层合并。 <b>Hel</b><b>lo</b> 会产出 **Hel****lo**, 中间那四个星号被解析成一个空的强调。

顺带丢掉空的(或只剩空白的)包裹层,并把 b/i/s 归一成 strong/em/del —— 不归一的话上面第 2 条根本认不出「同类」。

把一句话的一半重新加粗一次,富文本编辑器就会切成两个相邻节点, 所以这个形状一点都不牵强。

<caption> 不在任何一张表里,于是被静默删掉

它既不在「丢掉」也不在「拆掉外壳」也不在「算结构」的名单里,挺过清洗之后被 mdast 的表格序列化器丢掉 —— 而表格标题在维基百科、Google 文档和各种 CMS 的 表格里都很常见。figcaption 一直是保留的,同样的意图对 caption 漏了。

处理成表格前面的一个段落:那是唯一无损的去处。塞在表格里面等于没做, 塞进第一个单元格会改变表格的形状。

8.4 没有结构就不转

判据是「转换换不来任何结构」时直接退回纯文本。典型来源是代码编辑器: 从 VS Code 复制一段代码,text/html 是一堆带行内配色的 <div><span>, 转出来是若干互不相干的段落 —— 而 text/plain 里躺着的正是原封不动的代码。

判据只看结构(有没有标题 / 列表 / 表格 / 链接 / 强调 / 代码 / 图片), 不看内容。<p><div> 不算结构:只有段落的 HTML 转出来跟纯文本没区别, 走转换反而引入转义。

8.5 让路规则

这个处理器很容易变成「什么粘贴都归我管」,那会踩坏另外三件已经对了的事:

  1. 剪贴板里有图片文件 → 交给图片插入。截图粘贴时剪贴板里往往同时有 text/html(一个 <img>),抢过来会插一段没用的 HTML 源码而不是存图。
  2. 没有可转的结构 → 交给默认的纯文本粘贴(见 8.4)。
  3. 只有 text/plain → 完全不插手。菜单里的「粘贴为纯文本」正是靠这条生效。

8.6 链接协议走白名单

粘进来的链接会留在用户的文档里,之后可能被点开、被导出、被分享。 javascript: 只是最出名的那一个,黑名单永远列不全。被拦下的链接只丢掉可点的 入口,文字照留 —— 内容一个字不少(原则 P2)。

图片的 data: URI 反而要保留:丢掉它就等于丢内容。

8.7 已知的降级

失败模式全部是良性的 —— 转得不够好,用户得到的是有点丑的 Markdown, 然后手改一下;不会丢内容,也不会损坏文档。

  • 合并单元格colspan / rowspan)摊平成普通单元格,GFM 表达不了;
  • 嵌套表格摊平;
  • 单元格里的块级内容(多个段落)被压平成一行,段落边界丢失;
  • 超过 4MB 的 HTML 直接退回纯文本 —— 在主线程上啃几 MB 会让界面卡住;
  • <br> 转成硬换行(行尾反斜杠)。看着有点噪声,但纯文本里的 \n 是 软换行,含义不一样,改过去等于悄悄改了用户的排版。

MIT 许可发布。与 Typora 无关联,是一个独立实现。