# marktext 老 muya → @muyajs/core 新 muya 迁移追踪表
调研方案在 `/Users/ransixi/.claude/plans/glimmering-hatching-lightning.md`。
本表登记 P0~P3 共 ~80+ 条 commit 的迁移状态。
状态字段:
- `pending` — 待评估
- `verified-not-applicable` — 已验证新架构下 bug 不存在 / 无意义
- `test-only` — bug 复现失败,仅添加防御性回归测试
- `fixed` — 已实施修复 + 测试
- `skipped` — 决定不做(如纯 marktext 应用层)
PR 分组对应方案第三节的 5 个系列 + 后续 PR-6 测试合规:
- **PR-1a** 安全 + 已确认 crash(非 XSS) ✅
- **PR-1b** XSS 四联(独立 PR 便于安全审计) ✅
- **PR-2** Parser 合规性(含 footnote 测试基线)
- **PR-3** 编辑 / 光标 / IME
- **PR-4** Clipboard / 富文本
- **PR-5** P3 体验特性(按需)
- **PR-6** 测试合规:选择性迁 marktext muya 测试 + 接 CommonMark/GFM spec(PR-2~4 落地后做)
---
## P0 — 安全 / 数据损坏 / crash
| Hash | 范畴 | 说明 | PR | 状态 |
|---|---|---|---|---|
| 9884342f | table | normalizeTable 行单元数 > 表头 crash | PR-1a | `fixed`(含 3 个回归测试) |
| 9ffc5b1b | heading | 空 heading slug crash | PR-1a | `verified-not-applicable`(新仓无 slugger) |
| bca2ed62 | image | loadImageAsync 失败永久缓存 | PR-1a | `fixed`(含 3 个回归测试) |
| 36e825c2 | image | getImageInfo 空 firstChild | PR-1a | `verified-not-applicable`(新仓 getImageInfo 不读 firstChild) |
| fed1dac4 | xss | HTML 表格粘贴 XSS | PR-1b | `verified-not-applicable`(`utils/paste.ts` 已 `sanitize` 经 DOMPurify;anchor title 改用 `textContent` 比老版更安全) |
| 0dd09cc6 | xss | code lang + 超链接 XSS | PR-1b | `partial-fixed`:超链接路径 `sanitizeHyperlink` + `htmlTag` `isValidAttribute` 已就位;**langInputContent 残留 XSS 此 PR 实修** + 3 测试 |
| c959d185 | xss | Mermaid XSS | PR-1b | `verified-not-applicable`(`markdownToHtml.ts` + `diagramPreview.ts` 都已 `securityLevel:'strict'`,mermaid innerHTML 走 `sanitize`) |
| dc54c7b6 | xss | 代码块未 escape HTML | PR-1b | `verified-not-applicable`(`escapeHTML` 已用含 `&` 的 5 字符版本,codeBlockContent 已 escape)+ 4 个防御测试 |
| c47795e4 | xss | XSS + Electron(部分电子相关跳过) | PR-1b | `skipped`(Electron-only) |
| 0baf2e9e / 7de33f11 | xss | #1390 XSS | PR-13 | `verified-not-applicable`:marktext fix 给 inline html renderer 加 `BLOCK_TYPE6.includes(tag) \|\| !sanitize('<' + tag + '>') ? 'span' : tag` 降级,并把 `data-align` 入白名单。新仓 `inlineRenderer/renderer/htmlTag.ts:80-82` 已完整保留该降级链路 + `:110` `isValidAttribute(tag, attr, val)` 属性级过滤;`config/index.ts:401` `EXPORT_DOMPURIFY_CONFIG.ADD_ATTR: ['data-align']` 保留 data-align 白名单(PREVIEW 走 ALLOW_DATA_ATTR:false 因为预览从 state 重派生 data-align)。**新增 16 个防御测试**(`utils/__tests__/dompurifyXss.spec.ts`):embed/object/iframe 降级触发、span/code/mark 不降级、href/onclick/onerror 属性过滤、data-align ADD_ATTR + 实际 sanitize 保留 |
| sanitizeHyperlink 防御 | xss | 锁住 `javascript:/vbscript:/data:` 阻断 | PR-1b | `test-only`(8 个防御测试) |
| 6293d408 | table-ctrl | 老 tableBlockCtrl 删行/列后光标修复 (#572) | PR-7b | `fixed`:`Table.removeRow/removeColumn` 现返回相邻 cell 的 firstChild,`tableRowColumMenu.selectItem` 拿到 cursorBlock 后 `setCursor(0, 0)`。**新增 8 个回归测试**覆盖中间删/末尾删/整表删/越界 4 个分支 |
| f99addd2 | table-ctrl | selectedTableCells 清理 (#1900) | PR-7b | `verified-not-applicable`:新仓无 `selectedTableCells` 全局状态(grep 0 hit);跨 cell 选区在 `editor/index.ts:93` 由 `isSelectionInSameBlock` 守卫早 return,不会进入 marktext 旧那条"删整 column 后引用悬空"的代码路径 |
| 0a3fda63 + 2754e393 + 4b362e52 | architecture | post-refactor 修复合集(已拆条) | PR-13 | `skipped`(已拆 11 子条目登记到下方 "post-refactor 拆条" 节;三个原 hash 不单独迁移) |
| post-refactor: EventCenter listener 在 `destroy` 不清理 | event leak | `EventCenter.unsubscribeAll()` 缺位 | PR-17 | `fixed`:`event/index.ts` 新增 `unsubscribeAll() { this.listeners = {}; }`,`muya.ts destroy()` 在 `detachAllDomEvents()` 之后调用,pub/sub 闭包随实例释放,宿主页面可 GC Muya/plugins/DOM。**新增 2 个回归测试**:unsubscribeAll 清空已订阅;不影响后续订阅 |
| post-refactor: `EventCenter.emit` once-listener 迭代变更 | event correctness | `forEach` 内 `this.off` 跳元素 | PR-17 | `fixed`:`event/index.ts emit()` 改为 `eventListener.slice().forEach(...)` snapshot 迭代,once-listener 在回调里 `off` 只改原数组、不再令前进中的索引塌缩。**新增 3 个回归测试**:早 once-listener 移除自身后仍触发相邻 listener;多 once 单 emit 全清;once/regular 混合 — regular 多 emit 保留、once 单次后移除 |
| post-refactor: selection `document.querySelector` vs `this.doc` | iframe/multi-doc | marktext 改用 `this.doc.querySelector` | PR-13 | `verified-not-applicable`:marktext 改动是为 electron-vite 后的多文档场景;新仓没有 `this.doc` 字段也无 iframe/shadow-DOM 多 document 基建(`selection/index.ts:559`/`format.ts:441`/`loadImageAsync.ts:30,77`/`markdownToHtml.ts:116` 一致使用 `document.*`),结构上不假设多 document |
| post-refactor: selection/dom.js `traverseUp` / `findOutMostParagraph` | selection | 老 contentState 辅助 | PR-13 | `verified-not-applicable`:新仓 `selection/dom.ts` 无这两个辅助(grep 0 hit),整套 contentState ctrl 已被 OT/JSON-state 替代 |
| post-refactor: `history.undo()` 在 index 0 崩 | history | 访问 stack[-1] | PR-13 | `verified-not-applicable`:新仓 `history/index.ts:77 _change` 早期 `if (this._stack[source].length === 0) return;`,redo/undo 都走同一 `_change`,无 stack[-1] 风险 |
| post-refactor: `MutationObserver` 未 disconnect | leak | inputCtrl observer 泄漏 | PR-13 | `verified-not-applicable`:新仓全代码 0 `MutationObserver`(grep 0 hit),无 `inputCtrl`,结构上不存在 |
| post-refactor: `historyTimer` 未取消 | leak | 定时器在 destroy 后 fire | PR-13 | `verified-not-applicable`:新仓 `history/index.ts` 用 `_lastRecorded` 时间戳比较,无 setTimeout/Interval(grep 0 hit) |
| post-refactor: `renderCodeBlockTimer` 模块级状态 | leak/race | module-level 计时器跨实例 | PR-13 | `verified-not-applicable`:新仓 grep 0 hit `renderCodeBlockTimer`;code-block 渲染走 Prism 同步路径,无延迟渲染计时器 |
| post-refactor: `Muya.destroy()` 在无 plugins 时崩 | crash | 缺少 optional chain | PR-13 | `verified-not-applicable`:新仓 `muya.ts:145-146 destroy()` 已 `if (this.ui) this.ui.hideAllFloatTools();` 守卫;`_uiPlugins` 容器在 `init()` 前就初始化为 `{}` |
| post-refactor: 应用层 IPC / preferences / autosave / editor.vue | app-layer | electron-vite/preload/main/renderer | — | `skipped`:marktext renderer/main 应用层(electron.vite.config.js / src/main/* / src/preload/* / src/renderer/*),非 muya 内核范围 |
| post-refactor: docs (`ARCHITECTURE.md`, `BUILD.md`, `package.json` main) | docs | marktext 仓库文档 | — | `skipped`:marktext 仓库 docs / build 配置变更,不进 muya v0.x 包 |
## P1 — Parser / 渲染正确性
| Hash | 范畴 | 说明 | PR | 状态 |
|---|---|---|---|---|
| 1ecc3601 | parser | footnote 解析 + 510 行测试基线 | PR-2a | `fixed`(marked v16 block 扩展 + 12 个回归测试;3 个 negative 用例 marked 自带 `def` 规则替代 paragraph fallback) |
| 23435ce6 | parser | 任务列表缩进 | PR-2a | `test-only`(marked v16 内置 list tokenizer 不共用旧 fork 的缩进 bug;2 个防御测试锁定嵌套) |
| 57cd04c5 | parser | CommonMark example 475 + 353/387/520/521 等 | PR-2a | `fixed`(canOpenEmphasis 阻断 mid-run `_`;link/reference_link 加 lowerPriority;5 个 CM spec 用例) |
| ad5ddbf9 | parser | GFM example 558(link/image title 支持) | PR-2a | `test-only`(`parseSrcAndTitle` 已就位;4 个回归测试锁定 link/image title) |
| 372fe02f | parser | list 解析 #870(task + bullet 混排拆分) | PR-2a | `test-only`(`compatibleTaskList` 已就位;1 个回归测试) |
| 8891287b | parser | paragraph → list 转换 | PR-7a | `verified-not-applicable`:marktext fix 是 `updateCtrl.updateParagraphToList` 的 line-splitting 旧逻辑(无 marker 时 listItemLines 为空 → text 丢失)。新仓 `replaceBlockByLabel({label:'bullet-list', text})` 直接 `state.children[0].children[0].text = text` 整段保留,无 LIST_ITEM_REG 分行。**新增 6 个回归测试**锁住 contract |
| 270d33f6 | parser | list item lexer/parser(CM 264/265 不同 marker 拆表) | PR-2a | `test-only`(marked v16 + `compatibleTaskList` 已就位;2 个 CM spec 测试) |
| 04834032 | parser | tab 缩进 list | PR-7a | `verified-not-applicable`:commit title 误导,实际修的是 markup code-block 内 Tab → Emmet HTML 展开(`parseSelector(undefined)` 崩 + `lastWord` 未限定到光标前 + postText 丢失)。新仓 `codeBlockContent.tabHandler` 已含 `lastWordBeforeCursor` + `postText` + `parseSelector(str='')` 三重修复。**新增 5 个回归测试**(含 mid-text、empty、non-markup 分支) |
| 240d64aa | parser | 合并不同类型 list #706 | PR-7a | `verified-not-applicable`:marktext bug 在 `pasteCtrl` 合并 list-items 进现存 list 时未对齐 looseness。新仓 `pasteHandler` (`clipboard/index.ts:631-635`) 走 `for (state of remaining) ScrollPage.loadBlock(state.name).create(...) + insertAfter`,**每个粘贴状态独立成块**,不会与既存 list 合并,结构上不存在 looseness 错配 |
| 02841ffd | parser | list 后续段落归属(exportMarkdown 缩进配置) | PR-2b | `test-only`(stateToMarkdown 已实现 indent/listIndent 拆分;4 个 marktext 缩进 fixture + 4 个扩展 round-trip) |
| 5f191681 | parser | blockquote 内 list(exportMarkdown) | PR-2b | `test-only`(3 个 blockquote round-trip 测试) |
| insertLineBreak 行尾空格 | serializer | 列表项内空行带尾随空格 | PR-2b | `fixed`(`insertLineBreak` 去掉尾随空格,保留 `>` 前缀;1 个回归测试) |
| 70d49c30 | parser | `-foo` 误识 list item | PR-2a | `test-only`(marked v16 已要求 bullet 后接空格;2 个正负回归测试) |
| 7b7a9424 | math | math block 嵌套 | PR-7b | `verified-not-applicable`:marktext `insertContainerBlock` 缺 anchor 校验导致 math 嵌套;新仓所有 container 创建路径(front menu / quick-insert / `$$` enter convert)都门控在 `paragraph.content`,`canTurnIntoMenu` 对 math/code/html/diagram/table 返回 `[]`。**新增 7 个回归测试**锁住 front-menu 门 |
| d937fac0 | inline | inline 语法 (#1071 重复 `**\`x\`**` 只末尾加粗) | PR-2c | `test-only`(`lowerPriority` 的 `ignoreIndex` 已就位;2 个回归测试) |
| 57af8304 | inline | link/image dest 含括号 (#1169) | PR-2c | `test-only`(`correctUrl` 用 `findClosingBracket` 已就位;3 个回归测试) |
| 9c2f6cb3 | inline | inline math 样式 | — | `skipped`(CSS-only,新仓样式体系自有 inline math 样式) |
| 6dfa7938 | inline | inline math selection | — | `skipped`(CSS-only,新仓样式体系自有 selection 样式) |
| d9f64bab | inline | reference link 渲染 | PR-2a | `test-only`(lexer.ts:357 `labels.has(...)` 已就位;2 个回归测试) |
| b8e2cd82 | inline | inline html renderer | PR-13 | `verified-not-applicable`:marktext fix 给 marked `textRenderer` 加 `script(content, marker)` 让 sup/sub 出现在 HTML 导出。新仓 `utils/marked/extensions/superSubscript.ts:59-64` 直接在 marked extension `renderer` 中发射 `...` / `...`,编辑器渲染与 `renderToStaticHTML` 走同一发射器,无独立 textRenderer 待对齐。**新增 2 个 b8e2cd82 防御测试**锁住段落 / heading / list 内 sup/sub 同时出现的 HTML 输出 |
| 962fdf35 | inline | heading emoji 偏移 | — | `skipped`(CSS-only,新仓样式体系自有 emoji 处理) |
| 8e32838b | inline | 上/下标 | PR-2a | `test-only`(`super_sub_script` token + 渲染器已就位;3 个正负回归测试) |
| c0853f64 | inline | auto link / extension | PR-2a | `test-only`(auto_link + auto_link_extension + 边界 guard 已就位;4 个回归测试) |
| 1c42555a | block | 粘贴多行进 heading | PR-4a | `fixed`(提取 `mergePasteIntoHeading` 纯函数,6 个测试) |
| dec7502e | block | setext heading | PR-2a | `test-only`(marked v16 lheading + walkTokens `headingStyle` 已就位;3 个回归测试) |
| f00da152 | block | 嵌套块插表 crash | PR-7b | `verified-not-applicable`:marktext 老 `createFigure` 缺 anchor 校验导致 math/code/html/table 内插表崩;新仓 `canTurnIntoMenu` 同一道门把 table 也挡在外,`/table` quick-insert 只对 `paragraph.content` 触发。**新增 6 个回归测试**复用 `canTurnIntoMenu` 门同时锁住 table 不可嵌入 |
| 9cb2cbe8 | toc | TOC 更新(如做 TOC 参考) | PR-15 | `fixed`:新仓加 `muya.getTOC()` 公共 API,对齐 marktext `tocCtrl.js`(同步 9cb2cbe8 `\s` 正则修复,让 NBSP/tab 前后置都能正确剥离 atx `#` 标记)。`state/getTOC.ts` 委托实现,`utils/slug.ts` 导出 `generateGithubSlug`(与 marktext url.js 一致:ASCII `\w` only)。`packages/core/src/index.ts` 导出 `ITocItem` 类型。**新增 10 个回归测试**:空文档、单 atx、多层级、setext、raw inline 保留、`\s` 正则修复、CJK/emoji 退化、重复标题 slug 稳定但 githubSlug 同、跨调用 slug 稳定、跳过非 heading 顶层块 |
| reference link/image | parser+state | markdown 加载时 reference definition 丢失(marked v16 `def` block token 未处理)+ reference image 不走 `loadImageAsync` 解析后 src + 内联 label 查找漏大小写 | PR-16 | `fixed`:`markdownToState.ts` 新增 `case 'def'`,把 marked v16 提取的 `[label]: url "title"` block token 还原为 paragraph state(沿用 marktext "definition 是 paragraph text" 模型,**不**引入新 state 节点);`loadImageAsync.ts` 缓存并返回 resolved `url`(marktext `domsrc` 等价),`referenceImage.ts` 用它作为 `` fallback;`lexer.ts` 两处 `labels.has(...)` 调用前 `.toLowerCase()`(CommonMark §6.5);`ILinkReferenceDefinitionState` 标 `@deprecated`(unused,v0.3 移除)。**新增 8 个回归测试**:def→paragraph 保留、round-trip、inline tokenize、Full/Collapsed/Shortcut 三形式、title 透传、case-insensitive lookup、duplicate label 取首条、orphan ref-link |
## P2 — 编辑 / 光标 / 选择 / IME
| Hash | 范畴 | 说明 | PR | 状态 |
|---|---|---|---|---|
| 6f1e733c | cursor | codeblock 光标 #2013 | PR-3a | `verified-not-applicable`(旧 bug 根因是 `partialRender` + `singleRender` 重渲流程,新仓不存在;`codeBlockContent.backspaceHandler` 已有 `text[start.offset-1] === '\n'` 分支显式处理 `\n`) |
| 0a3efbf8 | selection | 文本选区 #622 | PR-3a | `verified-not-applicable`(新仓 selection 通过 `selection/index.ts:_listenSelectActions` 独立监听 mouse 事件,不受 `shownFloat` 影响) |
| 7936e3f4 | selection | 选区无法取消 #636 | PR-3a | `verified-not-applicable`(`content.ts:keydownHandler` 已对 `shownFloat` 内每个浮层细粒度白名单化判定是否 `preventDefault`) |
| 02dbb8af | search | 嵌套 block 搜索 | PR-3d | `verified-not-applicable`(新仓 `Search.search` 用 `scrollPage.depthFirstTraverse` 自然遍历嵌套 block;无 `CAN_NEST_RULES` 白名单限制) |
| 4c517b16 | search | search group | PR-3d | `verified-not-applicable`(`utils/search.ts:buildRegexValue` 已采用新仓正则 `(?X` 末尾 backspace 旧路径在 contentState 内被特化;新仓走 `Format.backspaceHandler` token-based + 浏览器原生删除;建议 examples/ 手测 `
` 后字符删除) |
| 5fb130d9 | table | shift+tab 表格导航 | PR-3d | `fixed`(`tableCell.tabHandler` 新增 `event.shiftKey` 分支 + `previousContentInContext()`;3 个回归测试) |
| 9e32c4a0 | table | cursor → next cell index 0 | PR-3d | `verified-not-applicable`(`tableCell.tabHandler` 已 `setCursor(0, 0, true)`,不会选中整 cell 文本) |
| 0028a4bc | table | cell copy 异常 | PR-7a | `verified-not-applicable`:marktext fix 是 `paragraphCtrl.selectTableCells` 单 cell descriptor 缺 `text` 字段。新仓无 selected-cells descriptor —— `getClipboardData` 同块分支直读 `anchorBlock.text.substring(begin, end)`(`clipboard/index.ts:133`)。**新增 3 个回归测试**锁住 table.cell.content 单块 copy 路径 |
| 3fa8a9ae | autopair | inline code 内禁用 | PR-3b | `verified-not-applicable`(`content.ts:autoPair` 已有 `isInInlineCode` 参数 + `format.ts` 调用前用 `_checkCursorInTokenType` 计算;defensive 测试 2 个) |
| 4278362f | autopair | inline math 内禁用 | PR-3b | `verified-not-applicable`(同上,`isInInlineMath` 参数已就位;defensive 测试 1 个) |
| bbea7eca | autopair | 优化自动补全 | PR-3b | `verified-not-applicable`(`!/[a-z0-9]/i.test(preInputChar)` 已在 markdown-syntax 分支;defensive 测试 3 个) |
| 358fa83d | autopair | 引号自动配对 | PR-3b | `fixed`(`content.ts:autoPair` 加 `postIsNotTouching` 门控,5 个回归测试) |
| 6a50b5cb | tasklist | 切换 task-list 光标跳末尾 | PR-3d | `verified-not-applicable`(`taskListCheckbox` click 已 `event.stopPropagation()`,不会触发 editor click → cursor restore;建议 examples/ 手测确认体感 OK) |
| c3f128e7 | tasklist | copy 保留勾选态 | PR-4b | `verified-not-applicable`:marktext fix 是其 DOM-based copy 的 checkbox 注入边界,新仓走 marked 渲染(task-list `[x]/[ ]` → ``),渲染层一致 |
| edbab6ed | ime | 中文输入误删 | PR-3c | `verified-not-applicable`(Ctrl+A 在新仓走浏览器原生,多 block 选区被 `editor.ts` 提前 return,`format.inputHandler` 期初也 `if (isComposed) return`;跨 block + IME 边角仍建议 examples/ 手测) |
| 67e18176 | ime | 中文回车多行 | PR-3c | `verified-not-applicable`(`content.ts:autoPair` 软换行补齐分支已包含 `event.type === 'compositionend'` 条件,新增 1 个 compositionend 防御测试) |
| 8a7e6559 | ime | compose bug | PR-3c | `verified-not-applicable`(`composeHandler` 翻转 `isComposed`;`keyupHandler` / `inputHandler` / Enter+Arrow 都已用 `!this.isComposed` 门控) |
| 63642d39 | typing | 回车 typewriter 抖动 | PR-3d | `verified-not-applicable`(新仓无 typewriter 模式,`scrollIntoView` 抖动场景不存在) |
| 6b3ead95 | keyboard | 非 US 键盘 | PR-3d | `skipped`(marktext 应用层 renderer keybindings 设置页,非 muya 内核) |
| ed1b3354 | keyboard | 图片选中按 delete | PR-3d | `fixed`(`selection/index.ts:_listenSelectActions` 把 `/Backspace\|Enter/` 替换为 `/^(?:Backspace\|Delete\|Enter)$/`,覆盖 Delete 键 + 锁住完整匹配避免子串碰撞;clipboard 路径无 selectedImage 副作用,无需同步修复) |
| b925f7d6 | clipboard | 移动端 cut | PR-4b | `verified-not-applicable`:新仓 `cutHandler` 起手即 `if (selection == null) return;`,等价 marktext 的 `if (!start || !end) return;` 守卫 |
| 393139e5 | clipboard | clipboard 过度 sanitize | PR-4b | `verified-not-applicable`:新仓 `getClipboardData` 单块/多块路径都 `text = substring(...)`/`mdGenerator.generate(...)` 直出,无 `escapeHtml`;含 2 个防御测试 |
| 54a3b585 | clipboard | 粘贴 HTML escape | PR-4a | `verified-not-applicable`:`utils/paste.ts` 已 `sanitize(html, PREVIEW_DOMPURIFY_CONFIG, false)` |
| 485fcfe0 | clipboard | image paste handler 不执行 | PR-4a | `verified-not-applicable`:新仓 pasteHandler 无 image paste 路径;进入 paste handler 后不会因 `!text && !html` 早退 |
| 5b1cd85d | clipboard | 末尾 html block 粘贴错误 | PR-13 | `verified-not-applicable`:marktext 老 `pasteCtrl` 用 `getLastBlock(blocks)` 在 fragment 树中递归找末叶并写 `lastBlock.text += cacheText`;如果末块是 `editable === false` 的 html-block,递归会进入 children 取错节点或崩。新仓 `clipboard/index.ts:631-649` 多段粘贴是 `for (state of remaining) → ScrollPage.loadBlock(state.name).create(...) + insertAfter`,结尾用 `wrapperBlock.firstContentInDescendant()` 取光标块(`block/base/parent.ts:251-258`,沿 `children.head` 向下找 `Content` 叶;html-block→html-container→code 是规则结构,永远命中一个可写 leaf)。无 fragment 末块的 cacheText 追加路径,结构上不触发 marktext bug |
| fb8fca7b | clipboard | copy/paste list | PR-4b | `verified-not-applicable`:turndown `paragraph`/`listItem` 规则已在 `utils/turndownService/index.ts`;checkbox 注入是 marktext DOM-based copy 特有,新仓走 marked 渲染不需要 |
| 067ec485 | clipboard | HTML paste handler | PR-4a | `partial-fixed`:text-only `
`/`` 内容(如行末 `\n`)保留——因为 collapse 只匹配纯空白 token 间隔,content 字符不动。
### PR-6b 交付(2026-05-20)
完成 marktext 测试补齐三件套:
- **footnote 510 行补齐**:`utils/marked/extensions/__tests__/footnote.spec.ts` 从 13 → 21 个测试,新增 8 个 multi-line body 场景(next-line / next-paragraph / 多段落 body / 嵌套 list / 嵌套 code block / 终止于非缩进段落 / 终止于不足 4-space 缩进)。顺手修了 `footnote.ts` cleanup 路径的小 bug:第一行 4-space 缩进未剥离导致 multi-line body 被误识别成 indented code block(解决方案:cleanup 加一道 `^ {4}` strip)。
- **markdown-basic round-trip**:新增 `test/spec/roundTrip.spec.ts` + 11 个 marktext fixture(`test/spec/fixtures/marktext-round-trip/{common,gfm}/`)。15 个测试(11 stability + 4 strict identity round-trip)。
- **list-indentation 5 个策略**:`state/__tests__/listSerialization.spec.ts` 原 11 个 + 新增 `dfm` (Daring Fireball) 策略 → 12 个。
合计 PR-6b 新增 24 个测试,1 个 footnote parser bug fix。
### PR-6b 待办(旧;保留为后续追踪参考)
目标:补足"非 bug regression"的测试覆盖。PR-2~5 的每条 fix 都自带回归测试,但 happy-path、合规性、广覆盖的测试目前稀疏。
### 值得迁(高信号)
- marktext `test/unit/specs/parser/marked` 系列的 lexer / tokenizer 单元测试 —— 对应新仓 `state/markdownToState.ts` + `inlineRenderer/lexer/`,纯输入→输出,架构无关
- markdown ↔ state ↔ html 的 round-trip 测试
- footnote / table / list / emoji / math 块和内联的 happy path 测试(区别于 bug regression)
### 值得替代(不搬 marktext 的,直接接上游)
- **CommonMark 0.31 合规**:把 [`spec.json`](https://github.com/commonmark/CommonMark/blob/master/test/spec.json) 接进 vitest 驱动 `markdownToHtml`。约 670 个 example,比 marktext 自带合规测试更广更权威
- **GFM 合规**:用 [GFM spec example list](https://github.github.com/gfm/) 同样做法
可接受 fail rate 阶梯:初期允许 5%(先有 baseline 看见缺口),逐步降到 1%;按 spec section 拆分单独跟踪。
### 不迁
- 针对 `ContentState.prototype.*` / 旧 ctrl 方法的行为测试(API 不存在)
- 光标位置 / DOM 交互 / partialRender 的实现细节测试(OT 架构后机制完全不同)
### 实施
- 拆 PR-6a(marktext 选择性测试搬运)+ PR-6b(CommonMark/GFM spec 集成 + baseline 报告)
- 单独的 vitest project 配置(`test:spec` 命令),与现有 unit test 分开,可独立看通过率
- 在 CI 加 spec compliance 趋势报告(每次 PR 不要求 100%,但不能下降)
---
## 进度统计
| PR | 计划条数 | 已完成 | 占比 |
|---|---|---|---|
| PR-1a | 6 | 4 | 67%(2 fixed + 2 verified-not-applicable,2 转 PR-3) |
| PR-1b | 7 | 6 | 86%(1 fixed + 4 verified-not-applicable + 1 skipped;防御测试 15 个) |
| PR-2 | 26 | 15 | 58%(PR-2a 8 commits = 2 fixed bugs + 9 test-only;PR-2b 3 commits = 1 fixed bug + 2 test-only;PR-2c 1 commit = 2 test-only + 3 skipped;3 条转 PR-3/PR-4;新增 57af8304 入册)|
| PR-3a | 5 | 5 | 100%(4 verified-not-applicable + 1 test-only;防御测试 2 个 soft-line) |
| PR-3b | 4 | 4 | 100%(1 fixed `358fa83d` + 3 verified-not-applicable;回归测试 13 个) |
| PR-3c | 3 | 3 | 100%(3 verified-not-applicable;+1 compositionend 防御测试;跨 block+IME 留 examples/ 手测) |
| PR-3d | 11 | 11 | 100%(2 fixed `5fb130d9`+`ed1b3354` + 6 verified-not-applicable + 2 test-only + 1 转 PR-4;回归测试 8 个) |
| PR-4a (粘贴) | 5 | 5 | 100%(2 fixed + 3 verified-not-applicable;5b1cd85d 末尾 html-block 经 PR-13 代码路径验证) |
| PR-4b (复制) | 7 | 7 | 100%(1 fixed + 6 verified-not-applicable;防御测试 8 个) |
| PR-4c (P3 抓标题) | 1 | 1 | 100%(fixed,5 个测试) |
| PR-5 | 18+ | 5 | 28%(PR-5a 1 fixed `a028a7c2`(行号,10 个测试)+ 4 verified-not-applicable:`8474a997`/`8af9605e`/`47cb2bbe`/`7aa0d1bf`) |
| PR-5a (P3 code block 行号) | 1 | 1 | 100%(fixed,10 个测试) |
| PR-6a | — | done | spec 合规基础设施落地(CM 572/652 = 87.7%, GFM 580/672 = 86.3%,1324 spec 测试 + 8 normalizer 单测 + 18 个 renderToStaticHTML 单测) |
| PR-6b | — | done | marktext 测试补齐落地(footnote 8 个新测试 + 1 个 parser fix;round-trip 15 个测试 + 11 fixture;list-indent +1 dfm 策略) |
| PR-13 (residuals) | 5 | 4 | 80%(4 项 A 组遗留收尾:3 verified-not-applicable `b8e2cd82`/`5b1cd85d`/`0baf2e9e`+`7de33f11` + 1 skipped (已拆条) `0a3fda63`+`2754e393`+`4b362e52`;`9cb2cbe8` 原计入此 PR 为 skipped,已转入 PR-15 处理;新增 18 个防御测试: 2 sup/sub HTML + 16 DOMPurify XSS;post-refactor 拆出 11 个子条目: 2 pending + 6 verified-not-applicable + 3 skipped 应用层/docs) |
| PR-15 (TOC) | 1 | 1 | 100%(1 fixed `9cb2cbe8`:新增 `muya.getTOC()` 公共 API + `generateGithubSlug` helper + `ITocItem` 类型导出;10 个回归测试) |
| PR-16 (reference link/image) | 1 | 1 | 100%(fixed:`markdownToState` 加 `case 'def'` + `loadImageAsync` 返回 resolved `url` + `referenceImage` 用它 + `lexer.ts` label lookup 大小写规范化 + `ILinkReferenceDefinitionState` 标 `@deprecated`;8 个回归测试 + examples demo) |
| PR-17 (EventCenter fixes) | 2 | 2 | 100%(2 fixed:`unsubscribeAll()` + `muya.destroy()` 调用消除 pub/sub 闭包泄漏;`emit()` `.slice()` snapshot 修复 once-listener 迭代索引塌缩;5 个回归测试)|
最后更新:2026-05-21(PR-17 落地:EventCenter listener 泄漏 + once-listener 迭代变更修复,5 个回归测试,全套 386 测试通过)