Plate 的 @platejs/markdown 合约覆盖:Markdown 插件解析、反序列化与序列化的非 React 契约验证
Plate 的 platejs/markdown 合约覆盖Markdown 插件解析、反序列化与序列化的非 React 契约验证【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plateplatejs/markdown是 Plate 富文本编辑器中负责 Markdown 与 Slate 文档模型双向转换的核心包。本指南围绕 2026-03-23 完成的Markdown 合约覆盖Contract Coverage Pass展开讲解MarkdownPlugin、deserializeMd、convertNodesDeserialize、serializeMd、convertNodesSerialize五个关键接入点seam的直接测试契约包括默认配置、parser 判定逻辑、自定义规则下的顶层文本包装、memoized 输出、onError与withoutMdx容错、remarkStringifyOptions透传、冲突过滤器、未知节点与withBlockId等边缘行为。读完本文你将掌握这套序列化插件对外承诺的行为边界以及如何在 monorepo 内用bun test、turbo完成针对性的回归验证。一、这次 Coverage Pass 的背景与目标在 3 月 14 日完成 helper/fallback 重构即markdownToSlateNodesSafely安全回退路径等之后platejs/markdown的间接测试较多但直接面向插件 API 与解析器合约的覆盖不足。因此这次任务被定义为一次窄范围narrow、非 React 的测试覆盖轮次目标只有一个为包的公开契约补上直接、确定性的测试证据。计划文档docs/plans/2026-03-23-markdown-contract-coverage-pass.md明确列出了五个最高价值切入点Highest-Value SeamsSeam文件职责MarkdownPluginMarkdownPlugin.ts插件默认配置、绑定 API、剪贴板 parser 判定deserializeMddeserializeMd.tsMarkdown 文本 → Slate 节点含容错回退convertNodesDeserializeconvertNodesDeserialize.tsmdast 节点 → Slate 节点的过滤与转换serializeMdserializeMd.tsSlate 文档值 → Markdown 字符串convertNodesSerializeconvertNodesSerialize.tsSlate 节点 → mdast 节点的过滤、文本合并与列表归并与此同时任务显式声明了推迟项Explicit DeferralsdefaultRules深度扫描、更广泛的 mdx/list/mention 矩阵已有其他测试覆盖、以及/react子路径。这保证了本次 pass 只聚焦纯合约不扩散到渲染层。二、直接插件 API 与 parser contractMarkdownPluginMarkdownPlugin是整个包的入口其核心价值在 MarkdownPlugin.ts 中体现为三部分默认配置、绑定 API 和剪贴板 parser 判定。2.1 默认配置契约插件通过createTSlatePlugin声明键KEYS.markdown默认 options 为options: { allowedNodes: null, disallowedNodes: null, plainMarks: null, remarkPlugins: [], remarkStringifyOptions: null, rules: null, }直接契约测试MarkdownPlugin.spec.ts验证了editor.getOptions(MarkdownPlugin)的返回值与此完全一致即默认情况下不限制节点、不注入 remark 插件、不使用自定义规则。任何对默认行为的改动都会立即破坏该测试这正是合约覆盖的意义。2.2 绑定 API 契约插件通过.extendApi将三个核心函数与编辑器实例绑定bindFirst暴露为editor.api.markdown命名空间.extendApi(({ editor }) ({ deserialize: bindFirst(deserializeMd, editor), deserializeInline: bindFirst(deserializeInlineMd, editor), serialize: bindFirst(serializeMd, editor), }))测试同时断言了editor.api.markdown.deserialize/deserializeInline/serialize与editor.getApi(MarkdownPlugin).markdown.deserialize均为函数且plugin.parser.format为text/plain——这是该插件对外承诺的剪贴板数据格式。2.3 parser 判定逻辑queryparser.deserialize直接委托给绑定后的api.markdown.deserialize(data)而parser.query是是否接管某次剪贴板粘贴的判定函数。从源码MarkdownPlugin.ts看其判定顺序为剪贴板携带text/html数据 → 返回false交给 HTML 解析避免与 HTML 粘贴冲突无文件且数据是纯 URLisUrl(data)→ 返回false放行给LinkPlugin处理避免破坏链接粘贴剪贴板携带文件 → 返回true按 Markdown 解析其余纯文本 → 返回true。对应的四个测试用例MarkdownPlugin.spec.ts逐一验证了有 HTML 时跳过、URL 放行、有文件时仍解析 URL、普通文本默认解析。这组测试把插件在什么情况下接管粘贴的行为钉死为公开契约。三、deserializer 直接覆盖自定义规则包装、memoized 输出与容错deserializeMd是 Markdown → Slate 的主入口deserializeMd.ts 将其拆为三个阶段markdownToAstProcessor仅解析 mdast、markdownToSlateNodesmdast → Slate 节点、deserializeMd顶层包装与容错。3.1 顶层文本包装custom rule wrapping当自定义规则把节点反序列化成纯文本节点而非元素时deserializeMd会把输出中的文本节点统一包装进段落保证返回值永远是合法的 Slate 文档return output.map((item) TextApi.isText(item) ? ({ children: [item], type: getPluginKey(editor, KEYS.p) ?? KEYS.p, } as TElement) : item );测试deserializeMd.spec.ts传入rules: { p: { deserialize: () ({ text: wrapped }) } }输入plain期望输出[{ children: [{ text: wrapped }], type: p }]。注意type取getPluginKey(editor, KEYS.p) ?? KEYS.p即尊重插件自定义的段落类型键回退到默认p。3.2 memoized 输出markdownToSlateNodes在options.memoize为 true 时先用parseMarkdownBlocks把文本切成 token 块再逐块解析并把原始块文本挂到每个输出节点的_memo字段上deserializeMd.ts。space类型的 token 会生成一个空段落并保留原始空白作为_memo。测试deserializeMd.spec.ts验证了两点输入one\n\n\n\n two四个换行配合parser: { exclude: [], trim: false }会保留一个_memo为\n\n\n\n的空段落节点——说明 memoize 模式下空白块不会被静默丢弃普通文本块的_memo就是原始块文本one、two。_memo是 Plate 内部用于在后续编辑中定位原始 Markdown 片段例如行内引用、注释同步的机制这份测试把它的存在性固定了下来。3.3 onError 与 withoutMdx 容错组合deserializeMd的容错路径deserializeMd.ts是这次 pass 的重点解析抛错时先调用options.onError(error)然后若withoutMdx为 false默认走markdownToSlateNodesSafely安全回退把失败片段转成可编辑文本若withoutMdx为 true不再回退直接返回空数组[]。测试用三种输入验证输入期望依据u不完整 MDX 尾部转成[{ children: [{ text: u }], type: p }]onError调用 1 次安全路径生效/ph\畸形 HTML 形 MDX转成可编辑文本/phonError调用 1 次安全路径生效抛错的 remark 插件 withoutMdx: true返回[]onError收到Error(boom)无回退、仅上报错误同时markdownToAstProcessor的测试确认了unified().use(remarkParse).use(remarkPlugins).parse(data)返回 mdastroot节点作为解析器契约的底层证据。四、deserializer 边缘车道冲突过滤器与未知节点convertNodesDeserialize负责 mdast → Slate 的逐节点转换过滤逻辑集中在shouldIncludeNodeconvertNodesDeserialize.ts。边缘测试convertNodesDeserialize.spec.ts覆盖了三条规则冲突过滤器allowedNodes与disallowedNodes同时配置且非空时直接抛出Error(Cannot combine allowedNodes with disallowedNodes)——这是对误用的硬性保护allowedNodes 白名单只保留命中列表的节点含text等内联类型null表示放行全部空数组表示过滤全部disallowedNodes 黑名单按类型排除同样作用于内联节点如boldallowNode.deserialize 自定义函数对每个节点调用返回 false 即排除可精确排除hr、bold等具体类型未知节点buildSlateNode对type: mysteryNode这种没有注册 rule 的 mdast 节点getDeserializerByKey返回空 → 最终输出[]静默丢弃而非抛错。另外buildSlateNode对mdxJsxTextElement/mdxJsxFlowElement走customMdxDeserialize分支返回数组时展开、否则包装成数组这是 MDX 元素进入 Slate 的统一入口。五、serializer 直接覆盖editor value 透传与 remarkStringifyOptionsserializeMdserializeMd.ts是 Slate → Markdown 的主入口。它通过getMergedOptionsSerialize合并编辑器配置构造unified处理器并内置两条 remark-stringify 默认项.use(remarkStringify, { emphasis: _, resourceLink: false, ...mergedOptions?.remarkStringifyOptions, })即默认使用下划线强调、优先输出引用式链接且允许remarkStringifyOptions覆盖这些默认值。直接测试serializeMd.spec.ts验证了两个契约editor value 透传不显式传value时序列化editor.children本身——把editor.children设为[{ children: [{ text: editor value }], type: p }]serializeMd(editor)输出editor value\nremarkStringifyOptions 转发传入{ remarkStringifyOptions: { bullet: } }与一个listStyleType: disc的列表项输出 Item\n——自定义 bullet 生效。slateToMdast在构造根节点时以isBlock true调用convertNodesSerialize这决定了顶层元素的序列化路径例如是否参与withBlockId包装。六、serializer 边缘车道冲突过滤器与 withBlockIdconvertNodesSerializeconvertNodesSerialize.ts实现了与反序列化对称的过滤逻辑但多了一层文本级过滤shouldIncludeText逐个检查文本节点的 mark 属性除text外的键对照allowedNodes/disallowedNodes/allowNode.serialize决定是否保留——这使禁止导出加粗这类需求可以精确到行内标记shouldIncludeNode对元素节点做与反序列化端一致的类型过滤且同样在allowedNodes与disallowedNodes同时配置时抛出Cannot combine allowedNodes with disallowedNodes。列表序列化时相邻的plistStyleType节点会被收集进listBlock交给listToMdastTree统一转换含 indent 与列表样式判断保证列表不会逐个元素发散成孤立段落。6.1 withBlockId保留块 ID 的 MDX 包装buildMdastNode中convertNodesSerialize.ts存在一个关键条件if (options.withBlockId node.id isBlock) { return wrapWithBlockId(mdastNode, node.id); }即只有同时满足「开启withBlockId」「节点带id」「是顶层块isBlock」三个条件时才会把序列化结果包装进块元素。wrapWithBlockIdwrapWithBlockId.ts生成一个带_mdxExplicitJsx: true标记的mdxJsxFlowElement{ attributes: [{ name: id, type: mdxJsxAttribute, value: String(nodeId) }], children: [mdastNode], data: { _mdxExplicitJsx: true }, name: block, type: mdxJsxFlowElement, }序列化输出形如block idabc123…原始 mdast…/block从而在导出 Markdown 时无损保留 Slate 节点的块 ID——这是 AI 编辑、引用同步、块级操作等场景依赖的数据通道。列表项场景则由listToMdastTree在内部逐项处理最终以fragment类型展开子节点。七、验证计划如何复现这套覆盖计划文档记录了完整的验证链路全部在仓库根目录执行# 1. 定点运行被改动的 markdown 相关测试 bun test packages/markdown/src # 2. 性能画像找出 markdown 包中最慢的 15 个用例 pnpm test:profile -- --top 15 packages/markdown/src # 3. 慢速用例清单确认新增用例未显著拖慢套件 pnpm test:slowest -- --top 15 packages/markdown/src # 4. 依赖与构建链 pnpm install pnpm turbo build --filter./packages/markdown pnpm turbo typecheck --filter./packages/markdown pnpm lint:fix这套流程兼顾了正确性bun test、性能回归profile/slowest、构建与类型turbo build/typecheck和代码规范lint:fix。platejs/markdown的 package 脚本见 packages/markdown/package.json也提供了bun化的plate-pkg p:test、p:typecheck等入口可在包内单独运行。八、结果与结论依据计划文档的 Result 章节本次 pass 的产出可以精确归纳为为MarkdownPlugin补上了默认配置、绑定 API、parser 反序列化的直接合约覆盖为deserializeMd补上了顶层文本包装、memoized 输出、onErrorwithoutMdx的直接覆盖为 serializer 补上了直接serializeMd输出与remarkStringifyOptions的覆盖为反序列化与序列化各补了一条边缘车道冲突过滤器、未知节点、withBlockId没有暴露任何运行时缺陷——本次 pass 严格保持 test-only。这也说明在 3 月 14 日的 helper/fallback 工作之后platejs/markdown的核心转换链路行为稳定测试的价值在于把「默认配置、剪贴板接管判定、容错回退、过滤语义、块 ID 导出」这些容易在后续迭代中被悄悄改动的行为固化为可回归的公开契约。对想要在 Plate 之上开发 Markdown 导入/导出功能的开发者这组测试与源码deserializer、serializer即是「什么能做、什么被禁止、出错时如何兜底」最权威的行为说明书。【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联
返回资讯列表 →