0030. A text format may hold one paragraph, and says which element it renders as¶
Status: Accepted; the rejected "Summary or trimmed" formatter and the trimmed formatter left for later, in Alternatives considered, are superseded by 0045 (2026-11-02)
Date: 2026-11-02
Context¶
Some fields hold one paragraph of formatted text rather than a document: an article summary, an image caption, a byline, a pull quote. The author wants bold, italic, a link and a content link; the site builder wants a guarantee that nothing else arrives, because the value is printed inside a figcaption, a blockquote or a teaser where a heading or a table would break the page.
Drupal core answers this with Short text, which is plain, or with a second formatted text field whose format permits few tags, which bounds the markup but not the structure: filter_html cannot say "one paragraph". This module can come closer than core and does not yet.
Most of what such a format needs already exists. Six of the eight ui toggles on the editor entity are the editor's own surfaces (UI_TOGGLES in src/Plugin/Editor/BlockNote.php:46-55, the six mapped onto the view at js/src/editor.tsx:91-98), so the chrome can already be switched off. The filter's blocks setting already restricts which block types a format permits, and paragraph can never be removed from it (BlockNoteFilter::REQUIRED_BLOCK at src/Plugin/Filter/BlockNoteFilter.php:108, enforced at 195 to 198 and 899), so a format offering paragraph and nothing else is already expressible.
What is not expressible is how many. DocumentValidator::findUnsupportedBlockTypes() checks types and never counts, and the only count anywhere is src/Document/DocumentParser.php:91 against maxBlocks, which is a resource limit and not editorial policy. A paragraph-only format today accepts two hundred paragraphs. The editor also offers a second one whatever the policy says: BlockNote's trailingBlock option defaults to true and draws a simulated empty block after any document that does not end in an empty paragraph, and clicking it inserts a real one.
Hard breaks, which a caption needs, already work in both directions and need nothing built. BlockNote 0.54.2 serializes a hardBreak by appending \n to the previous text or link item (nodeToBlock.ts:153-171) and splits stored text on /(\n)/ to recreate the nodes coming back in (blockToNode.ts:79-96); Render/InlineContentRenderer.php:199,364 explode on \n into #lines and templates/blocknote-inline-text.html.twig prints a br between them. Two golden fixtures already contain one.
The rendering side needs less than it appears to, and something different from what it appears to. A paragraph block already renders as <p class="blocknote-block blocknote-block--paragraph ...">, so a one-paragraph document is already valid HTML and needs no wrapper added. The gap is the opposite one: a caption or a byline wants the inline content with no block element around it, because the field template, the figcaption or the h2 it is printed inside supplies its own element, and a p nested there is either invalid or unwanted.
Two rules already in the tree constrain the answer. 0002 states that "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." And 0016 states that a setting lives on the surface that enforces it, with the test that only one setting ever needs to move for a mismatch to become visible.
One component has to be named correctly for any of this to be placed. Save-time enforcement in this module is not the filter, which renders and never sees a save; it is src/Plugin/Validation/Constraint/BlockNoteDocumentConstraintValidator.php:108-135, calling DocumentValidator.
Decision¶
A text format may declare that it holds one paragraph. The declaration is one mapping in the filter's own settings, filter_settings.blocknote.single_paragraph, with three keys:
single_paragraph:
enabled: bool
wrapper: p | span | none
allow_breaks: bool
The three are one mapping rather than three siblings because wrapper and allow_breaks are meaningless when the constraint is off, while FullyValidatable would require them at the top level anyway. When enabled is false the other two keys still exist and are ignored. The machine name is literal because it sits beside "Enabled blocks" on the format form; "snippet" is the word for the concept in the documentation and in the Add field entry, not the name of a config key.
It lives on the filter because it is enforced on save and on render, which is 0016's rule applied directly: DocumentValidator refuses a document for its sake and BlockNoteFilter::process() reads wrapper while turning a document into HTML.
An enabled single paragraph format permits no block type but paragraph. The runtime narrows the permitted list when it reads it, and leaves the stored list alone, so a builder who turns the constraint off again gets their block list back. Narrowing rather than refusing the contradictory state, enabled true beside a list naming a table, is forced by the same limitation 0002 already records: core's ConfigImporter validates nothing, recipes and config actions validate the schema tree, which carries no entity-type constraints, and a programmatic save is not validated at all, so no constraint can close every path while a read-time rule can. The format form hides the block list when the constraint is on, so the two never disagree in front of a site builder.
A document carrying more than one block is refused, never joined, with a violation naming the blocks. A paragraph with children is more than one block, because the check counts what DocumentValidator::findUnsupportedBlockTypes() already walks, children included.
The joined document is written back after the refused save, on 0024's existing terms and not as a preview: the editor is reloaded with the join, so the author reads what they would get and the next save stores exactly that. On first open the stored document is shown as stored, with the notices an unsupported block already draws; only a refused save replaces it. The join takes every block that carries inline content, in document order, separating them with a break where the format allows breaks and with a space where it does not, and drops every block that carries none, naming each dropped block in the violation. Amended 2026-09-25. The violation names the block types the document holds rather than each dropped block, since its advice to remove all but one paragraph makes a block's position moot; and the join reads a block the format holds only in a carrier from its stored text, so a stored heading's words are kept. This is 0002's existing rule and 0024's existing write-back, reused rather than extended. This record therefore supersedes nothing, and docs/workflows.md's statement that the widget path "does not stamp and does not normalize" stays true as written.
Enter does nothing. Shift+Enter inserts a break, when the format allows breaks. "Does nothing" means the key is handled and consumed: it does not split the block, does not insert anything, does not bubble to the form, and leaves the caret where it was. Consuming it is what keeps an accidental save impossible, and js/src/portable/caption-keys.ts already shows how to bind ahead of the default keymap, in the priority order 0021 documents. Amended 2026-11-02. The refusal is announced, once per focus: the first key the format consumes after the editable gains focus, Enter, or Shift+Enter where breaks are off, is announced politely through Drupal.announce() as "This text format permits one paragraph. Shift+Enter adds a line break.", or as "This text format permits one line." where breaks are off. A key consumed after that is not announced again until focus leaves the editable and comes back, so an author who presses Enter again is not told again. The words take 0023's verb family for a format's own policy, naming the text format and not the field, because a field may offer another format that permits more; the server's own refusal says "permits a single paragraph" in the same family.
Breaks can be turned off, through allow_breaks, which defaults to true. With breaks off, Shift+Enter is inert as well, so neither key inserts anything, and a paste has its newlines collapsed to a space rather than dropped, because two lines run together otherwise. A field for a title, a name or a byline is exactly what core recommends Short text for, and this is the setting that makes such a field safe here.
The editor closes its other doors to a second block. The client passes trailingBlock: false, so BlockNote stops drawing the simulated empty block after the document, and a paste of several blocks is flattened to inline content rather than inserted as blocks. The shipped snippet editor entity turns the side menu, the slash menu, the table handles and the file panel off, which removes the remaining insertion surfaces. A site builder may turn any of them back on, and the save still refuses, because the client is never the authority. Amended 2026-09-25. A paste is not flattened before it lands. It is read the way the format's other settings already say, Paste as Markdown included, and inserted as upstream inserts it; a guard then joins the blocks it left beside the one it landed in back into that paragraph, in the same transaction and on the same terms as the write-back's join above, a break between them where the format allows breaks and a space where it does not, so one undo reverts the paste and the join together. A drop is handled the same way, and a block the paste did not reach is never read, so a stored document of more than one block stays as stored until the author's own edit reaches it. The formatting toolbar also leaves out the block type select, whose one choice would be the paragraph already selected, and Nest block and Unnest block, for every single-paragraph format whatever its editor entity says.
None of those is an editor toggle under 0026. Suppressing Enter, dropping the trailing block and flattening a paste are not preferences about how the editor behaves for Drupal authors; they are the format's own policy shown while the author writes, which is what 0026 exempts from its rule, and the save enforces the same policy whatever the client did.
The format says which element the single paragraph renders as, through wrapper:
wrapper |
Output |
|---|---|
p (default) |
<p class="blocknote-block blocknote-block--paragraph ...">, byte for byte what the block renders as today |
span |
the same element and the same classes with span in place of p |
none |
the inline content alone, with no element and therefore no classes |
The element is a property of the format and not of a formatter, for 0016's reason plus reach: it has to apply to the processed computed property (src/BlockNoteProcessed.php:72), to whatever builds the search index, and to every check_markup() consumer, not to one view display. attach_styles sits on the formatter under the same record precisely because it changes the public page and nothing else, which this does not. The filter_autop analogy is not the argument and must not be mistaken for it: autop decides whether a paragraph exists by reading the text, while this states which element an already-decided paragraph is printed as.
Render never blanks a page. Turning the constraint on changes what a save accepts, not what an already stored document shows: every paragraph renders, each with the declared element; a block the narrowed list no longer enables renders nothing, which is what tests/src/Kernel/FilterPipelineTest.php already pins for any disabled block; and only the save refuses. That follows 0017, where a stored value a format has since stopped permitting still renders as stored. A stored document of three paragraphs therefore renders as three elements, or under none as three runs of phrasing content one after another with nothing between them. Amended 2026-09-24. Not quite nothing: the paragraph template ends in a newline, as it always has and as every other block's does, so the three runs are separated by one newline each, the same whitespace between blocks that section 11 of docs/rendering.md already declares is not part of the contract.
One field type, and one extra entry in the Add field UI. BlockNoteDocumentItem implements PreconfiguredFieldUiOptionsInterface and returns the snippet option only, because FieldTypePluginManager::getUiDefinitions() keeps the base definition and adds one field_ui:<type>:<key> entry per preset, so returning a document option as well would render two identical radios. The option preconfigures allowed_formats to the shipped snippet format. Amended 2026-09-25. It turns the field's file_upload.status off as well, found by using the entry: the snippet format permits the paragraph block alone, so no image, video, audio or file block exists to put an upload in, and both the upload route and the file panel's Upload tab already refuse one. Leaving it on offered an author something the field cannot do.
The entry is called "BlockNote snippet", and the name does not survive field creation: what is created is a blocknote_document field and every listing says so. That is correct rather than tolerated, because a builder may widen allowed_formats afterwards, at which point a label that had stayed behind would be a lie. Core lives with the same thing for entity_reference.
A snippet format ships, with breaks on, together with its editor.editor entity, as 0002 requires of every BlockNote format. Breaks are on because the motivating example is a caption with an author on a second line; the strict case is one checkbox away and the Add field entry's description says so.
The default formatter is labeled "Default". Its machine name, blocknote_default, does not change, and labels are not stored in config, so the rename from "BlockNote document" costs nothing and matches core's text_default "Default".
Alternatives considered¶
Joining several blocks into one instead of refusing. Rejected, and this was the first draft's rule. It contradicts 0002's "The server never rewrites the stored value", and the precedent cited for it was misread: BlockNoteBlockNormalizableInterface heals one property and its result keeps the block's type, id, content and children, none of which joining keeps. DocumentNormalizer::normalize() also runs only on the updater path (src/Document/DocumentUpdater.php:187,344), so a JSON:API or entity API save has no rewrite path at all and the validator could refuse but not join. The failure it invites is the decisive one: turning the setting on for a format already carrying documents would collapse each of them silently the next time anything saved its host, including a translation or a moderation transition, with heading text glued into the paragraph and no message anywhere. If self healing is ever wanted it is an explicit updater-path migration with its own ChangeOrigin case, which is what docs/workflows.md already prescribes for a schema change, and never a rewrite on save.
A second field type, blocknote_snippet. Rejected. It buys a distinct formatter, widget and Add field entry and no storage difference whatever, because the value is JSON either way. It costs a second policy surface: the filter renders and validates and cannot see which field a value came from, so the format would still have to carry the constraint and two places would claim to define it. The strongest case for it is recorded under Consequences instead, because its costs are real and are now this record's to own.
The element as a formatter setting. Rejected. It would govern one view display while processed, the search index and every other check_markup() consumer kept the old element, which is the mismatch 0016 exists to prevent.
div and figcaption as wrapper options. Rejected. Both accept flow content, so a theme's field template can wrap the paragraph in either without the module's help, and shipping them bakes a themer's guess into content policy. The point is not that a template could not produce them, since a block template handed the format's choice could re-tag a paragraph as anything: it is that span and none are the cases a site builder has to settle on the format, because they change what processed, the search index and every check_markup() consumer sees, while a flow-content wrapper is presentation and belongs to the theme.
Enter inserts a break. The real competitor. Rejected because Shift+Enter is already the universal break gesture in Drupal, CKEditor 5 included, and because silence teaches the constraint in a way a break does not. It stays the additive change to make if use shows authors concluding the field is broken.
Enter moves focus to the next control. Rejected. It is not native behavior anywhere in HTML, Tab already does it, and an unrequested focus jump is a context change that lands hardest on screen reader users.
Enter submits the form. Rejected, and named here because it is what a single-line input actually does and is the worst available outcome: an accidental node save from a caption field. It cannot happen here, because implicit submission applies to input elements and BlockNote edits a contenteditable, and consuming the key keeps it that way.
Announcing the refusal when Enter is pressed. Considered and rejected. 0023 has refusals speak to the author and 0010 announces a refused link through Drupal.announce(), so a consumed keystroke with no feedback is this module's only mute refusal, and a screen reader user gets no signal. It is recorded as refused rather than unnoticed so that the accessibility pass revisits it deliberately instead of rediscovering it. Amended 2026-11-02. Revisited by that pass and reversed: the refusal is announced once per focus, as the Enter paragraph of the decision now records, so no refusal in the module goes without feedback, and pressing Enter again is not answered every time.
A stored plain text or HTML column. Rejected for now. Rendered HTML must never be stored, because the render path makes per-viewer access decisions and the content link resolver withholds what a viewer may not see. A stored plain text column would be computed under whichever format was current and would drift the moment the policy changed, which is the rule tests/src/Kernel/SearchIndexingTest.php:82-83 already enshrines: "The format is the policy for the public output, and the index is built from that output". A column earns its place only for database work search cannot do, such as a Views sort or filter, and it need not be a field schema change when it comes: a module-owned table fed from the existing blocknote.document_saved event avoids the change core refuses on fields with data.
A summary column, and a "Summary or trimmed" formatter. Rejected. text_with_summary is deprecated from Drupal 11.5.0 and removed in Drupal 12.0.0, moved to a contrib project of the same name (#3427095, change record); core has stopped creating a body field automatically at all (#3447617); and text_summary() is deprecated in favor of a TextSummary service for removal in Drupal 13. The reasons upstream gives are the ones reached here independently: it is two fields in one, and a request for the summary to carry a format of its own was closed won't fix because the summary was only ever meant to be plain text (#1529188). A summary here is a second BlockNote field carrying a snippet format, which also gives it a format of its own, something text_with_summary never could. Amended 2026-11-02. The formatter half is superseded by 0045, which builds the Trimmed formatter with a summary field shown in its place. The summary column stays rejected.
Trimming as a setting on the default formatter. Rejected. Trimming a structured document has to decide whether to cut at a block boundary or mid paragraph, what to do with a partly included table or a carried inline item, and whether the result is still a document. That is different rendering logic, not a knob, and a site builder configuring a teaser looks for a formatter called "Trimmed". If it is ever built it is a separate plugin, blocknote_trimmed, labeled "Trimmed". Trimming is not deprecated upstream; only the summary pairing is. Amended 2026-11-02. Superseded by 0045: the plugin is built, and that record states what it does. Trimming as a setting on the default formatter stays rejected.
Two Add field entries, one per preset. Rejected on fact: the base type already has its own radio, so a document preset would render twice.
A second Add field entry for the no-breaks case. Rejected. An entry preconfigures allowed_formats to a shipped format, so a third entry means a third shipped format, and two adjacent entries differing by one checkbox is the kind of distinction site builders pick wrong, with no pair of names that carries it.
Other names. "BlockNote paragraph" is ruled out twice over: it collides with the Paragraphs module for site builders, and with paragraph, the one block id a format can never disable, for anyone reading the format form. "Passage" was the closest runner up. Also rejected: "inline mode", which collides with the inline_content plugin type and its own tab; "text only" and "simple text", which collide with the existing Text tab; "paragraph only", which parses as "only paragraphs"; and "one line", which allow_breaks makes untrue.
DocumentLimits as the home. Rejected. It is a container parameter whose own docblock says its limits "protect the site and are not editorial policy", and this is editorial policy exactly.
Consequences¶
The constraint's blast radius is the format, not the field. Every field using a snippet format is constrained, which is the price of one field type and shared policy config. A builder who wants one constrained field and one unconstrained one needs two formats.
Nothing distinguishes a byline from an article body in the interface. Manage fields, Views, JSON:API and the formatter dropdown all say blocknote_document. Only the format assigned to the field carries the difference.
Two view modes cannot render the same value with different elements. The element is a property of the format, so a second element means a second format and a re-save of every item. This is the accepted cost of the reach argument above.
docs/rendering.md section 11 needs a carve-out, because it lists "changing which element a block renders as" as a breaking change and this setting does exactly that, by the site builder's own choice, for a snippet format only. The carve-out is that a format's declared wrapper is part of the contract rather than a violation of it; what stays breaking is changing what an undeclared, default format emits. Section 3 needs the same carve-out, because its inherited rule that every block's outer element carries blocknote-block and blocknote-block--<type> is not true of a paragraph rendered with no outer element at all.
With wrapper: none there is nowhere to put the block's classes, so blocknote-block, blocknote-block--paragraph, and with them blocknote-align--*, blocknote-text-color--* and blocknote-background-color--*, are not emitted. A format that turns alignment or block colors on and the wrapper off is asking for something that cannot be expressed, and the format form says so where the setting is offered. With wrapper: span every one of those classes is emitted unchanged, so only the element name differs from today. Section 5's existing rule that empty content with no children renders nothing is untouched, so an empty snippet renders nothing whatever the wrapper says.
A theme that has overridden blocknote-block--paragraph.html.twig keeps its own hardcoded p and silently ignores the setting, because the shipped template is where the element becomes a variable. Adding a variable to a template is not a breaking change under section 11, but an override written before this record will not honor the format's choice, and the documentation has to say so.
Turning the constraint on for a format already in use makes every stored multi-block document need an author. Each one is refused on its next save until somebody opens it and reduces it, and a write-back shows them what the joined result would be. That is the accepted cost of refusing rather than joining, and it is a support burden a join would not have had.
The new keys need a post-update, because adding required keys to a FullyValidatable mapping is what blocknote.post_update.php:15-62 exists for, and without one an existing 1.0.x-dev site runs with stored formats missing them; BlockNoteFilter::setConfiguration() defaults the mapping when it is absent, as it does every other key, so a re-imported old export that never met the post-update still renders. The shipped format needs one too, because config/optional/ installs only when the module is installed, so a site already on 1.0.x-dev would otherwise meet an Add field entry pointing allowed_formats at a format that does not exist, and calculateDependencies() would record a dependency on missing config. ShippedFormatCollisionTest extends to the second id, and that post-update skips both entities when the site already owns blocknote_snippet, on the same rule the first id already follows.
The client has to learn both rules, which means a key in editorSettings or a change to policy, and members on src/Render/RenderPolicy.php. docs/extending.md documents the current set of keys and is part of the same change.
0016's table of every setting and its home gains a row. Amending a record is still changing a record, and it happens in the same change as the setting.
The Add field entry hands over a field nobody can write in until a role is granted use text format blocknote_snippet, because core disables the widget for a format the user may not use. The entry's description says so.
With breaks on, a break is a line in plain text too. DocumentNormalizer::plainText() splits on newlines, so a caption's two lines are two lines in a token and in a meta tag. Harmless, and stated so nobody reads it as a bug.
Search must not be pointed at plain_text. It is policy-blind by design, so indexing it makes refused content findable, and nothing in the property's name warns anybody. The documentation says to index the rendered output or processed, both policy-aware, which is where Layout Builder and Paragraphs land for the same reason. The property's own description at src/Plugin/Field/FieldType/BlockNoteDocumentItem.php:226 currently says it is "for search, tokens and meta tags" and contradicts that guidance, so it changes in the same work. Amended 2026-09-25. Changed: the description now says the property is for tokens and meta tags and must never be indexed for search, and points at the rendered output or processed instead.
References¶
- 0002, text formats as editor profiles, the rule that the server never rewrites a stored value, and which callers core validates a config entity for
- 0004, semantic HTML: the element is still chosen for semantics, supplied here by whatever the field is printed inside
- 0010, policy enforced as the author edits
- 0016, a setting lives where it is enforced
- 0021, the keymap priority order a caption already relies on
- 0023, refusal messages speak to the author
- 0024, the stripped document written back after a refused save
src/Plugin/Validation/Constraint/BlockNoteDocumentConstraintValidator.php, save-time enforcementsrc/Plugin/Filter/BlockNoteFilter.php,REQUIRED_BLOCKand the expansion of an empty block list- BlockNote 0.54.2
nodeToBlock.ts:153-171andblockToNode.ts:79-96, the hard break round trip - #3427095, #3447617 and #1529188, the deprecation of
text_with_summaryand its reasons