0005. One plugin type describes every block, default blocks included¶
Status: Accepted. The contract is no longer provisional. It has been exercised end to end by a block built outside the module's own vocabulary: blocknote_test's callout declares its own properties, ships its own template, refuses a shape only it knows is wrong, and registers its client specification from a bundle built with the shipped Vite preset, and a browser test drives it from the slash menu to the public page. The exercise added three things to the contract rather than changing any of it: a template property on the attribute, two optional interfaces for per-format block settings, and a client-side specification factory beside a plain specification. What it found missing is recorded in 0006: the attribute cannot declare the theme variables a block's own template needs.
Date: 2026-09-13
Context¶
BlockNote 0.54.2 ships fourteen default block types: audio, bulleted list item, checklist item, code block, divider, file, heading, image, numbered list item, paragraph, quote, table, toggle list item and video. A module that renders documents has to understand all of them, and a module that is worth extending has to let other modules add more.
The upstream behavior that constrains any such design:
A block type the client schema does not know cannot be loaded at all. Converting a block to a node throws when the type is not in the schema, so a single unknown block prevents the whole editor from mounting. This is why the client must have a spec for every type a document can contain, including types it does not support.
Block properties are a closed set. ProseMirror drops any attribute the node specification does not declare, so a default image, video, audio or file block cannot be made to carry an extra Drupal identifier. Identity for those blocks has to live inside their existing url property.
A custom block's property schema is not arbitrary. It is a set of primitive defaults with optional enumerated values. A custom block may have inline content, no content, or plain content, and may never have table content.
The default blocks are not uniform. A quote carries only background and text color, a table only text color, a code block only a language and plain content, a divider carries no properties at all, audio has no text alignment, and headings add a level and a flag marking them collapsible. Any code that assumes one shared property set across the default vocabulary is wrong.
On the Drupal side, one core behavior rules out the obvious protection mechanism. Making a block's providing module a configuration dependency of the text format does not protect the format: FilterFormat::onDependencyRemoval() removes only disabled filters whose provider is being uninstalled, and any other dependency removal deletes the format outright, taking with it the fields whose allowed formats depended on it. A dependency intended as a safety rail would become a delete button.
Decision¶
One plugin type describes every block, whether it is one of the fourteen upstream defaults or one a third-party module adds.
A #[BlockNoteBlock] PHP plugin declares a block's id, its property schema expressed in PHP using BlockNote's own property schema semantics and converted for the client, its content kind, its block version, the JavaScript library that registers its client specification, its renderer, its validator, and the entities an instance of it references. The fourteen default blocks are plugins on exactly these terms, with no privileged path, which is the only way to know that the contract is sufficient. src/Attribute/BlockNoteBlock.php carries the id, the label, the description, the content kind, the block version and the client library; src/BlockNoteBlockInterface.php carries the property schema, the renderer, the validator and the referenced-entity declaration, as getPropSchema(), build(), validate() and getReferencedEntities(). Amended 2026-11-01. A block id holds no colon, because BlockNote builds the selector that recognizes a pasted block from the type without quoting it, and a colon makes the selector invalid for the whole editor. BlockNoteBlockManager stops discovery with an exception that names the type and its module, and the attribute no longer has a deriver property, since a derived plugin id is spelled base_id:derivative_id. The restriction is planned to go once upstream quotes the selector; the draft in docs/drafts/ asks for that.
The contract covers blocks. Custom inline content is not part of it: there is no inline-content plugin type, no registerInlineContent() on the client runtime, and DocumentValidator accepts text and link and reports InvalidInlineItem for every other inline type. A seam shaped the way this one is, so that an extension adding an inline type is described the same way as one adding a block, is planned for a later release and will carry its own record; 0008 is the first consumer waiting on it. Amended 2026-09-21. The seam exists: 0027 records the inline content plugin type, its client registration, validator and renderer dispatch, the inline carrier and the inline pass on ReferenceExtractor. This paragraph describes the state before it. Custom styles are out of scope: the permitted styles are the fixed list the format's policy selects from. Amended 2026-09-26. The fixed list is seven styles now, not five: subscript and superscript joined bold, italic, underline, strike and code. See 0031.
Uninstalling is guarded by a validator, not by dependencies. A module uninstall validator, src/BlockProviderUninstallValidator.php, blocks uninstalling a module while any text format still enables one of its blocks. The administrator disables the block in the format first; documents that still contain it then behave exactly as any other unsupported block does, which is described in 0002. That step cannot be taken for the base module itself, because the format form refuses a block list without paragraph; uninstalling blocknote means turning its filter off on each format that has it on, as docs/extending.md section 5 spells out. No configuration dependency is declared from the format onto a block provider.
Client registration never authorizes persistence. A block specification registered in the browser gives the editor something to render; it grants nothing. For a document containing a block to be saved, that block must exist as a PHP plugin and be enabled in the item's format. Validation is where that is decided, and the client cannot reach it.
Libraries and specifications are resolved per instance. The libraries of the blocks enabled in a format are resolved before the editor attaches, and the schema handed to an editor instance is selected from that format's policy, not from everything that happens to be registered on the page. \Drupal\blocknote\Plugin\Editor\BlockNote::getLibraries() attaches blocknote/editor plus the library declared by each block the format enables, and nothing else. The fourteen default blocks declare no library, because their specifications are in the editor bundle itself, so a site that adds no block still loads only blocknote/editor.
Referenced entities are declared. A block plugin reports the entities an instance references, through getReferencedEntities(). The seam exists and is part of the contract; BlockNoteBlockBase answers with an empty array; MediaBlockBase overrides it for the image, video, audio and file blocks, which reference a file entity since 0007 gave them a file:<uuid> identity, and the media submodule's block overrides it too. In the first release that seam feeds file usage recording and nothing else; it reaches no render path, and the explicit output for a missing or inaccessible entity is produced by the block's own build(), which resolves the stored identity directly. It is a declared interface rather than a stored index, so that a usage integration can be added later without a table of its own.
Alternatives considered¶
A separate plugin type, or a hardcoded path, for the default blocks. Rejected. The default vocabulary is the only sizeable body of blocks that exists today, and if it does not go through the extension contract, the contract is untested by construction and will not fit the first real extension.
Configuration dependencies from the format onto block providers. Rejected for the reason in the context: because of how core removes dependencies from a format, this would turn a module uninstall into the deletion of the format and the loss of the fields that depend on it. An uninstall validator refuses the uninstall instead, with an explanation, and nothing is destroyed.
Letting the client be the source of truth for which blocks exist. Rejected. It makes the browser's registration list a security decision, and anything that can register a specification could then persist a block the server has no renderer or validator for.
One plugin type per concern, for example separate renderer and validator plugins. Rejected as unnecessary indirection for the first release. A block is a single idea, and splitting it into three registrations triples the work of adding one and makes version agreement between the parts a new problem.
Free-form property schemas. Rejected, because upstream does not support them. Properties are primitive defaults with optional enumerated values, and pretending otherwise in PHP would produce definitions the client cannot express.
Consequences¶
Adding a block means writing one PHP plugin and one client specification, built against the documented preset, and nothing else. No patch to this module, and no rebuild of its bundle.
Because the default blocks are plugins, the plugin contract has to be expressive enough for the asymmetric property sets upstream actually uses, including a block with no properties, a block with only a language, and a block with plain rather than inline content. That is a real constraint on the contract's design.
Two declarations describe one block, in PHP and in TypeScript, and they can disagree. The block version and the registry check described in 0003 turn that disagreement into a refused registration rather than into a document that saves and then fails to render.
An administrator cannot uninstall a module whose blocks are still enabled in a format without taking an explicit step first. That is friction, and it is preferred to the alternative, which is losing the format and the fields that reference it.
Because client registration authorizes nothing, a developer who registers a specification and expects it to work will get a validation error on save. The error names the block, so the missing half of the contract is discoverable rather than mysterious.
The referenced-entities seam is declared by each block plugin, which means a block that under-reports its references will silently break file usage accounting for its own content. Tests cover the blocks this module ships; a third-party block is responsible for its own.
References¶
- BlockNote 0.54.2:
@blocknote/coresrc/blocks/defaultBlocks.ts,src/blocks/defaultProps.ts,src/schema/blocks/types.ts,src/api/nodeConversions/blockToNode.ts;createBlockConfig,createBlockSpec, and the React helperscreateReactBlockSpec,createReactInlineContentSpec,createReactStyleSpec. - Drupal core 11.4.6:
core/modules/filter/src/Entity/FilterFormat.php::onDependencyRemoval(),core/lib/Drupal/Core/Extension/ModuleUninstallValidatorInterface.php. - 0002, 0003, 0004.