markdown:文档与 Markdown 的双向转换

3 分钟阅读
·

上一篇看了 changeset 怎么用 span 表示两份文档的差异。这篇换一个方向,看文档怎么和外部文本格式互转。prosemirror-markdown 做两件事:把文档序列化成 Markdown 字符串,把 Markdown 字符串解析回文档。参考代码是 prosemirror-markdown 的 6b95bfe,实现集中在三个文件:src/to_markdown.tssrc/from_markdown.tssrc/schema.ts,外加一个只做转发的 src/index.ts

第 9 篇的 DOMSerializer第 10 篇的 DOMParser 处理的是 HTML 这条外部格式通路,规则全部挂在 schema 上。Markdown 这条通路用了另一种接法:序列化和解析各带一张独立的配置表,解析侧还把词法分析整个交给了 markdown-it。这篇把两个方向的实现看完,最后和 DOM 通路摆在一起对照,看两种接法各自的代价。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐
02-28 Composition 与 IME:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析
04-04 domcoords:屏幕坐标与文档位置的双向换算
04-11 browser.ts:浏览器差异补丁集
04-18 view 收官:不用官方扩展,手写一个最小可用编辑器
05-09 扩展(上):keymap,最小的插件
05-16 commands:命令的签名约定与组合器
05-23 history:undo/redo 栈与 rebasing
06-06 inputrules:「# 空格」变成标题是怎么实现的
06-13 schema-basic:官方基础文档结构
06-20 schema-list:列表节点与最复杂的一批命令
07-04 gapcursor:光标落不进去的地方怎么办
07-11 dropcursor:拖拽时的插入位置指示
07-18 menu:菜单栏组件体系
08-01 collab(上):协作编辑的 rebase 原理
08-08 collab(下):receiveTransaction 与整个收发循环
08-15 changeset:变更集的计算与展示
09-05 markdown:文档与 Markdown 的双向转换(本篇)

包的结构

src/index.ts 的导出清单很短:schema、MarkdownParser 与 defaultMarkdownParser、ParseSpec 类型、MarkdownSerializer 与 defaultMarkdownSerializer、MarkdownSerializerState。schema.ts 里是一个内置 schema,节点和 schema-basic 那套基本对应,每个节点照样带 parseDOM 和 toDOM,所以它同时支持 DOM 通路;差异有几处:heading 的内容表达式收紧成 (text | image)*,code_block 多一个 params 属性记录 fence 后面的信息串,两种列表多 tight 属性,em 的 parseDOM 额外处理了 font-style 为 normal 时清除 mark 的规则。defaultMarkdownParser 和 defaultMarkdownSerializer 默认都针对这份 schema。两个方向的转换共用 schema 这个概念,但实现上互不相干,只用其中一个方向没有问题。

Markdown 双向转换管线

图里两行是两条独立管线。序列化方向,MarkdownSerializer 拿 nodes、marks 两张配置表驱动 MarkdownSerializerState,状态机把文档树走一遍,产出字符串。解析方向,markdown-it 先把文本切成线性 token 流,tokenHandlers 把每种 token 映射成 MarkdownParseState 的栈操作,栈收成文档。

序列化:两张表驱动一个状态机

MarkdownSerializer 的构造器收三样东西:nodes 表把节点类型名映射到渲染函数,marks 表把 mark 类型名映射到 MarkSerializerSpec,再加 options(escapeExtraCharacters、hardBreakNodeName、strict)。serialize(content) 先合并构造时的 options 和调用时传入的 options,之后主体只有三行:new 一个 MarkdownSerializerState,调 renderContent 走完文档,返回 state.out。

