Plate Input Rules DX 修订方案:用单一 inputRules 配置面取代 inputRuleGroups 的插件 API 重构计划
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
本文基于 Plate 仓库内的修订计划文档 2026-04-12-input-rules-dx-revision-plan.md(状态:Proposed)展开,系统讲解 Plate 输入规则(Input Rules)子系统的公开 API 重构方向:为什么保留核心输入规则运行时、为什么删除公开的inputRuleGroups配置、如何设计createInputRule主构建器与 preset 激活语义,以及配套的六阶段迁移计划、测试计划与验收标准。读完本文,你可以完整掌握 Plate 中*italic*、> quote、# heading这类 Markdown 快捷输入的规则注册、解析与归属模型,并了解该方案在当前仓库代码中的落地情况。
一、计划的目标与背景
该计划的核心立场是:保留核心的 input-rules 运行时,但重写公开 API 与归属模型(ownership model),让 DX(开发者体验)真正达到可发布的水准。目标可归纳为五条:
- 不保留公开的
inputRuleGroups配置; - 只有一个显而易见的
Plugin.configure(...)入口; - 共享的规则构建器(rule builders)放在 core 包,而不是在各功能包里重复本地 matcher 代码;
- 不为通用文本替换(smart quotes、箭头、分数等)单独发布 npm 包;
- 对类似快捷键的行为提供 shadcn 式的透明性(代码拷入项目、可直接检视和修改)。
计划文档中列出的"当前代码证据"(as-of 文档撰写时)包括:
- 运行时:InputRulesPlugin.ts、types.ts、resolvePlugin.ts、resolvePlugins.ts;
- 分散在各功能包、存在重复的规则构建逻辑:basic-nodes 的
markdownInputRules.ts、list/inputRules.ts、math 与 link 包各自的inputRules.ts(其中 link 的当前实现位于 LinkRules.ts); - 归属不合理的问题插件:BaseHeadingPlugin.ts、BaseCodeBlockPlugin.ts、link 包的
BaseLinkPlugin.ts,以及注册表侧的 autoformat-kit.tsx。
外部参照系来自本地克隆的对比结论:Tiptap 用 feature 自持的addInputRules()加上markInputRule、textblockTypeInputRule、nodeInputRule、wrappingInputRule、textInputRule等类型化构建助手;Lexical 把显式的 transformer 子集传给MarkdownShortcutPlugin;shadcn 模式则证明"拷贝到本地的代码"对希望检视并修改的产品糖(product sugar)更有吸引力。此外,计划的上游依据还引用了 editor 架构候选对比文档 editor-architecture-candidates.md。
二、严厉评估:哪些已经做对了,哪些仍然不对
2.1 已经做对的部分
- 由 core 持有 input-rules 分发逻辑(core-owned dispatch)是正确的选择;
- 功能插件各自持有功能行为(feature plugins owning feature behavior)是正确的选择;
- 同名规则覆盖(same-name override)加运行时索引(runtime indexing)是正确的方向。
2.2 仍然不对的部分
inputRuleGroups对普通使用场景来说过于仪式感(too ceremonial);@platejs/typography是"为产品糖做的包膨胀"(package sprawl);- 规则构建器逻辑在多个包之间重复;
BaseHeadingPlugin拥有标题快捷语法是糟糕的 DX,因为用户通常配置的是H1Plugin到H6Plugin,而不是聚合插件;shouldAutoLinkPaste与"按命名规则覆盖"的能力重复;- link 包的
automd目录是一个僵尸表面(zombie surface); getTextFromBlockStart作为独立导出是一种笨拙的工具函数泄漏;defineInputRule对主构建器而言太原始(raw),但又太小、不足以帮助开发者发现更好的路径。
结论:运行时值得保留;公开 API 还需要再做一次硬性的清理(one more hard cleanup pass)。
三、十一项核心决策
决策 1:删除公开的 inputRuleGroups
inputRuleGroups不应作为公开配置存活。它准确但笨重——它强迫每个使用者为一个概念("哪些快捷方式开着?")思考两个字段。计划要求的公开配置形态变为单字段:
ItalicPlugin.configure({ inputRules: { markdown: true, emphasisUnderscore: null, }, });这是最常见路径的三个优点:一个字段;显式的 preset 激活;同一个对象里逐规则覆盖/删除。
决策 2:保留 preset 捆绑,但降为内部定义细节
捆绑语义仍然需要,但不需要第二个公开配置字段来承载。插件定义侧应变为:
ItalicPlugin.extend({ inputRulePresets: { markdown: ["emphasisAsterisk", "emphasisUnderscore"], }, inputRules: { emphasisAsterisk: createInputRule({ type: "delimitedMark", mark: KEYS.italic, pattern: { start: "*", end: "*", trigger: "*" }, }), emphasisUnderscore: createInputRule({ type: "delimitedMark", mark: KEYS.italic, pattern: { start: "_", end: "_", trigger: "_" }, }), }, });公开配置(inputRules)同时接受 preset 名与规则名,配置语义如下:
| 条目类型 | true | { ... } | null |
|---|---|---|---|
| preset 条目 | 启用该 preset | —(不适用) | 删除该 preset |
| 规则条目 | 直接启用该规则 | 配置该规则 | 删除该规则 |
约束:同一个插件内,preset 名与规则名不得冲突。
决策 3:把分隔符变体拆成独立的公开规则名
这是文档认定的"真实失误":如果开发者真正关心*和_的差别,emphasis这个名字太粗糙。公开规则名应描述真正的覆盖单元:
emphasisAsterisk/emphasisUnderscorestrongAsterisk/strongUnderscoreboldItalicAsterisk/boldItalicUnderscore
链接与数学公式同理:暴露开发者真正想单独开关的行为单元,不要把实质不同的触发器藏在一个粗糙的规则名之下。
决策 4:不发布 @platejs/text-substitutions 包
smart quotes、箭头、分数、法律符号等通用替换属于"产品糖",不属于持久的编辑器语义,应移出packages/*。最佳归属:
- 通用构建器留在 core;
- 实际的替换规则集作为 registry kits / 拷贝代码放到
apps/www/src/registry/**; - 用户可以像 shadcn 安装的代码一样检视和修改它们。
这样做的收益:避免包膨胀、让 Plate 包聚焦文档语义、保留 shadcn 透明性、"我只想要其中三条规则"变得极简单。被否决的归属方案有三个:留在@platejs/autoformat(归属错误、方向已死);留在@platejs/utils(太隐蔽、语义模糊);留在@platejs/typography(比 autoformat 好,但对"可拷贝的糖"仍是过大的发布表面)。
决策 5:只加一个可发现的构建器 createInputRule
不要让开发者在一堆助手名里猜。保留defineInputRule作为低层逃生舱,新增createInputRule作为主 DX 表面。计划给出的四种类型化变体形态:
// 分隔 Mark 规则 createInputRule({ type: "delimitedMark", mark: KEYS.italic, pattern: { start: "*", end: "*", trigger: "*" }, }); // 块首匹配规则 createInputRule({ type: "blockStart", trigger: " ", match: ">", apply: ({ editor }) => { editor.tf.toggleBlock(KEYS.blockquote); }, }); // 终止符块规则(如 $$ 块级公式) createInputRule({ type: "terminalBlock", target: KEYS.p, terminal: "$$", onMatch: ({ editor, path }) => { // ... }, }); // 文本替换规则 createInputRule({ type: "textSubstitution", match: "...", format: "…", });设计规则:一个可发现的公开构建器;按type区分类型化变体;低层defineInputRule继续可用,承载自定义逻辑。
决策 6:保留组合式助手,但降为主构建器之下的次级 API
仓库确实需要共享的 matcher 逻辑,只是它不该是用户学习的第一样东西。core 只把这些高级组合助手作为次级 API 暴露:matchDelimitedText、matchBlockStart、matchTerminalBlock、matchTextSubstitution。它们用于构建自定义规则或支撑createInputRule,不是主要营销面。
决策 7:把共享构建器从包内部提升到 core
文档撰写时packages/basic-nodes内部的markdownInputRules.ts已经证明"共享层缺失"。归属应当是:core 拥有主构建器、高级 matcher/组合助手、共享的 input-rule 类型;功能包只拥有功能特定的规则定义。
决策 8:标题规则下沉到 H1Plugin 到 H6Plugin
标题快捷语法不应强迫用户只为获得#而安装/配置聚合的HeadingPlugin。最佳归属:BaseH1Plugin拥有h1规则、BaseH2Plugin拥有h2规则……BaseHeadingPlugin仅保留为便利聚合器。这带来更好的 kit DX 与包直觉。
决策 9:用 editor API 助手取代 getTextFromBlockStart
不要用"魔法边界选项"去重载editor.api.string(...)——那会让最基本的文本 getter 变得怪异。最佳动作:删除独立的getTextFromBlockStart.ts导出,新增editor.api.textFromBlockStart()。理由:名字直观、与实际反复出现的调用点匹配、string上不藏选项汤。若将来出现更多边界助手,再扩展为家族,现在不做过度泛化。
决策 10:让 InputRulesPlugin 变为 edit-only
输入规则运行时没有理由在非编辑表面运行。这个改动小而明显。
决策 11:硬切断 link 包的遗留 API 重复
三件事一起做:删除公开的packages/link/src/lib/automd;从BaseLinkPlugin.ts中移除shouldAutoLinkPaste配置;让pasteAutolink的命名规则覆盖/配置成为唯一的定制路径。理由:命名规则覆盖是更好的 API,同时保留两个表面等于行为控制的重复。
四、计划中的最终推荐 API
插件定义侧
ItalicPlugin.extend({ inputRulePresets: { markdown: ["emphasisAsterisk", "emphasisUnderscore"], }, inputRules: { emphasisAsterisk: createInputRule({ type: "delimitedMark", mark: KEYS.italic, pattern: { start: "*", end: "*", trigger: "*" }, }), emphasisUnderscore: createInputRule({ type: "delimitedMark", mark: KEYS.italic, pattern: { start: "_", end: "_", trigger: "_" }, }), }, });插件配置侧
ItalicPlugin.configure({ inputRules: { markdown: true, emphasisUnderscore: null, }, });应用本地拷贝的"产品糖"(registry 风格)
export const TypographyShortcutsKit = [ createSlatePlugin({ key: "typographyShortcuts", inputRulePresets: { defaults: ["smartQuotes", "ellipsis", "mdash"], }, inputRules: { smartQuotes: createInputRule({ type: "textSubstitution", format: ["“", "”"], match: '"', }), ellipsis: createInputRule({ type: "textSubstitution", format: "…", match: "...", }), mdash: createInputRule({ type: "textSubstitution", format: "—", match: "--", }), }, }).configure({ inputRules: { defaults: true, }, }), ];被拒绝的备选方案
| 备选 | 结论 | 理由 |
|---|---|---|
保留inputRuleGroups并在旁边加糖 | 拒绝 | 常见场景就算被糖衣覆盖,公开 API 仍然是分裂的 |
| 发布多个顶层助手名 | 拒绝 | 可发现性债务;一个主构建器加一个低层逃生舱更干净 |
| 文本替换保留在发布包中 | 拒绝 | 为"大多数人应当本地检视的行为"做框架表面膨胀 |
给editor.api.string加 block-start 标志 | 拒绝 | 为省一个助手名让简单 API 变怪异 |
五、六阶段迁移计划
Phase 1:重塑 core 类型
涉及文件:types.ts、packages/core/src/lib/plugin/SlatePlugin.ts、packages/core/src/react/plugin/PlatePlugin.ts、resolvePlugin.ts、resolvePlugins.ts。任务:用inputRules内的 preset 激活取代公开inputRuleGroups配置;把内部定义存储从 groups 改名为 presets;更新editor.meta.inputRules.plugins[*]暴露presets而非groups;保留"同名覆盖 → 优先级 → 确定性顺序"的解析链。
Phase 2:加入真正的构建器层
涉及文件:defineInputRule.ts 与packages/core/src/lib/plugins/input-rules/下的新构建器文件。任务:保持defineInputRule最小化;新增createInputRule;加入类型化变体所用的内部 matcher 助手;把 mark/block/text-substitution 的共享匹配逻辑从功能包移出。
Phase 3:提升共享 editor 助手
任务:把独立的getTextFromBlockStart导出替换为editor.api.textFromBlockStart();更新 code-block、list、basic-nodes 及其他规则家族改用该助手。
Phase 4:修复功能归属
涉及文件:BaseHeadingPlugin.ts 及同文件的标题叶插件、BaseCodeBlockPlugin.ts、BaseLinkPlugin.ts、link/list/math 三个包的inputRules.ts。任务:把标题规则归属移到叶插件;把粗糙的公开规则名改名为真实覆盖单元;重写功能规则改用 core 构建器而非本地 matcher 拷贝;移除shouldAutoLinkPaste;删除packages/link/src/lib/automd。
Phase 5:硬切断文本替换出包
涉及文件:原 typography 包的BaseTypographyPlugin.ts、BaseSymbolsPlugin.ts、autoformat-kit.tsx 及相关 registry kit 文件。任务:删除已发布的 typography/symbols 包方向;把已发布的替换规则移入 registry 本地的拷贝代码;把 kit 文件重命名为它实际的东西。
Phase 6:清理与文档
任务:让InputRulesPlugin变为 edit-only;文档只教inputRules;停止教inputRuleGroups;registry kits 以"显式的拷贝快捷代码"呈现,而不是隐藏包魔法。
六、测试计划
- Core:通过
inputRules的 preset 激活;同名覆盖;preset 加逐规则删除;不经 preset 直接启用单规则;不同规则的优先级排序;运行时 meta 暴露 presets 与 rules。 - Builders:
createInputRule({ type: 'delimitedMark' })、{ type: 'blockStart' }、{ type: 'terminalBlock' }、{ type: 'textSubstitution' }四种变体;低层defineInputRule仍支持自定义规则。 - Features:标题叶插件归属;
emphasisAsterisk/emphasisUnderscore分离;代码围栏与块级公式仍能正确转换;链接 autolink 用命名规则覆盖而非shouldAutoLinkPaste;列表与引用块继续尊重 code-block 守卫。 - Registry:拷贝的文本替换 kit 保持显式可编辑;registry 元数据真实地暴露激活的 presets/rules。
七、验收标准
- 公开配置只使用
inputRules; - preset 激活与逐规则覆盖共享同一个显而易见的配置面;
- 不存在已发布的文本替换包;
- 共享 matcher 逻辑不再在各功能包间重复;
- 标题快捷语法不再依赖
HeadingPlugin聚合插件; shouldAutoLinkPaste消失;packages/link/src/lib/automd消失;InputRulesPlugin为 edit-only;- 文档与 registry 示例与新形态一致。
八、结合仓库源码的实现印证
以下事实来自对当前仓库代码的核实,可帮助读者判断该方案在仓库中的落地程度:
运行时已 edit-only。从源码结构看,internal/InputRulesPlugin.ts 中InputRulesPlugin以createTSlatePlugin({ editOnly: true, key: 'inputRules' })创建,对应决策 10 已经落实。该插件overrideEditor重写了insertBreak/insertData/insertText三个 transform:遍历editor.meta.inputRules中对应目标下的规则,enabled返回false或resolve返回undefined时跳过,apply返回非false即视为"已处理"并break,否则回落到原始 transform(见 InputRulesPlugin.ts)。insertText分支通过editor.meta.inputRules.insertText.byTrigger[text]做触发字符索引,只评估当前输入字符相关的规则——这正是文档评估中肯定的"运行时索引"方向。
规则元模型与解析。types.ts 定义了InputRuleTarget('insertBreak' | 'insertData' | 'insertText')、带getBlockStartRange/getBlockStartText/getCharBefore/getCharAfter等惰性缓存 getter 的SelectionInputRuleContext,以及ResolvedInputRulesMeta(按目标分组、insertText按触发字符再索引、并记录id/pluginKey/priority/ruleIndex/pluginIndex)。resolvePlugins.ts 负责把各插件的inputRules定义(支持数组或(ctx) => 数组工厂形式,见 types.ts 的InputRulesDefinition)解析进editor.meta.inputRules,并在没有任何规则注册时给出 "Enable inputRules on the feature plugins you use instead." 的提示——印证了"core 分发 + 功能插件自持规则"的归属模型。
构建器层已在 core。index.ts 导出createInputRules、createRuleFactory、defineInputRule、types四个模块。createInputRules.ts 导出类型化的构建函数与次级 matcher 助手:createMarkInputRule(第 88 行)、matchBlockStart(第 166 行)、createBlockStartInputRule(第 233 行)、matchBlockFence(第 275 行)、createBlockFenceInputRule(第 312 行)、matchDelimitedInline(第 354 行)、createTextSubstitutionInputRule(第 615 行)。可以推断,实现上把文档"决策 6"命名的matchDelimitedText/matchTerminalBlock/matchTextSubstitution落成了matchDelimitedInline/matchBlockFence等具体名字,语义一一对应。createRuleFactory.ts 则提供了带默认值的工厂形态:createRuleFactory(config)支持type: 'mark' | 'blockStart' | 'blockFence' | 'insertText' | 'insertBreak' | 'insertData' | 'textSubstitution'七种类型(见 createRuleFactory.ts 的AnyRuleFactoryConfig),各字段可传静态值或(input) => 值的函数,priority/enabled可在工厂调用时覆盖。defineInputRule.ts 本身是一个带重载签名的恒等函数,保留了文档所说的"低层逃生舱"角色。
遗留表面已清除。在当前仓库中:packages/typography目录已不存在(对应决策 4 与 Phase 5);packages/link/src/lib/automd已删除;全仓库已搜不到shouldAutoLinkPaste公开配置(LinkRules.ts 中仅保留内部函数shouldAutoLinkPasteByDefault,作为粘贴自动链接的默认行为判定);独立的getTextFromBlockStart.ts文件不存在,取而代之的是运行时上下文里的getBlockStartText()getter——它通过editor.api.range('start', selection)取块首到选区的 range,再经editor.api.string(range)取文本(见 InputRulesPlugin.ts),与文档决策 9"不重载string的边界选项、避免工具函数泄漏"的精神一致(实现选择了上下文 getter 而非新增editor.api.textFromBlockStart()方法,属于对方案细节的等价演进)。
标题归属与功能规则。BaseHeadingPlugin.ts 与 BaseCodeBlockPlugin.ts 等文件按迁移计划 Phase 4 的指向继续存在,功能侧规则如 link 的粘贴 autolink 规则集中在 LinkRules.ts(第 244 行可见shouldLink: shouldAutoLinkPasteByDefault(...)的调用点),注册表侧的替换规则集继续以 autoformat-kit.tsx 这类 registry 组件形式提供,符合"拷贝代码可检视"的 shadcn 式透明性目标。
九、下一步:core-first 验证切片
文档给出的收尾建议是:若该方向获批,先做一个小的 core-first spike——
- 用
inputRules内的 preset 激活取代公开inputRuleGroups; - 加入
createInputRule; - 把
getTextFromBlockStart迁移到 editor API(实现为textFromBlockStart()); - 端到端迁移一个完整功能切片:
ItalicPlugin、H1Plugin、CodeBlockPlugin、link 的 paste autolink 覆盖。
这个切片足以在推平整个仓库之前证明该 DX 方案成立。从当前仓库状态看,上述四点中的大部分方向(edit-only 运行时、core 构建器层、link 清理、typography/automd 移除)已有对应代码落地,说明该计划已从"Proposed"进入执行期。
附:适用前提与阅读建议
- 本文讨论的是 Plate v2(
@platejs/core插件模型)下的 input-rules 子系统,代码依据均以当前仓库packages/core/src/lib/plugins/input-rules/目录为准; - 计划文档中引用的
.omx/specs/与.omx/plans/下的深访规格与 PRD 属于内部工作文档,未在仓库中随附,本文不对其内容作引用; - 如需进一步验证,可按以下路径深入:核心类型 types.ts、主构建器 createRuleFactory.ts、具体构建函数 createInputRules.ts、运行时 internal/InputRulesPlugin.ts、插件解析 resolvePlugins.ts。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考