0045. A trimmed formatter, with a summary field shown in its place¶
Status: Accepted
Date: 2026-11-02
Context¶
Step 4 of the conversion procedure in docs/converting.md switches each display of a converted text field to the document field. The rehearsal on a real site found teasers and cards showing the text through core's text_trimmed and text_summary_or_trimmed, which apply to text fields only, so without a counterpart the procedure could not finish on any site with listing pages.
Core's two formatters cut the filtered HTML. TextSummary::generate() takes the first trim_length characters of the markup, tags included, moves the cut back to the end of a paragraph, a line break or a sentence, and closes the elements left open only when the format runs filter_htmlcorrector. Summary or trimmed shows the item's summary column instead when it holds one.
0030 rejected a summary column and a "Summary or trimmed" formatter: text_with_summary is deprecated from Drupal 11.5.0 and removed in Drupal 12.0.0, and a summary here is a second BlockNote field carrying a snippet format. It rejected trimming as a setting on the default formatter, because trimming a structured document is different rendering logic, and said that if trimming were ever built it would be a separate plugin, blocknote_trimmed, labeled "Trimmed". The trimmed formatter stood in the backlog as parked.
A heading carries an id made from its words, numbered in document order (0043), so a heading whose words a trimmed rendering changes, by cutting them or by appending text to them, carries an id the whole document does not have.
Decision¶
The module ships the Trimmed formatter, blocknote_trimmed. Decided by the maintainer on 2026-11-01, because every converted site with teasers needs it. Its settings are a Limit, which counts characters or words; where to Cut: after whole blocks only, at a word boundary inside the block that crosses the limit, or at the exact character; the Text where it cuts, an ellipsis by default, which may be empty; and an optional Summary field, another BlockNote field of the bundle, such as a converted summary, shown whole in place of the trimmed document whenever it has content. It shows the document's blocks from the start up to the limit; a list or a table that does not fit is left out, a table is never cut, and images and media count nothing. This answers "Summary or trimmed" without a summary column. The rules below were decided in the building, each for the reason given.
It cuts the stored document, never its HTML. It keeps blocks and renders them through the item's text format with the renderer and templates of the whole document, so what it shows is the same HTML contract, and a cut shortens runs of text and never closes markup early. DocumentTrimmer is told what each block is by the formatter, through the block's plugin, and knows no plugin itself.
A table and a code block are never cut, a code block because appended text would read as code. A heading is cut only as the last resort below, and never ends a rendering that was cut, because it introduces nothing there: one that would be the last block shown is left out. Every heading shown, but one the last resort cuts, is therefore whole and keeps the id it has in the whole document.
A table of contents is never shown, and the blocks nested under it render in its place: the outline it lists is cut, and on a listing its links would reach another teaser's headings.
After whole blocks only, a block counts with everything nested under it, and a run of list items is one block. A cut inside a block keeps its text up to the cut and drops what is nested under it; a cut among the blocks nested in a list item, a quote or a toggle keeps the part that fits, so the markup stays a list, a quote or a disclosure.
A cut that would show no text falls back to the next finer one. When none shows text other than headings, the cuts inside a block are taken again with a heading cut like a paragraph and a table or code block that does not fit passed over until text other than a heading's is shown. A heading that fits is not counted as text shown for that purpose, so a heading before a code block too long to show stays whole, with the paragraph after the code block. A trimmed rendering is empty only when every block holding text is a table or a code block too long to show.
A character is an extended grapheme cluster, a character as a reader sees it: a letter and its accents, a flag, or an emoji joined from several code points counts one, and a cut never falls inside one. It is read with PCRE's \X, so the module needs no intl extension. A word is a run of characters that are not whitespace.
The text where it cuts ends the last block shown, as an unstyled run outside any link, or is a paragraph of its own after a block that holds no text or after a heading shown whole, so that it never changes a heading's words, and with them its id. Amended 2026-11-02. It is also a paragraph of its own after a toggle that holds the end of what is shown, in the block that holds the toggle, never inside the <details>, since a visitor may close the disclosure and hide the text with it. Disclosures ship open (toggleListItem), so the difference shows only once a visitor closes one or a theme collapses it.
A media item's caption counts nothing, as an image's does. BlockNoteBlockCaptionInterface, implemented by DrupalMedia, is the server's counterpart of a caption declared on the client, since the base module may not name the submodule's block.
A visitor who may not view the summary field gets the trimmed document, and the output carries the cacheability of that access check.
What the formatter renders is never longer than what is stored. When the limit leaves nothing out, the stored value is rendered as it is; otherwise the blocks kept are encoded with DocumentEncoder::FLAGS, characters and line separators written as themselves, as the editor writes them, so that encoding never takes a document within the size limit over it. Amended 2026-11-02. The text where it cuts can add more bytes than the cut removes, so a document stored within a few bytes of max_bytes and cut near its end could pass the limit with it. The formatter then leaves the text out, and the cut document, no longer than the stored one, stays within it.
This supersedes the two alternatives of 0030 that set the formatter aside: the rejected "Summary or trimmed" formatter, whose counterpart this is, and the trimmed formatter that 0030 left for later. The summary column stays rejected, and so does trimming as a setting on the default formatter.
Alternatives considered¶
Cutting the rendered HTML, as core does. Rejected. Counting characters of markup makes the limit depend on the tags, the classes and the theme's templates, and a cut inside an element needs a corrector to close what it left open. Cutting the stored document keeps the contract of docs/rendering.md for every block shown.
A summary column on the field. Rejected, for the reasons 0030 gives, which still hold.
Counting Unicode code points. Rejected after review: an exact cut split a flag in half, left a joiner dangling and lost a combining accent, and a family emoji counted five. PHP's intl extension counts grapheme clusters too, but Drupal core does not require it, and PCRE, which core does require, gives the same answer through \X.
Appending the text where it cuts to a heading shown whole. Rejected after review: the heading's id is made from its words, so Install became install-read-more, an id the whole document does not have. A heading the last resort cuts still takes the text, because its words, and its id, are already its own.
Consequences¶
Core counted the characters of the filtered markup, where this formatter counts the text a visitor reads, so the same limit shows more text; docs/converting.md step 4 says so to a site converting.
A heading the last resort cuts carries an id made from the words it shows, the text where it cuts included, which the whole document does not have. It happens only when a heading longer than what is left of the limit comes before any other text.
The formatter parses the stored document before the filter does, and encodes what it keeps again; a document the limit does not reach is handed to the filter as stored.
docs/rendering.md section 12 states what a trimmed rendering emits and omits, and is the contract a theme relies on.
References¶
- Drupal core 11.4:
core/modules/text/src/TextSummary.php,core/modules/text/src/Plugin/Field/FieldFormatter/TextTrimmedFormatter.php. - This module:
src/Plugin/Field/FieldFormatter/BlockNoteTrimmedFormatter.php,src/Render/DocumentTrimmer.php,src/Render/TrimKind.php,src/Render/TrimCut.php,src/Render/TrimUnit.php,src/BlockNoteBlockCaptionInterface.php,src/Convert/DocumentEncoder.php,config/schema/blocknote.schema.yml. - Tests:
tests/src/Unit/DocumentTrimmerTest.php,tests/src/Kernel/TrimmedRenderTest.phpandtests/fixtures/documents-trimmed/,tests/src/Kernel/TrimmedFormatterTest.php. - 0030, 0043.