节点渲染函数的签名是 (state, node, parent, index) => void,带上 parent 和 index 是因为有些渲染要看相邻节点。defaultMarkdownSerializer 里几个有代表性的:

  • blockquote 调 state.wrapBlock(”> ”, null, node, f)。wrapBlock 把 ”> ” 累加进 state.delim,之后每次 write 在行首自动补这个前缀,渲染完恢复旧值。嵌套引用的 ”> > ” 就是 delim 逐层累加出来的。
  • code_block 先在内容里用 /`{3,}/ 找最长的连续反引号串(只统计三个及以上的),fence 取得比它再长一个,内容里出现三个连续反引号时围栏就用四个。
  • ordered_list 用起始序号加 childCount 推出最大序号的宽度,补齐空格让 “9.” 和 “10.” 的点号对齐。
  • image 输出 ![alt](src "title") 形式,src 里的圆括号、title 里的引号都要转义。

marks 表的规格是 {open, close, mixable, expelEnclosingWhitespace, escape},open 和 close 可以是字符串也可以是函数。link 用函数:open 里调 isPlainURL,链接文本恰好等于 href、href 带协议前缀且没有 title 时输出 ”<” 走 autolink 形式,否则输出 ”“;close 对应输出 ”>” 或 ”“。code mark 的 escape: false,内容不转义,包裹用的反引号数量由 backticksFor 按内容里最长反引号串决定,旁边还要视情况补空格。text 节点的渲染是 state.text(node.text, !state.inAutolink),autolink 内部不转义。

真正干活的是 MarkdownSerializerState。字段不多:out 是累积输出,delim 是当前行首前缀,closed 记录上一个关闭的块。

write 里有个小机关:delim 非空且当前处于行首(atBlank,即 out 为空或以换行结尾)时,先把 delim 写进 out 再写内容,嵌套块里每一行的前缀就是这样补上的。text 方法按换行拆行,每行开头调一次无参 write,专门触发这个补前缀逻辑。

closeBlock 值得单独看。它不往 out 写任何东西,只是把节点记到 this.closed:

closeBlock(node: Node) {
  this.closed = node
}

换行推迟到下一次 write 里的 flushClose:先补一个换行结束当前行,再按 size 参数补空行,默认 size 2 即块与块之间空一行。推迟写的好处有两个:最后一个块后面不会多出空行;空行数量可以由下一个块决定。renderList 里两个同类型列表相邻时用 flushClose(3) 多隔一行,tight 列表的列表项之间用 flushClose(1) 只换行不空行。

内联内容的渲染在 renderInline,这是整个文件最绕的一段。它维护一个 active: Mark[] 表示当前打开的 mark 序列,遍历子节点时逐节点比对:

  1. 先处理 expelEnclosingWhitespace。em 和 strong 都带这个标记,因为 CommonMark 不允许强调标记内包裹空白。文本节点首尾的空白如果落在即将打开或即将关闭的 mark 里,就挪到 mark 外面输出,前导空白先写,尾随空白缓存到 trailing 变量里等 mark 关闭后再写。
  2. mixable mark 允许重排。em、strong、link 都是 mixable,「a b」和「a b」这种开闭顺序可以互换,代码把当前节点的 mark 序列尽量重排成与 active 对齐。看个具体例子:active 是 [strong],下一个文本节点的 marks 是 [em, strong],不重排的话公共前缀对不上,得先关 strong、开 em、再开 strong;把节点侧重排成 [strong, em] 之后公共前缀是 1,只需要补开一个 em。
  3. 算 keep,即新旧 mark 序列的公共前缀长度。先循环弹出并关闭 active 尾部,再把新 mark 逐个压入并输出开符号。
  4. code mark(escape: false)要求处于最内层,整段文本连同反引号一次性写出,中间不做任何转义。

forEach 走完之后还要手动补调一次 progress(null, 0, parent.childCount),把还开着的 mark 全部关闭、把缓存的尾随空白写出去,段尾的闭符号全靠这一下。

esc 函数负责转义。行内转义 ` * \ ~ [ ] _,下划线夹在词中间时不转;行首还要额外处理 ”- ”、”+ ”、”> ”、”#” 开头和 “1.” 这种有序列表开头。hard_break 的渲染也有细节:只有后面还跟着非 hard_break 的兄弟节点时才输出 “\\n”,结尾连续的 hard_break 直接丢弃;renderInline 里还会把结尾 hard_break 上的 mark 剥掉,避免闭符号前紧挨换行触发解析边界情况。

