18 KiB
3-13 [done] Rust Web 本地 Markdown GFM AST 解析器迁移方案 v1
更新时间:2026-05-09
归档时间:2026-05-21
归档说明:GFM AST 读侧、写侧 round-trip、表格/mark/任务列表/保存失败状态保留的回归证据已补齐;
web_shell.rs的 legacy marks 适配分支已改判为当前兼容层,不再作为阻塞归档的“必须移除”项。关联:
/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/local_folder_source.rs/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/local_markdown_parser.rs/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs/mnt/Data1T/mnote/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md
1. 结论
当前本地 Markdown 渲染链路已经进入“读侧 AST、写侧仍在过渡”的 partial 状态:
- 读侧已新增 Rust
local_markdown_parser.rs,并基于成熟 GFM AST 解析常见语法 - 写侧回 Markdown 仍主要依赖
local_folder_source.rs里的手写规则
因此当前问题已经不再是“要不要迁到 AST”,而是“如何把剩余写侧与兼容补丁继续收口”。
本方案当前聚焦的是:继续把 AST 映射到现有 PageAggregate / Tiptap bridge,并让剩余手写 parser/回写逻辑逐步退回过渡层,不再作为长期语义来源。
2. 当前问题
本地链路现在分成两段:
-
local_folder_source.rs负责读取.md、拆 frontmatter、把正文转成 block。 -
web_shell.rs负责把 legacy block 转成 Tiptap,再把 Tiptap 保存回 legacy block。
问题不在 Tiptap 本身,而在前面的 Markdown 语义输入过窄:
-
task list、inline code、bold、italic、strike、link 之前会被吞成纯文本。
-
table 只做了最低限度兼容。
-
解析逻辑是手写规则,扩展语法时会不断叠条件分支。
-
读写两端各自维护一份近似规则,容易出现回写不一致。
3. 目标
-
用 Rust 侧成熟 GFM AST 解析器替换手写 Markdown parser。
-
保留当前已验证的本地文件夹读写能力。
-
让 Markdown 语法来源尽量统一,减少“读得懂、写不回”的差异。
-
继续支持当前已暴露的语法面:paragraph、heading、task list、list、quote、fenced code、divider、pipe table、attachment link、inline code、bold、italic、strike、link。
-
不改变
PageAggregate、树 projection、权限、路由边界。
4. 非目标
-
不在这一步追求“所有 Markdown 语法无损互转”。
-
不把 VSCode 源码直接搬进来。
-
不把 Next/Wolai 导入链路里的
@blocknote/core markdownToBlocks作为本地 source 真相。 -
不把 tree/domain、Convex 或 editor island 的所有语义一起重写。
4.5 本地文件 AI 工具接入(关联 7-14)
按 7-14,本地
.md文件应通过mnote.doc.fetch(format: "markdown")供 AI 读取,通过mnote.doc.markdown_edit供 AI 写入。本方案的 GFM AST → markdown 序列化层是 AI 写入本地文件的关键依赖。
- AI 读取:
mnote.doc.fetch的source检测到本地文件路径时,直接返回.md原文(不经 AST 解析)。 - AI 写入:
mnote.doc.markdown_edit对本地文件执行 markdown 级搜索替换后,通过本方案的 markdown 序列化层写回文件系统。 - 不要求本地文件维护 blockId。AI 编辑基于文本,不基于块结构。
- 本地文件的 AI 写入适配器(
LocalFSAdapter)复用local_folder_source.rs的写链路。
本接入点不改变本方案的解析/序列化核心设计,仅在其上增加 AI 工具消费层。
5. 方案比较
方案 A:继续扩写手写 parser
优点:
- 改动小,能快速补洞。
缺点:
-
语法越来越多时会失控。
-
读写规则难以保证一致。
-
这条路本质上还是重复造轮子。
方案 B:Rust GFM AST + 语义映射层
优点:
-
语法基础来自成熟实现,降低长期维护成本。
-
AST 天然适合做 block / inline 双向映射。
-
更容易补齐 task list、table、inline mark 等常见 GFM 特性。
缺点:
-
需要补一层 AST 到
PageAggregate的适配。 -
初期需要做迁移和回归测试。
方案 C:复用前端/导入侧解析器
优点:
- 可以少写部分逻辑。
缺点:
-
本地文件夹主链是 Rust 服务,不适合把语义真相放回 JS 侧。
-
导入链路和本地打开链路不是同一职责。
推荐
选方案 B。它最符合当前架构:语义在 Rust 侧收口,前端只消费稳定 projection。
6. 架构设计
6.1 解析层
新增一个本地 Markdown 解析模块,职责只做三件事:
-
读取 Markdown 文本并构建 GFM AST。
-
归一化 frontmatter、正文、块级节点、行内节点。
-
输出可供
PageAggregate使用的中间表示。
建议把手写字符串规则替换成“AST -> 中间 IR -> block”的两段式转换,而不是直接从 AST 拼最终 JSON。
6.2 中间 IR
建议引入一个轻量中间层,表达 block / inline 的稳定语义:
-
block:paragraph、heading、task_item、list_item、quote、code_block、divider、table、media。
-
inline:text + marks(code、bold、italic、strike、underline、link)。
这样做的目的不是再造一套编辑器模型,而是把 Markdown 语义和 Tiptap/legacy block 解耦。
6.3 输出层
输出层仍然保留两条现有通路:
-
读入时:Markdown AST -> block document ->
PageAggregate.body.content -
保存时:editor document -> block document -> Markdown
web_shell.rs 里的 legacy / Tiptap 转换仍然存在,但它应该只负责编辑器适配,不再承担 Markdown 语法识别。
6.4 语法优先级
第一批必须稳定支持:
-
task list
-
inline code
-
bold / italic / strike
-
link
-
pipe table
-
fenced code
第二批再扩:
-
nested list
-
setext heading
-
autolink
-
footnote
-
raw HTML
-
block quote 多段落
7. 迁移步骤
Step 1:引入 AST 解析器
先把当前自写 parser 包进一个兼容接口,替换为 Rust GFM AST 实现。
要求:
-
读入结果和现在的
PageAggregate输出保持兼容。 -
已有 smoke 和 Rust 单测先不改大结构。
Step 2:统一 block / inline 映射
把 AST 到 block document 的映射集中到一个明确模块,不再散在 local_folder_source.rs 大文件里。
要求:
-
task list 变成真正的 todo / task item 语义。
-
inline mark 进入统一的 marks 表达。
-
table cell 里的 mark 也能保留。
Step 3:统一回写
把 block document -> Markdown 的输出改成与 AST 语义对齐的反向映射。
要求:
-
task list 回写为
- [x]/- [ ]。 -
行内 mark 回写为标准 Markdown。
-
不支持的结构明确降级,不静默丢内容。
Step 4:清理临时补丁
当 AST 迁移完成后,删除当前手写 parser 的临时补丁代码,保留测试和兼容层。
8. 测试计划
Rust 单测
至少覆盖:
-
task list 读写往返。
-
inline code / bold / italic / strike / link 往返。
-
table 单元格内 inline mark。
-
frontmatter title 保留。
-
未支持语法的降级策略。
浏览器 smoke
至少覆盖:
-
本地
.md打开后 checkbox 真正出现在 ProseMirror DOM。 -
code、strong、em、s、a在正文和表格单元格中真实渲染。 -
保存后刷新不丢语义。
回归边界
-
不破坏
file_tree/page_tree。 -
不改变 local workspace 的只读边界。
-
不影响 Convex workspace 链路。
9. 风险
-
GFM AST 到 block 的映射会比手写 parser 更清晰,但初期要补一轮语义归一化。
-
复杂 Markdown 结构不可能天然无损,必须明确哪些语法是支持、哪些是降级。
-
如果 AST 解析器选型过于底层,后续表格、任务列表和 inline mark 的兼容代码会变多,所以要优先选能直接拿到 AST 的成熟实现。
10. 完成判定
这个方案进入实现阶段的条件:
-
解析器选型明确。
-
AST -> block / inline 映射接口冻结。
-
当前手写 parser 的临时补丁有对应回归测试。
-
浏览器 smoke 能稳定证明 task list 和 inline mark 真正渲染出来。
11. 详细 checklist
11.1 选型与边界冻结
-
确认 Rust 侧 Markdown AST 解析器选型。
-
明确是否需要 GitHub Flavored Markdown 全集,还是只保留当前本地链路所需的 GFM 子集。
-
冻结 frontmatter 处理方式:解析器只解析正文,还是 frontmatter 也由同一模块统一处理。
-
冻结 AST 输出到中间 IR 的边界,不直接在 AST 层拼
PageAggregate。 -
明确 table、task list、inline mark、fenced code、attachment link 的优先级。
-
明确不支持语法的降级策略,要求可解释且可测试。
11.2 AST -> 中间 IR
-
建立
MarkdownAstDocument/ 等价中间结构。 -
建立 block 级节点映射:paragraph / heading / list / task / quote / code / divider / table / media。
-
建立 inline 级节点映射:text / code / bold / italic / strike / underline / link。
-
保证 task list 的 checked 状态进入正确的 task item 节点,而不是外层 list 节点。
-
保证表格单元格内的 inline mark 不丢失。
-
保证空段落、空表格单元格、空引用块的处理规则固定。
- 追加测试
markdown_empty_blockquote_parse/markdown_table_empty_cells_parse固定当前 comrak 行为
- 追加测试
-
统一处理标题优先级:frontmatter title > H1 > 文件名。
11.3 中间 IR -> PageAggregate / block document
-
把 AST 到
PageAggregate.body.content的映射集中到单一模块。 -
消除
local_folder_source.rs里散落的手写语法分支。 -
让 block document 输出保持与
web_shell.rs兼容。 -
保留 block id 稳定性,避免刷新后整篇文档重建导致的局部状态抖动。
-
保留 frontmatter 原文和页面设置写回边界。
-
对 unsupported 节点采用明确降级,而不是静默丢语义。
11.4 Inline mark 双向转换
-
code解析为 inline code mark。 -
bold解析为 strong mark。 -
italic解析为 emphasis mark。 -
strike解析为 strike mark。 -
link解析为带 href 的 link mark。 -
保存回写时按同一映射反向输出 Markdown。
-
确保 table cell 内的 inline mark 在读写两端都保留。
-
确保 legacy block -> Tiptap -> legacy block 不再丢 marks。
document_shell_bootstrap_preserves_inline_mark_conversion已断言 bold/italic/underline/strike/code/link 全部 6 种 core marks
11.5 Task list 双向转换
-
- [x]/- [X]进入 checked task item。 -
- [ ]进入未勾选 task item。 -
task item 文本内容走 inline IR,不走纯文本拼接。
-
Tiptap 的
taskList/taskItem与 legacytodo对应关系固定。 -
保存回写时 task item 重新输出为标准 Markdown checkbox 语法。
-
浏览器 smoke 断言 checked / unchecked 两种状态都出现。
11.6 Table 处理
-
统一 pipe table 解析规则。
-
单元格内容通过 inline IR 输出,不再只保留纯文本。
-
表头 / 普通单元格类型在 AST 映射中保持稳定。
-
保存回写时保留表格分隔行和列数对齐。
-
列对齐、空单元格、带 mark 单元格的降级策略要写入测试。
11.7 保存回写
-
保存链路只依赖 block document / editor document,不依赖 Markdown 手写规则。
-
前端编辑器保存路径仍能写回
.md。 -
task list、inline mark、table 的回写结果可再次被 AST 解析器读回。
-
保留 frontmatter
title/mnote_id/ 页面设置写回逻辑。 -
保存失败时保留编辑器状态并给出可解释错误。
11.8 回归测试
-
local_markdown测试组覆盖基础块、task list、inline marks、table、附件链接。 -
新增 AST 解析器单测,验证解析结果和当前手写 parser 预期一致或更强。
-
新增保存回写单测,验证 round-trip 不丢 task / marks。
-
新增 web shell 单测,验证 legacy <-> Tiptap 转换不丢 marks。
document_shell_bootstrap_preserves_inline_mark_conversion已扩展断言 bold/italic/underline/strike/code/link
-
新增浏览器 smoke,验证 checkbox、code、strong、em、strike、link、table cell marks。
-
确认本地
.md读写 smoke 不影响 file tree / page tree / page aggregate 路由。
11.9 清理与迁移收尾
-
把手写 parser 标记为过渡实现。
local_folder_source.rs中inline_nodes_to_markdown、editor_blocks_to_markdown_for_file、editor_blocks_to_markdown_with_rewrite均已添加中文过渡注释
-
移除
local_folder_source.rs中不再需要的临时解析函数。 -
复核
web_shell.rs中 legacy marks 适配分支。- 结论:
legacyStylesToTiptapMarks/legacyMarkArrayToTiptapMarks仍被legacyInlineContentToTiptap与document_shell_bootstrap_preserves_inline_mark_conversion覆盖,用于兼容历史 legacy block bootstrap;当前不安全移除。 - 退出条件:当 Rust 侧 Page Aggregate / block document 原生输出完全替代 legacy block bootstrap,且对应 web shell 单测不再断言这些函数时,再单独删除。
- 结论:
-
保留回归测试和兼容层,不删除验证资产。
-
迁移完成后把设计稿状态从
process移到done。
12. 审计记录(2026-05-21 Batch B Worker D)
12.1 已完成确认
以下 [x] 项经代码审查确认实际完成,证据充分:
- 11.1 全部 6 项:comrak 选型完成,GFM 子集确定,frontmatter 处理方式冻结,IR 边界明确,优先级确定,降级策略明确
- 11.2
MarkdownAstDocument、block/inline 映射、task list checked 状态、表格 inline 串联全部完成 - 11.3 AST→PageAggregate 映射集中到
local_markdown_parser.rs,兼容性验证通过 - 11.4 code/bold/italic/strike/link 解析+回写全部完成,表格内 inline mark 有集成测试
- 11.5 task list 双向转换全部完成,有 round-trip 测试
- 11.7 前端保存路径写回
.md、round-trip 可读回、frontmatter 保留均有测试覆盖 - 11.8 本地 md 读写 smoke 不影响 file tree/page tree/page aggregate
12.2 追认修正
| 项 | 原标记 | 审计结论 | 说明 |
|---|---|---|---|
| 11.2 标题优先级 | [x] | 应改为 [ ] | parse_markdown_page 始终使用 file_stem_title(file_name),完全忽略 frontmatter title 和正文 H1。tree.rs:7307 测试显式断言 frontmatter title 不应出现。此为误标,需修正。 |
| 11.2 标题优先级(复检) | [ ] Batch D Worker B 已修复 | → [x] | parse_markdown_page 已改为 frontmatter title > H1 > filename 三层优先级。新增 8 个单元测试覆盖三层优先级、引号包裹、ATX closing marker;已更新旧测试预期值。详见 local_markdown_parser.rs。 |
| 11.6 列对齐标记 | [ ] | 可追认为 [x] | local_markdown_save_writes_table_alignment_markers 测试已验证列对齐 marker 保存回写。 |
12.3 仍保留 [ ] 项的状态
| 项 | 状态 | 说明 |
|---|---|---|
| 11.2 空段落/空表格单元格/空引用块 | [x] 关闭 | 已补 markdown_empty_blockquote_parse / markdown_table_empty_cells_parse 固定当前 comrak 行为。 |
| 11.4 legacy→Tiptap→legacy marks | [x] 关闭 | document_shell_bootstrap_preserves_inline_mark_conversion 已扩展断言 bold/italic/underline/strike/code/link 全部 core marks。 |
| 11.6 列对齐/空单元格/带 mark 单元格降级策略测试 | [x] 关闭 | 列对齐、表格内 inline mark、round-trip 与 colspan 降级策略已由 local_markdown_save_writes_table_inline_marks、local_markdown_save_round_trips_tiptap_table_marks、local_markdown_save_writes_table_alignment_markers、local_markdown_save_table_handles_colspan_degradation 覆盖。 |
| 11.7 保存失败状态保留 | [x] 关闭 | task486-local-markdown-save-error-editor-preserves-content-smoke.js 已切到当前 /documents 主链并复核通过,保存失败时编辑器内容与 runtime error 状态均保留。 |
| 11.8 web shell 单测 | [x] 关闭 | document_shell_bootstrap_preserves_inline_mark_conversion 已扩展断言 bold/italic/underline/strike 覆盖所有 core marks。 |
| 11.9 手写 parser 标记为过渡 | [x] 关闭 | inline_nodes_to_markdown / editor_blocks_to_markdown_for_file / editor_blocks_to_markdown_with_rewrite 均已添加中文过渡注释。 |
| 11.9 web_shell.rs 临时适配分支移除 | [x] 改判为兼容层保留 | JS 侧 legacyStylesToTiptapMarks 等适配函数仍存在(已加过渡注释 TODO step-4),且当前 web shell bootstrap 单测仍显式覆盖;删除动作不属于本迁移阻塞项。 |
| 11.9 process→done | [x] 关闭 | 以上尾项均已有代码证据或兼容层退出条件,本文移动到 done/。 |
13. 归档复核(2026-05-21 Batch F)
- Reasonix Worker A 超时,无有效
final.md/result.json;记录见.codex/reasonix-tasks/results/batch-f-worker-a-gfm-archive-timeout.md。 - Codex 本地复核确认:
local_markdown_save_table_handles_colspan_degradation已覆盖 GFM pipe table 不支持 colspan/rowspan 时的降级策略,要求文字不丢且不 panic。local_markdown_save_writes_table_inline_marks/local_markdown_save_round_trips_tiptap_table_marks已覆盖表格单元格内 inline mark 写回与读回。local_markdown_save_writes_table_alignment_markers已覆盖列对齐 marker 写回。document_shell_bootstrap_preserves_inline_mark_conversion仍覆盖 legacy styles/marks 到 Tiptap marks 的兼容转换,因此web_shell.rs分支保留为兼容层。