Skip to content

0027. Inline content is added on the same terms as a block

Status: Accepted

Date: 2026-09-21

Context

Before this record, custom inline content had no seam at all. DocumentValidator::validateInlineContent() accepted only text and link and reported InvalidInlineItem for anything else; InlineContentRenderer::build() sent anything else to refuseItem(), which drops it and logs a debug message naming the type; and the client runtime published registerBlock() with no counterpart for inline content. 0005 named this gap and left it for a later release, and 0008 is the first consumer waiting on it.

Upstream's own shape constrains the design the same way it constrained the block contract. A custom inline content type declares a content kind of 'styled', 'plain' or 'none' (node_modules/@blocknote/core/src/schema/inlineContent/types.ts:11-15), and the shape of one instance follows that kind: 'styled' content is an array of styled text (and, recursively, of other custom items), 'plain' content is a string, and 'none' content is undefined (types.ts:106-118). createInlineContentSpec() turns the config into a ProseMirror node whose content, selectable and atom all follow the same kind, with atom set when the kind is 'none' (node_modules/@blocknote/core/src/schema/inlineContent/createSpec.ts:256-282). The React counterpart, createReactInlineContentSpec(), builds the same node and wraps whatever the type's own render function returns in InlineContentWrapper, which sets a data-inline-content-type attribute and one data-* attribute per non-default prop, so that an internal copy and paste round trips without a custom parse rule (node_modules/@blocknote/react/src/schema/ReactInlineContentSpec.tsx:75-108,126-147).

Converting document JSON into ProseMirror nodes throws for an inline node type the schema does not have: blockOrInlineContentToContentNode() throws "node type X not found in schema" for anything that is not text, a link or a known custom type (node_modules/@blocknote/core/src/api/nodeConversions/blockToNode.ts:291-307), and inlineContentToNodes() reaches that branch for every item that is not a string, a link or styled text (blockToNode.ts:159-186). A single unclaimed inline type therefore prevents the whole editor from mounting, exactly as an unclaimed block type does (0005). A link's own content is typed as styled text and nothing else (blockToNode.ts:101-122, linkToNodes()), so a custom inline item may never appear inside a link. And a styled custom item's own content can nest another styled custom item: contentNodeToInlineContent() builds it from whatever the node's children convert to, text, a link or another custom item, with no depth limit of its own (node_modules/@blocknote/core/src/api/nodeConversions/nodeToBlock.ts:368-381).

On the Drupal side, the settings mappings the module owns are closed FullyValidatable mappings, and block_settings was, before this record, the only dynamic extension point; 0008's Context makes the same point for the seam it needs. The module's own block carrier is preserved, not dropped: the client maps a block the format does not enable onto a spec of the module's own, keeps the original block in a prop, and the server refuses to save a document that still contains one, naming the offending blocks (0002). An inline carrier cannot copy that rule whole: refusing the save is what keeps a block carrier honest, but an inline carrier that refused the save the same way would mean a format switch, or a module an administrator disabled, could block an author from saving an otherwise unrelated edit anywhere in the document. That difference is a design constraint this record has to answer, not an oversight in the block design it borrows from. Amended 2026-09-21. The constraint narrows to the uninstalled-module case: a claimed type a format switch merely disables is refused by name on save, the same as a block; only a type no installed module claims must never block an unrelated save.

Decision

One plugin type describes every custom inline content type. A #[BlockNoteInlineContent] attribute (src/Attribute/BlockNoteInlineContent.php) declares a type's id, label, description, content kind, version, client library, template and deriver, mirroring #[BlockNoteBlock] (0005); src/BlockNoteInlineContentInterface.php carries the property schema, the renderer, the validator and the referenced-entity declaration, as getPropSchema(), build(), validate() and getReferencedEntities(); src/BlockNoteInlineContentBase.php and src/BlockNoteInlineContentManager.php give a plugin the same defaults and lookups a block plugin gets. The id is the type exactly as the document JSON spells it, because the same string has to identify the type on the client and on the server. A property schema is checked by src/Document/PropSchemaValidator.php, lifted out of BlockNoteBlockBase::validateProps() so that a block and an inline content type refuse an unknown, mistyped or out-of-range property with the same three messages and the same order; src/Document/ValidationContext.php is the abstract base both BlockValidationContext and the new InlineValidationContext (src/Document/InlineValidationContext.php) extend, so a plugin of either kind reports into the same shape and the constraint layer reads both the same way. The theme hook pair, blocknote_inline and blocknote_inline__TYPE, mirrors blocknote_block and blocknote_block__TYPE: a type declaring no template of its own renders through the generic templates/blocknote-inline.html.twig, and a type declaring one is found through its own module's templates directory, exactly as BlockNoteThemeHooks::theme() already does for a block.