strict 选项控制两张表覆盖不全时的行为。默认 strict 下,render 遇到没有渲染函数的节点类型直接抛错,getMark 遇到没有规格的 mark 同样抛错。strict: false 时 getMark 返回 blankMark(开闭都是空串),非叶子节点降级为只渲染内容,inline 内容走 renderInline,块级内容走 renderContent 再补 closeBlock。叶子节点在 strict: false 下会被整个丢掉,它没有内容可渲染,自定义的嵌入节点要特别注意这一条。

默认 marks 表只有 em、strong、link、code 四项,和 schema-basic 的 mark 集一一对应。自定义 schema 多出 mark 而表里没有,导出时抛错,这个报错反而是好事,漏配在第一次导出时就暴露,不会静默通过。

解析:markdown-it 出 token,栈归我们

MarkdownParser 的构造器收 schema、tokenizer(一个 markdown-it 实例)和 tokens 表。defaultMarkdownParser 用 MarkdownIt(“commonmark”, {html: false}),即 CommonMark 模式且禁用内联 HTML。

markdown-it 把文本切成线性 token 数组。块级结构用成对的 xxx_open 和 xxx_close 表示嵌套;内联结构统一装进一个 inline token,它的 children 里再放 text、strong_open、strong_close 这类内联 token;code_inline、code_block、fence 是单 token,内容直接放在 token.content 里。

tokens 表把 token 名映射到 ParseSpec,一共四种映射:block 对应一个包装块节点,open 开栈帧、close 收帧;node 对应单 token 叶子节点,hr、hardbreak、image 走这条;mark 对应开闭改活动 mark 集合;ignore 直接丢弃。attrs 可以是静态对象,getAttrs(token, tokens, i) 存在时优先,attrs 本身给成函数也会被调用,默认表里多数走 getAttrs。

noCloseToken: true 告诉生成器这个 token 没有 open/close 对,block 类(code_block、fence)生成的 handler 变成 openNode、addText、closeNode 三连,mark 类(code_inline)变成 openMark、addText、closeMark 三连。markdown-it 给这类 token 的 content 末尾带换行,addText 之前统一过一道 withoutTrailingNewline 去掉。

tokenHandlers 函数在构造 parser 时把整张表预生成成 handlers 字典,block 类会生成 type_open 和 type_close 两个 handler,另外补三个默认 handler:text 直接加文本,inline 对 children 递归调 parseTokens,softbreak 默认加一个空格。inline 这个递归意味着块级的节点栈和内联的 mark 集合共用同一套状态机,不需要为内联单开一套解析流程。解析时遇到没有 handler 的 token 类型直接抛错。

MarkdownParseState 的栈是这个文件的核心。栈帧是 {type, attrs, content, marks},初始栈底是 topNodeType。top() 取栈顶帧,push() 往栈顶帧的 content 塞节点,外面套一个栈非空的守卫。操作集:

  • openNode(type, attrs) 压一层新帧;closeNode() 弹帧,用 type.createAndFill(attrs, content) 建成节点塞进新栈顶的 content。
  • openMark、closeMark 改栈顶帧的 marks 集合;addText 用当前 marks 建文本节点,顺手调 maybeMerge 把它和前一个 marks 相同的文本节点合并,相邻同格式文本不会碎成多个节点。
  • addNode 同样走 createAndFill,失败返回 null 静默丢弃。注意这里不抛错:token 映射出的结构不满足 schema 约束时,这一段内容就没了。

parse() 跑完所有 token 后还有一行收尾:do { doc = state.closeNode() } while (state.stack.length),把没闭合的帧全部收掉,最后兜底返回空文档。markdown-it 保证 open/close 配对,这个循环正常只跑一轮收掉 topNode,但写法上对不平衡输入也是安全的。

MarkdownParseState 的栈变化

defaultMarkdownParser 的 tokens 表一共十五项:blockquote、paragraph、list_item、bullet_list、ordered_list、heading、code_block、fence 是 block;hr、image、hardbreak 是 node;em、strong、link、code_inline 是 mark。几个取 attrs 的例子:ordered_list 从 token 上读 start 属性,tight 由 listIsTight 判断(从当前位置往后找第一个非 list_item_open 的 token,看它的 hidden 字段,markdown-it 用这个字段标记 tight 列表里的段落);heading 的 level 从 tok.tag.slice(1) 来;image 的 alt 取 children[0].content。

