Implement local Markdown GFM parser and live refresh

This commit is contained in:
lix-2026
2026-05-08 10:57:24 +08:00
parent e082e71b5d
commit b183d24ba5
8 changed files with 1354 additions and 745 deletions
@@ -1,12 +1,6 @@
# 3-13 [process] Rust Web 本地 Markdown GFM AST 解析器迁移方案 v1
> 更新时间:2026-05-08
>
> 关联:
> - `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/local_folder_source.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`
> 更新时间:2026-05-08关联:`/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/local_folder_source.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. 结论
@@ -19,28 +13,39 @@
本地链路现在分成两段:
1. `local_folder_source.rs` 负责读取 `.md`、拆 frontmatter、把正文转成 block。
2. `web_shell.rs` 负责把 legacy block 转成 Tiptap,再把 Tiptap 保存回 legacy 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. 方案比较
@@ -48,31 +53,43 @@
### 方案 A:继续扩写手写 parser
优点:
- 改动小,能快速补洞。
缺点:
- 语法越来越多时会失控。
- 读写规则难以保证一致。
- 这条路本质上还是重复造轮子。
### 方案 BRust GFM AST + 语义映射层
优点:
- 语法基础来自成熟实现,降低长期维护成本。
- AST 天然适合做 block / inline 双向映射。
- 更容易补齐 task list、table、inline mark 等常见 GFM 特性。
缺点:
- 需要补一层 AST 到 `PageAggregate` 的适配。
- 初期需要做迁移和回归测试。
### 方案 C:复用前端/导入侧解析器
优点:
- 可以少写部分逻辑。
缺点:
- 本地文件夹主链是 Rust 服务,不适合把语义真相放回 JS 侧。
- 导入链路和本地打开链路不是同一职责。
### 推荐
@@ -86,7 +103,9 @@
新增一个本地 Markdown 解析模块,职责只做三件事:
- 读取 Markdown 文本并构建 GFM AST。
- 归一化 frontmatter、正文、块级节点、行内节点。
- 输出可供 `PageAggregate` 使用的中间表示。
建议把手写字符串规则替换成“AST -> 中间 IR -> block”的两段式转换,而不是直接从 AST 拼最终 JSON。
@@ -96,6 +115,7 @@
建议引入一个轻量中间层,表达 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 解耦。
@@ -105,6 +125,7 @@
输出层仍然保留两条现有通路:
- 读入时:Markdown AST -> block document -> `PageAggregate.body.content`
- 保存时:editor document -> block document -> Markdown
`web_shell.rs` 里的 legacy / Tiptap 转换仍然存在,但它应该只负责编辑器适配,不再承担 Markdown 语法识别。
@@ -114,19 +135,29 @@
第一批必须稳定支持:
- task list
- inline code
- bold / italic / strike
- link
- pipe table
- fenced code
第二批再扩:
- nested list
- setext heading
- autolink
- footnote
- raw HTML
- block quote 多段落
## 7. 迁移步骤
@@ -136,7 +167,9 @@
先把当前自写 parser 包进一个兼容接口,替换为 Rust GFM AST 实现。
要求:
- 读入结果和现在的 `PageAggregate` 输出保持兼容。
- 已有 smoke 和 Rust 单测先不改大结构。
### Step 2:统一 block / inline 映射
@@ -144,8 +177,11 @@
把 AST 到 block document 的映射集中到一个明确模块,不再散在 `local_folder_source.rs` 大文件里。
要求:
- task list 变成真正的 todo / task item 语义。
- inline mark 进入统一的 marks 表达。
- table cell 里的 mark 也能保留。
### Step 3:统一回写
@@ -153,8 +189,11 @@
把 block document -> Markdown 的输出改成与 AST 语义对齐的反向映射。
要求:
- task list 回写为 `- [x]` / `- [ ]`
- 行内 mark 回写为标准 Markdown。
- 不支持的结构明确降级,不静默丢内容。
### Step 4:清理临时补丁
@@ -168,9 +207,13 @@
至少覆盖:
- task list 读写往返。
- inline code / bold / italic / strike / link 往返。
- table 单元格内 inline mark。
- frontmatter title 保留。
- 未支持语法的降级策略。
### 浏览器 smoke
@@ -178,19 +221,25 @@
至少覆盖:
- 本地 `.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. 完成判定
@@ -198,89 +247,137 @@
这个方案进入实现阶段的条件:
- 解析器选型明确。
- 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 的优先级
- [ ] 明确不支持语法的降级策略,要求可解释且可测试。
- [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
- [ ] 建立 `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 不丢失
- [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 不丢失。
- [ ] 保证空段落、空表格单元格、空引用块的处理规则固定。
- [ ] 统一处理标题优先级:frontmatter title > H1 > 文件名。
- [x] 统一处理标题优先级: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 节点采用明确降级,而不是静默丢语义。
- [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 双向转换
- [ ] `code` 解析为 inline code mark。
- [ ] `bold` 解析为 strong mark。
- [ ] `italic` 解析为 emphasis mark。
- [ ] `strike` 解析为 strike mark。
- [ ] `link` 解析为带 href 的 link mark。
- [ ] 保存回写时按同一映射反向输出 Markdown。
- [ ] 确保 table cell 内的 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 在读写两端都保留。
- [ ] 确保 legacy block -> Tiptap -> legacy block 不再丢 marks。
### 11.5 Task list 双向转换
- [ ] `- [x]` / `- [X]` 进入 checked task item。
- [ ] `- [ ]` 进入未勾选 task item。
- [ ] task item 文本内容走 inline IR,不走纯文本拼接
- [ ] Tiptap 的 `taskList` / `taskItem` 与 legacy `todo` 对应关系固定。
- [ ] 保存回写时 task item 重新输出为标准 Markdown checkbox 语法
- [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 语法。
- [ ] 浏览器 smoke 断言 checked / unchecked 两种状态都出现。
### 11.6 Table 处理
- [ ] 统一 pipe table 解析规则。
- [ ] 单元格内容通过 inline IR 输出,不再只保留纯文本。
- [ ] 表头 / 普通单元格类型在 AST 映射中保持稳定
- [ ] 保存回写时保留表格分隔行和列数对齐。
- [x] 统一 pipe table 解析规则。
- [x] 单元格内容通过 inline IR 输出,不再只保留纯文本
- [x] 表头 / 普通单元格类型在 AST 映射中保持稳定。
- [x] 保存回写时保留表格分隔行和列数对齐。
- [ ] 列对齐、空单元格、带 mark 单元格的降级策略要写入测试。
### 11.7 保存回写
- [ ] 保存链路只依赖 block document / editor document,不依赖 Markdown 手写规则。
- [ ] 前端编辑器保存路径仍能写回 `.md`
- [ ] task list、inline mark、table 的回写结果可再次被 AST 解析器读回
- [ ] 保留 frontmatter `title` / `mnote_id` / 页面设置写回逻辑。
- [x] 保存链路只依赖 block document / editor document,不依赖 Markdown 手写规则。
- [x] 前端编辑器保存路径仍能写回 `.md`
- [x] task list、inline mark、table 的回写结果可再次被 AST 解析器读回。
- [x] 保留 frontmatter `title` / `mnote_id` / 页面设置写回逻辑。
- [ ] 保存失败时保留编辑器状态并给出可解释错误。
### 11.8 回归测试
- [ ] `local_markdown` 测试组覆盖基础块、task list、inline marks、table、附件链接。
- [ ] 新增 AST 解析器单测,验证解析结果和当前手写 parser 预期一致或更强。
- [ ] 新增保存回写单测,验证 round-trip 不丢 task / marks
- [x] `local_markdown` 测试组覆盖基础块、task list、inline marks、table、附件链接。
- [x] 新增 AST 解析器单测,验证解析结果和当前手写 parser 预期一致或更强
- [x] 新增保存回写单测,验证 round-trip 不丢 task / marks。
- [ ] 新增 web shell 单测,验证 legacy <-> Tiptap 转换不丢 marks。
- [ ] 新增浏览器 smoke,验证 checkbox、code、strong、em、strike、link、table cell marks。
- [ ] 确认本地 `.md` 读写 smoke 不影响 file tree / page tree / page aggregate 路由。
- [x] 确认本地 `.md` 读写 smoke 不影响 file tree / page tree / page aggregate 路由。
### 11.9 清理与迁移收尾
- [ ] 把手写 parser 标记为过渡实现。
- [ ] 移除 `local_folder_source.rs` 中不再需要的临时解析函数。
- [ ] 把手写 parser 标记为过渡实现。11
- [x] 移除 `local_folder_source.rs` 中不再需要的临时解析函数。
- [ ] 移除 `web_shell.rs` 中只为补丁存在的临时适配分支。
- [ ] 保留回归测试和兼容层,不删除验证资产。
- [ ] 迁移完成后把设计稿状态从 `process` 移到 `done`
- [x] 保留回归测试和兼容层,不删除验证资产
- [x] 迁移完成后把设计稿状态从 `process` 移到 `done`
+111
View File
@@ -270,6 +270,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
[[package]]
name = "caseless"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
dependencies = [
"unicode-normalization",
]
[[package]]
name = "cc"
version = "1.2.60"
@@ -355,6 +364,23 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "comrak"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aac0b255932a9cd52fbfd664b67957f9f2e095ae4711cb0e41b4e291edef94c2"
dependencies = [
"caseless",
"entities",
"finl_unicode",
"jetscii",
"phf",
"phf_codegen",
"rustc-hash",
"smallvec",
"typed-arena",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -555,6 +581,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "entities"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -605,12 +637,24 @@ dependencies = [
"core-domain",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "finl_unicode"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1148,6 +1192,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jetscii"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
[[package]]
name = "js-sys"
version = "0.3.95"
@@ -1421,6 +1471,7 @@ dependencies = [
"axum",
"base64",
"bridge-runtime",
"comrak",
"core-protocol",
"futures-util",
"leptos",
@@ -1510,6 +1561,45 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.11"
@@ -2195,6 +2285,12 @@ dependencies = [
"libc",
]
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.12"
@@ -2673,6 +2769,12 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "typed-arena"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
[[package]]
name = "typed-builder"
version = "0.23.2"
@@ -2705,6 +2807,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
+1
View File
@@ -22,4 +22,5 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
tower = "0.5"
base64 = "0.22"
comrak = { version = "0.52", default-features = false }
time = { version = "0.3", features = ["formatting"] }
@@ -520,7 +520,12 @@ pub async fn save(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
let result = save_local_markdown_page(root_uri, document_id, &body.content)?;
let result = save_local_markdown_page(
root_uri,
document_id,
body.conflict_detection_key.as_deref(),
&body.content,
)?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,671 @@
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
use comrak::{parse_document, Arena, Options};
use serde_json::{json, Map, Value};
#[derive(Debug, Clone)]
pub struct ParsedLocalMarkdownPage {
pub title: String,
pub mnote_id: Option<String>,
pub body: String,
}
#[derive(Debug, Clone)]
struct MarkdownAstDocument {
blocks: Vec<MarkdownBlock>,
}
#[derive(Debug, Clone)]
enum MarkdownBlock {
Paragraph(Vec<MarkdownInline>),
Heading {
level: u8,
content: Vec<MarkdownInline>,
},
Quote(Vec<MarkdownInline>),
CodeBlock {
language: String,
text: String,
},
Divider,
BulletListItem(Vec<MarkdownInline>),
NumberedListItem(Vec<MarkdownInline>),
Todo {
checked: bool,
content: Vec<MarkdownInline>,
},
Table {
alignments: Vec<TableAlignment>,
rows: Vec<MarkdownTableRow>,
},
Media {
name: String,
source_path: String,
},
}
#[derive(Debug, Clone)]
struct MarkdownTableRow {
is_header: bool,
cells: Vec<MarkdownTableCell>,
}
#[derive(Debug, Clone)]
struct MarkdownTableCell {
content: Vec<MarkdownInline>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MarkdownInline {
text: String,
styles: MarkdownInlineStyles,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct MarkdownInlineStyles {
bold: bool,
italic: bool,
strike: bool,
underline: bool,
code: bool,
link: Option<String>,
}
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
let (frontmatter, body) = split_frontmatter(markdown);
let title = frontmatter
.as_deref()
.and_then(|content| read_frontmatter_field(content, "title"))
.or_else(|| extract_first_h1_title(body))
.unwrap_or_else(|| file_stem_title(file_name));
let mnote_id = frontmatter
.as_deref()
.and_then(|content| read_frontmatter_field(content, "mnote_id"));
ParsedLocalMarkdownPage {
title,
mnote_id,
body: body.to_string(),
}
}
pub fn markdown_to_blocks(markdown: &str) -> Value {
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown))
}
pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> {
let value = trimmed
.strip_prefix('!')
.unwrap_or(trimmed)
.strip_prefix('[')?;
let (label, rest) = value.split_once("](")?;
let target = rest.strip_suffix(')')?.trim();
if target.is_empty()
|| target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with('#')
|| target.starts_with("mailto:")
{
return None;
}
let target_path = std::path::Path::new(target);
let extension = target_path.extension().and_then(|value| value.to_str())?;
if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") {
return None;
}
let fallback_name = target_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(target)
.trim();
let name = if label.trim().is_empty() {
fallback_name
} else {
label.trim()
};
Some((name.to_string(), target.to_string()))
}
fn parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument {
let arena = Arena::new();
let mut options = Options::default();
options.extension.table = true;
options.extension.tasklist = true;
options.extension.strikethrough = true;
options.extension.autolink = true;
options.extension.front_matter_delimiter = Some("---".to_string());
options.parse.tasklist_in_table = true;
let root = parse_document(&arena, markdown, &options);
let mut blocks = Vec::new();
for node in root.children() {
append_ast_block(node, &mut blocks);
}
MarkdownAstDocument { blocks }
}
fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
match node.data.borrow().value.clone() {
NodeValue::Paragraph => append_ast_paragraph(node, blocks),
NodeValue::Heading(heading) => blocks.push(MarkdownBlock::Heading {
level: heading.level,
content: collect_inline_children(node),
}),
NodeValue::BlockQuote => blocks.push(MarkdownBlock::Quote(collect_inline_children(node))),
NodeValue::ThematicBreak => blocks.push(MarkdownBlock::Divider),
NodeValue::CodeBlock(code_block) => blocks.push(MarkdownBlock::CodeBlock {
language: code_block.info.clone(),
text: code_block.literal.clone(),
}),
NodeValue::List(list) => {
for item in node.children() {
append_ast_list_item(item, list.list_type == ListType::Ordered, blocks);
}
}
NodeValue::Table(table) => blocks.push(ast_table_to_ir(node, table.alignments)),
NodeValue::HtmlBlock(html) => blocks.push(MarkdownBlock::Paragraph(vec![MarkdownInline {
text: html.literal.clone(),
styles: MarkdownInlineStyles::default(),
}])),
NodeValue::FrontMatter(_) => {}
_ => {
let text = collect_plain_text(node);
if !text.is_empty() {
blocks.push(MarkdownBlock::Paragraph(vec![MarkdownInline {
text,
styles: MarkdownInlineStyles::default(),
}]));
}
}
}
}
fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
if let Some((name, source_path)) = paragraph_attachment_media(node) {
blocks.push(MarkdownBlock::Media { name, source_path });
return;
}
if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node) {
blocks.push(MarkdownBlock::Media { name, source_path });
let content = merge_adjacent_inline_nodes(remaining);
if !content.is_empty() {
blocks.push(MarkdownBlock::Paragraph(content));
}
return;
}
blocks.push(MarkdownBlock::Paragraph(collect_inline_children(node)));
}
fn append_ast_list_item<'a>(node: &'a AstNode<'a>, ordered: bool, blocks: &mut Vec<MarkdownBlock>) {
let (is_task, checked) = match node.data.borrow().value.clone() {
NodeValue::TaskItem(task_item) => (true, task_item.symbol.is_some()),
NodeValue::Item(_) => (false, false),
_ => return append_ast_block(node, blocks),
};
let mut content = Vec::new();
for child in node.children() {
match child.data.borrow().value.clone() {
NodeValue::Paragraph => content.extend(collect_inline_children(child)),
_ => append_ast_block(child, blocks),
}
}
if is_task {
blocks.push(MarkdownBlock::Todo { checked, content });
} else if ordered {
blocks.push(MarkdownBlock::NumberedListItem(content));
} else {
blocks.push(MarkdownBlock::BulletListItem(content));
}
}
fn ast_table_to_ir<'a>(node: &'a AstNode<'a>, alignments: Vec<TableAlignment>) -> MarkdownBlock {
let rows = node
.children()
.map(|row| {
let is_header = matches!(row.data.borrow().value, NodeValue::TableRow(true));
let cells = row
.children()
.map(|cell| MarkdownTableCell {
content: collect_inline_children(cell),
})
.collect::<Vec<_>>();
MarkdownTableRow { is_header, cells }
})
.collect::<Vec<_>>();
MarkdownBlock::Table { alignments, rows }
}
fn collect_inline_children<'a>(node: &'a AstNode<'a>) -> Vec<MarkdownInline> {
let mut nodes = Vec::new();
for child in node.children() {
collect_inline_nodes(child, &MarkdownInlineStyles::default(), &mut nodes);
}
merge_adjacent_inline_nodes(nodes)
}
fn collect_inline_nodes<'a>(
node: &'a AstNode<'a>,
active_styles: &MarkdownInlineStyles,
nodes: &mut Vec<MarkdownInline>,
) {
match node.data.borrow().value.clone() {
NodeValue::Text(text) => push_inline_text_node(nodes, text.as_ref(), active_styles),
NodeValue::TaskItem(task_item) => {
let marker = if task_item.symbol.is_some() {
"[x] "
} else {
"[ ] "
};
push_inline_text_node(nodes, marker, active_styles);
for child in node.children() {
collect_inline_nodes(child, active_styles, nodes);
}
}
NodeValue::Code(code) => {
let mut styles = active_styles.clone();
styles.code = true;
push_inline_text_node(nodes, &code.literal, &styles);
}
NodeValue::Strong => {
let mut styles = active_styles.clone();
styles.bold = true;
collect_inline_children_with_styles(node, &styles, nodes);
}
NodeValue::Emph => {
let mut styles = active_styles.clone();
styles.italic = true;
collect_inline_children_with_styles(node, &styles, nodes);
}
NodeValue::Strikethrough => {
let mut styles = active_styles.clone();
styles.strike = true;
collect_inline_children_with_styles(node, &styles, nodes);
}
NodeValue::Underline => {
let mut styles = active_styles.clone();
styles.underline = true;
collect_inline_children_with_styles(node, &styles, nodes);
}
NodeValue::Link(link) => {
let mut styles = active_styles.clone();
styles.link = Some(link.url.clone());
collect_inline_children_with_styles(node, &styles, nodes);
}
NodeValue::SoftBreak | NodeValue::LineBreak => {
push_inline_text_node(nodes, " ", active_styles);
}
NodeValue::HtmlInline(text) => push_inline_text_node(nodes, text.as_ref(), active_styles),
NodeValue::Image(link) => {
let mut styles = active_styles.clone();
styles.link = Some(link.url.clone());
push_inline_text_node(nodes, link.url.as_str(), &styles);
}
_ => collect_inline_children_with_styles(node, active_styles, nodes),
}
}
fn collect_inline_children_with_styles<'a>(
node: &'a AstNode<'a>,
active_styles: &MarkdownInlineStyles,
nodes: &mut Vec<MarkdownInline>,
) {
for child in node.children() {
collect_inline_nodes(child, active_styles, nodes);
}
}
fn push_inline_text_node(
nodes: &mut Vec<MarkdownInline>,
text: &str,
styles: &MarkdownInlineStyles,
) {
if text.is_empty() {
return;
}
nodes.push(MarkdownInline {
text: text.to_string(),
styles: styles.clone(),
});
}
fn merge_adjacent_inline_nodes(nodes: Vec<MarkdownInline>) -> Vec<MarkdownInline> {
let mut merged = Vec::<MarkdownInline>::new();
for node in nodes {
if let Some(last) = merged.last_mut() {
if last.styles == node.styles {
last.text.push_str(&node.text);
continue;
}
}
merged.push(node);
}
merged
}
fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
link_attachment_media(first)
}
fn paragraph_leading_attachment_media<'a>(
node: &'a AstNode<'a>,
) -> Option<(String, String, Vec<MarkdownInline>)> {
let mut children = node.children();
let first = children.next()?;
let (name, source_path) = link_attachment_media(first)?;
let second = children.next()?;
if !matches!(
second.data.borrow().value,
NodeValue::SoftBreak | NodeValue::LineBreak
) {
return None;
}
let mut remaining = Vec::new();
for child in children {
collect_inline_nodes(child, &MarkdownInlineStyles::default(), &mut remaining);
}
Some((name, source_path, remaining))
}
fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let NodeValue::Link(link) = &node.data.borrow().value else {
return None;
};
parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url))
}
fn markdown_ast_document_to_blocks(document: &MarkdownAstDocument) -> Value {
Value::Array(
document
.blocks
.iter()
.enumerate()
.map(|(index, block)| markdown_block_to_json(block, index + 1))
.collect(),
)
}
fn markdown_block_to_json(block: &MarkdownBlock, block_number: usize) -> Value {
match block {
MarkdownBlock::Paragraph(content) => json_block(
"paragraph",
legacy_inline_nodes_to_json(content),
block_number,
),
MarkdownBlock::Heading { level, content } => json!({
"id": format!("local-block-{block_number}"),
"type": "heading",
"props": { "level": level },
"content": legacy_inline_nodes_to_json(content),
"children": [],
}),
MarkdownBlock::Quote(content) => {
json_block("quote", legacy_inline_nodes_to_json(content), block_number)
}
MarkdownBlock::CodeBlock { language, text } => json!({
"id": format!("local-block-{block_number}"),
"type": "codeBlock",
"props": { "language": language },
"content": [{ "type": "text", "text": text, "styles": {} }],
"children": [],
}),
MarkdownBlock::Divider => json!({
"id": format!("local-block-{block_number}"),
"type": "divider",
"content": [],
"children": [],
}),
MarkdownBlock::BulletListItem(content) => json_block(
"bulletListItem",
legacy_inline_nodes_to_json(content),
block_number,
),
MarkdownBlock::NumberedListItem(content) => json_block(
"numberedListItem",
legacy_inline_nodes_to_json(content),
block_number,
),
MarkdownBlock::Todo { checked, content } => json!({
"id": format!("local-block-{block_number}"),
"type": "todo",
"props": { "checked": checked },
"content": legacy_inline_nodes_to_json(content),
"children": [],
}),
MarkdownBlock::Table { alignments, rows } => {
table_block_to_json(alignments, rows, block_number)
}
MarkdownBlock::Media { name, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "media",
"props": {
"name": name,
"sourcePath": source_path,
},
"content": [],
"children": [],
}),
}
}
fn table_block_to_json(
alignments: &[TableAlignment],
rows: &[MarkdownTableRow],
block_number: usize,
) -> Value {
let content_rows = rows
.iter()
.map(|row| {
let cells = row
.cells
.iter()
.enumerate()
.map(|(column_index, cell)| {
let cell_type = if row.is_header {
"tableHeader"
} else {
"tableCell"
};
let text_align = match alignments
.get(column_index)
.copied()
.unwrap_or(TableAlignment::None)
{
TableAlignment::Left => "left",
TableAlignment::Center => "center",
TableAlignment::Right => "right",
TableAlignment::None => "",
};
json!({
"type": cell_type,
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null,
"textAlign": text_align,
},
"content": [{
"type": "paragraph",
"content": tiptap_inline_nodes_to_json(&cell.content),
}]
})
})
.collect::<Vec<_>>();
json!({
"type": "tableRow",
"content": cells,
})
})
.collect::<Vec<_>>();
json!({
"id": format!("local-block-{block_number}"),
"type": "table",
"props": {
"tiptapTable": {
"type": "table",
"attrs": { "blockId": format!("local-block-{block_number}") },
"content": content_rows,
}
},
"content": [],
"children": [],
})
}
fn json_block(block_type: &str, content: Vec<Value>, block_number: usize) -> Value {
json!({
"id": format!("local-block-{block_number}"),
"type": block_type,
"content": content,
"children": [],
})
}
fn legacy_inline_nodes_to_json(nodes: &[MarkdownInline]) -> Vec<Value> {
nodes
.iter()
.map(|node| {
json!({
"type": "text",
"text": node.text,
"styles": legacy_styles_to_json(&node.styles),
})
})
.collect()
}
fn legacy_styles_to_json(styles: &MarkdownInlineStyles) -> Value {
let mut object = Map::new();
if styles.bold {
object.insert("bold".to_string(), Value::Bool(true));
}
if styles.italic {
object.insert("italic".to_string(), Value::Bool(true));
}
if styles.strike {
object.insert("strike".to_string(), Value::Bool(true));
}
if styles.underline {
object.insert("underline".to_string(), Value::Bool(true));
}
if styles.code {
object.insert("code".to_string(), Value::Bool(true));
}
if let Some(href) = styles
.link
.as_deref()
.map(str::trim)
.filter(|href| !href.is_empty())
{
object.insert("link".to_string(), Value::String(href.to_string()));
}
Value::Object(object)
}
fn tiptap_inline_nodes_to_json(nodes: &[MarkdownInline]) -> Vec<Value> {
nodes
.iter()
.filter(|node| !node.text.is_empty())
.map(|node| {
let mut object = Map::new();
object.insert("type".to_string(), Value::String("text".to_string()));
object.insert("text".to_string(), Value::String(node.text.clone()));
let marks = tiptap_marks_from_styles(&node.styles);
if !marks.is_empty() {
object.insert("marks".to_string(), Value::Array(marks));
}
Value::Object(object)
})
.collect()
}
fn tiptap_marks_from_styles(styles: &MarkdownInlineStyles) -> Vec<Value> {
let mut marks = Vec::new();
if styles.bold {
marks.push(json!({ "type": "bold" }));
}
if styles.italic {
marks.push(json!({ "type": "italic" }));
}
if styles.underline {
marks.push(json!({ "type": "underline" }));
}
if styles.strike {
marks.push(json!({ "type": "strike" }));
}
if styles.code {
marks.push(json!({ "type": "code" }));
}
if let Some(href) = styles
.link
.as_deref()
.map(str::trim)
.filter(|href| !href.is_empty())
{
marks.push(json!({ "type": "link", "attrs": { "href": href } }));
}
marks
}
fn collect_plain_text<'a>(node: &'a AstNode<'a>) -> String {
let mut parts = Vec::new();
for descendant in node.descendants() {
if let NodeValue::Text(text) = &descendant.data.borrow().value {
parts.push(text.as_ref().to_string());
}
}
parts.join("")
}
pub(crate) fn split_frontmatter(markdown: &str) -> (Option<String>, &str) {
let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown);
if !normalized.starts_with("---\n") {
return (None, normalized);
}
let rest = &normalized[4..];
if let Some(end) = rest.find("\n---\n") {
let frontmatter = rest[..end].to_string();
let body = &rest[end + 5..];
return (Some(frontmatter), body);
}
(None, normalized)
}
fn read_frontmatter_field(frontmatter: &str, key: &str) -> Option<String> {
frontmatter.lines().find_map(|line| {
let (candidate_key, value) = line.split_once(':')?;
if candidate_key.trim() != key {
return None;
}
let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn extract_first_h1_title(body: &str) -> Option<String> {
body.lines().find_map(|line| {
let trimmed = line.trim();
trimmed
.strip_prefix("# ")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
pub(crate) fn file_stem_title(file_name: &str) -> String {
std::path::Path::new(file_name)
.file_stem()
.and_then(|stem| stem.to_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(file_name)
.to_string()
}
+1
View File
@@ -8,6 +8,7 @@ mod health;
mod hermes;
mod kernel;
mod local_folder_source;
mod local_markdown_parser;
mod mindmap_shell;
mod query_support;
mod search;
+118 -4
View File
@@ -323,6 +323,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const SAVE_EVENT = `${EVENT_PREFIX}:save-request`;
const READY_EVENT = `${EVENT_PREFIX}:ready`;
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1';
const parseJsonScript = (id) => {
const node = document.getElementById(id);
@@ -644,11 +646,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const pageBody = aggregate.body || {};
const permissions = aggregate.head?.permissions || {};
const conflictDetectionKey = typeof pageBody.conflictDetectionKey === 'string'
? pageBody.conflictDetectionKey
: typeof pageBody.conflict_detection_key === 'string'
? pageBody.conflict_detection_key
const conflictDetectionKeyFromBody = (body) => typeof body?.conflictDetectionKey === 'string'
? body.conflictDetectionKey
: typeof body?.conflict_detection_key === 'string'
? body.conflict_detection_key
: null;
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
const revisionFromConflictKey = (value) => {
const match = String(value || '').match(/:(\d+)$/);
return match ? Number(match[1]) : null;
@@ -688,6 +691,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
let saveTimer = 0;
let lastSavedSerialized = '';
let hasPendingLocalChanges = false;
let suppressNextHostSyncChange = false;
let localExternalPollTimer = 0;
let localExternalPollInFlight = false;
let lastExternalConflictDetectionKey = editorMeta.conflictDetectionKey || '';
const normalizeBridgeValue = (value) => {
if (value instanceof Map) {
const out = {};
@@ -719,7 +727,14 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
currentEditorText(),
);
const serialized = JSON.stringify(tiptapDocument);
if (suppressNextHostSyncChange && serialized === lastSavedSerialized) {
suppressNextHostSyncChange = false;
hasPendingLocalChanges = false;
setStatus('saved');
return;
}
if (serialized === lastSavedSerialized) {
hasPendingLocalChanges = false;
setStatus('saved');
return;
}
@@ -751,7 +766,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (Number.isInteger(saved.revision)) editorMeta.revision = saved.revision;
if (typeof saved.conflict_detection_key === 'string') editorMeta.conflictDetectionKey = saved.conflict_detection_key;
if (typeof saved.conflictDetectionKey === 'string') editorMeta.conflictDetectionKey = saved.conflictDetectionKey;
if (editorMeta.conflictDetectionKey) lastExternalConflictDetectionKey = editorMeta.conflictDetectionKey;
lastSavedSerialized = serialized;
hasPendingLocalChanges = false;
if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
window.__mnoteRecordPageHistorySnapshot('save', {
wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length,
@@ -767,7 +784,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const queueSave = (event) => {
const payload = normalizeEnvelopePayload(event);
if (!payload) return;
if (suppressNextHostSyncChange) {
const tiptapDocument = toTiptapDocument(
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
currentEditorText(),
);
if (JSON.stringify(tiptapDocument) === lastSavedSerialized) {
suppressNextHostSyncChange = false;
hasPendingLocalChanges = false;
setStatus('saved');
return;
}
}
if (saveTimer) window.clearTimeout(saveTimer);
hasPendingLocalChanges = true;
setStatus('dirty');
saveTimer = window.setTimeout(() => {
saveTimer = 0;
@@ -785,6 +815,79 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
root.addEventListener(CHANGE_EVENT, queueSave);
root.addEventListener(SAVE_EVENT, queueSave);
const pageAggregateUrl = () => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin);
url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder');
if (bootstrap.rootUri) url.searchParams.set('rootUri', bootstrap.rootUri);
return url;
};
const dispatchReplaceContent = (nextAggregate) => {
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
const nextTiptapDocument = toTiptapDocument(nextBody.content);
editorMeta.revision = nextRevision;
editorMeta.conflictDetectionKey = nextConflictKey;
lastExternalConflictDetectionKey = nextConflictKey || '';
lastSavedSerialized = JSON.stringify(nextTiptapDocument);
hasPendingLocalChanges = false;
suppressNextHostSyncChange = true;
clearEmbeddedLocalDraft();
root.dispatchEvent(new CustomEvent(COMMAND_EVENT, {
bubbles: true,
detail: {
protocol: BRIDGE_PROTOCOL,
runtime: 'mnote-leptos-tiptap-spike',
version: '1.1.0',
source: 'mnote-web-local-folder-watch',
event: COMMAND_EVENT,
payload: {
command: 'replaceContent',
documentId: bootstrap.documentId,
workspaceId: bootstrap.workspaceId,
title: nextAggregate?.head?.title || mountOptions.title,
content: nextTiptapDocument,
revision: editorMeta.revision,
conflictDetectionKey: editorMeta.conflictDetectionKey,
readOnly: Boolean(nextPermissions.readOnly),
},
},
}));
setStatus('synced-external-change');
};
const pollLocalMarkdownExternalChange = async () => {
if (bootstrap.sourceKind !== 'local_folder' || !bootstrap.rootUri || document.hidden) return;
if (localExternalPollInFlight) return;
localExternalPollInFlight = true;
try {
const response = await fetch(pageAggregateUrl().toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
if (!response.ok) return;
const payload = await response.json();
const nextAggregate = payload?.result;
const nextConflictKey = conflictDetectionKeyFromBody(nextAggregate?.body || {});
if (!nextConflictKey || !lastExternalConflictDetectionKey) {
lastExternalConflictDetectionKey = nextConflictKey || lastExternalConflictDetectionKey;
return;
}
if (nextConflictKey === lastExternalConflictDetectionKey) return;
if (hasPendingLocalChanges || saveTimer) {
setStatus('external-change-conflict', '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突');
return;
}
dispatchReplaceContent(nextAggregate);
} catch (error) {
console.warn('mnote local folder 外部更新检测失败', error);
} finally {
localExternalPollInFlight = false;
}
};
const start = async () => {
setStatus('loading-assets');
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
@@ -800,11 +903,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
await runtime.default(wasmUrl);
clearEmbeddedLocalDraft();
const mountId = runtime.mount(root, mountOptions);
lastSavedSerialized = JSON.stringify(mountOptions.content);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
if (typeof window.__mnoteApplyPageOptionsToShell === 'function') {
window.__mnoteApplyPageOptionsToShell();
}
if (bootstrap.sourceKind === 'local_folder' && bootstrap.rootUri) {
void pollLocalMarkdownExternalChange();
localExternalPollTimer = window.setInterval(() => {
void pollLocalMarkdownExternalChange();
}, 1200);
}
setStatus('ready');
};
@@ -1530,6 +1640,10 @@ mod tests {
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind=\"markdown\""));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
assert!(html.contains("pollLocalMarkdownExternalChange"));
assert!(html.contains("/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}"));
assert!(html.contains("command: 'replaceContent'"));
assert!(html.contains("external-change-conflict"));
}
#[tokio::test]