0040. Markup the converter cannot convert is kept in a block no plugin provides¶
Status: Accepted
Date: 2026-10-31
Context¶
The converter of 0038 unwraps an element it does not know when the element holds text, so the text survives, and dropped and reported the rest: an iframe, an object, an embed, an svg, a drupal-media or drupal-entity element until the waves that resolve them land, and an empty custom element an editor plugin stored, such as an embed placeholder. Each was a report line and nothing more. A site converting its bodies wants nothing lost: the early user's notes ask for markup to be kept and reported, or kept in a block an editor fixes, with known junk droppable by a rule of its own. 0038 deferred a carrier to the wave that writes documents into fields, because with no write path a dropped element is only a report line, and the carrier needs the editor to show it.
The module already has the parts such a block needs, from 0002 and 0035. A stored block whose type no plugin provides is refused on save by the field's constraint, renders nothing, with a debug message, and is counted by the document scan as MissingBlock. The client wraps a block its schema does not hold in its own carrier, blocknote_unsupported, which keeps the block verbatim in a prop and shows a notice, and the validator reports the wrapped type when a save sends the wrapper back. An inline content type no plugin claims does not keep a document from being saved: the client unwraps it on every save (js/src/carried.tsx).
The constraint's message for a type no plugin provides tells the author to ask an administrator to restore the module that provides it, and the status report's line says the same; neither fits markup no module will ever provide. 0023 asks a message to say what is wrong in the author's words and what they can do.
A format that holds one paragraph (0030) holds no other block.
Decision¶
Markup the old page showed that no rule can convert is kept in a block of type blocknote_unconverted, DocumentValidator::UNCONVERTED_BLOCK_TYPE. Its html prop holds the element's markup as the walker met it, after the HTML preprocessors, the source format's filters and any replacement a rule made, and its reason prop holds the report code that says why, several separated by spaces. It has no content and no children. Elements kept one after another in the same list of blocks, with nothing but whitespace between them, share one block, because they are one thing for an author to rebuild, such as an embed code split over a placeholder and an iframe. No plugin provides the type, and none may: the block plugin manager leaves out a plugin a module declares of that id, or alters in, logs it as an error and says so on the status report, as it does for the client's carrier, blocknote_unsupported. It does not throw, since every page that reads the block plugins, the theme registry among them, would fail with it.
A rule asks for it with ElementResult::carry($code, $detail). The module's embed rule returns it, so iframe, object, embed, svg, math, canvas, drupal-media and drupal-entity are kept; a site's rule does the same for markup it recognizes and cannot convert, such as a custom element its old editor's filter expanded. An empty element no rule converts is still dropped and reported, as 0038 has it: a browser shows nothing for it, and the ones a page meets most, left by a browser extension in the middle of a sentence, would split the sentence around a block an author has to remove. An element met inside a paragraph splits it, as any block does. Each kept element is reported in a category of its own, carried, listed once per event.
Keeping is the default. ConversionOptions::UNCONVERTED is carry unless a caller passes drop, which drush blocknote:convert-html and drush blocknote:convert take as --unconverted=drop; dropping reports the element as dropped with the same code. A format that holds one paragraph drops the element too. Markup a page never showed, such as a script or a form control, is stripped as before, and a rule that drops markup on purpose still drops it.
The block keeps the document from being saved until an author has removed it. findUnsupportedBlockTypes() counts the type, stored or wrapped in the client's carrier, apart from the types no plugin provides, and the constraint refuses it with a message of its own, unconvertedMarkupMessage, code unconverted_markup: what is wrong, then what the author does, naming the block by the label the editor shows on it. ConversionGate lets it through, the one block type no plugin provides that it accepts, since keeping it is the point.
It renders nothing. The renderer leaves it out as it leaves out any block no plugin provides, so its markup never reaches a page in any form.
The status report counts it on a line of its own. The document scan answers UnreadableReason::Unconverted for a document in a current revision holding one, stored or wrapped, and the requirement's line says that the markup is left out of the page and what to do: rebuild what each block shows with the editor's own blocks, remove the blocks and save. DocumentScanner::VERSION is raised to 2, so a pass counted before this starts over.
The editor shows it as markup, as text. The client wraps the block in its carrier, as it wraps any type its schema does not hold; the carrier recognizes the wrapped type and shows Markup that could not be converted, the reason in words, the markup as escaped text in a pre, and a hint to rebuild it and remove the block. The notice above the editor names it apart from the blocks a format does not permit.
Alternatives considered¶
Dropping, and reporting only. The default until now, kept as --unconverted=drop. Rejected as the default: the markup is gone from the document once the old field is deleted, and the report is the only trace. Kept, the markup costs the page nothing more than dropping it does, since it renders nothing, and is counted and recoverable.
Rendering the markup. Rejected: that is raw HTML inside a document, which 0004 refuses and the backlog parks. The block never renders, and blocks a save until a person acts.
A block plugin for it. Rejected: a plugin would make it a block a format can enable and an author can save, and the point is that it must not stay.
An inline carrier. Rejected: an inline type no plugin claims does not keep a document from being saved, so the markup would be saved and forgotten.
Keeping every empty element no rule converts too. Rejected: a browser shows nothing for it, and keeping one met in a sentence splits the sentence; a site whose element stood for something writes a rule that carries it.
A block of its own type for every kept element. Rejected for runs of elements: an embed code is often a placeholder and an iframe, or several iframe elements in a row, and one block for each is several things to remove for one thing to rebuild.
Folding it into the message for a block no plugin provides. Rejected: that message tells the author to ask for a module to be restored, and no module provides this block.
Consequences¶
A converted document holding the block cannot be saved until an author rebuilds what it shows and removes it, and its page lacks that markup until then: the cost the early user accepted for nothing being lost. A site that prefers the old behavior converts with --unconverted=drop.
The document scan starts a new pass on every site once, because its version changed.
The conversion fixtures in tests/fixtures/conversion/ keep the block where they once dropped embeds, and GoldenConversionTest expects the constraint to refuse exactly those documents, with this record's message. js/src/__tests__/conversion-roundtrip.test.ts opens them through the client's carrier, as the widget does, and reads the block back out of it unchanged.
When the media submodule converts a provider's player, an iframe it takes is no longer kept; a site converting again after that wave replaces what it kept only where the converter wrote the document and no author has edited it since, as 0039 describes.
References¶
src/Convert/ElementResult.php(carry()),src/Convert/HtmlWalker.php,src/Convert/HtmlConverter.php(mergeUnconverted()),src/Convert/BlockFactory.php(unconverted()),src/Convert/Rule/EmbedRule.php,src/Convert/ConversionGate.php,src/Convert/ConversionOptions.php,src/Convert/Report/ReportCategory.php.src/Document/DocumentValidator.php(UNCONVERTED_BLOCK_TYPE,findUnsupportedBlockTypes()),src/BlockNoteBlockManager.php(findDefinitions()),src/Hook/BlockNoteBlockPluginHooks.php,src/Plugin/Validation/Constraint/BlockNoteDocumentConstraint.php,src/Document/DocumentScanner.php,src/Document/UnreadableReason.php,src/Hook/BlockNoteDocumentScanHooks.php.js/src/schema.tsx(UnsupportedBlock),js/src/carried.tsx,js/src/portable/unsupported.ts.tests/src/Kernel/Convert/UnconvertedMarkupTest.php,tests/src/Kernel/DocumentScanTest.php,tests/src/Kernel/BlockPluginContractTest.php,js/src/__tests__/unconverted.test.tsx.- docs/converting.md section 3, docs/rendering.md section 2.
- 0002, 0004, 0023, 0030, 0035, 0038, 0039.