
marktext 列表语法 Round-Trip 测试夹具详解从 Lists.md 看 muya 的 CommonMark 列表解析与序列化【免费下载链接】marktextA simple and elegant markdown editor, available for Linux, macOS and Windows.项目地址: https://gitcode.com/gh_mirrors/ma/marktextLists.md 是 muyamarktext 的核心编辑器引擎位于packages/muyaRound-Trip 测试套件中专门针对列表Lists语法的测试夹具文件。它不是一个空泛的示例集合而是一组精心构造的探针覆盖无序列表、有序列表、嵌套缩进、混合项目符号、空列表项、注释分隔、lazy continuation 以及已知失败用例。通过本文你将理解 muya 如何把 Markdown 解析为内部状态树、再序列化回 Markdown 时如何保证收敛稳定以及这份夹具中每个片段背后的 CommonMark 语义与源码实现。一、什么是 Round-Trip 测试Lists.md 在其中扮演什么角色Round-Trip往返测试的核心思路是把一段 Markdown 文本送入解析器得到内部状态树再交给序列化器重新生成 Markdown然后断言第一遍输出与第二遍输出收敛一致即满足幂等性。muya 的这套测试位于 roundTrip.spec.ts其夹具目录为 packages/muya/test/spec/fixtures/marktext-round-trip包含common与gfm两组common/下是纯 CommonMark 语法夹具BasicTextFormatting、Blockquotes、CodeBlocks、Escapes、Headings、Images、Links、Listsgfm/下是 GFM 扩展夹具BasicTextFormatting、Lists、Tables。Lists.md对应的测试条目为common / Lists执行逻辑如下读取夹具原文用MarkdownToState生成状态树用StateToMarkdown({ listIndentation: 1 })重新序列化对输出再执行一次同样流程断言两次输出的归一化结果相等isStableUnderRoundTrip。代码中特别说明了两点设计取舍其一不做逐字节严格相等因为序列化器总是追加一个结尾换行、且列表缩进策略与旧版 marktext 的ExportMarkdown规范选择不同其二归一化函数刻意不剔除行尾空白因为两个尾随空格是 CommonMark §6.7 的硬换行标记剥离它会掩盖真实的往返不稳定问题。因此Lists.md 的定位是收敛稳定性契约夹具中列出的每一种列表写法都必须保证md → state → md → state → md收敛到同一结果这是 muya 编辑器源码模式与所见即所得模式来回切换不丢内容的底层保障。二、Lists.md 核心场景逐段解读1. 星号无序列表与编号自动延续夹具开篇是最基础的无序列表* an asterisk starts an unordered list * and this is another item in the list * and this is another item in the list紧随其后是有序列表的关键语义——编号由解析器自动续算与源码中的数字无关1. this starts a list *with* numbers 2. this will show as number 2 3. this will show as number 3. 4. any number, , -, or * will keep the list going.这段同时验证两个语义列表项内可以包含内联强调*with*编号1./2./3./4.之间有空行时列表依然保持连续CommonMark 中这是松散列表并且任何数字都能启动/延续列表。最后一行直接点名any number, , -, or * will keep the list going——即只要前面的标记是同一种有序分隔符源码写9.也会被渲染成递增编号。从源码看这个启动编号被记录在状态元数据里markdownToState.ts 的case list分支中start: /^\d$/.test(String(start)) ? Number(start) : 1即数字形式如9.取该数字为起点非数字形式回退为1同时sourceMarkers会记录每个列表项源码中的原始编号如4.。序列化端stateToMarkdown.ts 的_serializeListItem则优先消费sourceMarkers保持原样输出只有当列表发生插入、删除或重排、无法再与源码标记一一对应时才回退为从start递增编号。2. 四空格或 Tab嵌套子列表1. this starts a list *with* numbers 2. this will show as number 2 3. this will show as number 3. 4. any number, , -, or * will keep the list going. * just indent by 4 spaces (or tab) to make a sub-list 1. keep indenting for more sub lists * here im back to the second level这里验证 CommonMark 的缩进嵌套规则缩进 4 个空格或一个 Tab即可在当前列表项下创建子列表而且无序/有序可以在任意层级混合4.下挂*子列表*下再挂1.子子列表缩进级别决定层级* here im back to the second level表示撤销缩进即可回到上一级。值得注意夹具原文中的 HTML 注释!-- Strange indentation --放在两个有序列表块之间这是为了验证注释不会干扰列表的连续性判定——它把编号自动延续的场景切分为两段独立列表进行考察。3. 括号分隔符1)形式1) this starts a list *with* numbers 2) this will show as number 2 3) this will show as number 3CommonMark 允许有序列表使用)作为分隔符1.之外还有1)。这段夹具验证)分隔符同样具备编号自动续算能力且与.分隔符属于不同标记——在 markdownToState.ts 中delimiter: bulletMarkerOrDelimiter || .会记录实际分隔符而 stateToMarkdown.ts 的_serializeListBlock会根据lastListBullet ! bulletMarkerOrDelimiter决定是否需要插入空行来拆分列表——分隔符不同的两个有序列表会被当作两个独立列表处理。4. 深层嵌套四层与五层缩进- foo - bar - baz - boo与更复杂的五层结构- a - b - c - d - e - f - g - h - i前者验证同 marker-的连续四层嵌套后者验证缩进层级跳跃d下既有e再缩进一层又有f与d同级g回到c同级h回到b同级i回到顶层。这是对缩进深度即层级规则的极限压力测试往返一次必须保持层级完全不变。序列化端对缩进的处理在 stateToMarkdown.ts 的_serializeListItem中有详细注释子列表的缩进是在父列表项内容列的基础上再叠加一个缩进级别且数字缩进模式listIndentation为数值下N表示相对内容列的额外空格数——以-标记宽 2 列为例N1产生 2 列CommonMark 允许的最小嵌套N4产生 5 列只有dfmDaring Fireball Markdown模式才强制绝对 4 列嵌套。构造器中的校验Math.min(Math.max(listIndentation, 1), 4)把数字模式限制在 14 之间。5. 空项目符号- foo/-/- bar- foo - - bar单独一个-后面无内容是合法的空列表项。这段夹具被重复出现两次说明它是往返稳定性的重点关注对象。在序列化端stateToMarkdown.ts 的_startsWithEmptyDashBulletItem专门检测以空-项开头的列表其目的注释指向 CommonMark 264空 bullet 项之后紧跟无缩进段落时可能被解析成 setext 标题-恰好是 setext 下划线---的一部分因此序列化器在特定场景下会把-临时替换为SETEXT_SAFE_BULLET_MARKER以避免歧义。而空列表项本身在序列化时走if (!children.length) return ${indent}${itemMarker}\n;即输出裸标记加换行。6. 空 HTML 注释分隔连续的同类型列表CommonMark 示例 270夹具中用 TODO 标注了这一场景的意图**TODO:** Empty comments should not be displayed as HTML in preview mode because they may be used to separate consecutive lists of the same type (CM Example 270). - foo - bar !-- -- - baz - bim两个同为-标记的紧凑列表仅靠一个空 HTML 注释!-- --分隔。按 CommonMark 语义它们应被解析为同一个列表的延续因此注释在预览模式中不应渲染为可见的 HTML 元素。这段夹具直接引用 CommonMark 规范示例 270用于验证 muya 对注释仅作分隔符、不打断同类型列表的处理。7. Lazy 写法-one与2.two标记后无空格-one 2.two这里没有空格紧接文本。按 CommonMark 规则-one会被解析为普通段落而非列表项列表标记后必须跟空白2.two同理。夹具用这种陷阱写法验证解析器不会把无空格的-/数字.误判为列表保证往返后依然是段落而不是凭空产生列表。8. 同一列表中混合多种无序标记- foo - bar baz * foobar * quxCommonMark 中同一个无序列表内允许混合-、、*三种标记而不打断列表渲染层面 marker 不影响列表归属。后续夹具依次强化这一语义1. foo 2. bar 4) baz有序列表中混合.与)分隔符4) baz作为第三项编号从 1 递增到 3以及1. foo 2. bar 1) baz同一个有序列表中从.切换到)且源码编号倒退1)验证编号由解析器统一递增而非信任源码。随后还有无序标记三连- foo - bar foobar baz- foo - bar * foobar * baz- foo - bar * foobar * baz qux quux分别覆盖-→、-→*、-→*→的混合场景以及跨类型混合- foo - bar 1. foobar 2. baz1. foo 2. bar - foobar - baz1. foo 2. bar 1) foobar 2) baz覆盖无序中插入有序有序中插入无序有序中.→)切换。从实现看markdownToState.ts 通过token.items[0].bulletMarkerOrDelimiter记录列表首个项的标记而序列化端_serializeListBlock中lastListBullet ! bulletMarkerOrDelimiter的判断正是为了在这些不同 marker 但属于同一列表的场景下不插入多余空行若插入空行下一次解析会把列表变松散破坏往返收敛。9. Failing Tests已知失败与回归锁定夹具末尾专门列出四段当前实现尚未完全通过的用例这是整份文件信息量最大的部分第一段是宽松列表 混合编号1. this starts a list *with* numbers this will show as number 2 * this will show as number 3. 9. any number, , -, or * will keep the list going. * just indent by 4 spaces (or tab) to make a sub-list 1. keep indenting for more sub lists * here im back to the second level问题在于1.与、*的分隔符风格不同.vs 空且/*后跟了两个空格加上9.与1.的编号跳跃、4 空格与 8 空格的混合缩进往返后无法保持原样。第二段是2 空格缩进子列表1. this starts a list *with* numbers this will show as number 2 * this will show as number 3. 9. any number, , -, or * will keep the list going. * just indent by 2 spaces to make a sub-list 1. keep indenting for more sub lists * here im back to the second level核心分歧在于CommonMark 规定嵌套子列表至少需要缩进到标记后内容列1.宽 3 列子列表需缩进 3 列以上而 2 空格缩进在 CommonMark 下不足以构成嵌套但旧版 marktext 的解析曾接受 2 空格嵌套。往返时解析与序列化对2 空格是否算嵌套口径不一致导致不稳定。第三段引用CommonMark 示例 266Foo - bar - baz这是 lazy continuation 的经典反例紧跟在段落文本Foo之后的- bar会被解析为列表项而Foo成为该列表项之前的段落。往返中段落与列表的边界处理是该场景的难点。第四段是裸-被补成-- foo - - bar裸-在解析后成为空段落项重新序列化时输出-带尾随空格。而前面提到归一化函数刻意保留行尾空格硬换行标记语义因此这个差异会被测试暴露出来——这正是夹具把该片段同时放在正常用例和Failing Tests两处的原因它验证的是稳定性第二遍输出与第一遍一致而非与原文逐字节相等。这些预期失败用例并非放任不管而是由 expected-failures.json 与 runner.ts 构成的回归锁定机制管理其中登记了 CommonMark 与 GFM 规范用例的编号凡登记项被断言为仍然失败一旦某个用例意外通过测试会报告 unexpected pass 要求从清单中移除——合规只能上升不能回退。三、GFM 扩展任务列表Task List与 CommonMark 列表并行GFM 夹具 gfm/Lists.md 覆盖任务列表语法三种 marker 各一组- [ ] this is not checked - [ ] this is too - [x] but this is checked* [x] this is checked * [ ] this is not checked * [x] but this is checked [ ] this is not checked [ ] this is too [x] but this is checked实现上markdownToState.ts 的case list分支对listType task生成task-list状态meta 携带marker与loose对任务列表项生成task-list-item状态meta 携带checked布尔值与orderMarker。渲染端 taskList/index.ts 将任务列表渲染为带mu-task-list类名的ul紧凑列表额外加mu-tight-list类taskListItem/index.ts 渲染li classmu-task-list-item并内嵌复选框附件块checked属性的读写会通过jsonState.replaceOperation记录为可撤销的历史操作。序列化端则在_serializeListItem中追加[x]/[ ]前缀恢复源码形式。此外 taskList/index.ts 还实现了autoMoveCheckedToEnd选项——勾选项自动移动到列表末尾。四、如何运行与验证Round-Trip 测试与 CommonMark/GFM 规范测试共用 vitest# 在 packages/muya 目录下运行全部单元测试 pnpm test # 仅运行 round-trip含 common / Lists 条目 pnpm vitest run test/spec/roundTrip.spec.ts # 运行 CommonMark / GFM 规范符合性套件 pnpm test:spec pnpm test:spec:commonmark pnpm test:spec:gfm其中规范套件使用独立的 vitest.spec.config.tshappy-dom 环境超时放宽到 30 秒以容纳 670 个it.each用例脚本定义在 packages/muya/package.json 中。若你要修改列表解析或序列化逻辑请以common / Lists、GFM / Lists两个夹具的收敛性为准绳同时确保 expected-failures.json 中登记的失败清单没有出现 unexpected pass。五、总结Lists.md 的价值Lists.md 表面是一份测试样例实际上是 marktext/muya 列表引擎的语法契约总表解析侧markdownToState.ts确定列表类型order/task/bullet、loose属性、起始编号start、分隔符delimiter/marker、以及每个列表项的sourceMarkers与orderMarker序列化侧stateToMarkdown.ts决定缩进模式数字 14 或 dfm 4 列、编号回退策略、空 bullet 项的 setext 歧义规避、同类型相邻紧凑列表不插入空行测试侧roundTrip.spec.ts runner.ts expected-failures.json用收敛稳定性 失败清单锁定双重机制守护这些语义不因重构而回归。理解这份夹具就等于理解了 marktext 在源码模式 ↔ 所见即所得模式切换时为什么能做到列表的缩进、编号、marker、层级乃至任务勾选状态都不丢失——这正是 muya 编辑器状态树设计md → state → md双向转换在列表这一高频语法上的完整实践样本。【免费下载链接】marktextA simple and elegant markdown editor, available for Linux, macOS and Windows.项目地址: https://gitcode.com/gh_mirrors/ma/marktext创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考