A format enables inline content on the same terms as it enables blocks. inline_content and inline_content_settings are added to the filter's settings, resolved through the dynamic schema type blocknote.inline_content_settings.[%key], mirroring block_settings. An empty inline_content list means no inline content type is enabled, never every known type: mirroring the block list's empty-means-all rule would enable a type on every format that predates it, including one created with createBlockNoteFormat()'s own default and every format UninstallValidatorTest builds, because the base module ships no inline content type for "every known type" to mean. The stored list is taken as it is; the PluginExists constraint on the sequence's own items is the one thing mirrored from the block schema.

The validator and the renderer each gain one dispatch. DocumentValidator::validateInlineContent() looks up an item's type in BlockNoteInlineContentManager: an unclaimed type is carried with no report, a claimed type outside the format's inline_content list is refused with the new ForbiddenInlineContent, and a claimed, enabled type is checked by its own plugin through InlineValidationContext. Five more DocumentError cases carry what a plugin can refuse: UnknownInlineProp, InvalidInlinePropType and InvalidInlinePropValue for the three ways a property can be wrong, InvalidInlineContentKind for content that does not match the type's declared kind, and InlineRefused for whatever else a plugin's own validate() reports, worded by the plugin's own @problem fragment the way a block's is (0023). InlineContentRenderer::build() sends a claimed and enabled type to its plugin's build() through a new InlineRenderContext (src/Render/InlineRenderContext.php), and falls back to the item's own text, unstyled, for a type no plugin claims or the format does not enable, the same fallback the renderer already gives an unrecognized type today. ReferenceExtractor::walk() gains an inline pass over a block's content, a table cell's content and a styled custom item's own nested content, asking each claimed item's plugin for the entities it references; the pass writes no usage row of its own, the same declared-not-recorded shape 0005 gives a block's referenced entities.

Client registration is registerInlineContent(), on the same terms as registerBlock(). A specification is refused, at the same console levels registerBlock() already uses, for no id or no spec, for the two names BlockNote itself reserves (text, link), for a runtime version mismatch, or as a duplicate. A specification that is registered but the format does not enable is silent, exactly as an unregistered block type is. js/src/schema.tsx resolves a format's enabled, version-matched inline content types into the client schema the same way it resolves blocks.

An inline item the schema cannot hold is carried, not dropped, and the carrier never reaches the server. blocknote_unsupported_inline is an inline content type of the module's own, declared with content: 'none' so upstream's own rule makes it an atom, a single unit the author sees and deletes whole rather than edits into. Unlike the block carrier, which is refused on save so that a document is never silently narrowed by a save an author did not intend (0002), the inline carrier must leave the document saveable: the client unwraps every carrier back into the original item on every serialization and wraps an unclaimed item into a carrier on load, so the value stored is always the original item, never the carrier. A carrier that does arrive at the server is refused by name, InvalidInlineItem, as an editor-only shape a save should never have produced. Reinstalling the module that claims the type, or re-enabling it in the format, restores the item with nothing lost, because nothing about it was ever rewritten.

Uninstalling a module is guarded the same way for inline content as for blocks. BlockProviderUninstallValidator adds a second reason per format, naming the inline content types a format still enables that the module being uninstalled provides, read from the inline content manager's own definitions the same way the block reason already is read from the block manager's.

toExternalHTML is each type's own business. Neither the base module nor this record gives an inline content type a default for what an author's copy carries out of the editor; a type that wants a semantic export writes its own toExternalHTML, the same choice 0008 makes for the entity link it builds on this seam.

Alternatives considered

Extending #[BlockNoteBlock] with a kind property, instead of a second attribute. Rejected. A block and an inline content type disagree in what their content kinds mean (a block's 'table' kind has no inline counterpart, an inline type's 'none' kind has no block counterpart), in where an instance lives in a document (as a member of blocks versus as a member of some content array), in their theme hook families, and in whether a carrier for the type is refused on save or has to survive it. One attribute wide enough for both would carry members that mean nothing for half of what declares it, which is the same reasoning 0005 gave for not folding the default blocks into a hardcoded path instead of a plugin contract.

One carrier type synthesized per unknown inline id, instead of one generic carrier. Rejected. The carrier's whole job is to exist before the unclaimed type is known, so that the client schema is complete without knowing what a document might contain; a carrier synthesized per id would need the client to build a ProseMirror node specification at runtime for a type it has never seen, which upstream gives no primitive for and which the block carrier does not attempt either.

Enabling inline content through the existing blocks list. Rejected. The block schema's PluginExists constraint names the block plugin manager specifically, the format form's paragraph rule and its empty-list-means-all expansion are block-specific rules with no inline equivalent, and the editor plugin resolves a block's library from the block list; none of that machinery is inline content's to reuse without either breaking it for blocks or duplicating it under a different name, which is what a second list avoids.

An empty inline_content list meaning every known type, mirroring the block list. Rejected, for the reason given in Context: the base module ships no inline content type, so "every known type" is empty regardless, and a later inline content type would silently switch on for every format that predates it the moment it is installed, which is not what "no format asked for this" should ever do.

