T-4TE2-md-engine-plugin-contract
State: closed/done · Impact: high · Complexity: medium
@sksizer/obsidian-editor decides every live-preview decoration inside one 631-line enter
switch, and a host has no seam to add a construct or a widget. This task introduces the
EditorPlugin contract and pluginsFacet, splits the built-in constructs into one plugin module
each, and moves shared tree helpers to a tree module. It is a pure move: every existing test is
the oracle and consumers keep working unchanged.
| Location | Role today |
|---|---|
packages/ts/obsidian-editor/src/decorations.ts | One enter switch over node names builds marks, hides and widgets; also exports activeLines, resolveTarget, isImageTarget, livePreviewPlugin, blockWidgetField |
packages/ts/obsidian-editor/src/widgets.ts | Six WidgetType classes (CheckboxWidget, ImageWidget, HrWidget, CalloutTitleWidget, TransclusionPillWidget, CodeLangWidget) |
packages/ts/obsidian-editor/src/clicks.ts | clickHandlers walks the tree at the click position for Wikilink, Embed, HashTag |
packages/ts/obsidian-editor/src/extensions.ts#obsidianEditorExtensions | Fixed extension list; markdown({ extensions: [obsidianSyntax] }), obsidianCompletion(), one hand-written keymap |
packages/ts/obsidian-editor/src/autocomplete.ts#inCode | Tree helper living beside the completion sources |
packages/ts/obsidian-editor/src/commands.ts#findEnclosing | Second copy of the enclosing-node walk |
packages/ts/obsidian-editor/src/types.ts#ObsidianEditorOptions | No plugins or omit option |
packages/ts/obsidian-editor/src/index.ts | Exports the builders and no @codemirror/* types |
packages/ts/obsidian-editor/test/decorations.test.ts | Nine describe blocks (headings, inline emphasis, wikilinks, widgets, image embeds, callouts, code fences, active-line suppression, source mode); imports widget classes from the widgets module |
packages/ts/obsidian-editor/test/helpers.ts#makeState | Headless state with the full stack; collect, inlineDecorations, widgetAt |
Proposed
Section titled “Proposed”New packages/ts/obsidian-editor/src/plugin.ts:
interface EditorPlugin { id: string // 'core.callouts', 'host.bases'; duplicates throw syntax?: MarkdownExtension // Lezer dialect additions (D-7VMX: the only parser seam) inline?: Record<NodeName, InlineHandler> // (ref, cx) => boolean|void; cx.mark/line/hide/widget/claim/claimed block?: Record<NodeName, BlockHandler> // cx.place(from, to, blockDecoration) commands?: EditorCommand[] keymap?: KeyBinding[] completions?: CompletionSource[] clicks?: ClickHandler[] // { selector, run(view, pos, node, event) } theme?: Extension; extensions?: Extension // escape hatch: StateFields, codeFolding(), dropCursor() prec?: 'highest'|'high'|'default'|'low'|'lowest'}defineEditorPlugin(p) // identity helper for inferencepluginsFacet: Facet<EditorPlugin, CompiledPlugins> // combined ONCE per configuration (widget identity)EditorCommand (id, label, icon?, keybinding?, group, run, isActive?, isEnabled?,
slash?) is declared here so EditorPlugin.commands types today; the catalogue that fills it is
T-EXQ7-md-engine-command-catalogue. run is
(target: { state: EditorState; dispatch(tr: Transaction): void }, payload?: unknown) => boolean —
the StateCommand shape, which an EditorView satisfies, so commands run headless in tests.
obsidianEditorExtensions({ plugins, omit }) compiles [...BUILTIN_PLUGINS, ...host] minus omit
ids: syntax → markdown({ extensions }), completions → autocompletion({ override }),
keymap → keymap.of, handlers → a node-name dispatch table read by the unchanged pure builders
buildInlineDecorations(state, ranges) and buildBlockDecorations(state). CompiledPlugins is
computed once per configuration so widget classes keep identity across rebuilds.
Built-ins become internal plugins on the same contract, one file each under
packages/ts/obsidian-editor/src/plugins/: headings, emphasis, inline-code, wikilinks,
embeds, links, tags, tasks, hr, callouts, code-fences. Each owns its widget class.
The widgets module is deleted. Shared tree helpers (enclosing, inCode, splitTarget,
wikilinkTarget, resolveTarget, isImageTarget, blockAt) move to
packages/ts/obsidian-editor/src/tree.ts. The clicks module becomes a selector dispatcher over
CompiledPlugins.clicks. The package entry re-exports EditorView, Extension, KeyBinding,
Command, CompletionSource types so the chrome layer takes no @codemirror/* dependency.
Approach
Section titled “Approach”- Write
packages/ts/obsidian-editor/test/plugin-contract.test.tsfirst: duplicateidthrows;precorders handler dispatch;omitremoves a built-in;claim/claimedstops later handlers for the same node; a host plugin withinline: { HashTag }replaces the built-in tag decoration; onesyntaxTree(state).iteratepass perbuildInlineDecorationscall. - Create the plugin module with the interfaces above,
defineEditorPlugin, andpluginsFacetwhosecombinebuildsCompiledPlugins(inline and block dispatch tables keyed by node name, command list, keymaps, completion sources, click selectors, theme and extension arrays). - Create the tree module: move
findEnclosingfrom the commands module asenclosing,inCodefrom the autocomplete module,splitTarget,wikilinkTarget,resolveTarget,isImageTargetfrom the decorations module; addblockAt(state, pos)(outermost block node at a position). - Split the decorations module into the eleven plugin files, each a
defineEditorPluginwithinline/blockhandlers and its widget class. The decorations module keepsactiveLines,buildInlineDecorations,buildBlockDecorations,livePreviewPlugin,blockWidgetField, now iterating the tree once and dispatching by node name;HandlerContextimplementsmark,line,hide,widget,claim,claimed,place. - Rewrite the clicks module as the dispatcher; move the wikilink, embed and tag click handlers
into their plugins as
clicksentries. - Rewire
obsidianEditorExtensionsto compile plugins; addplugins?: EditorPlugin[]andomit?: string[]toObsidianEditorOptions; keep the extension order (options, mode, history, markdown, highlighting, completion, keymap, wrapping, theme, clicks, decorations, search, readOnly). - Delete the widgets module; update the package entry (add
defineEditorPlugin,pluginsFacet,BUILTIN_PLUGINS, the plugin and command types, the@codemirror/*type re-exports; keep every existing export). - Move each
describeof the decorations test intopackages/ts/obsidian-editor/test/plugins/as<plugin>.plugin.test.tswith assertions unchanged; import widget classes from their plugin module. - Run
bun testandtsc --noEmitin the package; runmoon run product-workbench:check product-agent-pants:checkto proveNotesEditor.vueandSkillBodyEditor.vueneed no change.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
packages/ts/obsidian-editor/src/plugin.ts | new | EditorPlugin, EditorCommand, handler and context types, defineEditorPlugin, pluginsFacet, CompiledPlugins |
packages/ts/obsidian-editor/src/tree.ts | new | Shared tree helpers moved from the decorations, commands and autocomplete modules; blockAt |
packages/ts/obsidian-editor/src/plugins/*.ts | new | Eleven built-in plugins: headings, emphasis, inline-code, wikilinks (owns wikilink clicks), embeds (owns ImageWidget, TransclusionPillWidget, embed clicks), links, tags (owns tag clicks), tasks (CheckboxWidget), hr (HrWidget), callouts (CalloutTitleWidget), code-fences (CodeLangWidget) |
packages/ts/obsidian-editor/src/plugins/index.ts | new | BUILTIN_PLUGINS in dispatch order |
packages/ts/obsidian-editor/src/decorations.ts | modify | Builders dispatch through CompiledPlugins; switch removed |
packages/ts/obsidian-editor/src/clicks.ts | modify | Selector dispatcher over compiled click handlers |
packages/ts/obsidian-editor/src/extensions.ts | modify | Compile [...BUILTIN_PLUGINS, ...plugins] minus omit |
packages/ts/obsidian-editor/src/commands.ts | modify | Use enclosing from the tree module |
packages/ts/obsidian-editor/src/autocomplete.ts | modify | Import inCode from the tree module |
packages/ts/obsidian-editor/src/types.ts | modify | plugins?, omit? on ObsidianEditorOptions |
packages/ts/obsidian-editor/src/index.ts | modify | New plugin exports and @codemirror/* type re-exports |
packages/ts/obsidian-editor/src/widgets.ts | delete | Widget classes move into their plugins; its only importers are the decorations module and the decorations test, both rewritten here |
packages/ts/obsidian-editor/test/decorations.test.ts | delete | Split per plugin |
packages/ts/obsidian-editor/test/plugins/*.plugin.test.ts | new | headings, emphasis, wikilinks, tasks (from describe('widgets')), embeds (from describe('image embeds')), callouts, code-fences, modes (from the active-line and source-mode describes) |
packages/ts/obsidian-editor/test/plugin-contract.test.ts | new | Contract tests (step 1) |
packages/ts/obsidian-editor/README.md | modify | The StateField note under “Known deviations and notes” points at the plugin modules; the full rewrite is T-OAY3-md-engine-readme-v2 |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
bun test packages/ts/obsidian-editor/test/plugin-contract.test.tspasses: duplicateidthrows,precorders dispatch,omitremoves a built-in,claimedstops later handlers, a hostinline: { HashTag }handler replaces the built-in tag decoration. - AC-2: every
test(...)name in today’spackages/ts/obsidian-editor/test/decorations.test.tsexists underpackages/ts/obsidian-editor/test/plugins/with the same assertions, andbun test packages/ts/obsidian-editorpasses. - AC-3:
grep -rn "widgets.ts" packages/ts/obsidian-editor/src packages/ts/obsidian-editor/testreturns nothing and the widgets module is gone frompackages/ts/obsidian-editor/src/. - AC-4:
grep -c "case '"overpackages/ts/obsidian-editor/src/decorations.tsprints0; each of the eleven files underpackages/ts/obsidian-editor/src/plugins/(excludingindex.ts) exports exactly onedefineEditorPlugin(...)value. - AC-5:
buildInlineDecorationscallssyntaxTree(state).iterateonce per invocation (asserted by the counting wrapper in the contract test). - AC-6:
packages/ts/obsidian-editor/src/index.tsexportsdefineEditorPlugin,pluginsFacet,BUILTIN_PLUGINS, the typesEditorPlugin,EditorCommand,EditorView,Extension,KeyBinding,Command,CompletionSource, and every export it has today;tsc --noEmitin the package is clean. - AC-7:
moon run product-workbench:check product-agent-pants:checkpasses with no change tosolutions/determined/experiences/notes/app/components/NotesEditor.vueorsolutions/determined/experiences/agent-pants/app/components/SkillBodyEditor.vue.
Out of scope
Section titled “Out of scope”- The command catalogue,
commandStates, chord derivation andaddKeymap: false(T-EXQ7-md-engine-command-catalogue). - Overlay anchors and their types (T-MNRO-md-engine-anchors).
- CRLF preservation,
requestRedraw, the swap-writer corpus (T-0GV4-md-engine-line-endings-corpus). - New plugins:
lists,slash,tables,math,mermaid,html-blocks,attachments,transclusion(their own tasks). - The README rewrite (T-OAY3-md-engine-readme-v2).
Dependencies
Section titled “Dependencies”- No task dependency. D-7VMX binds:
syntaxis the only parser seam, and no plugin may parse the document itself.
Discovery context
Section titled “Discovery context”Member of milestone M-931L; implements the engine half of decision D-K3EM-markdown-editor-chrome-and-plugin-environment (engine step E1).