Skip to content

0006. Media is a submodule, and a media block stores a UUID and a view mode

Status: Accepted; the view mode enforcement in the Decision section is superseded by 0015 (2026-09-16), the paragraph on the media theme variable by 0034 (2026-10-30), and the Decision section's "alt text is not here at all" and the trust boundary's "nothing the request carries reaches the output except the choice of which item and which view mode" by 0036 (2026-10-30)

Date: 2026-10-30

Context

A block editor on a Drupal site has to be able to place a media item, and Drupal already decided what that means. Media items are entities, the Media Library is the way an author picks one, a view mode is how a site builder decides what a placed item looks like, and the entity's own access control decides who may see it. None of that is this module's to reinvent.

Core draws the shape of the integration in two places. MediaLibraryOpenerInterface is how a consumer opens the library dialog: a class registered as an autoconfigured service, given a MediaLibraryState built by the consumer, answering checkAccess() and returning an AjaxResponse for the selection. Core's own text-editor consumer, MediaLibraryEditorOpener, is the closest sibling: it is opened for a text format, not for a field instance, and it checks the format and nothing else. The CKEditor 5 integration is the reference for everything else: drupalMedia stores a data-entity-uuid and a data-view-mode, the preview comes from a controller that renders the item through the site's own view builder, and the editor shows the author the same markup a visitor will get.

The constraints from this module's own design:

A block's properties are a closed set (ADR 0005), declared in PHP as PropSpec objects and in the client specification as BlockNote's propSchema. What a media block may store is therefore decided once, in both languages, and cannot be extended per site.

Whether a visitor may see a media item is a fact about the visitor. The BlockNote filter renders a document once and the result is cached for everyone, so an access check inside that render would make the filtered text vary per user. Core solved this for MediaEmbed with a lazy builder and a placeholder, and the same mechanism is available here because 0004 made the public output render arrays rather than a string.

The per-format block settings seam is two interfaces. BlockNoteBlockConfigurableInterface lets a block declare settings that live inside the BlockNote filter's own configuration under block_settings.<block id>, with a form, validation, configuration dependencies and dependency removal, and BlockNoteBlockEditorSettingsInterface lets a block put whatever the author's editor needs into editorSettings.blockSettings.<block id>. A media block is the first consumer of both, and was the reason they were designed.

One core behaviour rules out the naive use of configuration dependencies, and it is the same one ADR 0005 recorded. FilterFormat::onDependencyRemoval() removes only disabled filters whose provider is being uninstalled. Any other dependency removal deletes the format outright, and the fields whose allowed_formats named it go with it. A format that declares a dependency without handling its removal has turned a configuration delete into a data loss.

Decision

Media lives in modules/blocknote_media, and the submodule ships nothing enabled. It declares blocknote, media and media_library as dependencies, provides one block plugin, and enables that block in no text format. Installing it changes nothing an author sees; a site builder ticks Media in a format's BlockNote filter settings, and only then does the block exist for that format's authors. The shipped blocknote format lists its fourteen blocks explicitly and does not list drupalMedia, so installing the submodule beside the shipped format does not silently widen that format either.

The block stores a UUID and a view mode, and nothing else. Drupal\blocknote_media\Plugin\BlockNoteBlock\DrupalMedia declares the id drupalMedia, content: 'inline' and two properties: mediaUuid (string, default '') and viewMode (string, default 'default'). The block's inline content is the caption, so the caption is written, styled and linked under the same policy as the rest of the document rather than being a second text field with rules of its own. Alignment and width are deferred and alt text is not here at all: alt text belongs to the media entity, where Drupal already put it. The UUID rather than the entity id is stored because that is what survives a content sync, and it is what core's media embed stores.

Per-format settings are allowed_bundles and allowed_view_modes. Both are sorted lists of ids stored under filter_settings.blocknote.block_settings.drupalMedia. An empty allowed_bundles means every media type on the site; an empty allowed_view_modes means the default view mode only, and DrupalMedia::permittedViewModes() always puts default at the front of whatever is stored. Neither list is schema-constrained to existing configuration: a media type or a view mode may be deleted after a format named it, and a format whose stored settings suddenly failed validation could not be saved again to repair itself. Core's filter_settings.media_embed makes the same choice.

