Skip to content

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.

LocationRole today
packages/ts/obsidian-editor/src/decorations.tsOne enter switch over node names builds marks, hides and widgets; also exports activeLines, resolveTarget, isImageTarget, livePreviewPlugin, blockWidgetField
packages/ts/obsidian-editor/src/widgets.tsSix WidgetType classes (CheckboxWidget, ImageWidget, HrWidget, CalloutTitleWidget, TransclusionPillWidget, CodeLangWidget)
packages/ts/obsidian-editor/src/clicks.tsclickHandlers walks the tree at the click position for Wikilink, Embed, HashTag
packages/ts/obsidian-editor/src/extensions.ts#obsidianEditorExtensionsFixed extension list; markdown({ extensions: [obsidianSyntax] }), obsidianCompletion(), one hand-written keymap
packages/ts/obsidian-editor/src/autocomplete.ts#inCodeTree helper living beside the completion sources
packages/ts/obsidian-editor/src/commands.ts#findEnclosingSecond copy of the enclosing-node walk
packages/ts/obsidian-editor/src/types.ts#ObsidianEditorOptionsNo plugins or omit option
packages/ts/obsidian-editor/src/index.tsExports the builders and no @codemirror/* types
packages/ts/obsidian-editor/test/decorations.test.tsNine 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#makeStateHeadless state with the full stack; collect, inlineDecorations, widgetAt

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 inference
pluginsFacet: 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.

  1. Write packages/ts/obsidian-editor/test/plugin-contract.test.ts first: duplicate id throws; prec orders handler dispatch; omit removes a built-in; claim/claimed stops later handlers for the same node; a host plugin with inline: { HashTag } replaces the built-in tag decoration; one syntaxTree(state).iterate pass per buildInlineDecorations call.
  2. Create the plugin module with the interfaces above, defineEditorPlugin, and pluginsFacet whose combine builds CompiledPlugins (inline and block dispatch tables keyed by node name, command list, keymaps, completion sources, click selectors, theme and extension arrays).
  3. Create the tree module: move findEnclosing from the commands module as enclosing, inCode from the autocomplete module, splitTarget, wikilinkTarget, resolveTarget, isImageTarget from the decorations module; add blockAt(state, pos) (outermost block node at a position).
  4. Split the decorations module into the eleven plugin files, each a defineEditorPlugin with inline/block handlers and its widget class. The decorations module keeps activeLines, buildInlineDecorations, buildBlockDecorations, livePreviewPlugin, blockWidgetField, now iterating the tree once and dispatching by node name; HandlerContext implements mark, line, hide, widget, claim, claimed, place.
  5. Rewrite the clicks module as the dispatcher; move the wikilink, embed and tag click handlers into their plugins as clicks entries.
  6. Rewire obsidianEditorExtensions to compile plugins; add plugins?: EditorPlugin[] and omit?: string[] to ObsidianEditorOptions; keep the extension order (options, mode, history, markdown, highlighting, completion, keymap, wrapping, theme, clicks, decorations, search, readOnly).
  7. 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).
  8. Move each describe of the decorations test into packages/ts/obsidian-editor/test/plugins/ as <plugin>.plugin.test.ts with assertions unchanged; import widget classes from their plugin module.
  9. Run bun test and tsc --noEmit in the package; run moon run product-workbench:check product-agent-pants:check to prove NotesEditor.vue and SkillBodyEditor.vue need no change.
LocationKindChange
packages/ts/obsidian-editor/src/plugin.tsnewEditorPlugin, EditorCommand, handler and context types, defineEditorPlugin, pluginsFacet, CompiledPlugins
packages/ts/obsidian-editor/src/tree.tsnewShared tree helpers moved from the decorations, commands and autocomplete modules; blockAt
packages/ts/obsidian-editor/src/plugins/*.tsnewEleven 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.tsnewBUILTIN_PLUGINS in dispatch order
packages/ts/obsidian-editor/src/decorations.tsmodifyBuilders dispatch through CompiledPlugins; switch removed
packages/ts/obsidian-editor/src/clicks.tsmodifySelector dispatcher over compiled click handlers
packages/ts/obsidian-editor/src/extensions.tsmodifyCompile [...BUILTIN_PLUGINS, ...plugins] minus omit
packages/ts/obsidian-editor/src/commands.tsmodifyUse enclosing from the tree module
packages/ts/obsidian-editor/src/autocomplete.tsmodifyImport inCode from the tree module
packages/ts/obsidian-editor/src/types.tsmodifyplugins?, omit? on ObsidianEditorOptions
packages/ts/obsidian-editor/src/index.tsmodifyNew plugin exports and @codemirror/* type re-exports
packages/ts/obsidian-editor/src/widgets.tsdeleteWidget 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.tsdeleteSplit per plugin
packages/ts/obsidian-editor/test/plugins/*.plugin.test.tsnewheadings, 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.tsnewContract tests (step 1)
packages/ts/obsidian-editor/README.mdmodifyThe StateField note under “Known deviations and notes” points at the plugin modules; the full rewrite is T-OAY3-md-engine-readme-v2
  • AC-1: bun test packages/ts/obsidian-editor/test/plugin-contract.test.ts passes: duplicate id throws, prec orders dispatch, omit removes a built-in, claimed stops later handlers, a host inline: { HashTag } handler replaces the built-in tag decoration.
  • AC-2: every test(...) name in today’s packages/ts/obsidian-editor/test/decorations.test.ts exists under packages/ts/obsidian-editor/test/plugins/ with the same assertions, and bun test packages/ts/obsidian-editor passes.
  • AC-3: grep -rn "widgets.ts" packages/ts/obsidian-editor/src packages/ts/obsidian-editor/test returns nothing and the widgets module is gone from packages/ts/obsidian-editor/src/.
  • AC-4: grep -c "case '" over packages/ts/obsidian-editor/src/decorations.ts prints 0; each of the eleven files under packages/ts/obsidian-editor/src/plugins/ (excluding index.ts) exports exactly one defineEditorPlugin(...) value.
  • AC-5: buildInlineDecorations calls syntaxTree(state).iterate once per invocation (asserted by the counting wrapper in the contract test).
  • AC-6: packages/ts/obsidian-editor/src/index.ts exports defineEditorPlugin, pluginsFacet, BUILTIN_PLUGINS, the types EditorPlugin, EditorCommand, EditorView, Extension, KeyBinding, Command, CompletionSource, and every export it has today; tsc --noEmit in the package is clean.
  • AC-7: moon run product-workbench:check product-agent-pants:check passes with no change to solutions/determined/experiences/notes/app/components/NotesEditor.vue or solutions/determined/experiences/agent-pants/app/components/SkillBodyEditor.vue.
  • No task dependency. D-7VMX binds: syntax is the only parser seam, and no plugin may parse the document itself.

Member of milestone M-931L; implements the engine half of decision D-K3EM-markdown-editor-chrome-and-plugin-environment (engine step E1).


← Back to Tasks