Storing the carrier and teaching the server to read it, as the block carrier does. Rejected. The block carrier exists to make a save fail loudly when a document still names a block the format does not have; an inline carrier built the same way would make every save fail loudly for content the author never touched, the exact opposite of what an inline edit anywhere else in the document should do. Unwrapping on every serialization keeps the stored document exactly what it always was, which a stored carrier could never guarantee once a second reader had to agree on what it meant.

A fixed toExternalHTML for every inline content type, such as a <span> naming the type. Rejected. A generic wrapper would tell nothing about what was actually pasted, and upstream's own precedent, the entity link 0008 builds on this seam, already needs a semantic anchor of its own; leaving the choice to each type costs nothing the base module would otherwise provide well.

Consequences

The implementation lands in stages, each with its own review, the way 0008 said this seam would. This record's own commit adds the PHP contract: the attribute, the interface, the base class, the manager, ValidationContext and its two subclasses, PropSchemaValidator, InlineRenderContext, the generic theme hook and template, and the uninstall validator's inline reason, reading the filter's stored settings directly because the filter itself carries no inline_content key yet. The filter's settings and form, the validator's and renderer's dispatch, the ReferenceExtractor pass, and the client seam each land in a later commit; none of them changes anything this record states. Amended 2026-09-21. 0cdd1ea replaced the direct settings read with getEnabledInlineContentIds().

Two declarations describe one inline content type, in PHP and in TypeScript, versioned the same way a block's are, and they can disagree; the version check in js/src/schema.tsx turns that disagreement into a carried item rather than a document the server refuses.

A module's inline content types block its uninstall while any format still enables one, the same friction 0005 accepts for blocks, and for the same reason: the alternative is losing a format and the fields that depend on it.

A type registered on the client but not enabled in a format is silent, so a developer who forgets to enable a type after registering it gets a validation error on save that names the type, not a mysterious failure to render.

An unclaimed inline type is invisible to the validator: nothing inside it is checked, because nothing claims to know its shape, and nothing but its own text is rendered. That is a narrower guarantee than a claimed type gets, and it is the price of letting an uninstalled module's content keep saving at all.

The client has no clamp for an inline prop whose value falls outside its declared list, the same gap a block prop has; a save refuses the value, and the author finds out on save rather than as they type.

A type brings its own insertion surface: nothing here gives an inline content type a slash menu entry or a suggestion menu trigger, because insertion is keyed on what the type is for, not on a fact this seam could state generically. 0008 states what that means for an entity link.

bun run build:extension builds one bundle per inline content type that needs its own library, the same as for a block; a test module with both a block and inline content extension point builds more than one bundle from more than one Vite configuration.

The @problem fragment convention 0023 states for a block plugin's own message is now also how an inline content plugin words its own refusal, a second sentence frame, "The <label> in the <block> block <fragment>.", that a module author follows for a type as well as for a block. Amended 2026-10-31. The frame is now "The <label> in the <block> block cannot be saved: <sentence>.", with the plugin's message a sentence of its own, as 0023's fragment rule records for both frames.

References

  • BlockNote 0.54.2: node_modules/@blocknote/core/src/schema/inlineContent/types.ts, node_modules/@blocknote/core/src/schema/inlineContent/createSpec.ts, node_modules/@blocknote/core/src/api/nodeConversions/blockToNode.ts, node_modules/@blocknote/core/src/api/nodeConversions/nodeToBlock.ts, node_modules/@blocknote/react/src/schema/ReactInlineContentSpec.tsx.
  • This module: src/Attribute/BlockNoteInlineContent.php, src/BlockNoteInlineContentInterface.php, src/BlockNoteInlineContentBase.php, src/BlockNoteInlineContentManager.php, src/Document/ValidationContext.php, src/Document/BlockValidationContext.php, src/Document/InlineValidationContext.php, src/Document/PropSchemaValidator.php, src/Render/InlineRenderContext.php, src/Hook/BlockNoteThemeHooks.php, src/BlockProviderUninstallValidator.php, templates/blocknote-inline.html.twig, blocknote.services.yml. Later commits: src/Plugin/Filter/BlockNoteFilter.php, src/Document/DocumentValidator.php, src/Render/InlineContentRenderer.php, src/Render/RenderPolicy.php, src/File/ReferenceExtractor.php, js/src/runtime.ts, js/src/schema.tsx, js/src/editor.tsx, js/src/index.tsx, js/src/unsupported-inline.ts.
  • Tests: tests/src/Kernel/InlineContentPluginContractTest.php, tests/src/Kernel/BlockPluginContractTest.php, tests/src/Kernel/UninstallValidatorTest.php, tests/src/Kernel/ExtensionRenderTest.php.
  • 0002, 0004, 0005, 0008, 0014, 0023.