View modes are configuration dependencies, media types are not, and the removal is handled. DrupalMedia::calculateBlockSettingsDependencies() returns config: core.entity_view_mode.media.<id> for every permitted view mode except default, which no configuration entity declares. BlockNoteFilter implements RemovableDependentPluginInterface and its onCollectionDependencyRemoval() hands each removal to the block's onBlockSettingsDependencyRemoval(), which drops the deleted view mode from the list and answers Changed. Deleting a media view mode therefore edits the format's settings; it does not delete the format. That handler is the whole reason declaring the dependency is safe, and a kernel test stands on it. Media types are not declared as dependencies at all: a media type carries content, a format naming one is a preference rather than a structural dependency, and permittedBundles() simply intersects the stored list with the types that exist, so a deleted type drops out of what the author is offered without any configuration changing. Amended 2026-09-20. The displays that make a view mode available to a media type are not dependencies either, though 0015 now offers view modes per type from them. ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval() (web/core/lib/Drupal/Core/Config/ConfigManager.php) treats a dependent whose recalculated dependencies still name anything in the removal set as one that cannot be fixed, and deletes it, and that set reaches the format transitively; a format naming core.entity_view_display.media.<type>.<mode> would survive the deletion of one media type only by dropping every view mode that type displayed, even where another permitted type still displays it. Per-type availability is therefore read from the displays wherever it is needed and declared nowhere, and the dependency stays on the view mode, which is what the stored list names.

The opener authorises on the format, not on the host entity. MediaLibraryBlockNoteOpener::checkAccess() checks three things: that the named format loads, that the account has use access to it, and that it is a BlockNote format with drupalMedia enabled. It does not check host entity access or field access. The first design checked both, and this record departs from it for a structural reason: the library URL is computed once per format in Editor::getJSSettings(), where no host entity exists and no field instance does either. Checking them would mean building a library URL per widget instance, which is a new seam in the base module for a check that grants nothing: selecting an item in the dialog writes nothing anywhere, and whether the document may be stored is decided by the document constraint on the save, with the host entity and the field in scope. This is exactly the line core's MediaLibraryEditorOpener draws.

allowed_bundles is checked wherever the item is in hand, and not at render. The list decides what the Media Library dialog offers, through the MediaLibraryState that getEditorSettings() builds. It is checked again wherever the item itself is in hand: DrupalMedia::validate() loads the item a document names and refuses drupal_media_forbidden_bundle when its type is outside the list, and MediaPreviewController::access() refuses an item whose type the format does not permit, the same way it refuses a view mode. A UUID no item carries passes both, because the type of an item that is gone is nothing either place can know, and refusing it would let a deletion block an unrelated edit. The render path stays lenient: DrupalMedia::build() hands the lazy builder a UUID, a view mode and a langcode and no format settings at all, so an item of a type the format has since stopped permitting is shown the way every other page shows it, which is the policy docs/rendering.md already states for a view mode a format stopped permitting. Core checks its allowed_media_types in the dialog alone, so the save and the preview are stricter than core's media embed here; they are what the format's own form text promises the administrator who ticks the boxes.

The selection travels as one AJAX command naming the format. Drupal\blocknote_media\Ajax\InsertMediaCommand renders {"command": "blocknoteMediaInsert", "media": {"uuid", "label", "bundle"}, "format": "<format id>"}. It names the format rather than an editor instance for the same reason the opener authorises on the format, and it works because only one modal dialog can be open at a time. Core's MediaLibrarySelectForm::updateWidget() adds the CloseDialogCommand, so this command never closes the dialog itself. Selecting nothing, or a media id that does not load, adds no command at all.

