0016. A setting lives where it is enforced¶
Status: Accepted
Date: 2026-09-16
Context¶
This change adds two settings that do not obviously belong anywhere, a toggle for how a paste is read and a placeholder shown in an empty document, and makes the three table size caps nullable and off by default, which reopens the question of where they belong too. Nothing in the tree said which of the filter, the editor entity, the widget or a container parameter a new setting should join, so this record states the rule before later changes add settings of their own.
Three surfaces already exist, and each already has one job. The filter's own settings, filter_settings.blocknote, are read by BlockNoteFilter::process() on every render and by DocumentValidator::validateContent() on every save; nothing in this module reaches storage without passing through the filter's policy. The editor entity's settings, editor.settings.blocknote, are read only by BlockNote::getJSSettings() and shape what drupalSettings hands the client; core stores whatever BlockNote::buildConfigurationForm() submits verbatim, at EditorHooks.php:590-591, and nothing server-side ever consults them again. The widget's settings, field.widget.settings.blocknote_editor, are read only by BlockNoteEditorWidget::formElement() while building the form element the author sees; they never reach the filter, the editor plugin or a save-time check.
0002 already drew this line once: "the filter carries the policy" and "the editor derives from the filter", with the editor's own settings named as "user interface toggles only: formatting toolbar, side menu, slash menu, link toolbar, table handles and file panel". This record generalizes that sentence into a rule that covers a setting affecting behavior as well as one switching off a surface, and covers the widget and the container parameters 0002 did not need to mention.
Drupal core's own precedent for a setting about the shape of the input control, rather than the content it produces, is the widget. StringTextareaWidget::formElement() declares rows and placeholder as widget settings (web/core/lib/Drupal/Core/Field/Plugin/Field/FieldWidget/StringTextareaWidget.php:24-29,42-47), typed rows: type: integer and placeholder: type: label in core.entity.schema.yml:215-217, and neither setting is read anywhere outside the widget. This module's own rows setting already followed that precedent before this record was written.
CKEditor 5 looks like a counter-example, because it puts an interface concern, the toolbar, in the position of authority: CKEditor5::getGeneratedAllowedHtmlValue() computes the tags and attributes the configured toolbar and plugins can produce and writes them into filter_html's own allowed_html setting (web/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php:791-797), so the editor entity is authoritative and the filter's policy is derived from it, the reverse of the arrangement in this module. That arrangement exists because CKEditor 5 produces HTML, a format filter_html has to describe after the fact by inspecting what the toolbar could have written. It does not transfer here. This module's stored value is JSON, validated on save against the properties and content kind each block plugin declares (DocumentValidator::validateBlocks()), and the filter is already the one and only place that policy is declared and enforced; there is nothing for a second, editor-side description of the same policy to derive, and nothing for the editor to be authoritative over. Building the two-surface arrangement here would mean inventing a filter-side restatement of whatever the editor's toggles imply, the discipline CKEditor 5 needs only because its filter counterpart cannot otherwise know what the toolbar allows.
The container parameter blocknote.limits (src/Document/DocumentLimits.php) predates this record and is not reconsidered by it: the byte, block and nesting limits, and the three table caps this change makes nullable and off by default, protect the site's resources rather than express an editorial choice, they are read by the parser and the validator on every save and every render, and a value large enough to matter is also large enough that a site operator, not a site builder using the format form, is the one who should be changing it.
Decision¶
A setting lives on the surface that enforces it, and only one setting ever needs to move for a mismatch to become visible: a check that runs on a value that never reaches it is dead code, and a value nobody checks is a promise the interface makes and nothing keeps.
- A setting enforced on save belongs in the filter's
block_settingsor policy. IfDocumentValidatoror a block plugin's ownvalidate()refuses a document for the setting's sake, orBlockNoteFilter::process()reads it while turning a document into HTML, it is filter configuration,filter_settings.blocknoteorblocknote.block_settings.<block id>. - A setting that only changes what the editor offers belongs on the editor entity. If nothing server-side ever reads it again after
BlockNote::getJSSettings()sends it to the client, it iseditor.settings.blocknote, alongside the six existing UI toggles. - A setting about the shape of the input control belongs on the widget. If it changes the fallback textarea or how the editor is presented, and reaches neither the filter's policy nor the editor plugin, it is
field.widget.settings.blocknote_editor, alongsiderowsand (now)read_only. - The size limits stay a container parameter, outside all three, because they protect the site rather than shape content, and a value a site operator sets in
services.ymlis not configuration a site builder should be able to raise from the format form. - A new setting states which of these four homes it lives on, in the change that adds it, rather than leaving the answer to be inferred from where the code happened to land.
ui.paste_markdown is on the editor entity: BlockNote::getJSSettings() sends it and nothing else reads it, and switching it off narrows how a paste is read, not what a saved document may contain: the blocks a paste can produce are already bounded by the same schema and policy every other write goes through, whichever way a paste was read. placeholder is on the widget, beside rows, because it sets an attribute of the fallback textarea and is read only by BlockNoteEditorWidget::formElement(); the client reads it back off that same textarea (js/src/editor.tsx), not off editorSettings. The three table caps stay container parameters beside the byte, block and depth limits they already sit with.
Every setting and its home¶
| Setting | Home | Enforced |
|---|---|---|
blocks (enabled block types) |
Filter, filter_settings.blocknote |
Save and render: DocumentValidator::findUnsupportedBlockTypes(), BlockNoteFilter::process() |
block_settings.<block id> (e.g. media's allowed_bundles, allowed_view_modes) |
Filter, blocknote.block_settings.<block id> |
Save, by each block plugin's own validate(); a media view mode is curation under 0015, healed rather than refused |
styles (permitted inline styles; Amended 2026-09-26. seven names now: bold, italic, underline, strike, code, subscript, superscript; see 0031) |
Filter, filter_settings.blocknote |
Save and render, and as the author types under 0010 |
text_colors, background_colors |
Filter, filter_settings.blocknote |
Save and render, and as the author types under 0010 |
links, link_schemes |
Filter, filter_settings.blocknote |
Save and render, and as the author types under 0010 |
Amended 2026-09-26. link_new_tab (whether a permitted link may open in a new tab) |
Filter, filter_settings.blocknote |
Save and render, and as the author edits; see 0032 |
ui.formatting_toolbar, side_menu, slash_menu, link_toolbar, table_handles, file_panel |
Editor entity, editor.settings.blocknote |
Editor only: which surfaces render |
ui.paste_markdown |
Editor entity, editor.settings.blocknote |
Editor only: how a paste is read, never what may be stored |
ui.click_into_links |
Editor entity, editor.settings.blocknote |
Editor only: what a click on a link does, never what may be stored; see 0026 |
rows |
Widget, field.widget.settings.blocknote_editor |
Widget only: the fallback textarea's height |
placeholder |
Widget, field.widget.settings.blocknote_editor |
Widget only: the fallback textarea's placeholder and the empty document's placeholder |
read_only |
Widget, field.widget.settings.blocknote_editor |
Widget only, though absolute: no editor is built at all |
allowed_formats |
Field settings, field.field_settings.blocknote_document |
Save and the widget's offered formats |
file_upload.* |
Field settings, field.field_settings.blocknote_document |
The upload route and the file panel's offered Upload tab |
attach_styles |
Formatter settings, field.formatter.settings.blocknote_default |
The public page only |
max_bytes, max_blocks, max_depth, max_table_cells, max_table_columns, max_document_table_cells |
Container parameter, blocknote.limits |
Save (DocumentParser, DocumentValidator) and render (BlockNoteFilter) |
alignments |
Filter, filter_settings.blocknote |
Save and render, and as the author types under 0010; see 0017 |
block_settings.heading (allowed_levels, allow_toggle) |
Filter, blocknote.block_settings.heading |
Save, by Heading::validate() and its propSchemaFor() (Amended 2026-10-31. public as getPropSchemaFor(); see 0038); see 0017 |
block_settings.codeBlock (languages, default_language) |
Filter, blocknote.block_settings.codeBlock |
Save, by CodeBlock's propSchemaFor() (Amended 2026-10-31. public as getPropSchemaFor(); see 0038), and render; see 0017 |
block_settings.table (merged_cells; Amended 2026-09-20. also header_cells and cell_colors) |
Filter, blocknote.block_settings.table |
Save, by Table::validate(); see 0017 |
Amended 2026-09-24. single_paragraph (enabled, wrapper, allow_breaks) |
Filter, filter_settings.blocknote |
Save, by DocumentValidator, and as the author writes; wrapper on render as well, by BlockNoteFilter::process(); see 0030 |
Alternatives considered¶
CKEditor 5's two-surface model, the editor authoritative and the filter derived. Rejected. It exists to let filter_html describe, after the fact, what an HTML-producing toolbar could have written. This module validates JSON against block plugin schemas directly; the filter is already the single authority, and there is nothing left for an editor-side description of the same policy to derive or to be authoritative over.
A per-field editor options array, letting a field override its format's editor settings. Already rejected by 0002, which places the editor profile on the text format so that "which formatting and which blocks are permitted, and who is allowed to use them" stays answered in one place; a per-field override would reopen exactly the question 0002 closed.
Consequences¶
ui.paste_markdown, placeholder and the three nullable table caps are the first settings placed by this rule rather than by precedent alone; a later change that narrows heading levels, code block languages, merged cells and text alignment cites this record for the rule and adds its own rows to the table above rather than restating the rule.
0002's sentence naming six toggles was the list at the time it was written; this record is what says a seventh, behavioral toggle may join the editor entity beside them, and what a future toggle has to be true of to join them too.
A setting whose home is wrong is now a defect this record can be cited against: a filter setting nothing on the save path reads should move to the editor entity or the widget, and a widget setting the filter or the editor plugin reads back should move the other way.
The placeholder reaches the editable as aria-placeholder, in the spirit of 0011's rule that what the fallback textarea carries, the editable carries too.
References¶
Drupal core 11.4.6: web/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php, web/core/lib/Drupal/Core/Field/Plugin/Field/FieldWidget/StringTextareaWidget.php, web/core/config/schema/core.entity.schema.yml, web/core/modules/editor/src/Hook/EditorHooks.php.
This module: src/Plugin/Editor/BlockNote.php, src/Plugin/Field/FieldWidget/BlockNoteEditorWidget.php, src/Plugin/Filter/BlockNoteFilter.php, src/Document/DocumentValidator.php, src/Document/DocumentLimits.php, config/schema/blocknote.schema.yml, blocknote.services.yml.
The paste-reading toggle, the empty-document placeholder and the table caps that prompted this record. 0002, 0010, 0011, 0015.