Skip to content

feat(core): expose plugin public APIs and tool-directed plugin options#184

Open
gohabereg wants to merge 2 commits into
mainfrom
feat/plugin-public-api
Open

feat(core): expose plugin public APIs and tool-directed plugin options#184
gohabereg wants to merge 2 commits into
mainfrom
feat/plugin-public-api

Conversation

@gohabereg

Copy link
Copy Markdown
Member

Why

Plugins registered through core.use() are write-only participants: they subscribe to the EventBus and act on the editor, but nothing can call back into them — not the integrator, not another plugin. Separately, the only way a tool can feed configuration to a plugin is the untyped [key: string]: unknown escape hatch (ShortcutsPlugin read tool.options['shortcut'] with no contract and no ownership), which does not scale past one plugin.

This adds both missing directions, keyed by one identity (a plugin's static name) and typed by one technique (declaration merging), so the two features stay symmetric.

What's in here

SDK

  • Two empty augmentable interfaces — EditorjsPluginApiMap and ToolPluginOptionsMap — that plugin packages fill in under their own name. Neither sdk nor core ever knows a concrete plugin.
  • EditorjsPlugin gains an optional publicApi; EditorjsPluginConstructor gains the static name.
  • EditorAPI.plugins, typed Partial<EditorjsPluginApiMap>.
  • BaseToolOptions.plugins and BaseToolFacade.pluginOptions(id), merged per plugin id.

Core

  • PluginRegistry holds one shared mutable record. Plugins receive EditorAPI in their constructor — before later plugins exist — so a snapshot would permanently hide anything registered afterwards. api.plugins is a getter resolving at access time, making registration order irrelevant.
  • ShortcutsPlugin migrates onto both mechanisms as the proving case and exposes register/unregister.

UI

  • BlocksUI now dispatches KeydownUIEvent. The event type existed and ShortcutsPlugin listened for it, but nothing ever dispatched it — no keyboard shortcut had ever reached a plugin in the running app. A plugin claiming a key via preventDefault() takes precedence over the built-in undo/redo bindings.

Breaking changes

  • options.shortcut is no longer read. Move it under options.plugins.shortcuts. The old flat key still type-checks (tool options carry an index signature), so it fails silently — grep for it.
  • Plugin constructors need a static name. A compile error for plugins exposing a publicApi; others silently fall back to the (minifiable) class name, so declare it regardless.

Verification

  • yarn build, yarn lint, yarn test (932 tests) all green.
  • Type-level tests with @ts-expect-error cover the augmentation behaviour, the Function.name shadow, and per-id option merging.
  • Verified in the playground: CMD+B and CMD+I apply formatting through the full new path (native keydown → KeydownUIEventShortcutsPluginpluginOptions('shortcuts')applyInlineTool).
  • Undo behaviour was checked against a stashed baseline to confirm the new preventDefault gate changes nothing.

Known limitations (documented, not hidden)

docs/plugins.md gains a TypeScript caveats section covering what the compiler does and does not check:

  • A tool's static options is not validated where it is written — it is an unannotated literal, and a tool package that sees no augmentations has an empty map that accepts any key. The use() second argument is checked. Opt in with satisfies plus a type-only dependency on the plugin package.
  • An automatic use()-site guard was prototyped and rejected: TypeScript cannot distinguish "this id is a typo" from "this id's package isn't imported here", so it false-positived on valid code (registering BoldInlineTool from a package lacking the shortcuts augmentation). That failure mode gets worse as tool/plugin registration moves out of core.
  • ShortcutsPlugin lives in core and is not re-exported, so its types are not reachable by consumers, and cannot reach dom-adapters/ui without a dependency cycle. Extracting it to packages/plugins/shortcuts is the follow-up.

Follow-ups (not in this PR)

  • Extract ShortcutsPlugin into its own package so its augmentation is reachable everywhere.
  • Undo-of-typing is broken — pre-existing, confirmed against a baseline, untouched here.
  • packages/ui has no test harness, so the keydown producer is covered by browser verification rather than a unit test.

Planning artifacts (proposal, design with rejected alternatives, delta specs, tasks) are included under openspec/changes/add-plugin-public-api/.

🤖 Generated with Claude Code

Plugins registered through `core.use()` were write-only: they could act on the
editor but nothing could call back into them. The only way a tool could feed a
plugin was the untyped `[key: string]: unknown` escape hatch on tool options,
which does not scale past one plugin.

This adds both missing directions, keyed by one identity (a plugin's static
`name`) and typed by one technique (declaration merging):

- `api.plugins` namespace on `EditorAPI`, backed by a per-editor `PluginRegistry`
  holding one shared record so registration order does not matter
- plugins may expose a `publicApi`, reachable by the integrator and other plugins
- tools declare plugin-directed config under `options.plugins.<name>`, merged per
  plugin id with `use()` overrides winning
- `BaseToolFacade.pluginOptions(id)` returns a plugin's own slice
- two empty augmentable interfaces in the SDK (`EditorjsPluginApiMap`,
  `ToolPluginOptionsMap`) that plugin packages fill in, so neither `sdk` nor
  `core` knows any concrete plugin

`ShortcutsPlugin` migrates onto both mechanisms as the proving case and gains a
register/unregister public API.

`BlocksUI` now dispatches `KeydownUIEvent`. That event type existed and
`ShortcutsPlugin` listened for it, but nothing ever dispatched it, so no keyboard
shortcut had ever reached a plugin in the running app. A plugin that claims a key
via `preventDefault()` takes precedence over the built-in undo/redo bindings.

BREAKING CHANGE: `ShortcutsPlugin` no longer reads the flat `options.shortcut`.
Move it under `options.plugins.shortcuts`.

BREAKING CHANGE: plugin constructors declare a static `name`. It is a compile
error for plugins exposing a `publicApi`; others silently fall back to the
(minifiable) class name, so declare it regardless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gohabereg
gohabereg requested a review from Copilot July 23, 2026 16:45
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

Unit Tests

Package Coverage Delta
@editorjs/collaboration-manager 85.81% 0% ⚪️
@editorjs/dom-adapters 86.95% 0% ⚪️
@editorjs/core 89.67% -0.8% 🔴
@editorjs/ot-server 20% 0% ⚪️
@editorjs/clipboard-plugin 66.66% 0% ⚪️

Mutation Tests

Package Mutation score Dashboard URL
@editorjs/collaboration-manager 0.00% 🔴 Dashboard
@editorjs/dom-adapters 0.00% 🔴 Dashboard
@editorjs/core 29.41% 🔴 Dashboard
@editorjs/clipboard-plugin 91.67% 🟢 Dashboard

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a typed, symmetric plugin “two-way contract”: plugins can expose a public API via api.plugins[pluginName], and tools can provide plugin-directed configuration via options.plugins[pluginName], both typed through SDK declaration merging. It also completes the keyboard-shortcut path by having BlocksUI dispatch KeydownUIEvent so ShortcutsPlugin can actually receive key events at runtime.

Changes:

  • SDK: adds augmentable EditorjsPluginApiMap / ToolPluginOptionsMap, PluginId, PluginsAPI, ToolPluginOptions; extends plugin/tool contracts and adds EditorAPI.plugins.
  • Core: adds PluginRegistry and wires plugin publicApi registration during plugin construction; migrates ShortcutsPlugin to typed tool plugin options and exposes register/unregister.
  • UI & tools: dispatches KeydownUIEvent in BlocksUI and migrates built-in inline tool shortcut config under options.plugins.shortcuts.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/ui/src/Blocks/Blocks.ts Dispatch KeydownUIEvent before local key handling and yield when claimed via preventDefault().
packages/tools/italic/src/index.ts Move shortcut config under options.plugins.shortcuts.
packages/tools/bold/src/index.ts Move shortcut config under options.plugins.shortcuts.
packages/sdk/src/tools/facades/BaseToolFacade.ts Add per-plugin-id merged plugins handling and pluginOptions(id) accessor.
packages/sdk/src/tools/facades/BaseToolFacade.spec.ts Add unit tests for pluginOptions() and per-id merge semantics.
packages/sdk/src/pluginTypeMaps.spec.ts Add type-level tests for map augmentation behavior.
packages/sdk/src/index.ts Export plugin API/options maps and helper types from SDK entrypoint.
packages/sdk/src/entities/EditorjsPlugin.ts Add generic plugin id typing, publicApi, and static name typing for constructors.
packages/sdk/src/entities/EditorjsPlugin.spec.ts Add type-level tests for static name inference and Function.name shadowing.
packages/sdk/src/entities/EditorjsAdapterPlugin.ts Extend adapter plugin contracts to carry typed static name as well.
packages/sdk/src/entities/BaseTool.ts Add plugins to base tool options, typed as ToolPluginOptions.
packages/sdk/src/api/EditorAPI.ts Add plugins: PluginsAPI to the EditorAPI interface.
packages/plugins/clipboard-plugin/src/index.ts Add static name to ClipboardPlugin.
packages/dom-adapters/src/index.ts Add static name to DOMAdapters.
packages/core/src/plugins/ShortcutsPlugin.ts Migrate to tool.pluginOptions('shortcuts') and expose typed publicApi for runtime shortcut registration.
packages/core/src/plugins/ShortcutsPlugin.spec.ts Add tests for tool-declared shortcuts + public API registration/unregistration.
packages/core/src/index.ts Register plugin public APIs into PluginRegistry after construction; type use() plugin overload by PluginId.
packages/core/src/components/PluginRegistry.ts New registry backing api.plugins.
packages/core/src/components/PluginRegistry.spec.ts Unit tests for registry register/unregister behavior.
packages/core/src/components/PluginRegistry.integration.spec.ts Integration test proving late-registered plugin APIs become visible via the shared record.
packages/core/src/api/index.ts Implement EditorAPI.plugins getter delegating to PluginRegistry.
packages/collaboration-manager/test/mocks/createManager.ts Update EditorAPI mock to include plugins.
packages/collaboration-manager/src/CollaborationManager.ts Add static name to CollaborationManager.
openspec/changes/add-plugin-public-api/tasks.md Add planning/tasks for the change set.
openspec/changes/add-plugin-public-api/specs/ui/spec.md Spec updates for keydown delegation and plugin-precedence handling.
openspec/changes/add-plugin-public-api/specs/tool-plugin-options/spec.md Spec for tool → plugin namespaced options and per-id merging rules.
openspec/changes/add-plugin-public-api/specs/sdk/spec.md Spec updates for plugin/tool contracts and new maps/types.
openspec/changes/add-plugin-public-api/specs/plugin-public-api/spec.md Spec for plugin identity and public API exposure via api.plugins.
openspec/changes/add-plugin-public-api/specs/core/spec.md Spec updates for shortcuts plugin + registry requirements.
openspec/changes/add-plugin-public-api/proposal.md Proposal describing motivation, scope, and breaking changes.
openspec/changes/add-plugin-public-api/design.md Design rationale and trade-offs for typing + runtime registry.
openspec/changes/add-plugin-public-api/.openspec.yaml OpenSpec change metadata.
docs/plugins.md Documentation for name, api.plugins, tool plugin options, and TS caveats.
docs/diagrams/plugin-lifecycle-flow.mmd Diagram updates to include registry population step.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

/**
* The shared record backing `api.plugins`
*/
readonly #record: Record<string, unknown> = {};
Comment thread packages/core/src/components/PluginRegistry.ts Outdated
Comment thread docs/diagrams/plugin-lifecycle-flow.mmd Outdated
Comment on lines +71 to +73
Dev->>Plugin: plugin.destroy()
Plugin->>EventBus: removeEventListener (cleanup)
Plugin->>Registry: unregister(name) (drops the public API)
Address PR review feedback:
- back the registry with a null-prototype object so plugin names are plain
  string keys and cannot hit Object.prototype accessors
- use an own-property check for duplicate detection, consistent with
  ToolsManager, instead of `in`
- correct the lifecycle diagram: nothing unregisters a plugin on destroy,
  the registry lives for the lifetime of the Core instance

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants