feat(core): expose plugin public APIs and tool-directed plugin options#184
Open
gohabereg wants to merge 2 commits into
Open
feat(core): expose plugin public APIs and tool-directed plugin options#184gohabereg wants to merge 2 commits into
gohabereg wants to merge 2 commits into
Conversation
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>
Unit Tests
Mutation Tests
|
Contributor
There was a problem hiding this comment.
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 addsEditorAPI.plugins. - Core: adds
PluginRegistryand wires pluginpublicApiregistration during plugin construction; migratesShortcutsPluginto typed tool plugin options and exposesregister/unregister. - UI & tools: dispatches
KeydownUIEventinBlocksUIand migrates built-in inline tool shortcut config underoptions.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 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Plugins registered through
core.use()are write-only participants: they subscribe to theEventBusand 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]: unknownescape hatch (ShortcutsPluginreadtool.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
EditorjsPluginApiMapandToolPluginOptionsMap— that plugin packages fill in under their ownname. Neithersdknorcoreever knows a concrete plugin.EditorjsPlugingains an optionalpublicApi;EditorjsPluginConstructorgains the staticname.EditorAPI.plugins, typedPartial<EditorjsPluginApiMap>.BaseToolOptions.pluginsandBaseToolFacade.pluginOptions(id), merged per plugin id.Core
PluginRegistryholds one shared mutable record. Plugins receiveEditorAPIin their constructor — before later plugins exist — so a snapshot would permanently hide anything registered afterwards.api.pluginsis a getter resolving at access time, making registration order irrelevant.ShortcutsPluginmigrates onto both mechanisms as the proving case and exposesregister/unregister.UI
BlocksUInow dispatchesKeydownUIEvent. The event type existed andShortcutsPluginlistened for it, but nothing ever dispatched it — no keyboard shortcut had ever reached a plugin in the running app. A plugin claiming a key viapreventDefault()takes precedence over the built-in undo/redo bindings.Breaking changes
options.shortcutis no longer read. Move it underoptions.plugins.shortcuts. The old flat key still type-checks (tool options carry an index signature), so it fails silently — grep for it.name. A compile error for plugins exposing apublicApi; others silently fall back to the (minifiable) class name, so declare it regardless.Verification
yarn build,yarn lint,yarn test(932 tests) all green.@ts-expect-errorcover the augmentation behaviour, theFunction.nameshadow, and per-id option merging.KeydownUIEvent→ShortcutsPlugin→pluginOptions('shortcuts')→applyInlineTool).preventDefaultgate changes nothing.Known limitations (documented, not hidden)
docs/plugins.mdgains a TypeScript caveats section covering what the compiler does and does not check:static optionsis 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. Theuse()second argument is checked. Opt in withsatisfiesplus a type-only dependency on the plugin package.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 (registeringBoldInlineToolfrom a package lacking theshortcutsaugmentation). That failure mode gets worse as tool/plugin registration moves out ofcore.ShortcutsPluginlives incoreand is not re-exported, so its types are not reachable by consumers, and cannot reachdom-adapters/uiwithout a dependency cycle. Extracting it topackages/plugins/shortcutsis the follow-up.Follow-ups (not in this PR)
ShortcutsPlugininto its own package so its augmentation is reachable everywhere.packages/uihas 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