# 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`

## 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 的所有语义一起重写。

## 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 选型与边界冻结

- [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` 分支保留为兼容层。
