Plate 代码块围栏回归测试实战:用CodeBlockKit锁定三反引号自动格式化契约
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
导读
三反引号(```````)在行首被完整输入时,是否应该自动把段落“升级”成代码块?这个看似微小的交互,恰恰是 Markdown 编辑体验中最容易产生回归的边界。本文以docs/plans/2026-04-17-codeblock-fence-test.md这份回归测试计划为骨架,完整还原 Plate 项目中如何定位测试缝、如何在核心包与 app 集成层分别锁定三反引号自动格式化行为,并结合createBlockFenceInputRule的源码剖析围栏匹配的底层原理。读完你将掌握:block fence 输入规则的两种触发模式、CodeBlockRules.markdown({ on })的正确用法,以及如何在 shipped app kit 层面编写可防回归的集成测试。
背景:一个关于``````` 不回退成代码块的疑问
问题的起点是一次用户提问:当输入三反引号时,编辑器不应把内容错误地转换为代码块。这句话听起来像是在描述 bug,实际上是在要求为“正确的自动格式化行为”补上回归测试——即锁定“三反引号确实会触发代码块提升”这一既有契约,防止未来改动悄悄破坏它。
从 计划文档 可以看到当时的定位结论:
- 该行为的真正所有者是code-block 插件的输入规则(input-rule)路径,而不是文档 UI 或示例页面;
- 已有的经验记录指向block-fence 输入规则才是正确的测试缝(seam);
- 核心包
@platejs/code-block其实早已存在直接的输入规则测试,真正缺失的覆盖是shipped app kit(CodeBlockKit)这一集成层。
也就是说,这次任务不是“发现并修复 bug”,而是“在回归最可能悄悄溜进来的地方把契约锁死”。
相关经验:两条支撑本次决策的最佳实践
计划文档明确引用了两份解决方案文档,它们共同决定了测试怎么写、规则怎么配。
围栏匹配与功能应用应当分离
block-fence-input-rules-should-split-fence-matching-from-feature-apply.md 指出:块级数学公式($$)与围栏代码块(```````)本质上是同一类块围栏规则——在块首匹配一个围栏、要求光标位于块尾,然后在围栏补全或按下 Enter 时提交。过去这套共享逻辑被埋在各自包内,导致重复的“折叠选区、段落类型、块尾、块首围栏”检查散落各处。
解决方案是抽出核心原语createBlockFenceInputRule,并用一个on选项表达真正的产品语义:
createBlockFenceInputRule({ fence: '```', on: 'match', // 最后一个定界符使围栏完整时触发 block: KEYS.p, // 仅在段落内生效 isBlocked, apply, });on: 'match':当最后一个字符使围栏完整(如输入第三个反引号)时触发;on: 'break':当已完整的围栏后按下 Enter 时触发。
核心负责匹配器(折叠选区、当前块查找、块类型门控、光标位于块尾门控、块首围栏文本匹配),而各包继续保有语义(插入代码块、插入公式等),互不越权。
显式注册规则实例,而非布尔键开关
input-rules-should-register-explicit-rule-instances-while-packages-export-markdown-families.md 则解释了为什么测试与配置中看到的是CodeBlockRules.markdown({ on: 'match' })而不是inputRules: { fence: true }。旧的布尔键配置把“包拥有的规范规则语义”与“kit 层的布尔激活”混为一谈;新设计下:
- 包导出语义化 markdown 家族:如
CodeBlockRules.markdown(...)、BoldRules.markdown({ variant: '*' })、MathRules.markdown({ variant: '$' }); - kit 显式注册具体规则实例:
CodeBlockPlugin.configure({ inputRules: [CodeBlockRules.markdown({ on: 'match' })] }); - 运行时只存具体规则,不再需要“可用规则注册表”或字符串键激活层。
标点符号变成variant/on之类的选项值,而不是顶层配置键,公开 API 因此大幅简化。
核心机制:createBlockFenceInputRule的两种触发路径
从源码看,核心原语位于 packages/core/src/lib/plugins/input-rules/createInputRules.ts,其内部按on取值分派到两种defineInputRule:
on: 'break'分支(createInputRules.ts#L315-L328):
if (config.on === 'break') { return defineInputRule({ priority: config.priority, target: 'insertBreak', // 挂到插入换行事件 enabled: config.enabled, resolve: (context) => matchBlockFence(context, { block: config.block, fence: config.fence, resolveMatch: config.resolveMatch, }), apply: config.apply, }); }它把target设为'insertBreak',即监听“回车”。此时matchBlockFence直接用完整围栏fence去匹配块首文本。
on: 'match'分支(createInputRules.ts#L330-L351):
const trigger = config.fence.at(-1); // 取围栏最后一个字符:'`' return defineInputRule({ priority: config.priority, target: 'insertText', // 挂到文本插入事件 enabled: config.enabled, trigger, resolve: (context) => { if (context.text !== trigger) return; return matchBlockFence(context, { block: config.block, fence: config.fence.slice(0, -trigger.length), // 匹配剩余部分 '``' resolveMatch: config.resolveMatch, }); }, apply: config.apply, });这里有两个值得注意的实现细节:
- 触发字符是围栏的最后一个字符:
trigger = config.fence.at(-1),即第三个反引号。当用户依次输入`、`、`,前两个字符不满足触发条件,第三个反引号插入时才进入matchBlockFence; - 匹配时用“去掉触发字符后的前缀”:
config.fence.slice(0, -trigger.length)得到'``',因为此刻第三个反引号已经被insertText事件消费,块首文本恰好是 ``,需要拿前缀去比对,否则永远匹配不上。
而matchBlockFence(createInputRules.ts#L275-L310)就是那套被抽出来的共享匹配逻辑,逐条验证:
if (!context.isCollapsed || !selection) return; // 1. 折叠选区 const blockEntry = context.getBlockEntry(); if (!blockEntry) return; const [blockNode, path] = blockEntry; const endPoint = editor.api.end(path); if (config.block && blockNode.type !== editor.getType(config.block)) return; // 2. 块类型门控 if (!endPoint || !editor.api.isEnd(selection.focus, path)) return; // 3. 光标在块尾 const range = context.getBlockStartRange(); const blockText = context.getBlockStartText(); if (!range || blockText === undefined || blockText !== config.fence) return; // 4. 块首文本 == 围栏四个条件缺一不可:光标必须折叠、必须在段落内、必须在块尾、块首全文必须正好等于围栏。例如输入 `` 后再输入x而不是第三个反引号,就永远无法命中;这也解释了为什么“x位于围栏之后”会天然阻止误转换——匹配要求块首文本严格等于围栏本身。
包侧实现:CodeBlockRules.markdown的围栏配置
代码块包通过 packages/code-block/src/lib/CodeBlockRules.ts 把核心原语封装成语义化规则工厂:
export const CodeBlockRules = { markdown: createRuleFactory< { on: 'break' | 'match' }, { block: string; fence: string }, BlockFenceInputRuleMatch >({ type: 'blockFence', fence: '```', block: KEYS.p, enabled: ({ editor }) => !isCodeBlockInputBlocked(editor), priority: 100, apply: ({ editor, on }, match) => { insertCodeBlockAtPath(editor, match.path); return true; }, }), };要点:
fence: '```'与block: KEYS.p声明“三反引号 + 段落”这一组合;enabled通过editor.api.some({ match: { type: codeBlock } })判断文档中是否已存在代码块,若存在则禁用规则,避免嵌套场景下的误触发(见 CodeBlockRules.ts#L7-L12);apply调用insertCodeBlockAtPath:先removeNodes删除围栏段落,再insertNodes插入code_block结构(内含一个空的code_line),最后把选区移动到新代码行的开头(CodeBlockRules.ts#L14-L37)。这一步保证了围栏段落被整体替换,而不是留下前两个反引号残骸;createRuleFactory在 packages/core/src/lib/plugins/input-rules/createRuleFactory.ts 中把工厂配置转译为createBlockFenceInputRule,将on: 'break' | 'match'透传为两条不同的输入规则。
核心包的直接测试:三用例锁定三种行为
BaseCodeBlockPlugin的输入规则测试位于 packages/code-block/src/lib/BaseCodeBlockPlugin.inputRules.spec.tsx,它使用@platejs/test-utils的 JSX 语法构造文档,再通过createSlateEditor搭建编辑器实例。
用例一:on: 'match'下三反引号提升为代码块
初始文档是一个只含 `` 的段落,光标位于末尾(偏移 2)。配置inputRules: [CodeBlockRules.markdown({ on: 'match' })]后:
editor.tf.insertText('`'); // 输入第三个反引号,触发规则 editor.tf.insertText('code'); // 继续输入正文 expect(input.children).toEqual( <fragment> <hcodeblock> <hcodeline>code</hcodeline> </hcodeblock> </fragment> );断言的核心是:围栏段落被替换为code_block -> code_line -> "code",用户随后键入的code直接落在代码块的第一行里。
用例二:围栏段落整体替换,不留残骸
第二个用例专门回归“替换围栏段落而非把前两个反引号留在后面”:
// 选区锚定在偏移 2,即 `` `` `` 之后 editor.tf.insertText('`'); expect(editor.children).toMatchObject([ { children: [{ children: [{ text: '' }], type: 'code_line' }], type: 'code_block', }, ]);这个断言对应insertCodeBlockAtPath的removeNodes + insertNodes实现:如果某次重构改成“原地改写节点”,前两个反引号很可能残留为段落文本,此测试会立即失败。
用例三:on: 'break'下回车触发提升
第三个用例验证另一种 DX:围栏已经完整(段落文本为 ```),光标在块尾,此时触发的是 Enter。配置改为{ on: 'break' },然后:
editor.tf.select({ anchor: { offset: 3, path: [0, 0] }, focus: { offset: 3, path: [0, 0] } }); editor.tf.insertBreak(); // 模拟回车断言结果同样是文档被替换为空的code_block。它证明同一个CodeBlockRules.markdown只需要切换一个选项,就能从“打完三个反引号立即生效”切换为“回车后生效”,这正是最佳实践文档所说的“一个选项切换两种完成方式”。
新增覆盖:在 shippedCodeBlockKit表面锁定契约
计划文档的关键结论是:核心包已经有直接测试,真正缺的是 app 集成层。CodeBlockKit是正确的主人车道,因为它在 apps/www/src/registry/components/editor/plugins/code-block-kit.tsx 中把CodeBlockRules.markdown({ on: 'match' })真正接线进了 registry 编辑器表面:
export const CodeBlockKit = [ CodeBlockPlugin.configure({ inputRules: [CodeBlockRules.markdown({ on: 'match' })], node: { component: CodeBlockElement }, options: { lowlight }, shortcuts: { toggle: { keys: 'mod+alt+8' } }, }), CodeLinePlugin.withComponent(CodeLineElement), CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf), ];注意CodeBlockKit同时携带了lowlight语法高亮与mod+alt+8切换快捷键,而基础版BaseCodeBlockKit(code-block-base-kit.tsx)不注册任何输入规则——这进一步说明输入规则是 kit 层显式选择的,而不是插件的隐含行为。
于是本次计划新增的回归测试落在 apps/www/src/tests/package-integration/code-block/current-kit.slow.tsx,文件后缀.slow表明它属于慢速集成测试通道:
const createEditor = (text: string, offset = text.length) => createSlateEditor({ plugins: [BaseParagraphPlugin, ...CodeBlockKit], selection: { anchor: { offset, path: [0, 0] }, focus: { offset, path: [0, 0] } }, value: [{ children: [{ text }], type: 'p' }], } as any); describe('CodeBlockKit current contract', () => { it('promotes triple backticks into a code block in the shipped kit surface', () => { const editor = createEditor('``', 2); editor.tf.insertText('`'); // 补全围栏 editor.tf.insertText('code'); // 键入正文 expect(editor.children).toMatchObject([ { children: [{ children: [{ text: 'code' }], type: 'code_line' }], type: 'code_block', }, ]); expect(editor.selection).toEqual({ anchor: { offset: 4, path: [0, 0, 0] }, focus: { offset: 4, path: [0, 0, 0] }, }); }); });与核心包测试相比,这个用例有两个额外价值:
- 测试的是真实出货配置:插件不是手工拼装的
BaseCodeBlockPlugin.configure(...),而是用户实际会安装的CodeBlockKit整体,任何 kit 层配置漂移(例如误删inputRules、改错on值、破坏lowlight初始化)都会暴露; - 额外断言了选区位置:
selection必须落在path [0, 0, 0](即新code_line内)偏移 4 处,锁定了insertCodeBlockAtPath中“提升后光标进入代码行开头”的交互细节。
运行与验证
计划文档的验证步骤是“运行定向测试”。该项目使用 Bun 作为包管理器与测试运行器(见根目录 bun.lock 与 bunfig.toml),因此可在仓库根目录执行:
bun test packages/code-block/src/lib/BaseCodeBlockPlugin.inputRules.spec.tsx bun test apps/www/src/__tests__/package-integration/code-block/current-kit.slow.tsx第一行验证核心包三个直接用例,第二行验证新增的 shipped kit 回归测试。计划文档的结论是:新回归测试直接通过,本轮无需携带任何修复——因为这次请求的本质是“把契约锁在回归最可能发生的地方”,而不是修一个已经存在的错误。换言之,CodeBlockRules.markdown({ on: 'match' })在CodeBlockKit中的接线被证明与预期行为一致。
小结:本次测试计划沉淀的三条可复用经验
- 测试缝要选在行为真正的所有者处:三反引号自动格式化属于 code-block 输入规则路径,而非文档 UI;在
CodeBlockKit接线层补测试,比再写一个核心包用例更能防止实际出货配置的回归。 - 覆盖要同时包含“行为”与“副作用”:核心包三个用例分别锁定了
on: 'match'提升、围栏段落整体替换(不留残骸)、on: 'break'回车提升;集成层用例还额外断言了提升后的选区位置,形成纵深防御。 - 规则配置的语义化是关键:
on: 'match' | 'break'这种选项让同一个CodeBlockRules.markdown通过一个参数切换触发时机,而createBlockFenceInputRule在核心处统一处理折叠选区、块类型、块尾与块首文本四个门控——测试与实现共享同一套心智模型,回归自然更早暴露。
如果你需要在应用中接入代码块自动格式化,请记住:在插件配置中显式注册CodeBlockRules.markdown({ on: 'match' })(或{ on: 'break' })而非依赖布尔键开关;若希望直接使用已出货的 kit,则只需把CodeBlockKit加入插件列表,行为即与本文所述的回归测试完全一致。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考