The HTML contract¶
This is what a BlockNote document turns into on a public page, and what a theme may rely on.
1. What this contract is¶
The HTML on the page is this module's own. It is built in PHP from the stored JSON, as render arrays, and passed through Twig templates that ship with the module. Nothing the editor draws reaches a visitor: the bn- classes, the drag handles, the resize widgets and the editor's stylesheet all stay on the form. A page that displays a document loads no JavaScript from this module at all.
The contract covers one page that is not a public one. A widget whose Read-only setting is on builds no editor: it renders its stored document through this same pipeline, from the same render arrays and the same templates, and through the text format that is the policy either way. An edit form carrying such a widget therefore holds the HTML described below, inside the form item that carries the field's label, along with the blocknote/content stylesheet; the widget attaches none of the editor's JavaScript or CSS. tests/src/Kernel/ReadOnlyWidgetTest.php compares that markup with the field formatter's, byte for byte.
A display may show the start of a document instead, through the Trimmed formatter. What it keeps renders as this document describes, and section 12 says what it keeps and what it leaves out.
Everything below is asserted. tests/src/Kernel/RenderBlockHtmlTest.php pins one instance of every shape and says what it means; tests/src/Kernel/GoldenRendererTest.php renders the whole fixture corpus and compares it to the expected HTML stored beside each fixture, byte for byte. Changing any of it means changing a fixture, which is a visible, deliberate act with a release implication. Treat it as you would a public PHP API.
Two things are not pinned and should not be relied on. Whitespace between blocks is whatever the templates happen to emit, and the corpus records it only so that a change is noticed; inside inline content, though, no whitespace is ever introduced, which is a rule (see 4). And the order in which classes appear in a class attribute is stable in practice but is not a promise.
2. The block envelope¶
Every block renders as exactly one outer element, with one exception: a paragraph whose format declares the wrapper none renders with no outer element at all, which section 5 describes and record 0030 decides. A block's children are the blocks nested under it, and where they go depends on whether the outer element may legally contain block content: a li and a blockquote hold theirs, a p and a heading cannot and so are followed by them.
A block may carry a style its format lists, whose classes go on its outer element, as section 3 describes; nothing else about the envelope changes with it.
A block's id is never emitted, in any form. The one id a page carries is a heading's, generated from the heading's words when the page is built, as heading in section 5 describes, so two documents holding the same words in the same language still produce the same HTML. ADR 0043 is the record.
| Block type | Outer element | Children |
|---|---|---|
paragraph |
<p>, or the <span> or bare inline content a single-paragraph format declares |
siblings, after the closing tag, or after the inline content where there is no element |
heading |
<h1> to <h6>, or <details> when collapsible and it has children |
siblings, unless collapsible: then inside the <details> |
quote |
<blockquote> |
inside |
bulletListItem |
<li> inside <ul> |
inside the <li> |
numberedListItem |
<li> inside <ol> |
inside the <li> |
checkListItem |
<li> inside <ul role="list"> |
inside the <li> |
toggleListItem |
<li><details> inside <ul role="list"> |
inside the <details> |
codeBlock |
<pre><code> |
siblings |
divider |
<hr /> |
siblings |
table |
<table> |
siblings |
tableOfContents |
<nav> |
siblings |
image, video, audio, file |
<figure> |
siblings |
drupalMedia |
<figure> |
siblings |
drupalMediaGallery |
<div> holding a <ul role="list"> |
inside, one <li> each |
callout |
<div role="note"> |
inside, after its text |
drupalMedia and drupalMediaGallery come from the blocknote_media submodule rather than from the base module, and callout from the blocknote_extras submodule, and they are in this table because their envelopes are part of the same contract. The media view and the <figcaption> are siblings inside the <figure>, in that order, and the figure is there whether or not the media itself can be shown. A gallery's children are its items, media blocks only, each in an <li> of its own.
Consecutive list items of one type share a list element. The renderer groups them, because BlockNote stores list items as sibling blocks and never as a list. A block of another type between two items ends the run and starts a new list; a nested list is built from the items' children and sits inside its parent li.
An ordered list carries start when the first item of the run asks for a number above 1. The value is read as an integer and clamped to 1000000.
A block whose type no installed plugin provides, and a block whose type the text format has stopped enabling, both render nothing and are logged at debug level, children included. The document keeps them, so enabling the type again brings them back; the unsupported-block carrier described in ADR 0002 is the editor's view of the same blocks, not the page's. One such type is the module's own: blocknote_unconverted, which the HTML converter keeps markup it could not convert in (0040). No plugin provides it, so it renders nothing, and its markup never reaches the page in any form; a theme has nothing to style for it.
A paragraph whose content is empty and which has no children renders nothing at all. BlockNote keeps an empty paragraph at the end of every document, so this is the last block of nearly every stored document there is, and a page would otherwise end in a <p> no author wrote. Empty means an empty content list, which is what the editor writes; a paragraph holding one run of no text is a paragraph and keeps its element. An empty paragraph that has children keeps its element too, because dropping it would drop the children with it.
3. The class vocabulary¶
The vocabulary is closed. A class not on this list cannot appear, and a property whose value is outside the vocabulary produces no class rather than an unknown one, because a class nothing can style is worse than no class at all. The one open addition is a block's style, below, whose classes the site writes in its text format.
What is closed is the base vocabulary: the classes the module itself emits, for the blocks it provides, BlockNote's fourteen default blocks and the table of contents, and for the structures around them. A block another module provides brings its own classes, and they are that module's contract to make and keep, not this one's. The two rules it inherits are that every block's outer element carries blocknote-block and blocknote-block--<type> like any other, and that a class from a closed list of values emits nothing rather than something unknown when the value is outside the list. The blocknote-media and blocknote-gallery classes below are the blocknote_media submodule's, and the blocknote-callout classes the blocknote_extras submodule's, listed here because the submodules ship with the module and a theme will meet them.
The first of those two rules has one exception: a paragraph in a format restricted to a single paragraph (decision record 0030) whose declared wrapper is none has no outer element at all, so blocknote-block and blocknote-block--paragraph are not emitted, and neither is any alignment or color class the block would otherwise carry. See 5 for what such a paragraph renders as under each wrapper value.
A text format may list styles for its block types (0046), such as paragraph.lead.large|Lead, and a block carries one at most, in its blockStyle property. The property holds the style's classes as the format lists them, separated by single spaces, lead large: a list of tokens in the format of an HTML class attribute, which any consumer of the document splits on whitespace. A style is its set of classes, so a value holding a listed style's classes in another order, large lead, is that style, and the page carries them in the order the format lists them. A block with no style has no blockStyle key at all. The classes go on the block's outer element, after every other class there, the module's own and any a block adds, and nowhere else, with no wrapper and no data attribute: <p class="blocknote-block blocknote-block--paragraph lead large">, a list item's on its <li> and a media block's on its <figure>. A class is made of letters, digits, - and _, and never starts with bn-, blocknote- or ProseMirror, which BlockNote, this module and ProseMirror use, so no style emits a class of this vocabulary. A style the format does not list for the block's type, because the line was removed, its classes changed or the block type has none, emits nothing, and the block renders as it would without it. A table never has a style, and a paragraph in a single-paragraph format whose wrapper is none has no element to carry one. A block another module provides carries its style the same way, as long as it builds its outer element through BlockNoteBlockBase::blockAttributes(), which docs/extending.md section 1 describes; a theme that overrides a block's template keeps the classes only where it prints attributes, as the shipped templates do. The module ships no CSS for any style; what a class means is the site's, and a style's class that collides with a class the theme uses for something else is the site's to avoid. In the editor the same classes are on the block's editable element, and the stylesheets the default theme names under blocknote-stylesheets in its .info.yml are what style them there, as the README describes.
A custom inline content type another module provides follows the same two rules: its outer element carries blocknote-inline and blocknote-inline--<type>, its other classes are that module's own contract to make and keep, and a property value outside a closed list emits no class rather than an unknown one. docs/extending.md section 13 is the plugin contract that builds this markup.
| Class | Where |
|---|---|
blocknote-block |
the outer element of every block |
blocknote-block--<type> |
the outer element of every block |
blocknote-block--unavailable |
a media block with no URL to show: the stored URL was refused, or the file its identity named is gone or withheld |
blocknote-list |
the <ul> or <ol> around a run of list items |
blocknote-list--<type> |
the same element |
blocknote-inline |
the outer element of a custom inline content type, including the anchor of a resolved content link |
blocknote-inline--<type> |
the same element |
blocknote-link--new-tab |
a link, plain or content, that opens in a new tab |
visually-hidden |
the <span> holding a new-tab link's hidden text, and the one holding a callout's tone; core's own class, which the system/base library styles |
blocknote-align--center, --right, --justify |
a block or a table cell |
blocknote-align--left |
a drupalMedia block |
blocknote-text-color--<name> |
a block, an inline run or a table cell |
blocknote-background-color--<name> |
the same |
blocknote-cell |
every <th> and <td> |
blocknote-caption |
a <figcaption> |
blocknote-toggle |
a <details>, whether from a heading or a toggle item |
blocknote-toggle__summary |
its <summary> |
blocknote-heading |
the heading inside a collapsible heading's <summary> |
blocknote-check-list-item__label |
the <label> around a checklist item's checkbox and text |
blocknote-check-list-item__checkbox |
the checkbox of a checklist item |
blocknote-check-list-item__content |
the text of a checklist item |
blocknote-check-list-item--checked |
the <li> of a ticked checklist item |
blocknote-file-link |
the link a media block renders when it shows no preview |
blocknote-file-name |
the name a media block shows when its URL was refused |
blocknote-image, blocknote-video, blocknote-audio |
the <img>, <video> or <audio> itself |
language-<id> |
the <code> of a code block that named a language the format lists, or any language when the format lists none |
blocknote-media |
the <span> a media block renders in place of a media item it cannot show |
blocknote-media--inaccessible |
the same <span>, when this visitor may not view the item |
blocknote-media--missing |
the same <span>, when no media item carries the stored uuid |
blocknote-gallery--cols-2, --cols-3, --cols-4 |
the <div> of a drupalMediaGallery, by its column count |
blocknote-gallery__items |
the <ul> inside it |
blocknote-callout--note, --info, --success, --warning, --danger |
the <div> of a callout, by the tone it renders in |
blocknote-callout__icon |
the inline <svg> of a callout's tone |
blocknote-callout__title |
the <p> holding a callout's title |
blocknote-callout__content |
the <p> holding a callout's text |
| the classes of a block's style | the outer element of a block carrying a style its format lists, after the module's own classes |
<name> is one of gray, brown, red, orange, yellow, green, blue, purple and pink, which is BlockNote's own palette. default is the absence of a colour and emits nothing. left is the default alignment and emits nothing either, because a page already reads in its own direction. An alignment the format forbids emits no class either, as a forbidden color does.
<type> is the block type in dashes: bulletListItem becomes bullet-list-item. The conversion is \Drupal\blocknote\BlockNoteBlockBase::cssClassSuffix(), and it is the same conversion that names the theme hook and the template file.
A language identifier reaches a class only when the lower-cased, trimmed value matches /^[a-z0-9][a-z0-9+#._-]{0,31}$/. Anything else, including a name of 33 characters, leaves the <code> with no class.
4. Inline content¶
A run of text carries any of seven styles, at most five of them together, and they always nest in one order, outermost first: code, bold, italic, underline, strike, subscript, superscript. The order is fixed so that two documents saying the same thing produce the same bytes. A run that also carries a colour gets a <span> innermost of all, holding both colour classes.
So bold alone is <strong>text</strong>, bold and italic is <strong><em>text</em></strong>, and the most one run may carry, every style but code and subscript with a text color and a background color, is <strong><em><u><s><sup><span class="blocknote-text-color--red blocknote-background-color--yellow">text</span></sup></s></u></em></strong>. code shares a run with nothing, no other style, no color, no new-tab flag and no link around it, and subscript and superscript never share one, because the editor's schema excludes both combinations and the editor cannot open a document that holds them; a save carrying either is refused (see 9). A run stored before that rule, or written without validation, still renders in the order above, code outermost and <sub><sup>text</sup></sub> for the pair, deterministic like every other combination this section describes.
A newline inside a run of inline content is a hard break the author typed, and becomes <br />. In plain content, which is what a code block holds, a newline stays a newline: <pre> preserves it. A code block's styles are ignored entirely, because BlockNote's code block cannot carry any.
No whitespace is introduced anywhere in inline content, except the one space that opens a new-tab link's hidden text, described below. The two inline templates control it explicitly, so a run never gains a space it did not have and adjacent runs never gain one between them.
A link becomes <a href="…"> with its own content rendered inside it, styles and all. A link inside a link is refused on save and is never stored, so it never reaches here. The href is read by \Drupal\blocknote\Render\UrlSanitizer and accepted only when it is http:, https:, mailto:, tel:, a root-relative path, a fragment, a protocol-relative URL or a scheme-less relative path. Anything else is refused, and a refused link renders its own content with no anchor around it, so the words survive and the target does not. The refused URL never appears on the page, in any position, including as the link's text.
A link whose first text run carries the newTab style, in a format that permits it, becomes <a href="…" target="_blank" rel="noopener" class="blocknote-link--new-tab">…text…<span class="visually-hidden"> (opens in a new tab)</span></a>: the hidden text is the anchor's last child, after the author's own words, with its leading space inside the hidden span itself rather than beside it, so nothing visible follows the link's own words and a screen reader announces the indication right after them. The hidden words are translated into the language the document is rendered in, not the interface language. rel is noopener alone, never nofollow. Only the first run of a link's content is read for this; a run elsewhere in the link, or outside any link altogether, has no effect on render even though it may be accepted on save (see 9). In a format that does not permit a link to open in a new tab, which includes every format that permits no links, or when the flag is absent, the anchor carries none of this and is byte for byte what it always was. system/base is attached to the page whenever this markup is emitted, because nothing else attaches the .visually-hidden rule the hidden text depends on.
The sanitiser decodes character references before it reads the scheme, the way a browser decodes text: every HTML5 named reference, such as : and 	, the legacy names a browser accepts without a semicolon, such as  , and decimal and hexadecimal references with or without the semicolon. It decodes once, so a reference that decoding produces is left as it is, and it strips whitespace and control characters afterwards. So javascript:, javascript:, java	script:, javascript:, javascript:, a newline inside the scheme and a null byte are all caught, and a scheme it cannot read is refused rather than guessed at. The document validator reads a link's scheme the same way, so a refusal on save names the scheme the target spells.
A protocol-relative target, //host/path, is the one relative form that leaves the site: it names no scheme and the browser fills in whichever of http: and https: the page carrying it was served over. A link may use it, and the format's link policy then decides it: the target survives only in a format that permits both http: and https:, because either is what the browser may resolve it to. In this position a browser reads a backslash as a slash, so /\host/x, \\host/x and \/host/x are the same URL as //host/x and get the same answer everywhere this contract mentions the form. One leading slash or backslash is a path on this site, whichever way it leans.
There are two inline content types BlockNote itself provides, text and link, which is its whole default vocabulary. A custom inline content type, one that is neither, emits whatever its own plugin's template builds: a type with no template of its own renders through the generic blocknote_inline hook as <span class="blocknote-inline blocknote-inline--<type>">… media item carries, and the page depends on the media list cache tag, so creating an item with that uuid makes the page recover.--inaccessibleis an item this visitor may not view, and the page carries the cacheability of the refusal and theuser.permissions` context. Any block that defers part of its own content inherits this limitation, and it is recorded in ADR 0006.
A media item that embeds itself renders nothing. A media type may carry a BlockNote field of its own, so an item can name itself, directly or through a chain of other items. The renderer keeps a stack of what it is in the middle of rendering and the repeat produces no markup and a logged warning; the figure and the caption around it are already on their way out, so the rest of the page is unaffected. There is no class for this state, deliberately: adding one means adding it to the closed vocabulary in section 3.
A stored viewMode the format has stopped permitting, and one the item's own bundle has no enabled display for, both fall back to the default view mode with a debug log. The item is shown the way every other page shows it rather than not at all: a display deleted long after a document named it is a fact about the site, not about the document. An item whose media type the format has stopped permitting is rendered the same way, for the same reason. The two lists are enforced differently before the page: a media type outside the list is refused on save and in the editor's preview, while a view mode outside the list is refused nowhere, the editor's select and its preview show the default, and a write through the document updater stores the default. ADR 0015 says why.
What the media block draws inside the editor is not part of this contract. bn-drupal-media, the blocknote-media-block classes and the rest of the block's editor markup may change in any release, like everything else the editor draws. The editor-side hooks docs/extending.md section 6 lists are the exceptions.
drupalMediaGallery¶
This block comes from the blocknote_media submodule too. It has no content of its own, and its children are its items, each a drupalMedia block.
<div class="blocknote-block blocknote-block--drupal-media-gallery blocknote-gallery--cols-3"><ul class="blocknote-gallery__items" role="list"><li><figure class="blocknote-block blocknote-block--drupal-media">THE MEDIA ITEM<figcaption class="blocknote-caption">A caption</figcaption></figure></li><li><figure class="blocknote-block blocknote-block--drupal-media">THE MEDIA ITEM</figure></li></ul></div>
Each <li>, and its own styled content, where it declares any, follows every rule in this section the same as a block's own inline content does. A type no plugin claims, or one the format has stopped enabling, renders only its own text, unstyled and with no element around it, and is logged at debug level; the document keeps the item unchanged, so enabling the type again brings its markup back. The editor's own carrier for such an item, blocknote_unsupported_inline, is what the author sees while editing it; it never reaches storage and never reaches this page, because the client unwraps it back into the original item before every save. docs/extending.md section 13 is the full contract.
A content link is the base module's own custom inline content type. It resolves to an anchor over the target's current address, <a href="…" class="blocknote-inline blocknote-inline--entity-link">…ible is an item this visitor may not view, and the page carries the cacheability of the refusal and the user.permissions context. Any block that defers part of its own content inherits this limitation, and it is recorded in ADR 0006.
A media item that embeds itself renders nothing. A media type may carry a BlockNote field of its own, so an item can name itself, directly or through a chain of other items. The renderer keeps a stack of what it is in the middle of rendering and the repeat produces no markup and a logged warning; the figure and the caption around it are already on their way out, so the rest of the page is unaffected. There is no class for this state, deliberately: adding one means adding it to the closed vocabulary in section 3.
A stored viewMode the format has stopped permitting, and one the item's own bundle has no enabled display for, both fall back to the default view mode with a debug log. The item is shown the way every other page shows it rather than not at all: a display deleted long after a document named it is a fact about the site, not about the document. An item whose media type the format has stopped permitting is rendered the same way, for the same reason. The two lists are enforced differently before the page: a media type outside the list is refused on save and in the editor's preview, while a view mode outside the list is refused nowhere, the editor's select and its preview show the default, and a write through the document updater stores the default. ADR 0015 says why.
What the media block draws inside the editor is not part of this contract. bn-drupal-media, the blocknote-media-block classes and the rest of the block's editor markup may change in any release, like everything else the editor draws. The editor-side hooks docs/extending.md section 6 lists are the exceptions.
drupalMediaGallery¶
This block comes from the blocknote_media submodule too. It has no content of its own, and its children are its items, each a drupalMedia block.
<div class="blocknote-block blocknote-block--drupal-media-gallery blocknote-gallery--cols-3"><ul class="blocknote-gallery__items" role="list"><li><figure class="blocknote-block blocknote-block--drupal-media">THE MEDIA ITEM<figcaption class="blocknote-caption">A caption</figcaption></figure></li><li><figure class="blocknote-block blocknote-block--drupal-media">THE MEDIA ITEM</figure></li></ul></div>
Each <li>, with the author's own wording as its content: a page target's own access is left unchecked (see below), so the public anchor carries no title, which would otherwise show the target's current label to a visitor who may not be allowed to read it. Its first text run may carry the same newTab style a plain link's does, with the same effect: target="_blank", rel="noopener", the blocknote-link--new-tab class and the same hidden "(opens in a new tab)" text, appended after the author's own wording, in a format that permits it. The editor shows that title to the author while writing, from a prefetch that is checked against view label before it is built. It renders as text alone, its own content with no element around it, for one of three reasons: the target is missing, a uuid no entity carries; the target is withheld, which happens only for a linked file or a media item whose source file the current account may not see; or the target resolved and is left unchecked but answered no address to link to. Its own content may not hold a plain link or another content link at any depth, and a plain link's content may not hold a content link either: both are refused on save and never reach here. A content link holding no text renders nothing at all, since its anchor would have no accessible name; the editor deletes one as soon as it is left empty. A content link may also carry an optional substitution, naming an address rule a resolver follows in place of the one above when the format permits it; only the href differs, everything else in this paragraph stays true. ADR 0028 and ADR 0029 are the records.
5. Per-block reference¶
Each example below is the literal output for one block. Newlines shown inside an example are part of it.
paragraph¶
<p class="blocknote-block blocknote-block--paragraph blocknote-align--justify blocknote-text-color--green">Plain, <strong>bold</strong> and a <a href="https://example.com/page">link</a>.</p>
Children follow the `he format has stopped permitting, and one the item's own bundle has no enabled display for, both fall back to the default view mode with a debug log. The item is shown the way every other page shows it rather than not at all: a display deleted long after a document named it is a fact about the site, not about the document. An item whose media type the format has stopped permitting is rendered the same way, for the same reason. The two lists are enforced differently before the page: a media type outside the list is refused on save and in the editor's preview, while a view mode outside the list is refused nowhere, the editor's select and its preview show the default, and a write through the document updater stores the default. ADR 0015 says why.
What the media block draws inside the editor is not part of this contract. bn-drupal-media, the blocknote-media-block classes and the rest of the block's editor markup may change in any release, like everything else the editor draws. The editor-side hooks docs/extending.md section 6 lists are the exceptions.
drupalMediaGallery¶
This block comes from the blocknote_media submodule too. It has no content of its own, and its children are its items, each a drupalMedia block.
<div class="blocknote-block blocknote-block--drupal-media-gallery blocknote-gallery--cols-3"><ul class="blocknote-gallery__items" role="list"><li><figure class="blocknote-block blocknote-block--drupal-media">THE MEDIA ITEM<figcaption class="blocknote-caption">A caption</figcaption></figure></li><li><figure class="blocknote-block blocknote-block--drupal-media">THE MEDIA ITEM</figure></li></ul></div>
Each <li>. Empty content with no children renders nothing.
A format restricted to a single paragraph (0030) declares which element every paragraph in the document renders as instead, through single_paragraph.wrapper: p, the default, is the shape above, byte for byte; span is the same element and the same classes with span in place of p:
<span class="blocknote-block blocknote-block--paragraph blocknote-align--justify blocknote-text-color--green">Plain, <strong>bold</strong> and a <a href="https://example.com/page">link</a>.</span>
and none is the inline content alone, with no element and therefore none of those classes:
Plain, <strong>bold</strong> and a <a href="https://example.com/page">link</a>.
The none output is still markup, not plain text: it carries <strong>, <a href>, <br /> and character references exactly as the other two do, and only the block's own element is gone. It belongs in a phrasing-content position such as a figcaption, an h2 or a table cell. It must never be printed into an attribute value such as alt or title, where the first href=" ends the attribute early; the plain_text computed property is what an attribute wants.
Children, where a stored document still carries any, follow the same way under every wrapper. A paragraph rendered outside such a format, or while the constraint is off, always gets p. 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.
heading¶
<h1 class="blocknote-block blocknote-block--heading" id="sample-document">Sample document</h1>
The level is the level prop, clamped to 1 to 6. A collapsible heading with children is a disclosure instead, and its id is on the heading inside the summary:
<details class="blocknote-block blocknote-block--heading blocknote-toggle" open><summary class="blocknote-toggle__summary"><h2 class="blocknote-heading" id="toggleable-h2">Toggleable H2</h2></summary><p class="blocknote-block blocknote-block--paragraph">Inside the toggle heading.</p>
</details>
A collapsible heading with no children is an ordinary heading: an empty disclosure does nothing.
Every heading the page renders carries an id, after its class, made from its text when the page is built; nothing is stored. The text is every run of the heading's inline content, a link's and a custom inline item's own words included. It is transliterated to ASCII by core's transliteration service in the language the document is rendered in, so each translation renders its own ids, and a character with no ASCII counterpart counts as a separator; then lowercased, with every run of characters other than a to z and 0 to 9 joined into one hyphen and a hyphen at either end dropped, and cut at 60 characters, dropping a hyphen the cut leaves at the end. A heading whose text leaves nothing, an empty one included, gets heading. So Résumé and naïve café is resume-and-naive-cafe, and in a German document Über Größe is ueber-groesse.
An id is unique within its document. Headings are taken in document order, a block before the blocks nested under it, and an id an earlier heading took is followed by -2, or by the first of -3, -4 and on that is free: a second Introduction is introduction-2, and a later heading whose own words give introduction-2 is introduction-2-2. Only the headings the page renders count, so a heading nested under a block that renders nothing because no plugin provides its type or the format does not enable it takes no id and changes no other. An id is not unique within a page: two documents on one page may each hold an introduction, and nothing keeps a heading's id apart from an id the theme writes. A block of another module may be a heading of the document's outline as well (docs/extending.md section 1): its id is made and numbered with the headings', and where it prints it is that module's contract.
An id is as stable as the words it is made from. Editing a heading's text changes its id, and adding a heading with the same words before it renumbers it.
A level the format has since stopped permitting renders at its stored level, and a collapsible heading renders as a disclosure whether or not the format still permits one: changing either at render would change the document's structure, where dropping a colour changes only its presentation.
quote¶
<blockquote class="blocknote-block blocknote-block--quote blocknote-background-color--yellow">A quotation.</blockquote>
Children render inside.
bulletListItem and numberedListItem¶
<ul class="blocknote-list blocknote-list--bullet-list-item"><li class="blocknote-block blocknote-block--bullet-list-item">Bullet one</li>
</ul>
<ol class="blocknote-list blocknote-list--numbered-list-item" start="5"><li class="blocknote-block blocknote-block--numbered-list-item">Numbered, custom start</li>
</ol>
start appears only when it is above 1.
checkListItem¶
<ul class="blocknote-list blocknote-list--check-list-item" role="list"><li class="blocknote-block blocknote-block--check-list-item blocknote-check-list-item--checked"><label class="blocknote-check-list-item__label"><input class="blocknote-check-list-item__checkbox" type="checkbox" disabled checked /><span class="blocknote-check-list-item__content">Checked item</span></label></li>
</ul>
The <label> holds the checkbox and the item's text, so the text names the checkbox, and the item's children follow the label inside the <li>. The checkbox is always disabled: public output reflects a document, it does not edit one. An item that is not ticked has neither the checked attribute nor the --checked class. The role="list" is stated because the optional stylesheet takes the markers off this list and Safari then drops the list semantics.
toggleListItem¶
<ul class="blocknote-list blocknote-list--toggle-list-item" role="list"><li class="blocknote-block blocknote-block--toggle-list-item"><details class="blocknote-toggle" open><summary class="blocknote-toggle__summary">Toggle list item</summary><p class="blocknote-block blocknote-block--paragraph">Toggle child</p>
</details></li>
</ul>
An item with no children keeps its details and summary, so a run of toggles looks uniform. The disclosure ships open, because a visitor should not have to hunt for content the page already has.
codeBlock¶
<pre class="blocknote-block blocknote-block--code-block"><code class="language-typescript">const x: number = 1;
console.log(x);</code></pre>
The runs of text are concatenated, styles are ignored and newlines are literal. A language that does not match the identifier pattern leaves the <code> bare. There is no data-language attribute.
divider¶
<hr class="blocknote-block blocknote-block--divider" />
table¶
<table class="blocknote-block blocknote-block--table"><colgroup><col style="width:120px" /><col /><col style="width:200px" /></colgroup><thead><tr><th class="blocknote-cell blocknote-background-color--gray" colspan="2" scope="colgroup"><strong>Merged header</strong></th><th class="blocknote-cell" scope="col">H3</th></tr></thead><tbody><tr><th class="blocknote-cell" rowspan="2" scope="rowgroup">Row-spanning</th><td class="blocknote-cell blocknote-align--center">b1</td><td class="blocknote-cell blocknote-text-color--red">c1</td></tr><tr><th class="blocknote-cell" scope="row">b2</th><td class="blocknote-cell"><a href="https://example.org">cell link</a></td></tr></tbody></table>
A cell is a <th> when its row is within headerRows, or it is one of the first headerCols cells of its row, counted by position in the row rather than by grid column, so a merged cell earlier in the row shifts nothing; it is a <td> otherwise. scope is col or colgroup in the head and row or rowgroup in the body, the group form whenever the matching span is above 1. colspan and rowspan are omitted when they are 1, and spans are clamped to 1000. Attribute order on a cell is class, colspan, rowspan, scope. A stored colspan or rowspan renders whether or not the format still permits merged cells, a stored header row or column renders as <th> whether or not the format still permits header cells, and a stored cell color the palette permits renders its class whether or not the format still permits cell colors; only the save refuses each of them. A cell color outside the palette emits no class, as it does on any block.
The variants: a table with no header rows has no <thead> and puts every row in <tbody>; a table whose header rows cover every row has a <thead> and no <tbody>; a table with no usable column width has no <colgroup> at all. A cell may also be stored as a bare list of inline content, which is upstream's partial form, and is read as a cell with default properties. A column width is clamped to 10000 pixels, and the <colgroup> is built from at most 1000 columns however many the document names. A table with no rows renders no element of its own, logged at debug level, and the blocks nested under it are rendered in its place.
image, video, audio and file¶
<figure class="blocknote-block blocknote-block--image blocknote-align--center"><img class="blocknote-image" src="https://example.com/img.png" alt="img.png" width="512" /><figcaption class="blocknote-caption">An image caption</figcaption></figure>
<figure class="blocknote-block blocknote-block--video"><video class="blocknote-video" src="https://example.com/v.mp4" width="640" controls preload="metadata"></video><figcaption class="blocknote-caption">A video</figcaption></figure>
<figure class="blocknote-block blocknote-block--audio"><audio class="blocknote-audio" src="https://example.com/a.mp3" controls preload="metadata"></audio><figcaption class="blocknote-caption">An audio</figcaption></figure>
<figure class="blocknote-block blocknote-block--file"><a class="blocknote-file-link" href="https://example.com/f.pdf">f.pdf</a><figcaption class="blocknote-caption">A file</figcaption></figure>
All four are always a <figure>, so that one selector reaches every media block. The <figcaption> is absent when there is no caption. An image, video or audio whose author turned the preview off renders as the link form the file block always uses, and a link with no name shows the sanitised URL as its text.
An image's alt is the block's name, and the name is the image's alternative text. An upload sets the name to the file's name, an author changes it with Rename image in the editor, and an image with no name renders alt="".
A media URL is read by the same sanitiser as a link but with a narrower allowlist: http:, https:, a root-relative path and a scheme-less relative path. mailto:, tel:, data:, file: and protocol-relative URLs are all refused, in every spelling of the last of those, because a media source is fetched by the browser without the visitor choosing to follow it. A block whose URL was refused keeps its name and caption and loses the URL:
<figure class="blocknote-block blocknote-block--image blocknote-block--unavailable"><span class="blocknote-file-name">img.png</span><figcaption class="blocknote-caption">An image caption</figcaption></figure>
The <span> is omitted when there is no name. A block with a refused URL, no name and no caption has nothing left to show and renders no element of its own, logged at debug level; blocks nested under it are still rendered, in its place, as they are under an empty paragraph, and so is the cacheability of a file identity that was not served.
A block's url may also hold the identity of a file uploaded through the editor, written as file: followed by the file's uuid in lower case and nothing after it: no path, no query, no fragment. The uuid is what is stored because the four file blocks carry no property the module could add an identity to, and because a uuid survives the file being moved or renamed.
An identity is resolved before it is sanitised, never instead of it. FileUrlResolver loads the file by uuid and produces the URL the file is served by, relative to the site root, and that URL then goes through the media allowlist exactly as a URL an author typed does. The URL names no host, so markup cached under one domain of a site is correct under every other. The allowlist refuses the file: scheme permanently, so a file: value reaching it is one no resolver claimed, and a document rendered through a render context built without a resolver puts no identity on a page.
There are two answers other than a URL. A uuid no file carries is missing. A file on a scheme a download callback serves, which is every scheme but public, is withheld from an account that may not view it. Both render the unavailable shape above: the name and the caption survive, and no URL appears.
Who may view such a file is decided in one place for the page and for the download callback alike. Drupal's own answer comes first, which for a file in no file field means the account that owns it. Failing that, the module asks its own file_usage rows: an account that may view an entity referencing the file, and the field of that entity whose document names it, may see the file on the page exactly as it may fetch it by URL.
Each answer says what the page now depends on. A resolved file adds the file's own cache tag, so replacing the file updates every page showing it. A missing file adds the file_list tag, so a page holding a reference to a deleted file recovers when a file is next saved. A withheld file adds the file's tag, the cacheability of both access results that withheld it, every referencing entity it asked about, and the user.permissions and user contexts. The user context is there because the first answer turns on who owns the file, which a refusal from it does not declare.
A file: value that is not a well-formed identity is refused when the document is saved, named by its scheme like any other dangerous media URL. A well-formed one is accepted on its form alone: whether a file still carries that uuid is answered when the page is built, because a file deleted afterwards would otherwise make every entity holding it unsaveable until an author had edited each block by hand.
previewWidth becomes a width attribute on the image or video, clamped to 10000, and is omitted when the document does not carry one. Audio never carries a width, and neither does a link form.
tableOfContents¶
<nav class="blocknote-block blocknote-block--table-of-contents" aria-label="Table of contents"><ul><li><a href="#section">Section</a><ul><li><a href="#part">Part</a></li></ul></li></ul></nav>
The base module's own block, which an author places where the list should be. It holds no content and declares no properties: it lists the headings of the document it sits in when the page is built, every heading that has text, at every level, in document order, each as a link to the id the heading renders with (heading). Its entry is the heading's plain text, never its styles or links, because a link may not sit inside another. A heading nests, in a <ul> inside its <li>, under the closest heading before it of a lower level, so skipping a level nests by one and a document that starts below its top level keeps the shallower heading that follows at the top. The list's own elements carry no class, so a theme reaches them through blocknote-block--table-of-contents. The aria-label is translated into the language the document is rendered in. Headings after the block are listed as well as those before it. A document holds one: a save refuses every one after the first (9), and the editor offers none while the document has one. A document stored with more, without being validated, lists the headings in the first alone: each later one renders the blocks nested under it in its place, logged at debug level.
A document with no heading that has text leaves it nothing to list: it renders no element of its own, logged at debug level, and the blocks nested under it are rendered in its place. Blocks nested under it otherwise follow the wrapperexists to do, by the site builder's own choice on that format, and is part of this contract rather than a violation of it. What stays breaking is changing what an undeclared, default format emits, and changing which element a givenwrapper` value maps to.
The classes of a block's style (0046) are a carve-out from the closed vocabulary. They are the site builder's own, written in the format, and appear only on the outer element of a block whose stored blockStyle the format lists. Changing or removing a style in the format changes the page of every block carrying it at once, by the site builder's choice, the same posture the single-paragraph carve-out above takes for wrapper. What stays breaking is moving the classes to another element, or emitting anything else for a style, such as a wrapper or a data attribute.
A new-tab link's target, rel, blocknote-link--new-tab class and hidden text (0032) are part of this contract. They appear only on a link whose stored newTab style is set and whose format permits it, by the author's own choice on that link, the same posture the single-paragraph carve-out above already takes for wrapper.
12. A trimmed rendering¶
The Trimmed formatter, blocknote_trimmed, shows the start of a document, for a teaser or a card. It cuts the stored document, never its HTML: the blocks it keeps go through the item's text format, the renderer and the templates this document describes, so each block it shows renders as it does in the whole document, and a cut shortens runs of text inside the elements and closes none of them early. Its Attach the default styles setting is the Default formatter's (8). ADR 0045 is the record.
It walks the blocks the page renders, in document order, a block before the blocks nested under it, and counts the characters, or the words, of the text each one shows: the inline content of a block, the words of a link and a content link included; the text of a code block; and the text of a table's cells. An image, a video, an audio, a file, a media item and its caption, a divider and any other block without text count nothing, and are shown until the limit is reached. A character is a character as a reader sees it, an extended grapheme cluster, so a letter and the accents on it, a flag or an emoji joined from several count one, and every space and line break counts; a word is a run of characters that are not whitespace.
The Cut setting says where the limit may fall:
| Setting | Where it cuts |
|---|---|
| After whole blocks only | After the last block of the top level that fits whole, with everything nested under it. A run of list items is one block, the list. |
| At a word boundary | Inside the block that crosses the limit, after the last whole word that fits. |
| At the exact character | Inside the block that crosses the limit, at the limit, never inside a character. With a limit that counts words, the cut falls after a whole word, as at a word boundary. |
A cut inside a block keeps the block's element, its classes and its runs up to the cut, styles, colors and links included, a link keeping what of its words comes before the cut, and drops the spaces before the cut. The block loses the blocks nested under it, and the blocks after it are left out. A cut inside a paragraph, a list item, a checklist item, a quote or a toggle's summary works that way, so a list keeps the items before the one cut, and a toggle cut in its summary is a <details>. In the editor it is a read-only box that names it and lists nothing; that box and its blocknote-table-of-contents-box classes are not part of this contract. ADR 0043 is the record.
drupalMedia¶
This block comes from the blocknote_media submodule. It is here because its output is part of the same contract and a theme meets it in the same place. Do not confuse its identity with the file identity above: that one is file:<uuid> in a media block's url property and names a managed file, this one is a mediaUuid property naming a media entity.
<figure class="blocknote-block blocknote-block--drupal-media">THE MEDIA ITEM<figcaption class="blocknote-caption">A caption</figcaption></figure>
THE MEDIA ITEM stands for whatever the site's own view builder makes of the item in the view mode the block stores. The inner markup is the theme's and this contract says nothing about it beyond where it sits. The figure carries blocknote-align--left, blocknote-align--center or blocknote-align--right beside these two when the author aligned the item; none, which the editor calls Break text, is the default and emits nothing, and the block offers no justify. Center and right are from the closed list every other block draws from (section 3); left is a class only this block carries, since left is a text block's default and emits nothing there. Section 8 says which of them float. ADR 0044 is the record. The <figcaption> is omitted when there is no caption, exactly as it is for the four file blocks. The caption is the block's inline content, so everything section 4 says about styles, colours and links applies to it.
An image item's alternative text may be overridden for one use. A block whose alt property holds text other than whitespace renders the item with that text as the image's alt, and a block whose decorative property is true renders it with alt="", whatever alt holds; a block with neither renders the item's own alternative text, as every block saved before the two properties existed does. The text is set on the item's image source field and on its thumbnail before the item is built, whichever of the two the view mode shows, so the item's markup is still the theme's and only the value of that attribute changes. It applies only to an item whose source field is an image field with alternative text enabled, the way core's media embed decides it, and changes nothing for any other item. A block that is both decorative and carries text, or carries more than 512 characters of it, is refused on save. Whitespace alone is no text: the editor trims what it stores, and the server reads it the same way. ADR 0036 is the record.
The media item is built behind a lazy-builder placeholder and replaced at the end of the response, because whether a visitor may see it is a fact about that visitor and the document around it is cached once for everyone. Two things follow.
The figure's classes cannot vary by the state of the media. The figure is printed while the document is filtered and the item is resolved afterwards, so there is no blocknote-block--unavailable here the way there is on a file block whose URL was refused. The state is expressed inside the figure instead:
<figure class="blocknote-block blocknote-block--drupal-media"><span class="blocknote-media blocknote-media--missing">Missing media</span><figcaption class="blocknote-caption">A caption</figcaption></figure>
<figure class="blocknote-block blocknote-block--drupal-media"><span class="blocknote-media blocknote-media--inaccessible">Inaccessible media</span><figcaption class="blocknote-caption">A caption</figcaption></figure>
--missing is a uuid no media item carries, and the page depends on the media list cache tag, so creating an item with that uuid makes the page recover. --inaccessible is an item this visitor may not view, and the page carries the cacheability of the refusal and the user.permissions context. Any block that defers part of its own content inherits this limitation, and it is recorded in ADR 0006.
A media item that embeds itself renders nothing. A media type may carry a BlockNote field of its own, so an item can name itself, directly or through a chain of other items. The renderer keeps a stack of what it is in the middle of rendering and the repeat produces no markup and a logged warning; the figure and the caption around it are already on their way out, so the rest of the page is unaffected. There is no class for this state, deliberately: adding one means adding it to the closed vocabulary in section 3.
A stored viewMode the format has stopped permitting, and one the item's own bundle has no enabled display for, both fall back to the default view mode with a debug log. The item is shown the way every other page shows it rather than not at all: a display deleted long after a document named it is a fact about the site, not about the document. An item whose media type the format has stopped permitting is rendered the same way, for the same reason. The two lists are enforced differently before the page: a media type outside the list is refused on save and in the editor's preview, while a view mode outside the list is refused nowhere, the editor's select and its preview show the default, and a write through the document updater stores the default. ADR 0015 says why.
What the media block draws inside the editor is not part of this contract. bn-drupal-media, the blocknote-media-block classes and the rest of the block's editor markup may change in any release, like everything else the editor draws. The editor-side hooks docs/extending.md section 6 lists are the exceptions.
drupalMediaGallery¶
This block comes from the blocknote_media submodule too. It has no content of its own, and its children are its items, each a drupalMedia block.
<div class="blocknote-block blocknote-block--drupal-media-gallery blocknote-gallery--cols-3"><ul class="blocknote-gallery__items" role="list"><li><figure class="blocknote-block blocknote-block--drupal-media">THE MEDIA ITEM<figcaption class="blocknote-caption">A caption</figcaption></figure></li><li><figure class="blocknote-block blocknote-block--drupal-media">THE MEDIA ITEM</figure></li></ul></div>
Each <li> holds the item's usual figure, as drupalMedia above describes it, its caption and its alternative text included, rendered in the gallery's viewMode and with no alignment class, whatever view mode and alignment the item stores. The gallery's view mode falls back to the default one as an item's does: where the format does not permit it, and for an item whose own media type has no enabled display for it. blocknote-gallery--cols-<n> carries the gallery's column count, 2, 3 or 4, and 3 for a value outside those. The list carries role="list" because the optional stylesheet removes its markers, and Safari drops the semantics of a list without them. There is no lightbox and no JavaScript on the page: what an item does when clicked is its view mode's, so a site that wants an item to zoom or to link somewhere gives the gallery a view mode that does. ADR 0047 is the record.
A gallery with no item renders nothing, and a child that is not a media block, which a save refuses, is left out. A format that stops enabling the gallery renders nothing for it, its items included, as section 2 says of any block; a format that stops enabling the media block renders the gallery's items nothing, and the gallery with them.
callout¶
This block comes from the blocknote_extras submodule. It is a note set apart from the text, in a tone, with an optional title, and its children are inside it.
<div class="blocknote-block blocknote-block--callout blocknote-callout--warning" role="note"><span class="visually-hidden">Warning:</span><svg class="blocknote-callout__icon" aria-hidden="true" focusable="false" width="1.25em" height="1.25em" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="…"/></svg><p class="blocknote-callout__title">Mind the gap</p><p class="blocknote-callout__content">Stand back from the doors.</p><ul class="blocknote-list blocknote-list--bullet-list-item">…</ul>
</div>
The element is a <div> with role="note", which marks content that is parenthetic to the main content, and not an <aside>, which would be a landmark wherever the document is not inside sectioning content, so a page would gain one landmark per callout. Inside it come, in order: the tone's name followed by a colon, in a <span class="visually-hidden">, so that a screen reader reads the tone first; the tone's icon; the title, when the block has one, in a <p class="blocknote-callout__title">, which is plain text and never a heading, so that nothing inside a callout enters the document's outline or its table of contents; the block's text in a <p class="blocknote-callout__content">, left out when the block has none; and the blocks nested under it, which a save keeps free of headings at any depth. A heading below a callout in a document written without validation, such as one a migration saved, renders as a paragraph with the same text and no id, and no table of contents lists it. The tone's name is translated into the language the document is rendered in, and its hidden text needs core's system/base library, which the block attaches wherever it renders.
blocknote-callout--<tone> names the tone the callout renders in: note, info, success, warning or danger. A callout with no tone renders in the first tone the format offers, the default the editor leaves out of the document. A stored tone the format no longer offers renders as note, with its name and its icon, so a page never loses a callout; the save refuses that tone, and the editor offers the format's first one instead. Each tone has one fixed icon, an unmodified Remix Icon glyph from modules/blocknote_extras/js/icons/remix/, printed as inline SVG with aria-hidden="true" and focusable="false", sized at 1.25em and filled with currentColor, so it needs no stylesheet to be drawn and follows the text's color and a forced colors mode. It carries no style attribute. A theme hides it with CSS or replaces it in the template. The block carries a text color and an alignment as any text block does, and no background color, which would compete with the tone's.
A format that stops enabling the callout renders nothing for it, its children included, as section 2 says of any block. ADR 0048 is the record.
6. What is never emitted¶
A block's id. An id on any element but a heading, whose generated id section 5 describes. Any data-* attribute built from a property. A style attribute, with one exception: style="width:NNpx" on a <col>, which ADR 0004 records and bounds, because a column width is an unbounded pixel value an author sets by dragging and cannot be a class from a closed vocabulary. The value is written by the renderer after being read as an integer and clamped; no author string reaches a style attribute. A URL that failed the allowlist, in any position, including as the text of the link that carried it. Any of the editor's own bn- classes.
7. Overriding the markup¶
Every block type is registered as its own theme hook, blocknote_block__<type>, with blocknote_block as its base hook, which is what makes a template in a module or a theme discoverable for a single block type. The type in a hook name is snake case: bulletListItem becomes blocknote_block__bullet_list_item, and its template blocknote-block--bullet-list-item.html.twig. Every inline content type is registered the same way, as blocknote_inline__<type> with blocknote_inline as its base hook, converted to snake case the same way.
A suggestion exists for every registered block type and every registered inline content type, including those provided by other modules and those this module ships no template for; a block with no template of its own renders through blocknote-block.html.twig, and an inline content type with no template of its own renders through blocknote-inline.html.twig. The module implements no hook_theme_suggestions_* anywhere, so the hook list above is the whole story.
One override costs more than the others: a theme that replaces blocknote-block--paragraph.html.twig takes over the element the paragraph renders as, so a format's own wrapper setting stops reaching the page. Section 5 says what the shipped template does with it.
The templates that ship:
| Theme hook | Template |
|---|---|
blocknote_block |
blocknote-block.html.twig |
blocknote_block__paragraph |
blocknote-block--paragraph.html.twig |
blocknote_block__heading |
blocknote-block--heading.html.twig |
blocknote_block__quote |
blocknote-block--quote.html.twig |
blocknote_block__bullet_list_item |
blocknote-block--bullet-list-item.html.twig |
blocknote_block__numbered_list_item |
blocknote-block--numbered-list-item.html.twig |
blocknote_block__check_list_item |
blocknote-block--check-list-item.html.twig |
blocknote_block__toggle_list_item |
blocknote-block--toggle-list-item.html.twig |
blocknote_block__code_block |
blocknote-block--code-block.html.twig |
blocknote_block__divider |
blocknote-block--divider.html.twig |
blocknote_block__table |
blocknote-block--table.html.twig |
blocknote_block__table_of_contents |
blocknote-block--table-of-contents.html.twig |
blocknote_block__image |
blocknote-block--image.html.twig |
blocknote_block__video |
blocknote-block--video.html.twig |
blocknote_block__audio |
blocknote-block--audio.html.twig |
blocknote_block__file |
blocknote-block--file.html.twig |
blocknote_list |
blocknote-list.html.twig |
blocknote_table_cell |
blocknote-table-cell.html.twig |
blocknote_inline_text |
blocknote-inline-text.html.twig |
blocknote_inline_link |
blocknote-inline-link.html.twig |
blocknote_inline |
blocknote-inline.html.twig |
blocknote_inline__entity_link |
blocknote-inline--entity-link.html.twig |
A block another module provides ships its own template by naming it in the template property of its #[BlockNoteBlock] attribute, and the registration then carries that module's templates/ directory as its path. blocknote_media ships blocknote-block--drupal-media.html.twig and blocknote-block--drupal-media-gallery.html.twig that way, and blocknote_extras ships blocknote-block--callout.html.twig. A theme overrides such a template exactly as it overrides one of the module's own, by putting a file of the same name in its own templates/; a template added to a module that is already installed is found after a theme registry rebuild. docs/extending.md section 4 is the full story.
Every block hook receives the same standard variables, so a template written against blocknote_block works for any type: block_type, props, attributes, content_attributes, content, children, level, checked, language, toggleable, element, url, name, caption, width, show_preview, available, colgroup, header_rows and body_rows. A variable a type has no use for stays at its default. A block that declares variables on its attribute receives them as well: the table of contents declares headings that way, each entry an id, a text and the children nested under it, blocknote_media declares media for the media block and items for the gallery, the rendered items in order, and blocknote_extras declares tone, tone_label, the tone's name with its colon, and icon, the inline SVG, for the callout; a declared variable never replaces a standard one. A list receives list_type, block_type, items, start and attributes; a cell receives header, attributes and content; a run of inline text receives text, lines, styles, attributes and wrapped; an inline link receives href, attributes and content; and blocknote_inline receives one standard set, inline_type, props, attributes and content, which a blocknote_inline__<type> hook receives as well, together with the variables its type declares.
A template for any block that can have children must print children, in the place the table in section 2 gives, or the blocks nested under it are missing from the page. Every shipped block template does, including the ones whose element cannot hold block content, which print it after the closing tag, except the gallery's: its children are its items, which it receives as items and prints one to an <li>, and its children is always empty.
Overriding a template is the supported way to change the markup. The classes and the structure above are what the module produces; what your template produces is yours, and this contract stops at the template.
8. The optional stylesheet¶
css/blocknote.content.css styles the few things a theme has no default for: the nine colours, the three alignments, checklists, toggles, table cell borders, media sizing, the gallery's grid and the callout's box. It has no resets and no typography, and is shipped as the library blocknote/content.
The alignments place a block, and only a media item aligned to the left or the right floats. A block aligned to the center or the right has its content centered or set against the right edge, a media item aligned to the center its whole figure, on a line of its own, and a media item with no alignment, the default, is on a line of its own at the start. A media item aligned to the left or the right floats to that side, the way core's system/base library floats an item that core's Align images filter gives align-left or align-right, so the text after it flows beside it: its figure is at most half as wide as the element that holds it, with a margin of 1em on the side the text flows past, and an image, video or audio player inside it is scaled down to fit. Left and right are the left and the right in a right-to-left document too, as core floats them and as the editor places them. The float is cleared where the element holding the figure ends, by an ::after the stylesheet gives that element, which is the end of the document or of the list item or quote the item is nested in. That rule has no specificity, so a theme's own ::after on the same element, such as the closing quotation mark Claro gives a quote, wins where the two disagree. An image, video, audio or file block never floats. In the editor nothing floats: a media item aligned to the left or the right keeps its own row, set against the left or the right edge, because a float in the editor's list of blocks breaks its side menu, its drag handle and selection. A theme that wants the item to float differently, or not at all, styles blocknote-align--left and blocknote-align--right itself.
A callout is a box with a border at its start and a background in its tone's palette color, gray for note, blue for info, green for success, orange for warning and red for danger, and its icon in the box's start padding. Its text keeps the page's own color; the border and the icon take the tone's text color through --blocknote-callout-color, which a theme sets to recolor both.
A gallery's list is a grid of as many equal columns as its blocknote-gallery--cols-<n> class says, without markers, with a gap of --blocknote-gallery-gap, 1em by default, between its items, and an image or a video in an item scaled down to its column. A theme that lays galleries out otherwise, such as a masonry or a carousel, styles blocknote-gallery__items itself.
The field formatter attaches it, and its attach_styles setting, labelled "Attach the default styles" and on by default, takes it away. A theme that styles documents itself turns the setting off on the field display and owns the presentation entirely.
Every colour in the stylesheet is a custom property with the module's default as its fallback, so a theme overrides one value instead of fighting specificity. The defaults are BlockNote's palette, except that the gray, red, orange and yellow text colors are darker, so that each of the nine text colors has a contrast ratio of at least 4.5:1 against white, as WCAG 2.1 AA asks of text. The editor shows the same text colors in its light color scheme. The properties are --blocknote-color-<name>-text and --blocknote-color-<name>-background for each of the nine palette names, and --blocknote-table-border for the cell border. Setting --blocknote-color-red-text anywhere above the document changes every red run under it.
One consequence is worth stating plainly, because it surprises people. The filter produces the markup and the formatter attaches the stylesheet, so they are attached by different things. Any other path to the same HTML gets the markup without the stylesheet: the field's computed processed property, a processed_text element a Views field builds, or the text format used on a field that is not a BlockNote field. That is the price of letting the formatter setting decide; moving the attachment into the filter's own result would take the setting away from themes, and is not a change to make without reopening that decision.
9. The validation contract¶
What may be stored is narrower than what may be rendered, because the renderer has to cope with documents written before a rule existed while the validator decides what is written today.
A saved document is an envelope: an object carrying a version of 1 and a list of blocks. Each block is an object with a non-empty string type, optional props whose values are all primitives, optional content and an optional list of children. A property a block's plugin does not declare, a property of the wrong type and a value outside the declared values are each refused by name. Two blocks may not claim the same id, anywhere in the document, children included. A document holds one table of contents, and every one after the first is refused, because each lists every heading and many of them would multiply the page.
Content is checked against the kind the block declares. inline content is a list of text, link and custom inline content items, with the seven boolean styles, the boolean newTab style and the two colour styles and nothing else on a run of text; a link carries a non-empty href and a content list that may hold text only. newTab is checked by name only, never by position: a non-boolean value is refused the same as any other malformed style, and true while the format does not permit a new-tab link is refused naming the format, whether the run carrying it is inside a link or not. Two combinations are refused on a run whatever the format permits, because the editor's schema excludes them: code with any other style, any color, default included, or the new-tab flag, or inside a link, and subscript with superscript. A style, color, flag or link the format forbids is refused on its own and is not counted again; a flag set to false and an empty color count as absent. ADR 0037 is the record. plain content, which the code block holds, is a list of text items carrying no styles. table content is a map with rows, columnWidths, headerRows and headerCols, whose cells are either tableCell objects with colspan, rowspan, the two colours and an alignment, or bare lists of inline content.
A custom inline content item, one whose type is neither text nor link, is checked against its own plugin when one claims the type: an undeclared property, a property of the wrong type and a value outside its declared values are each refused by name, its own content is checked against the content kind the plugin declares, and a plugin's own rule is refused with its own message. A claimed type the format's inline_content does not enable is refused by name too. A type no plugin claims is not checked at all, and the document keeps it unchanged. docs/extending.md section 13 is the full contract.
A content link is checked the same way, against its own plugin: entityType must name a content entity type this site has, with a uuid key, and uuid must match a fixed character grammar, the same grammar a file: identity's uuid is checked against. Whether the named entity actually exists, and whether the current account may see it, is not checked here at all: that is answered when the page is built, not when the document is saved, the same posture a file: identity's own grammar-only check already takes. substitution, when present, must be a string the format's own permitted list holds; an absent value, NULL and '' are all accepted and mean the same thing.
A link whose scheme the sanitiser refuses is refused on save, with its scheme named and its target withheld from the message. It does not reach storage, which is why the renderer's own refusal of the same URL is a backstop rather than the first check. A media block's url is refused on save the same way and for the same reason, against the narrower media allowlist: a target the renderer would drop is a hole in a page nobody is told about, and the save is the last moment the author who typed it can act. A protocol-relative target is named as protocol-relative rather than by a scheme, because there is none to name.
A text format narrows all of this further. A format may permit only some styles, some text colours, some background colours, some alignments, some link schemes, links that open in a new tab, or no links at all, may limit heading levels, forbid collapsible headings, list the code languages, and forbid merged cells, header rows and columns, or cell colors, and a document carrying what its format forbids is refused on save; the refusal names the format for a style, a colour, an alignment, a link, a link's new-tab choice or a block type, and names the block for a heading level, a collapsible heading, a code language, a merged cell, a header row or column, or a cell color. A block's blockStyle must be a string, and one of the styles the format lists for the block's type, written exactly as listed; any other value is refused, naming the format, the block and the classes. No block's own plugin declares the property: the validator checks it for every block and takes it off before the plugin's own checks run. A format restricted to a single paragraph (0030) also refuses a document of more than one block, children counted, naming the format, the number of blocks and their types, and, where it does not permit line breaks, a line break anywhere in the document's text, naming the format.
The limits are counted before any of that, because a document large enough to be a denial of service must be refused before it is walked: 1048576 bytes, 5000 blocks and 10 levels of nesting. No table size limit ships: a table is one block and its cells are its content, so the byte limit already bounds it. A site that wants a cap on cells in one table, columns in one table, or table cells across a document sets max_table_cells, max_table_columns or max_document_table_cells to a number, none of which is implied by the others: a table's cells are not blocks, so a document of many small tables passes every per-table limit and still names every cell a page will build a render array for. A site raises or sets one of these by overriding the blocknote.limits parameter in sites/default/services.yml. At most 10 problems are reported at once, followed by one violation saying the list was cut short.
The byte, block and nesting limits belong to the parser, so rendering enforces them by parsing at all. Any table limit the site has set is counted again on the render path, because nothing else there counts cells: a value that reached storage without being validated, which is any programmatic save that did not ask for it, renders nothing at all and the refusal is logged as a warning naming the limit. A site with no table limit set walks no table on the render path either, the same as on save. The whole document goes, not the table alone, for the same reason a malformed document renders nothing: it was never checked, and publishing the rest of it would say that it had been.
File usage reads a document through the same parser. A value stored without validation that fails the byte, block or nesting limit therefore records no file usage for the files it names, and a temporary file it names is not made permanent.
10. The golden fixtures¶
tests/fixtures/documents/ holds the corpus. Each <name>.json is a document exactly as it is stored, and <name>.html beside it is what the shipped blocknote text format makes of it, byte for byte, with nothing cleaned up or reformatted. The three single-paragraph-*.json fixtures are the one exception: GoldenRendererTest renders them through a format it builds itself, with single_paragraph.enabled on and wrapper set to the value the name carries, because the shipped blocknote format keeps the constraint off and the shipped blocknote_snippet format, which turns it on, declares p alone. Every other fixture is unaffected by that: single-paragraph-* aside, the corpus is still read against the shipped blocknote format alone.
| Fixture | What it covers |
|---|---|
default-vocabulary.json |
one instance of every block type, written by BlockNote 0.54.2 |
editor-session-table.json |
a table from a real editing session: merged cells, a header row, a header column, resized columns |
lists-and-toggles.json |
nested lists, runs that must not merge, checklists, toggles, a collapsible heading |
inline-content.json |
every style singly and combined, the nesting order, combinations a save refuses, all eighteen colours, every link form including refused ones and one that opens in a new tab, hard breaks |
media.json |
each of image, video, audio and file with a preview, as a link, and with a refused URL |
edge-cases.json |
clamped and out-of-range values, an unknown colour, an unknown block type, the unsupported-block carrier |
carriers.json |
unsupported-block carriers nested under a list item and inside a toggle, and a top-level carrier with a list nested under it, written through the editor's own schema |
children-of-leaf-blocks.json |
blocks nested under a code block, a divider, a table and each of the four file blocks, which print them after their own element; and under an image with nothing to show, a video with a refused URL and no name or caption, and a table with no rows, which render the children alone |
single-paragraph-p.json, single-paragraph-span.json, single-paragraph-none.json |
the same one-paragraph document, rendered by a single-paragraph format under each wrapper value (0030) |
long-document.json |
a long document written in the editor: 258 blocks nested up to five deep, six tables, media blocks, every inline style, palette colors and three link schemes |
headings-and-contents.json |
heading ids: repeated words, accents, styled and linked text, an empty heading, a heading nested in a list item and one in a collapsible heading, words cut at 60 characters, a heading whose words give a taken id, and a heading under a block no plugin provides, which counts toward no repeat; and a table of contents before the headings and a second one after them with a block nested under it, which renders that block alone |
tests/src/Kernel/GoldenRendererTest.php globs the directory, so a fixture added to it is covered immediately and is registered nowhere else. The same test asserts that every block type the module provides appears in at least one fixture, so a new block plugin without a fixture fails the build. It also reads every fixture's HTML against the list in 6: no data-* attribute, an id on every heading and on nothing else, made of lowercase letters, digits and single hyphens and repeated nowhere in the document, no style other than a pixel width on a <col>, no bn- class, no block id anywhere in the output, and no link target or media source of the fixture's own document that the allowlist refuses, as an attribute value or as text, whether spelled as it was stored or as a browser reads it. It installs the base module only, so a block from blocknote_media or from a contributed module is not in the corpus and cannot be: its markup is pinned by its own module's tests instead. The guarantee is the same in kind and is asserted in a different file: tests/fixtures/documents-media/drupal-media.json is a document of media blocks, an existing item aligned and not, a caption with two styled runs and a space between them, an item no one holds and an item with a child, and MediaRenderTest compares what it renders to for a visitor who may view media and for one who may not with drupal-media.permitted.html and drupal-media.denied.html beside it. The item's own markup is the site's, so the test cuts the display to the source field and drops the line breaks and indentation between tags, keeping a space that sits on a line; everything else is compared as the corpus above is. js/src/__tests__/fixture-roundtrip.test.ts loads default-vocabulary.json, editor-session-table.json, long-document.json and carriers.json, which the editor wrote, back into an editor and asserts the JSON survives unchanged, carriers.json through the schema the widget builds. The hand-written fixtures are left out, because they hold shapes the editor would normalize. tests/src/Kernel/BlockContentValidationTest.php validates every fixture against a format with the id blocknote that permits every heading level, because the corpus was written when level 1 was permitted by default and the shipped format now permits 2 to 6: carriers.json, children-of-leaf-blocks.json, edge-cases.json, headings-and-contents.json, inline-content.json and media.json are refused, each for the kinds of error the test records, and the others are accepted.
To change a fixture's expected HTML, change the renderer first, then regenerate:
ddev exec BLOCKNOTE_UPDATE_FIXTURES=1 phpunit --bootstrap /var/www/html/web/core/tests/bootstrap.php tests/src/Kernel/GoldenRendererTest.php
Then read the diff. The variable exists so that regenerating is one command and reviewing is unavoidable; continuous integration never sets it, so drift fails the build rather than fixing itself.
The trimmed formatter's output is pinned the same way, against tests/fixtures/documents-trimmed/, by tests/src/Kernel/TrimmedRenderTest.php, which section 12 describes; the same command with that class's path regenerates it.
11. Stability¶
A breaking change to this contract is: removing or renaming a class, changing which element a block renders as, moving children from inside an element to outside it or back, changing the inline nesting order, widening the URL allowlist, or emitting anything listed in 6.
Changing how a heading's id is made is breaking too, because a link with a fragment depends on it: the text it is read from, the transliteration and the characters it keeps, the length it is cut at, the fallback, the numbering of a repeat and which headings count toward one. An author editing a heading's words, which changes its id, is not a change to the contract.
Not breaking: adding a class to the vocabulary, adding a theme hook or a template, adding a variable to a template, changing whitespace between blocks, changing the optional stylesheet, changing what the editor draws, and changing where a trimmed rendering cuts, as long as what it keeps renders as this document says (section 12).
A single-paragraph format's declared wrapper (0030) is a carve-out from the first rule above. Changing which element a paragraph renders as is what wrapper exists to do, by the site builder's own choice on that format, and is part of this contract rather than a violation of it. What stays breaking is changing what an undeclared, default format emits, and changing which element a given wrapper value maps to.
The classes of a block's style (0046) are a carve-out from the closed vocabulary. They are the site builder's own, written in the format, and appear only on the outer element of a block whose stored blockStyle the format lists. Changing or removing a style in the format changes the page of every block carrying it at once, by the site builder's choice, the same posture the single-paragraph carve-out above takes for wrapper. What stays breaking is moving the classes to another element, or emitting anything else for a style, such as a wrapper or a data attribute.
A new-tab link's target, rel, blocknote-link--new-tab class and hidden text (0032) are part of this contract. They appear only on a link whose stored newTab style is set and whose format permits it, by the author's own choice on that link, the same posture the single-paragraph carve-out above already takes for wrapper.
12. A trimmed rendering¶
The Trimmed formatter, blocknote_trimmed, shows the start of a document, for a teaser or a card. It cuts the stored document, never its HTML: the blocks it keeps go through the item's text format, the renderer and the templates this document describes, so each block it shows renders as it does in the whole document, and a cut shortens runs of text inside the elements and closes none of them early. Its Attach the default styles setting is the Default formatter's (8). ADR 0045 is the record.
It walks the blocks the page renders, in document order, a block before the blocks nested under it, and counts the characters, or the words, of the text each one shows: the inline content of a block, the words of a link and a content link included; the text of a code block; and the text of a table's cells. An image, a video, an audio, a file, a media item and its caption, a divider and any other block without text count nothing, and are shown until the limit is reached. A character is a character as a reader sees it, an extended grapheme cluster, so a letter and the accents on it, a flag or an emoji joined from several count one, and every space and line break counts; a word is a run of characters that are not whitespace.
The Cut setting says where the limit may fall:
| Setting | Where it cuts |
|---|---|
| After whole blocks only | After the last block of the top level that fits whole, with everything nested under it. A run of list items is one block, the list. |
| At a word boundary | Inside the block that crosses the limit, after the last whole word that fits. |
| At the exact character | Inside the block that crosses the limit, at the limit, never inside a character. With a limit that counts words, the cut falls after a whole word, as at a word boundary. |
A cut inside a block keeps the block's element, its classes and its runs up to the cut, styles, colors and links included, a link keeping what of its words comes before the cut, and drops the spaces before the cut. The block loses the blocks nested under it, and the blocks after it are left out. A cut inside a paragraph, a list item, a checklist item, a quote or a toggle's summary works that way, so a list keeps the items before the one cut, and a toggle cut in its summary is a <details> holding its summary alone. A block whose own text fits keeps it whole, and the cut falls among the blocks nested under it, so a quote or a toggle holds the part of its content that fits.
A table and a code block are never cut, and a heading is cut only as the last resort below describes. A table, a code block or a heading that does not fit is left out whole, and the rendering ends before it. Unless the last resort below finds no other text to show, a heading never ends a rendering that was cut: one that would be the last block shown is left out, with any heading before it that would then be last. Every heading a trimmed rendering shows, but one cut as the last resort describes, is therefore whole and carries the id it carries in the whole document (heading), since a trimmed rendering keeps the start of the document and ids are numbered in document order; a heading cut that way takes its id from the words it shows, the text where it cuts included.
When the cut a display asks for would show no text, the next finer cut is taken: after whole blocks only falls back to a word boundary, which falls back to the exact character. When none of them shows text other than headings, because a heading, a table or a code block longer than what is left of the limit comes before any other text, 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 then shown whole, followed by the text after the table or code block it introduces, when there is any. A document whose only text is a table longer than the limit renders nothing.
The Text where it cuts setting, an ellipsis by default, is appended wherever something was left out, as an unstyled run at the end of the last block shown, outside any link:
<p class="blocknote-block blocknote-block--paragraph">Integrates the <a href="https://www.blocknotejs.org/">Bloc</a>…</p>
When the last block shown holds no text, such as an image or a table, or is a heading shown whole, the text is a paragraph of its own at the end, <p class="blocknote-block blocknote-block--paragraph">…</p>. When the end of what is shown lies inside a toggle, a toggle list item or a collapsible heading with its blocks, the paragraph follows the toggle, in the block that holds it, and never sits inside its <details>, since a visitor may close the disclosure and hide the text with it. An empty setting appends nothing, and so does a document the text would take over max_bytes, the size limit of a document. A document the limit does not reach renders whole, a table of contents apart, with nothing appended.
A trimmed rendering never shows a table of contents: the outline it lists is cut, and on a listing of teasers its links would reach the headings of another document. The blocks nested under one render in its place.
The Summary field setting may name another BlockNote field of the bundle, such as the one the HTML converter wrote a summary into (docs/converting.md section 9). When that field has content and the visitor may view it, its items render in place of the trimmed document, whole, as the Default formatter renders them; otherwise the document is trimmed. The output then carries the cacheability of that access check.
tests/src/Unit/DocumentTrimmerTest.php pins where a trimmed rendering cuts. tests/src/Kernel/TrimmedRenderTest.php renders documents of the corpus, and the documents in tests/fixtures/documents-trimmed/ that only a trimmed rendering needs, under several settings, and compares each with the HTML in that directory named after the case, byte for byte; it also asserts that no case shows a table of contents and that every heading a case shows carries the id and the words it has in the whole document.