0004. Public output is render arrays and our own semantic HTML¶
Status: Accepted on 2026-09-13. The probe described under "What closes this record" ran against Drupal 11.4.6 and answered all three questions in favour of this decision.
Date: 2026-09-13
Context¶
A stored document is JSON. Something has to turn it into the HTML a visitor sees. There are three candidate producers of that HTML: the upstream export functions running in a browser, the upstream server utilities running in Node, or PHP in Drupal.
The upstream export functions were examined against 0.54.2 and neither is a rendering path.
blocksToFullHTML emits the editor's live DOM. Its output contains editor chrome: toggle buttons with inline SVG, resize handles, disabled checkbox inputs, a wrapper element around tables, and trailing break spans. It is a faithful copy of an editing surface, which is exactly what a public page must not be.
blocksToHTMLLossy is the lossy export and has visible defects. It emits a lowercase classname= attribute on links, leaves a U+FFFC object replacement character inside empty paragraphs, writes malformed table column width attributes, and lets nested paragraphs escape out of list items. Round-tripping through it regenerates block ids and degrades file blocks.
@blocknote/server-util does not work out of the box. It imports @blocknote/core/yjs, which pulls an optional peer dependency that is not installed, so the import fails outright. Under JSDOM its opaque origin makes toggle blocks throw a security error when they touch local storage. It hard-requires React, ReactDOM and JSDOM.
On the Drupal side, the constraints are clearer. Module-provided templates are found only for theme hooks registered in hook_theme(), and a suggestion template inside a module needs its specific hook registered with a base hook. The filter that produces public output receives only a string and returns a FilterProcessResult, which carries cacheability metadata, attachments and lazy placeholders. Core's MediaEmbed filter is the reference for rendering entities inside a filter within a bounded render context, so that cache contexts and tags raised by the entities do not leak into the wrong place and are not lost either.
Decision¶
Public output is built in PHP, as nested render arrays, and the HTML is the module's own.
DocumentRenderer::build() returns a render array tree. Each block becomes one theme hook, blocknote_block__<type>, registered in hook_theme() with blocknote_block as its base hook so that templates shipped by the module are discovered. Consecutive list items are grouped into list elements by the renderer, since BlockNote stores them as sibling blocks rather than as a list. Inline content is rendered by a dedicated service that escapes text and strips dangerous link protocols.
The blocknote filter renders that tree inside a bounded render context and transfers the cacheability, attachments and placeholders it collected into its FilterProcessResult, the way MediaEmbed does. Blocks whose output varies per user, such as media subject to entity access, can be rendered through lazy placeholders so that the surrounding document stays cacheable.
The markup is semantic: h1 to h6, p, grouped ul and ol with nesting and a respected start value, blockquote, pre wrapping code, hr, figure, details with summary, and table with thead and correct column and row spans. Presentation is expressed with a small vocabulary of classes prefixed blocknote-, not with inline styles. The upstream bn- class prefix stays inside the editor and never reaches a public page.
There is one exception to the no-inline-styles rule, and only one: style="width:NNpx" on a <col> element inside a table's <colgroup>. A column width is an unbounded number of pixels that an author sets by dragging a column edge, so it cannot be expressed as a class from a closed vocabulary, and a class per possible width is not a vocabulary at all. The alternatives were both worse than the exception: dropping the widths silently rebuilds the author's table to the browser's own column algorithm, and a width attribute on col is not valid HTML. The value is written by the renderer, never by the document: it is read as an integer, clamped to a sane range and refused otherwise, so no author string reaches the style attribute. Every other presentational decision is a class, and a new inline style anywhere else is a change to this record.
The HTML contract is documented and pinned by golden fixtures. docs/rendering.md states the contract, including the <col> width exception recorded above, and tests/fixtures/documents/*.json are stored envelopes with the expected HTML in the .html beside each, rendered through the shipped format and asserted byte for byte by tests/src/Kernel/GoldenRendererTest.php, so that a change to the markup is a visible change to a fixture rather than an accident. tests/src/Kernel/DocumentRendererTest.php still asserts the renderer's rules on documents written inline in the test, and the vitest round trip loads the same envelopes into the editor.
A minimal optional CSS library styles the things that have no default appearance in a theme, meaning checklists, colors and toggles, and the formatter has a setting to attach it, on by default, so that a theme can turn it off and own the presentation entirely. Both exist: css/blocknote.content.css ships as the blocknote/content library, and the formatter's attach_styles setting attaches it.
What closes this record¶
A probe that renders an inline document through the filter with a media stub that denies access, answering: whether the cache contexts the stub raises reach the rendered page and vary it correctly, whether attachments declared inside the block render arrays survive the filter boundary, and whether a lazy placeholder placed inside a block is replaced correctly when the filter's result is rendered. These are properties of the boundary between the render system and the filter system, and they are verified by running them, not by reading core.
Probe results¶
Run on 2026-09-13 against Drupal 11.4.6, in tests/src/Kernel/FilterPipelineSpikeTest.php, with a blocknote_test media block whose output is built lazily and whose access depends on a permission of the acting account. One line per question above, then what the run taught about the boundary. Amended 2026-09-23. The test moved to tests/src/Kernel/FilterPipelineTest.php: the rename dropped "Spike", leftover build-process vocabulary that named nothing about the test; the probe and its result are unchanged.
- Cache contexts. They reach the page and vary it. The placeholder's
user.permissionscontext and the stub's cache tag are on the rendered page's metadata for a permitted account and for a denied one, and the denied account gets the explicit inaccessible output rather than nothing. They are deliberately absent from theFilterProcessResultitself: a placeholder's cacheability is merged only whenRenderer::replacePlaceholders()runs at the root of the render, which is exactly why the surrounding document stays cacheable. - Attachments. They survive. A library declared beside the block's render array is on the filter result's attachments and on the rendered page's metadata. The one rule to know is that
#attachedis illegal on the element that carries#lazy_builder, so a block that needs both puts the library on a wrapper and the lazy builder on a child. - Lazy placeholders. They survive and are replaced. The processed text carries a
<drupal-render-placeholder>element, the filter result's#attached['placeholders']carries the one placeholder with its#lazy_buildercallback and its#cachemetadata, the placeholder is replaced by the block's output when the result is rendered, and no protected text is ever in the processed string.
The filter follows core's MediaEmbed: it renders the document tree inside a bounded render context, merges the collected BubbleableMetadata into the result, and sets the processed text afterwards, because FilterProcessResult::merge() returns a new instance rather than mutating the one it was called on. The same test pins the refusals that belong to this boundary: a disabled block renders nothing, and malformed JSON, a non-object document and an unsupported envelope version render nothing and log which check refused them.
Alternatives considered¶
Export HTML in the browser and store it alongside the JSON. Rejected. It makes the client authoritative over what a visitor sees, which is a security boundary in the wrong place, and it leaves the stored HTML stale whenever the rendering rules, a template or a referenced entity changes. No JSON-native Drupal module surveyed stores rendered HTML as a column.
Render with Node, using the upstream server utilities. Rejected as a requirement. It does not currently run without patching, it needs React, ReactDOM and JSDOM, and it would make a Node runtime a hard dependency of serving a page. A Node path may be optional in the future; it is not the public output path.
Emit a flat HTML string in PHP rather than render arrays. Rejected. A string cannot carry cacheability metadata, cannot carry attachments, cannot hold a lazy placeholder, and cannot be overridden by a theme. Every one of those is needed: media blocks vary by access, and themes must be able to change markup without patching the module.
Reuse the upstream class names and stylesheet on the public page. Rejected. It would ship the editor's stylesheet to every visitor and tie public markup to a pre-1.0 library's internal class names.
Consequences¶
The module owns its markup, which means it owns every upstream change in block semantics. When BlockNote adds a block or changes how one is represented, a PHP renderer and a template have to be written for it, and the fixture corpus is what proves the result.
Editor and page can diverge. What an author sees while editing is produced by the upstream React components; what a visitor sees is produced by our templates. Keeping them close enough is a design obligation and is checked by eye, not by a test.
The markup is a documented contract pinned by fixtures, so themes can rely on it, and changing it is a deliberate, visible act with a version implication: the change shows as a diff in docs/rendering.md and in the expected HTML beside every envelope in the corpus.
Rendering entities inside a filter is the subtlest part of the module. Cacheability that is lost there produces stale pages, and cacheability that is over-collected produces pages that cannot be cached at all. This is why the boundary gets a probe of its own rather than being assumed to work.
There is no public-page JavaScript. A page that displays a BlockNote document loads no editor code at all, which keeps the cost of the large admin bundle described in 0003 off the public side entirely.
References¶
- BlockNote 0.54.2:
@blocknote/coresrc/api/exporters/html/,@blocknote/server-util. - Drupal core 11.4.6:
core/modules/media/src/Plugin/Filter/MediaEmbed.php,core/modules/filter/src/FilterProcessResult.php,core/lib/Drupal/Core/Render/RendererInterface::executeInRenderContext(). - Theme hook and base hook registration for module templates:
hook_theme()documentation incore/lib/Drupal/Core/Render/theme.api.php. - 0001, 0002, 0005.