The preview renders through the same service as the page. blocknote_media.preview (GET /blocknote-media/{editor}/preview, with uuid, an optional view_mode and a CSRF token) answers with HTML built by MediaRenderer::build(), which is the same call the public page's lazy builder makes. The editor and the page therefore agree by construction rather than by two implementations being kept in step. The Drupal-Media-Label header carries the item's label percent-encoded, or Media <id> for an account that may not read it: a header value is bytes, which a browser hands the client decoded as ISO-8859-1, so a label outside Latin-1 arrives garbled unless it travels as ASCII and the client reads it back with decodeURIComponent(). Amended 2026-09-20. A Drupal-Media-Bundle header carries the item's media type id beside the label, so the editor can offer the item the view modes its type displays; a machine name is ASCII and travels as it is. The response is private with a five minute maximum age: one account's view of one item is not worth caching on the server. Amended 2026-10-31. A label whose encoding is longer than 512 bytes (MediaPreviewController::LABEL_HEADER_LIMIT) is shortened: MediaPreviewController::labelHeader() keeps the whole grapheme clusters that fit and adds an ellipsis, so the cut falls neither inside a character nor inside a percent-escape and the header still decodes. The 255 characters a media item's name holds come to 2,295 bytes encoded in Georgian or CJK and 3,060 in four-byte characters, and a proxy in front of PHP reads a response's headers into a buffer of its own, 4 KB by default in nginx, and answers 502 when they do not fit. A shortened name still tells the author which item the preview shows, where a withheld one would leave only the generic "Media item". The label shares that buffer with the item's own alternative text, and 0036 sets the two caps together. Amended 2026-11-02. The two calls now pass the same language too. The page renders the item in the langcode of the field item, which for a field that is not translatable is the default translation's on every translation of the host, and the preview rendered it in the interface language, so an author editing the French translation from an English administration interface saw the English item in the editor and visitors the French one on the page. Core's own media preview takes no langcode either. The widget now publishes the item's langcode on its textarea as data-blocknote-langcode, the media block adds it to the format's preview URL as langcode, and the route renders the item, and reads its headers, in that language when it is one of the site's languages, the locked ones included, and in the interface language when the request names none or one the site does not know. The URL is built once per format in Editor::getJSSettings(), where no field and no host exist, so the field's language cannot be baked into it on the server; the client adds it after the CSRF token, which covers the route's path alone and stays valid. The language chooses which translation of an item the account may already view is rendered, and nothing else the request carries reaches the output on its account. The access callback checks view on that translation, not on the default translation alone, because a translation's status is its own: an unpublished translation of a published item is refused, where checking the default translation let the headers quote its label and alternative text, and a published translation of an unpublished item previews, as the page renders it.

The trust boundary of that HTML is worth stating, because the client inserts it into the document the author is editing. What the route renders is a media item this site owns, through this site's own view builder, in a view mode this format permits, for the account asking, with access checked first. Nothing the request carries reaches the output except the choice of which item and which view mode, and both are validated before anything is rendered. A UUID that is malformed, and one no item carries, are answered by the controller rather than by the access callback: AccessManager checks every requirement on a route whatever any one of them says, so a callback that ended the request itself would answer before the CSRF token and the format's use access had been combined, and the status code would tell anybody who asked whether a UUID names an item on this site. The callback therefore returns an access result and nothing else, and it says the same thing whether or not the item exists. A future change that let the route render content supplied by the requester would turn it into a cross-site scripting hole, and that is the line to hold.

The item itself is deferred, the figure and the caption are not. DrupalMedia::build() returns the themed block with a #lazy_builder on blocknote_media.media_renderer:build carrying the UUID, the view mode and the langcode, #create_placeholder set, the user.permissions cache context and the media_list cache tag. The figure, its classes and the caption stay in the ordinary render flow, so the document stays cacheable once for everyone and the visual structure survives media that is gone or withheld, the way core's media embed keeps its caption past a missing entity.

A block already printed cannot go back and change its own classes, so the state of the item is expressed inside the placeholder and never on the figure. That is a real limitation of the design and it is why the two deferred states below are spans inside the figure rather than modifiers on it.

The recursion guard is a stack, and a repeat renders nothing. A media type may itself carry a BlockNote field, so an item can name itself directly or through a chain. MediaRenderer keeps an array of the uuid:view_mode keys it is in the middle of rendering, pushes before rendering and pops in a finally. The render happens inside build() through renderInIsolation(), and the bubbled metadata is re-applied to the returned #markup by hand, which is what makes the guard a stack with a matching pop: a placeholder nested inside this one is replaced while this entry is still open. A per-request set of keys that is never cleared was the obvious alternative and was rejected, because it would refuse the same media item appearing legitimately twice on one page. A repeat renders nothing and logs a warning naming the UUID; the figure and the caption around it are already on their way out, so the page is not silently empty.

Missing and inaccessible are stated, not hidden. A UUID no media item carries renders <span class="blocknote-media blocknote-media--missing">Missing media</span> and depends on the media list cache tag, so creating an item with that UUID makes the page recover. An item the visitor may not view renders <span class="blocknote-media blocknote-media--inaccessible">Inaccessible media</span> carrying the cacheability of the refusal. Both are inside the figure, beside the caption.

The media theme variable is added through hook_theme_registry_alter(). The base module registers blocknote_block__<type> for every block definition with one fixed variable set, which is what lets a template written against blocknote_block work for any type; ThemeManager copies only declared variables out of the render element, so an undeclared #media would be dropped in silence. BlockNoteMediaThemeHooks::themeRegistryAlter() adds the variable to this block's hook. A second hook_theme() implementation naming the same hook would have worked only while blocknote_media sorted after blocknote in the module list, which is not a thing a shipped contract may depend on. This is a gap in the extension contract rather than a virtue of the workaround: #[BlockNoteBlock] lets a block ship a template through its template property but gives it no way to declare the variables that template needs. Closing it means a variables property on the attribute, merged into the base set, and it is deliberately left to a later release rather than added here on one consumer's evidence.

