Sitelet https://github.com/editor-js/document-model/pull/192
Skip to content

feat!: implement per-block plugin data - #192

Open
gohabereg wants to merge 8 commits into
spec/tunes-as-pluginsfrom
feat/plugin-block-data
Open

gohabereg wants to merge 8 commits into
spec/tunes-as-pluginsfrom
feat/plugin-block-data

Conversation

@gohabereg

Copy link
Copy Markdown
Member

Implements the plugin-block-data change from #189. Stacked on #189 — review that one first; this PR's diff is implementation only.

Makes per-block data a working, plugin-owned store. Today BlockNode has a tunes store that nothing above the Model can reach: updateTuneData crashes for an entry the block wasn't created with, BlocksAPI can't read or write it, undo is a silent no-op, and CollaborationManager drops the event as "Unknown event type".

What's in it

  • Model. The tunes store becomes plugins, keyed by plugin name. Writing to an absent entry creates it; setting a key to undefined removes it; empty entries are left out of serialization.
  • Renames. TuneIndex → PluginDataIndex, Index.tune() → Index.pluginData(), TuneModifiedEvent → PluginDataModifiedEvent, BlockTuneName/BlockTuneSerialized → PluginDataName/PluginDataSerialized, and the BlockTune model entity → PluginDataNode.
  • Undo/redo. EditorDocument.modifyData applies Modified changes addressed by a PluginDataIndex, so the generic inversion in UndoRedoManager works for plugin data.
  • Collaboration. PluginDataModifiedEvent becomes a Modify operation, and OperationsTransformer gains a plugin-data branch so such an operation can also be the one transformed against — without it the transformer throws 'Unsupported index type' on the first remote plugin-data change, on every client and on ot-server. ModifyOperationData's payload widens from Record<any, any> so scalar values are representable.
  • API. BlocksAPI.getPluginData({ block, plugin }) and updatePluginData({ block, plugin, data, userId? }); insert/insertMany accept a plugins map; move and convert preserve plugin data and split leaves it on the original block. A new augmentable EditorjsPluginDataMap types both accessors, alongside EditorjsPluginApiMap and ToolPluginOptionsMap.
  • v2 conversion. composeDataFromVersion2 carries each v2 block's tunes into plugins, keys verbatim, so a v3 plugin adopting a v2 tune's name finds its data. Object data is copied key by key; a primitive or array is stored under value. The converter dropped tunes silently before.

Breaking (pre-1.0)

The serialized block key tunes → plugins, and the serialized index k: "tune" → k: "plugin". ot-server is not a passthrough — it keeps its own EditorJSModel and applies every operation — so clients and server deploy together.