和 DOM 通路的对照

DOMParser 和 MarkdownParser 做的是同一件事:外部格式转文档。两条通路的接法几乎相反,值得逐项摆在一起。

规则的位置。DOMParser 的规则写在 schema 上,每个节点和 mark 自带 parseDOM,DOMSerializer 用同一套 schema 上的 toDOM。Markdown 通路的两张表(序列化的 nodes/marks、解析的 tokens)独立于 schema,构造 serializer 和 parser 时传入。后果是 schema 每加一个自定义节点,Markdown 这边要同步维护两处配置:漏了序列化项,导出时抛错(strict: false 时降级为忽略该节点、只渲染内容);漏了解析项,输入出现对应语法时抛错。DOM 通路没有这个问题,代价是规则分散在 schema 各处,想换一套 HTML 方言就得动 schema 定义。

输入的形态。DOMParser 拿到的是浏览器已经解析好的 DOM 树,工作是树到树的规则匹配,难在输入是任意来源的脏 HTML:不合法嵌套、缺少包装节点、上下文不匹配都要当场修,它内部那个上下文栈大半代码在做修复。MarkdownParser 拿到的是 markdown-it 切好的线性 token 流,结构受 CommonMark 约束,open/close 配对有保证,所以它的栈只有压帧、收帧、改 marks 三种操作,没有任何修复逻辑。复杂度没有消失,是转移给了 markdown-it,这也是这个包愿意多一个依赖的原因。

输出的形态决定序列化的写法。DOMSerializer 输出的是树,schema 上 toDOM 的声明式描述就够用。Markdown 输出是带行首前缀、空行规则、mark 嵌套顺序的文本流,「段落之间空一行但最后一个段落后面不空」这种约束声明式表达不了,所以必须有一个显式状态机:closed 推迟换行,delim 累积行首前缀,active mark 栈管理开闭顺序。

失败策略也不一样。DOMParser 尽量修复,mark 加不上就丢 mark,节点缺包装就补包装,目标是尽力读出内容。Markdown 通路的默认行为是抛错,addNode 那里静默丢弃已经是它最宽容的部分。

还有一个对称性问题。DOM 通路的序列化和解析共用 schema 上的声明,toDOM 和 parseDOM 写在同一个节点上,天然容易保持一致。Markdown 通路的两张表分开维护:序列化表里 code_block 的 fence 逻辑是一套代码,解析表里 fence 到 code_block 的映射是另一套代码,两边的一致性没有任何机制保证。这个包的测试用同一个「文本、文档」对同时断言两个方向,parse(text) 要等于文档,serialize(doc) 要等于文本,相当于人工维护 round-trip 的一致性。

两种接法的取舍

什么场景走哪条通路比较清楚。粘贴板和 HTML 导入走 DOMParser,输入来自别的网页或富文本工具,结构不可控,修复能力刚好用上,而且 schema 自带规则,不用额外配置。Markdown 作为存储和交换格式走 prosemirror-markdown,比如文档存成 .md 进 git,或者和评论系统、静态站点生成器对接。这时输入输出都受 CommonMark 约束,显式的两张表换来的是可预期:输出长什么样由你的 nodes/marks 表完全决定,不依赖 schema 作者 toDOM 写得如何。

维护成本要算清楚:自定义节点在 Markdown 通路下是三处同步,schema 定义、序列化表、解析表,漏任何一处都是运行时才暴露。实际做法是把 defaultMarkdownSerializer 的 nodes、marks 和 defaultMarkdownParser 的 tokens 各展开复制一份再改,这几张表都是构造参数传进去的普通对象,parser 一侧的注释也明说 tokens 字段就是留给人复制修改的。tokenizer 可以换成开了插件的 markdown-it 实例,比如表格、删除线插件,对应地在 tokens 表里加映射、在 marks 表里加开闭串,这是这条通路扩展新语法的方式。

下一篇看 search,一个纯 view 层的查找替换插件。


1300 字 · 53 段落
xi ming

Written by xi mingFollow onGitHub