docs/plans/2026-04-12-input-rules-dx-revision-plan.md
Proposed.
Keep the core input-rules runtime, but fix the public API and ownership model so the DX is actually worth shipping:
inputRuleGroupsPlugin.configure(...) entry pointCurrent code evidence:
External comparison evidence from local clones:
addInputRules() plus typed builder helpers like
markInputRule, textblockTypeInputRule, nodeInputRule,
wrappingInputRule, textInputRuleMarkdownShortcutPlugininputRuleGroups is too ceremonial for normal usage@platejs/typography is package sprawl for product sugarBaseHeadingPlugin owning heading shorthand is bad DX because people usually
configure H1Plugin through H6Plugin, not the aggregate pluginshouldAutoLinkPaste duplicates what named rule override should ownpackages/link/src/lib/automd is a zombie surfacegetTextFromBlockStart as a standalone export is an awkward utility leakdefineInputRule is too raw to be the main builder, but too tiny to help
people discover the better pathThe runtime is worth keeping. The public API still needs one more hard cleanup pass.
inputRuleGroupsinputRuleGroups should not survive as public config.
It is accurate, but clunky. It forces every consumer to think about two fields for one concept: "which shortcuts are on?"
The public config should become:
ItalicPlugin.configure({
inputRules: {
markdown: true,
emphasisUnderscore: null,
},
});
That is the common path:
We still need bundle semantics. We just do not need a second public config field for them.
Plugin definition should become:
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: "_" },
}),
},
});
Public config should accept both preset names and rule names inside
inputRules.
Config semantics:
true enables the presetnull removes the presettrue enables the rule directly{ ... } configures the rulenull removes the ruleConstraint:
This was a real miss.
emphasis is too coarse if the developer actually cares about * vs _.
Public rule names should describe the real override unit:
emphasisAsteriskemphasisUnderscorestrongAsteriskstrongUnderscoreboldItalicAsteriskboldItalicUnderscoreSame rule for links and math:
@platejs/text-substitutions as a published packageThis should move out of packages/*.
Harsh take: smart quotes, arrows, fractions, legal marks, and other generic substitutions are product sugar, not durable editor semantics.
Best fit:
apps/www/src/registry/**Why this wins:
Rejected:
@platejs/autoformat: wrong ownership, dead direction@platejs/utils: too hidden and semantically vague@platejs/typography: better than autoformat, still too much
published surface for copied sugarcreateInputRuleDo not make developers guess a zoo of helper names.
Keep:
defineInputRule as the low-level escape hatchAdd:
createInputRule as the main DX surfaceShape:
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: "…",
});
Design rule:
typedefineInputRule still available for custom logicThe repo still needs shared matcher logic. It just should not be the first thing users learn.
Core should expose advanced composition helpers only as secondary APIs:
matchDelimitedTextmatchBlockStartmatchTerminalBlockmatchTextSubstitutionThese helpers exist to build custom rules or power createInputRule.
They are not the primary marketing surface.
markdownInputRules.ts should not stay package-internal.
It already proves the shared layer is missing.
Core should own:
Packages should own:
H1Plugin through H6PluginHeading shorthand should not force users to install/configure
HeadingPlugin just to get #.
Best ownership:
BaseH1Plugin owns h1BaseH2Plugin owns h2BaseHeadingPlugin remains a convenience aggregator onlyThat gives better kit DX and better package intuition.
getTextFromBlockStart with an editor API helper, not string optionsDo not overload editor.api.string(...) with magic boundary options.
That would make the most basic text getter weird.
Best move:
editor.api.textFromBlockStart()Reason:
stringIf more boundary helpers appear later, then widen to a family. Do not pre-generalize now.
InputRulesPlugin edit-onlyThis runtime has no business running outside editing surfaces.
That change is small and obvious.
Do all of these together:
packages/link/src/lib/automdshouldAutoLinkPaste from
BaseLinkPlugin.tspasteAutolink override/config the only customization pathReason:
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,
},
});
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 and add sugar next to itReject.
That keeps the public API split even if the common case is sugar-coated.
Reject.
That is discoverability debt. One master builder plus a low-level escape hatch is cleaner.
Reject.
That is framework surface inflation for behavior most people should inspect locally.
editor.api.string with block-start flagsReject.
That makes a simple API weird to save one helper name.
Files:
Tasks:
inputRuleGroups config with inputRules preset activationeditor.meta.inputRules.plugins[*] to expose presets, not groupsFiles:
packages/core/src/lib/plugins/input-rules/Tasks:
defineInputRule minimalcreateInputRuleFiles:
packages/slate and packages/coreTasks:
editor.api.textFromBlockStart()Files:
Tasks:
shouldAutoLinkPastepackages/link/src/lib/automdFiles:
Tasks:
Tasks:
InputRulesPlugin edit-onlyinputRules onlyinputRuleGroupsinputRulescreateInputRule({ type: 'delimitedMark' })createInputRule({ type: 'blockStart' })createInputRule({ type: 'terminalBlock' })createInputRule({ type: 'textSubstitution' })defineInputRule still supports custom rulesemphasisAsterisk / emphasisUnderscoreshouldAutoLinkPasteinputRulesHeadingPluginshouldAutoLinkPaste is gonepackages/link/src/lib/automd is goneInputRulesPlugin is edit-onlyIf this direction is approved, the next execution pass should start with a small core-first spike:
inputRuleGroups with preset activation inside inputRulescreateInputRulegetTextFromBlockStart to editor.api.textFromBlockStart()ItalicPluginH1PluginCodeBlockPluginThat slice is enough to prove the DX before ripping through the rest of the repo.