Notes for review

  • The SDK's BlockTune tool contract is untouched here. It never read Model tune data; removing it is the next PR in the stack.
  • The naming of the plugins key is under discussion in docs(openspec): propose replacing Block Tunes with plugins #189. If it changes, the rename is mechanical and lands here.
  • Task 6.4 is intentionally unchecked: the archive gate uses an all-checked tasks list as the ready-to-archive signal, and its second half (filling the new capability's ## Purpose, which the mechanical fold writes as a placeholder) can only happen after /archive.

Verified: yarn build, yarn lint and yarn test clean; surviving mutants killed in the plugin-data paths; openspec validate plugin-block-data --type change passes.

🤖 Generated with Claude Code

gohabereg and others added 8 commits September 26, 2026 00:17
Renames the model's tune vocabulary so per-block extra data belongs to
plugins rather than to a dedicated Block Tune entity:

- BlockTune entity -> PluginDataNode, with key deletion when a value is set
  to undefined and an isEmpty getter
- TuneIndex -> PluginDataIndex, serialized as {"k":"plugin",...,"plugin":...}
- TuneModifiedEvent -> PluginDataModifiedEvent, BlockTuneEvents ->
  PluginDataEvents
- BlockTuneName/BlockTuneSerialized -> PluginDataName/PluginDataSerialized
- the serialized block key `tunes` -> `plugins`

Behavior changes beyond the rename:

- BlockNode.updatePluginData creates a missing entry instead of throwing. The
  node is created empty and its listener attached before the first update,
  because PluginDataNode dispatches synchronously.
- entries live in a null-prototype record, and an empty plugin name throws, so
  a document-supplied name like __proto__ is stored as plain data
- empty entries are left out of serialization
- EditorDocument.modifyData applies a Modified change addressed by a
  PluginDataIndex, so undo/redo and remote operations can replay plugin data
- the bubbled event carries the acting user from the context instead of a
  hardcoded 'user'

BREAKING CHANGE: the serialized block key `tunes` is now `plugins`, and the
serialized index kind "tune" is now "plugin".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- re-export the renamed model types (PluginDataName, PluginDataSerialized,
  PluginDataIndex, PluginDataModifiedEvent, createPluginDataName)
- declare EditorjsPluginDataMap, a third augmentable map alongside
  EditorjsPluginApiMap and ToolPluginOptionsMap, and resolve a plugin's data
  shape through PluginDataFor<Id>. An undeclared id falls back to a plain
  record rather than never, since per-block data is often stored by a plugin
  that has no reason to publish its shape.
- add BlocksAPI.getPluginData / updatePluginData and a `plugins` map on insert

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- BlocksAPI.getPluginData / updatePluginData, backed by BlocksManager, with
  the acting user defaulting to the configured one
- insert() accepts a `plugins` map, and convert() carries plugin data over to
  the converted block
- fix insert() dropping the block's data: BlocksManager spread `...data` into
  the block init while BlockNode reads a nested `data` key, so inserting with
  data silently produced an empty block. `plugins` is passed as its own key.
- fix BlocksAPI.insert not forwarding `focus`, `plugins` and `userId`
- add UndoRedoManager integration tests over a real model: undoing a first
  write removes the entry, redo restores it, and undoing one key leaves the
  plugin's other keys untouched
- fix the BlocksAPI integration spec wiring a second, empty model into the API,
  which made every direct model read in it see an empty document

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- map PluginDataModifiedEvent to a Modify operation carrying the new and
  previous values, instead of logging it as an unknown event type
- add a PluginDataIndex branch to OperationsTransformer#applyTransformation,
  which dispatches on the against-operation's index kind and previously threw
  'Unsupported index type'. Without it the first remote plugin data change
  threw in OTClient's pending-operation reduce, in transformStacks for every
  remote event, and in ot-server's conflicting-operation reduce.
- widen ModifyOperationData's payload from Record<any, any> to unknown, so a
  scalar plugin data value is representable, and narrow the two payload length
  reads that relied on the old type
- move the ot-server fixtures off the `tunes` key

Two concurrent writes to the same key stay untransformed: server order decides
and the last applied one wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mutation testing (scoped to the changed files, as CI does) pointed at four
real gaps:

- BlocksManager.insert: nothing asserted that block data is nested under a
  `data` key rather than spread, nor that the plugins map is forwarded and
  omitted when absent
- convertBlock: nothing covered carrying plugin data to the converted block
- EditorDocument.modifyData: the PluginDataIndex branch had no model-level test
- BlockNode: the initial-data path of plugin node creation was unasserted

Fixing the last one surfaced a bug in BlockNode.serialized: it assigned entries
into a plain object, so a plugin named `__proto__` set the object's prototype
instead of becoming a key and vanished from the serialized block. The entry map
is now built with Object.fromEntries. The previous test only passed because its
expected literal was broken the same way — writing the key as a computed
property, to satisfy the naming-convention lint rule, is what exposed it.

Scores for the changed files: model 90.38 (threshold 75), core 86.72,
collaboration-manager 52.08 (threshold 0, unchanged file-level scores). No
surviving mutants remain on lines this branch touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
composeDataFromVersion2 dropped each v2 block's `tunes` silently. It now maps
them onto the v3 `plugins` key:

- names are kept verbatim, so a v3 plugin that adopts a v2 tune's name finds
  its data without a rename table
- object data is copied key by key
- a primitive, an array or null goes under the `value` key, since a plugin entry
  is a key/value map while v2 tune data is any JSON value
- a block with no tunes, or an empty map, produces no `plugins` key
- the entry map is built with Object.fromEntries, so a tune named `__proto__`
  survives instead of setting a prototype

Adds the file's first spec, including the converter's existing data handling,
and an integration test taking a v2 document through the converter into the
model and reading it back with api.blocks.getPluginData.

The spec stubs DOMParser, which the pre-existing inline-fragment extraction
needs and this package's node test environment lacks. That extraction stays
untested, so the file's mutation score is low (25%, threshold 0) — it simply
had no spec at all before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- model.md, events.md, index-serialization.md: PluginDataNode,
  PluginDataIndex (`k: "plugin"` with `plugin`/`key` fields),
  PluginDataModifiedEvent, and Index.pluginData
- plugins.md: a new "Per-block plugin data" section for plugin authors — the
  two API calls, the EditorjsPluginDataMap augmentation, and the behaviors that
  bite (key removal, per-key undo granularity, last-writer-wins on the same
  key, preserved data for unregistered plugins, move/convert/split, v2 carry
  over) — plus the two new api.blocks rows and the third type map
- diagrams: model-tree-structure, events-catalog, architecture-overview
- docs-updater agent: map the renamed model entity to docs/model.md

Mentions of the Block Tune *tool kind* stay: they belong to remove-block-tunes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every task is done except the final one, which is the ready-to-archive signal:
leaving it unchecked keeps the archive gate quiet until a maintainer comments
/archive on the PR, per CONTRIBUTING.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Unit Tests

Package Coverage Delta
@editorjs/dom-adapters 86.95% 0% ⚪️
@editorjs/shortcuts-plugin 100% 0% ⚪️
@editorjs/model 98.53% +0.05% 🟢
@editorjs/core 76.39% +3.25% 🟢
@editorjs/editorjs 100% 0% ⚪️
@editorjs/ot-server 20% 0% ⚪️
@editorjs/clipboard-plugin 66.66% 0% ⚪️
@editorjs/model-types 67.85% +8.03% 🟢
@editorjs/collaboration-manager 86.01% +0.2% 🟢

Mutation Tests

Package Mutation score Dashboard URL
@editorjs/dom-adapters No files to mutate found.
@editorjs/shortcuts-plugin No files to mutate found.
@editorjs/model 89.76% 🟢 Dashboard
@editorjs/core 70.93% 🟡 Dashboard
@editorjs/clipboard-plugin No files to mutate found.
@editorjs/collaboration-manager 51.27% 🔴 Dashboard

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.

1 participant