Alternatives considered

Core's media_embed filter. Rejected. It is a filter over HTML: it reads <drupal-media> elements out of markup, and a BlockNote format stores JSON and permits exactly one filter, its own (ADR 0002). Reusing it would mean rendering the document to HTML containing <drupal-media> and then running a second filter over the module's own output, which contradicts the rule that the renderer is the sanitizer. What is reused instead is everything portable: the UUID and view mode model, the lazy builder, the library opener interface and the preview-through-the-view-builder pattern.

A generic entity-reference block. Rejected for 1.0. One block that can hold any entity type sounds leaner and is not: it would need per-entity-type view mode lists, per-entity-type access answers, a UI for choosing the type, and a Media Library replacement for entity types the library does not cover. Media is the case sites actually have, and getReferencedEntities() on the block plugin contract is already the seam another entity type's block would be built on. This block is the worked example for that.

Storing the rendered HTML in the document. Rejected, and it is the failure mode this whole design exists to avoid. Rendered HTML goes stale the moment the item is edited, replaced or deleted; it bakes one account's access answer into a stored document; it puts markup this module did not build on a public page; and it would make a document impossible to migrate to a site where the item has a different id. What is stored is the identity, and everything else is answered when the page is built.

Building the library URL per widget instance, so that the opener can check the host entity and the field. Rejected. See the opener decision above: it adds a seam to the base module for a check that authorises no write.

A fourth class for the recursion guard. Considered and not taken. docs/rendering.md declares the public class vocabulary closed, so a visible recursion state means adding a documented class. Rendering nothing is honest, is logged, and keeps the vocabulary to the two states a visitor can do something about. If a visible state is ever wanted, the class has to be added to the contract in the same change.

Consequences

A site that wants media in documents installs a second module and enables a block in a format. That is two steps a single built-in block would not need, and it is the price of the base module not depending on media and media_library.

The block offers a view mode and a caption and nothing else in 1.0. An author who wants an image aligned right or scaled to half width cannot ask for it here; a site builder answers with a view mode. Alignment and width props are a document change and a later decision.

A lazy-built block cannot vary its own outer classes by the state of what it defers. A theme cannot style "a media block whose item is missing" from the figure; it styles the span inside it. Any block that defers part of its content inherits this.

The preview route is a second render path for the same item, and it is cheap only because it calls the same service. Anything that makes MediaRenderer::build() and the public page disagree breaks the promise the route exists to keep.

MediaRenderer holds its recursion stack on the service instance, which is per request and always popped in a finally. A long-running process that reuses the container is therefore fine, but a fatal error inside the isolated render would leave an entry set for the rest of that process.

A media item that embeds itself, directly or through a chain, shows a visitor its figure and caption with nothing where the item would be. Only the warning in the log names the cause.

Declaring configuration dependencies on view modes obliges this block to handle their removal forever. The handler is small and the test that proves it is not optional: without it, deleting a media view mode deletes every text format that permitted it, and every field whose allowed_formats named that format.

The class vocabulary that docs/rendering.md calls closed is pinned by golden fixtures for the base module's blocks, and no base-module fixture can cover a submodule's block, because GoldenRendererTest does not install blocknote_media. The three classes this block adds are pinned by the submodule's own kernel tests instead. The guarantee is the same in kind and is asserted in a different file, which is what any third-party block's classes will also be.

References

  • Drupal core 11.4.6: core/modules/media_library/src/MediaLibraryOpenerInterface.php, core/modules/media_library/src/MediaLibraryEditorOpener.php, core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php::updateWidget(), core/modules/media/src/Plugin/Filter/MediaEmbed.php, core/modules/ckeditor5/src/Controller/CKEditor5MediaController.php, core/modules/filter/src/Entity/FilterFormat.php::onDependencyRemoval().
  • modules/blocknote_media/src/Plugin/BlockNoteBlock/DrupalMedia.php, src/MediaRenderer.php, src/MediaLibraryBlockNoteOpener.php, src/Controller/MediaPreviewController.php, src/Hook/BlockNoteMediaThemeHooks.php.
  • 0002, 0004, 0005, 0007.