0023. Refusal messages speak to the author, name what was refused in their own words, and say what they can do¶
Status: Accepted
Date: 2026-09-20
Context¶
Every message an author reads while writing a BlockNote document was written at a different time, by a different pass over a different surface, and it showed. The 32 violation messages on BlockNoteDocumentConstraint were written together as one block when the constraint was built (0001). The link and color policy's own wording came with the extensions that enforce it as the author types (0010). The size limits got a client-side echo of the server's own numbers, worded on its own (0016). The per-block settings forms (Heading, Table, CodeBlock, DrupalMedia) each wrote their own validation errors as each block was built. Nothing had ever read all of them side by side.
That showed up as four kinds of drift, found by reading every message together for the first time:
- Two verbs for one idea. A format's content policy was refused with "does not allow" in three client-side notices (
js/src/links.tsx's link refusal,stripped.tsx's stripped-decoration hint andcarried.tsx's carried-blocks hint) and with "does not permit" in the server's ownforbiddenStyleMessage,forbiddenAlignmentMessageandforbiddenLinkSchemeMessage. A size limit was refused with a bare semicolon clause on the server ("...; this site allows @limit.") and a full two-sentence explanation on the client (js/src/limits.tsx'slimitMessage()), for the same three limits, checked in the same places, by the same module. - The block named by its machine type, not its label. Every violation
DocumentValidatorraises throughreportBlockError()carried the block's raw type string,heading,table,drupalMedia, in its@typeplaceholder, the same string a plugin'sidattribute declares and nothing an author was ever shown anywhere else. The one place that already read right wasBlockNoteDocumentConstraintValidator::validate()'s ownblocksMessage, which resolves each unsupported type to its plugin's label before it builds the message, because the person who wrote that one call site happened to reach forgetDefinition()['label']. - Jargon that leaked past the boundary.
unsupportedVersionMessagetold an author their document used "BlockNote document schema version @version," a fact about the storage format's internal versioning scheme, not something the author who typed the words ever chose or needs to reason about.DrupalMedia's missing-item error read "a media block names one media item by its UUID," which asks an author to know what a UUID is to understand what they did wrong. - A refusal with nothing to do next.
formatMessage,notAllowedFormatMessageandblocksMessageeach named a problem and stopped, where the size-limit messages, the block-conversion guard's client notices and the media block's empty-state message all already named the action: shorten it, remove them, switch formats, ask an administrator.
The surfaces are: the 32 constraint violations (BlockNoteDocumentConstraint.php), the file upload route's refusals (FileUploadController.php) and the media renderer's missing/inaccessible placeholders, on the server; and the notices above the editor, the link toolbar's refusal, the size-limit warnings, the carried-block and unsupported-block labels, and the media block's own status text, on the client. A message on any of these surfaces exists to let an author do one thing: understand, without leaving the page, what about their document the site will not accept, and what to do about it. A message that names an internal concept instead of what the author actually did, or that stops after naming the problem, fails that job even when it is grammatically correct.
Not every message on these files is in scope. A settings form field's label or description, read by a site builder configuring the module rather than an author writing a document, is a different kind of text with a different audience and is left alone here, and so is a per-block settings form's own validation error (Heading, Table, CodeBlock, DrupalMedia), read by that same site builder, for the same reason. A log message and an exception that never reaches a rendered page or a client notice are left alone too: nobody is being spoken to.
Decision¶
Every message in scope is rewritten, or read and kept, against one style, stated here as the rule future strings follow:
- Address the author. Do not make "the site" the actor where the format itself is the one deciding; "this text format does not permit X" is what the format's own policy is doing, "this site stores at most X" is what the site's own limit is doing, and neither is passive box-checking done to the author.
- Name what was refused in the author's own words. A block is named by its plugin's label ("Heading," "Table," "Media"), never its machine type ("heading," "table," "drupalMedia"). A style is named by its toolbar name. A color is named the way the color picker and the notices already name it,
@color textor@color background, never "the colour @color" with no word for which of the two properties it is. A link is named by its scheme. Neverprop,UUID,schema,bundle,entityornodein the rendered text: an author does not choose a prop, a UUID, a schema version, a bundle or an entity; they choose a block, a property's value, a document, a content type or an item. Amended 2026-09-20. A block type no plugin on this site provides is the one deliberate exception: it has no label to name it by, somissingBlockPluginMessagenames it by its machine type, in a message of its own with an action that fits ("ask an administrator to restore the module that provides them"), never folded intoblocksMessage's "switch to a format that permits them," which is false for a type no format can ever permit. Amended 2026-09-20.invalidLinkMessage,invalidTableContentMessageandinvalidTableCellMessageprint a raw JSON member name (href,columnWidths,colspan) in a parenthetical; this is not the exception above, and it is not forbidden by this rule either, because these three fire only for a shape the editor cannot produce, where the member name is the one thing the person who hand-wrote the payload needs to fix it. Amended 2026-10-31. The three now print it after a colon, where the fragment rule below has every name a message fills in stand, andinvalidLinkMessagenames a link inside a link in words, "a link inside a link", where it printednested, which is the name of no member. - Shape: what is wrong, then what the author can do, two sentences at most. Not every message has a second sentence, because not every refusal has an action beyond what the first sentence already implies, but a message that has one does not stop before it.
- Name a block's position only when several blocks of that type exist in the document, otherwise the label alone is enough. No message in scope currently needs this, because none reports on more than one block of the same type at a time; it is stated for the message a future block plugin writes.
- The verb families: "This text format does not permit X" for a text format's own content policy (styles, colors, alignments, link schemes, block types, merged cells, collapsible headings, media types). "This site stores at most X" for a numeric limit (document bytes, blocks, nesting depth, table cells, table columns). "Could not" for a failure the author did not cause (a network error, a malformed server response, a file the upload handler could not save). These three are not interchangeable: a format's policy is a choice a site builder made for that format; a site's limit is a fact about what the database and the request can hold; a failure is neither, and saying "does not permit" about a failure would blame the author for something nobody chose. Amended 2026-09-20. On the server the first family names the format,
"The text format %format does not permit X,"because one entity can hold two BlockNote fields on different formats and the sentence has to say which one refused;forbiddenColorMessageandblocksMessagewere brought in line with the other four server messages in this family, which already named it. On the client the same family stays"This text format does not permit X"with no name, because a widget's notice speaks about the one format it is attached to and never has a second one to distinguish (js/src/links.tsx,stripped.tsx,carried.tsx,schema.tsx). - Placeholders keep the
@,%and:conventionst()andDrupal.t()already use:%formatand%fieldfor values quoted back verbatim (a violation that names configuration by its own name),@for everything else, plain text an author reads as part of the sentence. - A plugin's
@problemfragment is a clause that completes "The<label>block<fragment>."BlockNoteDocumentConstraint::$blockRefusedMessageis'The @type block @problem.', with@typenow resolved to the block's label byDocumentValidator::label(). A plugin passing the third argument toBlockValidationContext::error()writes a fragment that continues that sentence grammatically and lower case, with no trailing period of its own:'cannot be collapsible in this text format', not'this text format does not permit collapsible headings', so that the assembled sentence reads "The Heading block cannot be collapsible in this text format," not two independent clauses stapled together with parentheses.docs/extending.md's validating-a-block section states this frame for module authors outside this repository. Amended 2026-10-31. The frame is now'The @type block cannot be saved: @problem.', and'The @type in the @block block cannot be saved: @problem.'for an inline content plugin (0027), and the fragment is a sentence of its own after the colon, lower case and with no trailing period, that refers to the block as "it": "it cannot be collapsible in this text format", "it has no media item; choose one with Select media, or delete the block". A clause completing the frame made the block's label its subject, and a language that marks a noun's case declines that subject by the verb: Georgian puts it in the dative for "has no media item" and in the nominative for "uses a media type", which one translated frame cannot do for every fragment. Each fragment'st()context is nowFollows "The <block> block cannot be saved:", orFollows "The <inline content> in the <block> block cannot be saved:". For the same reason a name that a message fills in stands after a colon, never inside its sentence:forbiddenStyleMessagereads "does not permit this formatting: @style",forbiddenColorMessage"this color: @color",forbiddenAlignmentMessage"this alignment: @alignment", the three messages naming a scheme "links to this kind of address: @scheme", andincompatibleStylesMessage"carries two kinds of formatting the editor cannot show together: @style and @other". Every name the constraint validator fills in carries thet()contextNamed after a colon in a refusal, and the palette's nine color names, which the text format form's color checkboxes share, carryEditor palette color. Four names now follow the editor more closely: an alignment is "centered text", "right-aligned text" or "justified text", as the editor's notice of what it took off names it, where the server said "center alignment"; a new-tab flag outside any link is Open in a new tab, its button, where it was "a link that opens in a new tab", a link that is not there; thedefaultcolor, which the editor shows as no color, is "a text color" or "a background color", not the picker's "Auto"; and the content link's fragment names Edit link, the text on the button, where the second pass named Edit, its tooltip. Amended 2026-10-31. Four messages still filled an untranslated English word into the middle of their sentence, and now follow the rule:invalidPropTypeMessageandinvalidInlinePropTypeMessage, which read "holds text, and this site expects a number", now read "holds the wrong kind of value: @actual. This site expects this kind: @expected.", andinvalidBlockContentMessageandinvalidInlineContentKindMessage, which read "holds a table, and what it carries is not that", now read "carries content of the wrong kind. This site expects this kind: @kind."DocumentValidatorreports the JSON type of a value (boolean,number,string,arrayornull) or the content kind a plugin declares (none,inline,styled,plainortable) instead of a word, and the constraint validator names each throught()with the contextNamed after a colon in a refusal: "yes or no", "number", "text", "list", "nothing", "unformatted text" and "table". - US spelling for every string touched, British spelling left alone where it was not touched.
forbiddenColorMessage's "colour" becomes "color," consistent with the rest of the strings that already used American spelling before this pass. - Announcements state the outcome first.
Drupal.announce()calls in scope (the link refusal, the size-limit warning, the escape announcement) already did this before this record and are unchanged by it; it is stated here because it is now a rule the next one follows too.
Every message the inventory found, on every surface listed above, was read against this style. Most needed no change: forbiddenStyleMessage, forbiddenAlignmentMessage, forbiddenLinkSchemeMessage, js/src/limits.tsx's limitMessage(), block.tsx's media status text and FileUploadController's own refusals already matched it, in some cases better than the messages that were rewritten to match them. Amended 2026-09-20. forbiddenStyleMessage, forbiddenAlignmentMessage and forbiddenLinkSchemeMessage matched the verb ("does not permit"), not the whole frame: they already named the format, "The text format %format does not permit X," which is a fact the verb-families bullet above did not say until this same date's amendment to it. forbiddenColorMessage and blocksMessage are now worded the same way. Where a message quoted a fact about the code, in a test assertion, a fixture or a documentation paragraph, the quote is updated in the same change; a record's own Context, Alternatives or Consequences prose describing what a message used to say is not rewritten, only its Status line would be if the decision itself changed, which this one does not.
Amended 2026-10-31. A second pass read every message in scope again, together with those written since: forbiddenLinkNewTabMessage, forbiddenInlineContentMessage, the two single-paragraph messages, incompatibleStylesMessage (0037), the content link's and the media block's @problem fragments, the client notices for a stripped new-tab flag and for content links, and, beyond this record's surfaces, the document scan's lines for a document whose check failed, on the status report and from Drush, and the block menu's rows of the console table in docs/extending.md. Four rules came out of it. A value filled into a placeholder is named in the editor's words too, and through t(): BlockNoteDocumentConstraintValidator turns the stored name DocumentValidator reports into the style's toolbar name ("Subscript", not subscript), the color as the color picker names it ("Red text", "Auto background"), "center alignment", "a link", and a scheme or "scheme-relative" or "unrecognized", where forbiddenStyleMessage had printed subscript, forbiddenColorMessage an untranslated text or background after a stored color name, and incompatibleStylesMessage stored names beside the untranslated "a link", "red text" and "opening in a new tab". Every "does not permit" message on the server gains the second sentence it lacked, naming what the author can do: remove the formatting, the color or the links, align the block left, change the address, or clear Open in a new tab, each beside switching to a format that permits it; the two refusals of a target this site never permits say to change the address or remove the link or the block. A @problem fragment says what the author can do after a semicolon, inside the one sentence its frame allows: the media block's empty refusal is now "has no media item; choose one with Select media, or delete the block", its refusal of a media type points to Replace media, or to Convert to paragraph when the format permits no type the site still has, and the content link's refusal of an address it may not follow says to choose the content again with Edit. invalidInlineStyleMessage keeps the stored key, for the reason invalidLinkMessage keeps a member name: the editor never writes a key this site does not know or a value of the wrong type. Besides those, forbiddenInlineContentMessage takes blocksMessage's frame, so a content link is no longer "Content link content", and singleParagraphBreakNotPermittedMessage says "line breaks" where it said "a line break here", with no place to point at. The scan's failed lines say that whether the document renders in full is not known, that the first error of each kind is logged, and to report it to the module it names before the BlockNote issue queue. The client's notices, the link toolbar's refusal, the size limit warnings, the carried-block labels, the media block's status text and the upload route's refusals were read and kept.
Amended 2026-10-31. A third look found the rule still broken in three messages and one more like them. invalidPropValueMessage and invalidInlinePropValueMessage put the stored value in the middle of the sentence ("is neutral, and this site allows: ...") and now read "holds a value this site does not allow: @value. This site allows these values: @allowed."; invalidInlineItemMessage printed none or a PHP type name and now names a value that is no item "a bare value, not an item" and an item with no type "an item with no type", through kindName() with the same t() context, and keeps the item's stored type for any other; and malformedMessage printed the raw code in parentheses and now reads "This is not a valid BlockNote document: @problem.", where the problem is a clause of its own such as "it has no version number", worded by BlockNoteDocumentConstraint::malformedProblem() so that the constraint and DocumentUpdater word it alike. The code stays on the violation as its code.
Alternatives considered¶
Error codes with a client-side lookup table. Rejected. It would move the wording into a second place that has to be kept in step with the server's DocumentError cases and every plugin-defined kind, and a third-party block's custom kind would have no entry, which is the exact failure the @problem fragment already avoids by carrying the plugin's own words with it.
One generic message with a details list. Rejected for the same reason 0010 rejected closing the link form without saying why: an author who is told only that something is wrong, with the specifics in a collapsed list, is worse off than one told what and given one sentence about what to do, and Drupal's own validation UI already renders a list of distinct messages well.
Leaving the strings as they were and documenting the inconsistency instead of fixing it. Rejected. A note that four sets of strings disagree is not worth as much as making them agree, which is cheap, and the module has not been released, so there is no compatibility cost to changing wording now that there will be after 1.0, once translations exist for it on localize.drupal.org.
Consequences¶
Every translation of a changed string is invalidated. docs/drafts/localize-ka.md already carried a checklist note that the translation file goes stale before release; that note is extended to say this pass changed most of the strings it translates, so the file has to be regenerated from the release's code before it is ever imported, not amended string by string.
tests/src/Kernel/JavascriptTranslationExtractionTest.php pins the exact set of source strings _locale_parse_js_file()-equivalent extraction finds in the committed client bundle; every client string this pass touched moved that pin, and the test was updated in the same change that changed the string, as bun run verify regenerated the bundle.
Every test, fixture and documentation paragraph that quoted one of the changed messages as a fact about the code was updated in the same commit as the source change that made the quote wrong, never left to drift, per the project's own rule that a fact lives in one place and is corrected where it is stated.
The @problem fragment convention is now a documented rule for a module outside this repository that defines its own block: a fragment has to read as a clause completing "The <label> block <fragment>," which constrains how such a plugin's own messages are worded and is a real, if narrow, addition to the extension contract's surface. A block whose own label happens to end in the word "block" reads awkwardly in that frame: CodeBlock shipped labeled "Code block," so invalidPropValueMessage for a narrowed language read "The language property of the Code block block is python..." with the word doubled, author-reachable and untested, because nothing pinned the rendered sentence, only its placeholders. The fix is the label, renamed to "Code": stripping a trailing "block" from the label generically inside label() was rejected, because a label is a TranslatableMarkup a translator may render in a language where "block" is not the last word, or not a separate word at all, and code written to strip an English suffix would silently do nothing, or the wrong thing, to a translated string it cannot parse. Renaming the one shipped label that had the word is a change a plugin author controls and reviews in their own language; stripping it in the frame is a change no one reviews per language. Amended 2026-10-31. A fragment now reads as a sentence of its own after "The <label> block cannot be saved:", as the fragment rule in the decision says; the label still comes before the word "block", so the rename still matters.
DocumentValidator::label() adds one more call to BlockNoteBlockManager::getDefinition() per violation reported through reportBlockError(), on the save path only, never the render path; this is the same call BlockNoteDocumentConstraintValidator already made for blocksMessage and costs nothing a save was not already paying for.
Amended 2026-09-20. The five @problem fragments a block plugin's own t() call produces (Heading::validate(), Table::validate(), DrupalMedia::validate() twice, and the test module's Callout::validate()) each carry ['context' => 'Completes "The <block> block ..."']. English reads the fragment as a clause continuing the sentence it is spliced into without help, but a translator on localize.drupal.org sees only the extracted fragment, and a language with subject agreement or verb-final order may not compose the two halves the way English does; the context tells the translator what sentence the string completes. docs/extending.md shows the same option on the worked Callout example, so a module author outside this repository follows it too. Amended 2026-10-31. The context now reads Follows "The <block> block cannot be saved:", the frame the fragment rule in the decision gives, and the content link's fragments carry the inline frame's.
References¶
- This module:
src/Plugin/Validation/Constraint/BlockNoteDocumentConstraint.php,src/Document/DocumentValidator.php(label(),reportBlockError(),checkColor()),src/Plugin/BlockNoteBlock/Heading.php,Table.php,modules/blocknote_media/src/Plugin/BlockNoteBlock/DrupalMedia.php,src/Controller/FileUploadController.php,modules/blocknote_media/src/MediaRenderer.php,js/src/upload.tsx,index.tsx,carried.tsx,limits.tsx,links.tsx,schema.tsx,stripped.tsx,modules/blocknote_media/js/block.tsx. - Tests:
tests/src/Kernel/BlockContentValidationTest.php,tests/src/Kernel/JavascriptTranslationExtractionTest.php, and every other test named in this pass's commits. docs/extending.md(validating a block, the@problemfragment),docs/drafts/localize-ka.md.- 0001, 0010, 0016.