0002. Text formats are the editor profile and the permission set¶
Status: Accepted on 2026-09-13. The probe described under "What closes this record" ran against Drupal 11.4.6 and BlockNote 0.54.2 and answered every question in favour of this decision; the fallback in "Alternatives considered" is no longer live.
Date: 2026-09-13
Context¶
A site builder configuring a rich-text editor in Drupal expects two questions to be answered in one place: which formatting and which blocks are permitted, and who is allowed to use them. Core answers both with text formats. A filter_format config entity carries the filters, an editor config entity attached to it carries the editor and its settings, and the filter module generates one permission per format, use text format <id>.
The module stores JSON, not HTML, which is why using text formats at all is a decision rather than a default. Several details of the core APIs decide whether it can work.
An editor plugin may declare is_xss_safe: TRUE. Core then never runs the value through the editor XSS filter, neither on initial load nor on a format switch. A known core JavaScript bug means the switch request is still sent, because the client reads format.editor.isXssSafe on a string, but the server refuses it and the value is preserved. Switching format destroys and re-creates the editor instance.
Editor attachment is decided per text_format render element, not per field type. Any field that offers a format whose editor is ours will be handed our editor, including a core Body field, unless the module prevents it.
Core's ProcessedText calls every enabled filter's prepare() before any filter's process(), so a second filter can transform the text before ours ever sees it. A format with no enabled filters emits its text verbatim as trusted markup, which for a JSON document means printing the JSON on the page. HTML restricting filters run after a renderer would have produced its output and would strip it.
Config dependencies do not protect a format. FilterFormat::onDependencyRemoval() removes only disabled filters whose provider is being uninstalled; any other dependency removal deletes the format outright, and fields whose allowed_formats depend on that format go with it.
The use text format permission is enforced by the text_format render element and nowhere else. Programmatic saves, including JSON:API, are not checked by core.
Two contributed modules in the same family, Gutenberg and ProseMirror, both store JSON and both integrate through the Editor plus Filter pair, which is evidence the shape works in practice.
Decision¶
A BlockNote editor profile is a text format.
Definition. A BlockNote format is a filter_format whose attached editor config entity uses the blocknote editor plugin. A validation constraint on the filter_format config entity, not merely a check in the format form, requires that the blocknote editor and the blocknote filter imply each other, and that no other filter is enabled in that format. Putting the constraint on the entity rather than only in the format form makes it hold for every caller of the entity's typed data validate(), and the format form refuses such a format as well. The form does not call the constraint: \Drupal\blocknote\Hook\BlockNoteHooks::validateFormat() restates the same three rules against the submitted values, with its own messages placed on the offending elements, because on a new format the editor config entity does not exist yet and the editor the administrator just chose is not on the filter_format entity the constraint would see. It does not reach configuration import, recipes or command line tools: core's ConfigImporter dispatches events and validates nothing, recipes and config actions validate the config schema tree, which carries no entity-type constraints, and a programmatic save of a config entity is not validated at all. That is a limitation of core, not a gap this record accepts lightly, and its consequence is that a format created by import can fail the definition; an item using such a format then renders nothing, which is the refusal path the formatter already implements and logs.
The filter carries the policy. The blocknote filter plugin turns the stored envelope into HTML through the PHP renderer, and its settings are the single source of content policy: which block plugins are enabled, which inline styles and colors are permitted, the link policy, and per-block settings such as allowed media bundles and view modes. Because core runs every prepare() before any process(), and because a filter that restricts HTML would strip the renderer's output, no second filter is permitted in the first release. The renderer is the sanitizer.
The editor derives from the filter. The blocknote editor plugin declares is_xss_safe: TRUE and supports_content_filtering: FALSE. Its getJSSettings() derives the client schema and the client policy from the same format's filter configuration, and it returns the libraries of the enabled blocks. Its own settings are user interface toggles only: formatting toolbar, side menu, slash menu, link toolbar, table handles and file panel, all on by default. Amended 2026-09-21. The editor entity now carries eight toggles: the six surfaces named here, paste_markdown (0016) and click_into_links (0026).
Refusal to render. The field formatter and the computed processed property render nothing, and log, when an item's format does not satisfy the definition above. They do not fall through to a generic processed_text render, which would print the stored JSON on the page. Amended 2026-11-01. The blocknote filter's process() refuses a format that enables a second filter too, which closes the one path left open: a direct call to check_markup(), which runs the filters of any format and would have handed the renderer's output to the second one. The filter returns an empty result carrying the format's and its editor's cache tags and logs one refusal, as the formatter does. This enforces the definition above and decides nothing new, so it is an amendment and not a record. A filter plugin is not told its format, so a load hook gives each loaded format's blocknote filter the format's id; an instance no loaded format owns checks nothing, and the other routes to a document still refuse through FormatRegistry::isBlockNoteFormat().
The widget uses the core element. The widget renders a text_format element whose textarea holds the envelope. Core then enforces use text format X, handles the no-access case by disabling the control and preserving the value, and drives attach, detach and serialize through the Text Editor module's JavaScript.
Formats are fenced in both directions. A BlockNote field offers only BlockNote formats. A text_format element that is not a BlockNote widget has BlockNote formats removed from its options by an element process callback, and the editor JavaScript refuses to attach without the widget's marker. Without both halves, a Body field could select a BlockNote format and be handed a JSON editor over HTML content.
Unsupported blocks are preserved, not dropped. The client bundle registers a module-owned blocknote_unsupported block spec in every schema. When a document contains a block whose type the format does not enable, which happens after a format switch or after an administrator disables a block, the client maps it onto that spec, keeps the original block in a prop, and renders it read only with a notice. The server never rewrites the stored value. Saving a document that still contains such a block is refused with a violation naming the offending blocks, so the author either removes them or switches back to a format that supports them.
What closes this record¶
A throwaway editor plugin, filter and widget on a real site, answering these questions. Each is a question about core behavior that source reading alone does not settle.
- Does
use text format Xgate the widget as expected, and does the no-access path disable the control while preserving the stored value? - Does initial load survive a document containing literal HTML, a code block, and ampersands inside the JSON, with
is_xss_safe: TRUE? - Does a format switch survive the same content, including a failed switch request and rapid repeated switches, without corrupting or losing the value?
- Does the AJAX serialize trigger write the document into the form element on "add another item", without unmounting the editor?
- Do multiple editor instances on one page attach, serialize and detach independently?
- Is a BlockNote format genuinely impossible to select on a core Body field, and does the editor refuse to attach if one is forced?
- Does a format that fails the definition above render nothing rather than printing JSON?
- After switching to a format that lacks a block the document contains, does the client map it to the unsupported spec, and does the save refuse with a violation naming that block?
Probe results¶
Run on 2026-09-13 against Drupal 11.4.6, BlockNote 0.54.2 and Claro, on a development site and in tests/src/FunctionalJavascript/TextFormatMechanicsTest.php. One line per question above.
- Permission gating and the no-access path. Yes. An author without
use text format blocknote_fullgets core's disabled control with its explanatory text, the editor does not attach at all, the format control is removed, and saving the node preserves both the stored document and its format byte for byte while every other field on the form stays editable. - Initial load with literal HTML, code and ampersands. Untouched.
is_xss_safe: TRUEmakesElement::filterXss()returnFALSE, so the rendered#valueis never rewritten; a paragraph holding<script>,&and&and a code block holding<,&&and<div>survive load, save and reload unchanged, and the public page escapes them with no script element. - Format switching, the failed switch request and rapid switching. Safe, with one bug found and fixed. The known core bug fires
POST /editor/filter_xss/<format>on every switch becauseeditor.jsreadsisXssSafeon a string; the server answersfalseand the value is preserved. Because that request is asynchronous and attaching happens in its callback, switching faster than the requests return used to leave the editor built from a superseded format's schema; the module now ignores an attach whose format is not the one core recorded on the element. - The AJAX serialize trigger. Yes. "Add another item" writes every open document into its textarea and rebuilds the form without losing one; the first item's content survives, a new editor attaches to the new item, and both save.
- Multiple instances. Independent. Two fields with different formats mount, switch, serialize and save separately, and a widget with unlimited cardinality runs one editor per delta.
- A Body field cannot select a BlockNote format. Confirmed in both directions. An ordinary text field's format select offers no BlockNote format, and the bundle refuses to attach to a textarea that is not a BlockNote widget.
- A format that fails the definition renders nothing. Confirmed. With an item's format forced to
basic_htmlin the database, the public page renders no output for the field, no JSON reaches the page, and the log records which entity, field, delta and format were refused. - The unsupported-block outcome. Works, with one bug found and fixed. Switching to a format that lacks a block, and disabling a block in the format a document already uses, both produce the same result: the block is wrapped in
blocknote_unsupportedwith the original kept verbatim in a prop and its id unchanged, the editor shows a notice naming the original type, and saving is refused with a violation naming that type rather than the wrapper. Switching back unwraps it and the save succeeds. The fix was needed because core restores the textarea to the value it rendered whenever the author has typed nothing, which meant the unwrapped document was not the document being submitted; the module now writes a wrapped or unwrapped document back to the textarea, and updates core'sdata-editor-value-originalwith it, so what is submitted is always what the author is looking at.
Two further facts the probe established. The client schema is built from the block types the format enables, which is also what restricts the slash menu, the block type select and the side menu, because BlockNote derives all of them from the schema. And paragraph is always in the schema whatever the format says, because BlockNote names it literally as the block to create on Enter and as the trailing block.
Alternatives considered¶
Keep the filter and the text_format element, but own the editor lifecycle in JavaScript and move editor options into widget settings. This was the fallback had the probe found the Text Editor API fighting JSON content, most plausibly around format switching or the serialize contract. It did not: the two problems the probe found were both in this module's own code and both fixable inside it. It keeps the permission model and the format-level content policy, and gives up core's attach and detach machinery along with the guarantee that the module behaves like every other editor on the page. It is not the first choice, because owning that lifecycle means reimplementing behavior core already gets right for AJAX, dialogs and vertical tabs.
Drop text formats entirely and configure everything on the field. Rejected. It would need a bespoke permission system for per-block access, and it discards a mental model site builders already have. The cost is not repaid by the flexibility.
Allow other filters alongside the BlockNote filter. Rejected for the first release. Core's ordering guarantees mean another filter can rewrite the JSON before the renderer parses it, and a filter that restricts HTML, placed after the renderer, strips the markup the renderer just proved safe. Supporting this properly means auditing each combination, which is not first-release work.
Consequences¶
Site builders configure BlockNote where they already configure CKEditor 5, with permissions they already understand, and nothing in the module invents a second place to say who may write what. Amended 2026-09-21. The format form shows every BlockNote setting as one set of vertical tabs beside the editor select, Blocks, Text, a tab per block that has settings, and Editor interface, and hides the other filters while BlockNote is the editor and they are off. That is presentation only: the filter's settings are still stored under the filter, the editor's under the editor entity, every field keeps its name, and the rule of 0016 is untouched; the form regroups the elements with #group, the way CKEditor 5 groups its plugin settings, and adds nothing to what is stored.
A BlockNote format is not a general-purpose format. It cannot be combined with HTML filters, it cannot be used on a Body field, and the fencing that enforces this is module code in two places, the field and the element process callback, both of which have to keep working.
Content policy lives at the format, not at the field. Two fields that need different block vocabularies need two formats, not two field configurations. That is the same trade core text fields make.
Because config dependencies do not protect a format, deleting a format that fields depend on remains destructive, and the module does not attempt to prevent it through dependencies. The protection the module does provide is narrower and is described in 0005: a module cannot be uninstalled while a format still enables one of its blocks.
Disabling a block in a format does not alter documents that already contain it. Those documents cannot be saved until an author removes the block or an administrator re-enables it. This is deliberate: content is never silently dropped, and the cost is an author who cannot save until a decision is made.
Because core enforces the format permission only in the render element, the format has to be checked again for programmatic saves. What does that today is core's own AllowedValues constraint on the filter_format-typed property, which refuses any format the acting account may not use on every validation, with the widget suppressing that violation on the form for a format the author cannot change. The narrower check this record once wanted, made only when the value or the format actually changed, was not built and is not owed. Core's AllowedValues constraint is the whole of the permission check, and what it does not read is the field's own allowed_formats setting, which \Drupal\blocknote\Plugin\Validation\Constraint\BlockNoteDocumentConstraintValidator enforces instead, so a programmatic save is refused both for a format the account may not use and for a format the field does not offer. The reasoning is recorded in 0001.
References¶
- Drupal core 11.4.6:
core/modules/editor/src/Element.php,core/modules/editor/js/editor.js,core/modules/filter/src/Element/TextFormat.php,core/modules/filter/src/FilterProcessResult.php,core/modules/filter/src/FilterPermissions.php,core/modules/filter/src/FilterFormatAccessControlHandler.php,core/modules/filter/src/Entity/FilterFormat.php. - CKEditor 5 integration as the reference implementation of the attach, detach and serialize contract:
core/modules/ckeditor5/js/ckeditor5.js. - Gutenberg: https://www.drupal.org/project/gutenberg
- ProseMirror: https://www.drupal.org/project/prosemirror
- 0001, 0004, 0005.