Files
mnote/design/03-rust-web/done/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md
T

464 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 当前问题
本地链路现在分成两段:
1. `local_folder_source.rs` 负责读取 `.md`、拆 frontmatter、把正文转成 block。
1. `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
优点:
- 改动小,能快速补洞。
缺点:
- 语法越来越多时会失控。
- 读写规则难以保证一致。
- 这条路本质上还是重复造轮子。
### 方案 BRust 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 的稳定语义:
- blockparagraph、heading、task_item、list_item、quote、code_block、divider、table、media。
- inlinetext + markscode、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 选型与边界冻结
- [x] 确认 Rust 侧 Markdown AST 解析器选型。
- [x] 明确是否需要 GitHub Flavored Markdown 全集,还是只保留当前本地链路所需的 GFM 子集。
- [x] 冻结 frontmatter 处理方式:解析器只解析正文,还是 frontmatter 也由同一模块统一处理。
- [x] 冻结 AST 输出到中间 IR 的边界,不直接在 AST 层拼 `PageAggregate`
- [x] 明确 table、task list、inline mark、fenced code、attachment link 的优先级。
- [x] 明确不支持语法的降级策略,要求可解释且可测试。
### 11.2 AST -> 中间 IR
- [x] 建立 `MarkdownAstDocument` / 等价中间结构。
- [x] 建立 block 级节点映射:paragraph / heading / list / task / quote / code / divider / table / media。
- [x] 建立 inline 级节点映射:text / code / bold / italic / strike / underline / link。
- [x] 保证 task list 的 checked 状态进入正确的 task item 节点,而不是外层 list 节点。
- [x] 保证表格单元格内的 inline mark 不丢失。
- [x] 保证空段落、空表格单元格、空引用块的处理规则固定。
- 追加测试 `markdown_empty_blockquote_parse` / `markdown_table_empty_cells_parse` 固定当前 comrak 行为
- [x] 统一处理标题优先级:frontmatter title > H1 > 文件名。
### 11.3 中间 IR -> PageAggregate / block document
- [x] 把 AST 到 `PageAggregate.body.content` 的映射集中到单一模块。
- [x] 消除 `local_folder_source.rs` 里散落的手写语法分支。
- [x] 让 block document 输出保持与 `web_shell.rs` 兼容。
- [x] 保留 block id 稳定性,避免刷新后整篇文档重建导致的局部状态抖动。
- [x] 保留 frontmatter 原文和页面设置写回边界。
- [x] 对 unsupported 节点采用明确降级,而不是静默丢语义。
### 11.4 Inline mark 双向转换
- [x] `code` 解析为 inline code mark。
- [x] `bold` 解析为 strong mark。
- [x] `italic` 解析为 emphasis mark。
- [x] `strike` 解析为 strike mark。
- [x] `link` 解析为带 href 的 link mark。
- [x] 保存回写时按同一映射反向输出 Markdown。
- [x] 确保 table cell 内的 inline mark 在读写两端都保留。
- [x] 确保 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]` / `- [X]` 进入 checked task item。
- [x] `- [ ]` 进入未勾选 task item。
- [x] task item 文本内容走 inline IR,不走纯文本拼接。
- [x] Tiptap 的 `taskList` / `taskItem` 与 legacy `todo` 对应关系固定。
- [x] 保存回写时 task item 重新输出为标准 Markdown checkbox 语法。
- [x] 浏览器 smoke 断言 checked / unchecked 两种状态都出现。
### 11.6 Table 处理
- [x] 统一 pipe table 解析规则。
- [x] 单元格内容通过 inline IR 输出,不再只保留纯文本。
- [x] 表头 / 普通单元格类型在 AST 映射中保持稳定。
- [x] 保存回写时保留表格分隔行和列数对齐。
- [x] 列对齐、空单元格、带 mark 单元格的降级策略要写入测试。
### 11.7 保存回写
- [x] 保存链路只依赖 block document / editor document,不依赖 Markdown 手写规则。
- [x] 前端编辑器保存路径仍能写回 `.md`
- [x] task list、inline mark、table 的回写结果可再次被 AST 解析器读回。
- [x] 保留 frontmatter `title` / `mnote_id` / 页面设置写回逻辑。
- [x] 保存失败时保留编辑器状态并给出可解释错误。
### 11.8 回归测试
- [x] `local_markdown` 测试组覆盖基础块、task list、inline marks、table、附件链接。
- [x] 新增 AST 解析器单测,验证解析结果和当前手写 parser 预期一致或更强。
- [x] 新增保存回写单测,验证 round-trip 不丢 task / marks。
- [x] 新增 web shell 单测,验证 legacy <-> Tiptap 转换不丢 marks。
- `document_shell_bootstrap_preserves_inline_mark_conversion` 已扩展断言 bold/italic/underline/strike/code/link
- [x] 新增浏览器 smoke,验证 checkbox、code、strong、em、strike、link、table cell marks。
- [x] 确认本地 `.md` 读写 smoke 不影响 file tree / page tree / page aggregate 路由。
### 11.9 清理与迁移收尾
- [x] 把手写 parser 标记为过渡实现。
- `local_folder_source.rs``inline_nodes_to_markdown``editor_blocks_to_markdown_for_file``editor_blocks_to_markdown_with_rewrite` 均已添加中文过渡注释
- [x] 移除 `local_folder_source.rs` 中不再需要的临时解析函数。
- [x] 复核 `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 单测不再断言这些函数时,再单独删除。
- [x] 保留回归测试和兼容层,不删除验证资产。
- [x] 迁移完成后把设计稿状态从 `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` 分支保留为兼容层。