0028. The base module links to content by uuid and resolves it by core's link target rule¶
Status: Accepted; the rejected substitution-prop alternative is superseded by 0029 (2026-09-21)
Date: 2026-09-21
Context¶
Core 11.3.0 added an entity link feature that did not exist when 0008 was written: the entity_links filter (Drupal\filter\Plugin\Filter\EntityLinks, change record https://www.drupal.org/node/3524296), EntityLinkTargetInterface (Drupal\Core\Entity\EntityLinkTargetInterface), link_target handlers on the file, media, shortcut and menu link content entity types, and EntityLinkSuggestionsController behind the ckeditor5.entity_link_suggestions route. The module's floor is ^11.4, so every piece of it exists on every site this module installs on.
Core's filter resolves a stored data-entity-type and data-entity-uuid pair by loading the entity, preferring its translation in the rendered langcode, and asking getHandlerClass('link_target', 'view') for the address; an entity type with no such handler falls back to toUrl()->toString(TRUE). That is the same rule this record adopts. Core's filter also does three things this module will not copy. It never checks view access before rewriting an href. A uuid that resolves to nothing strips the entity's identity from the markup along with its cacheable dependency, so the page never recovers once the target reappears. And its catch block calls Error::logException('filter', $e) with a string where Error::logException() is typed LoggerInterface $logger, which is an uncaught TypeError on any exception the loop raises, including the PluginNotFoundException an unknown entity type throws from getStorage(); that defect is open as core issue #3607178, unfixed at 11.4.x.
Linkit remains the contrib answer this record's predecessor chose, but its own project page now tells a site to consider core's feature instead: "As of version 11.3, Drupal core provides link autocomplete suggestions in CKEditor similar to what this module does. Sites using or considering using Linkit should evaluate whether they can use the core solution instead of Linkit." Its current release is 7.0.17, reporting 155,534 sites across its major versions. Nothing in its source, at that tag or at the newer 7.1.x branch, is aware of core's entity_links filter or link_target handlers; the two features were built independently.
Three things this module already has make the base module a reasonable home for a content link now, where 0008 judged it was not. The inline content seam 0027 built gives a type its own plugin, its own settings mapping and its own client registration without depending on anything the content link itself does not need. FileUrlResolver and ResolvedFileUrl (0007) already give a linked file the three-way answer, resolved, missing or denied, that a content link needs for a file or a media item's source file. And the module already owns its link toolbar (0010), so a second inline type with its own toolbar surface is an extension of a pattern the client already has, not a new one. 0008's reason for putting the type in blocknote_linkit, that linking to content is a linkit feature and linkit is contrib, no longer holds: core ships the feature this module needs, and linkit is now the integration, not the requirement.
Decision¶
A content link is the base module's own inline content type, declared on the seam 0027 built: plugin id entityLink, content kind styled so the author's own text stays the link text, no client library of its own because the editor plugin already attaches blocknote/editor for every format and the client spec registers itself when the bundle loads. Its label, wherever an author reads it, is "Content link": 0023 forbids the word "entity" in author-facing text, and a refusal names the type the same way a block's label is spliced into a refusal. Documents and this record use "entity link" only when they describe the type to a developer.
Its props are entityType and uuid, and validation checks their grammar, nothing more. entityType must name a content entity type this site has, with a uuid key; uuid must match the same character grammar UrlSanitizer::fileUuid() checks for a file identity. Whether the named uuid actually exists, and whether the current visitor may see it, is answered when the page is built, not when the document is saved, the same posture the file identity in 0007 already takes. A content link's own content may not hold a plain link or another content link at any depth: refused on save, flattened to its own text when rendered, and taken back by the client's editing policy. Amended 2026-09-22. A third prop, substitution, optional and absent unless set, is declared by 0029 and checked against the format's own permitted list on save and on output; entityType, uuid and their grammar are unchanged.
Resolution follows core's own rule, with the failure modes core's filter has fixed. EntityLinkResolver loads the entity by uuid, resolves its translation in the document's own render language exactly as core's filter does, then asks the entity type's link_target view handler for the address, falling back to toUrl()->toString(TRUE) for a type that declares none, which is EntityLinks::getUrl() verbatim. Unlike core's filter, every step after the type and uuid are read runs inside a catch that logs and answers missing, so an unknown type, a type without a uuid key, or an undefined link template never reaches an uncaught exception. A uuid that resolves to nothing keeps its identity in the document and bubbles the entity type's list cache tag, so the page recovers once an entity with that uuid exists. Access is where a content link and core's own filter differ on purpose. A page a content link resolves through the generic rule, toUrl() or another type's own handler, is left unchecked: the target enforces its own access, the entity's cache tags bubble, and no user.permissions context is added, the same boundary core's own menu link and shortcut handlers already accept. A file entity type, and a media item whose view handler answers the unchecked address of its own source field's file, both go through the access-checked resolution FileUrlResolver already gives an uploaded file, because for those two the address is the resource itself rather than a page that enforces its own access; a denied or missing answer renders the item's own text with the refusal's cacheability. file is offered as a linkable type for exactly this reason: it has a link_target handler, and a link to a file is the file. A resolved anchor carries the target's current title as its own title attribute, the hover text a visitor sees; the link's own wording, whatever the author typed or picked, stays its visible text and is never replaced, the same separation a plain link already keeps between its href and its content. Amended 2026-09-21. The public anchor carries no title attribute after all: this paragraph's own unchecked-page rule means a title built from ResolvedEntityLink::title() would put an access-unchecked label on a public page, disclosing the current title of a target the visitor may not be allowed to view. The editor alone shows the title, from the view label-checked prefetch the Consequences section already describes; the public anchor carries its href and its classes only. Amended 2026-09-21 (second). "A media item whose view handler answers the unchecked address of its own source field's file" is decided by string equality: EntityLinkResolver compares the media item's generated link against $sourceFile->createFileUrl(TRUE) and treats a match as the source field's own file. A source plugin or a contrib substitution that answers the same file through a different URL, an absolute one, or one carrying a query string, is not recognized by this comparison and is left unchecked as a page address instead; this record already accepted that gap as the cost of a string comparison over a heavier per-source resolution.
A format's inline_content_settings.entityLink governs only what the picker offers, as entity_types, keyed by entity type id, each holding a list of bundle ids. An entity type absent from the map is not offered; a listed type with an empty bundle list offers every bundle of that type; a listed bundle narrows the offer to it. The default is ['entity_types' => []], nothing offered until a site builder ticks a type, the same empty-means-none posture 0027 gives the format's own inline_content list. This map never governs what render() accepts: a hand-written document naming any linkable entity type and any well-formed uuid resolves and renders, the boundary 0008 already accepted for file:<uuid> and repeats here. Deleting a bundle a format has permitted drops that bundle from the stored list; when that empties a type's list, the type is dropped too, because an empty list would otherwise silently come to mean every bundle. Deleting the module that provides an entity type carries no module dependency: the type simply stops appearing in entityTypeManager->getDefinitions(), so it drops out of both the form and the search on its own, the way a deleted media type already does (0006).
The module answers its own content search, on a route of its own, /blocknote/{filter_format}/entity-link-suggestions. It requires filter_format.use access, a custom access check that the format is a BlockNote format enabling the content link, a CSRF token, and the JSON response format, mirroring ckeditor5.entity_link_suggestions with the text format in the position core gives the editor, because a BlockNote format's permission is what gates the type, not an editor configuration entity. This is stricter than core's own route, and stricter than linkit's, which has no CSRF token at all.
A resolvable entity gets a usage row through the module's existing file usage recorder, the same as an uploaded file. getReferencedEntities() returns the linked entity whenever its grammar holds, and the reference extractor already turns every referenced entity into a usage row for a file. 0008 originally said a linked entity is not a usage row, reasoning that usage tracking beyond files was out of scope. That reasoning does not cover a linked file specifically: core's own editor module already records file usage for a data-entity-type="file" reference inside a text field, through _editor_parse_file_uuids() and editor_entity_insert(), because a file nothing tracks usage for can be deleted out from under a document that still names it. A content link linking to a file is exactly that shape, so it gets the row core already gives the equivalent case; a content link to any other entity type still writes no usage row, because nothing about linking to a node or a term threatens it with deletion the way an orphaned file does.
fragment and query are refused for now. Both core's filter and linkit preserve a query string and a fragment from the stored href onto the resolved address; this record does not add either as a prop yet, so a document naming one has nowhere to put it. Adding them later is additive: two more optional string props, carried through unchanged, with no change to the grammar or the resolution rule this record states.
Alternatives considered¶
Linkit-only, as 0008 first wrote it. Rejected as the whole answer, for the reason Context gives: the module's floor already ships everything a content link needs, and depending on linkit for the base case would mean a site without linkit installed getting no content link at all. Linkit's remaining value, a curated profile of matcher and substitution plugins and the ability to point a link at a file's own URL through a chosen substitution rather than the module's own file-versus-page rule, is real and stays available to a submodule that layers on top of this seam.
A substitution prop in the base module, mirroring linkit's own vocabulary. Rejected here. This record's file-versus-page rule already answers the one distinction that needs an access check, and a general substitution vocabulary is linkit's own concept, tied to its own plugin type; adding it to the base module's props would commit the document's stored shape to an idea only a submodule needs. Amended 2026-09-21. Superseded by 0029: the base module declares substitution as an optional prop, validated against a per-format permitted list and resolved through a decorated EntityLinkResolverInterface, so that one stored type serves every search backend.
Resolving through core's own entity_links filter, letting the BlockNote filter add it as a second filter or invoke it directly. Rejected: BlockNoteFormatConstraint forbids a BlockNote format from enabling a second filter, and 0002 declined to reopen that rule; and core's filter carries the three defects Context describes, which this record exists partly to avoid rather than inherit.
Storing the resolved path at save time, alternative A that 0008 already rejected for the same reasons: an alias change, an entity id change across a content sync, or a deleted target would each leave a stale or dead link with no cache tag to invalidate it.
Reading the ckeditor5_link_suggestions bundle flag core's own hook sets on every node bundle, instead of a format's own settings. Rejected: the flag is site-wide and owned by ckeditor5, set only by that module's hook, so it says nothing about what one BlockNote format should offer, and a BlockNote format's own settings are where every other permission this module enforces already lives (0016).
Reusing Drupal\ckeditor5\Controller\EntityLinkSuggestionsController directly. Rejected: the class is marked @internal, its access() helper assumes an editor configuration entity and the entity_links filter enabled on the format, and using it would add a hard dependency on ckeditor5, which a BlockNote format does not otherwise need.
Consequences¶
A document can hold three link vocabularies now: BlockNote's own link for a plain URL, the entity link blocknote_linkit may still add (0008), and this record's entityLink. The nested-link rule keeps any two of them from wrapping each other, whichever pair it is. Amended 2026-09-22. blocknote_linkit as landed adds no inline type of its own: it swaps the search and decorates the resolver (0029), so a document holds two link vocabularies, link and entityLink, and the nested-link rule keeps either from wrapping the other.
A submodule that wants a curated content search, a linkit profile, or a substitution prop builds on this seam rather than replacing it: it points the same suggestionsUrl settings key at its own route and, where it wants a different resolution for one entity type, extends or replaces the resolver's per-type branch. The JSON shape this record's route answers, and the settings key a submodule swaps the URL under, are what such an addition has to keep. Amended 2026-09-21. The seams that exist today are narrower than this paragraph first claimed: the suggestionsUrl settings key, swapped through core's own hook_editor_js_settings_alter(), and EntityLinkResolverInterface, which a module may decorate for a per-entity-type substitution. Neither EntityLink's prop schema nor the client's entityLinkPropSchema accepts an attribute a submodule did not declare, so a substitution prop is not a seam this record's code offers yet; it needs a decision of its own before a submodule can add one. Amended 2026-09-21 (second). That decision is 0029.
Linking a selection of styled text replaces it with a content link whose own content is the picked entity's current title; whatever character styles the selection carried are not kept, because BlockNote's own props replace a selection's content rather than wrap it. Amended 2026-09-21. Linking a selection keeps the selection's own text as the content link's content; the picked entity's current title is used only when nothing was selected, which is what the [[ menu, which has no selection, always inserts (js/src/links.tsx, js/src/editor.tsx). Whatever character styles the selection carried are still not kept, because BlockNote's own insertInlineContent() replaces the selection's content rather than wrapping it.
Translation follows the document's own render language, resolved through the linked entity's own hasTranslation()/getTranslation(), the same rule core's filter uses; a document rendered with no explicit language sees the entity's default translation.
Resolving a content link costs one uuid lookup and one entity load per distinct entityType:uuid pair a page renders, and a repeated pair inside one request costs nothing further, because core's own entity storage keeps a uuid lookup in its static cache; 0007 measured the same cost for a file identity and found no reason for a batch resolver.
Amended 2026-09-21. The usage row a content link to a file writes is not gated by the search picker's own view re-check: once the row exists, FileUrlResolver::resolve() and the download hook grant the file to every account that may view the host document, whether or not that account could have found the file through the picker itself. This is the same boundary 0007 already draws for an uploaded file, and the same one core's own editor module draws for data-entity-type="file", so this record changes nothing about it; it only extends the same boundary to a hand-written content link naming a private file's uuid directly.
The widget's own prefetch, which the editor reads to show a title without a round trip, checks view label access before naming a title, the same check MediaPreviewController already makes for its own label header; the page path itself stays unchecked, because a title in the editor is a bigger leak than an address a visitor could already reach.
Nothing gives a content link BlockNote's own link affordances: Ctrl+click, autolink, and the paste-to-link rule stay link's alone, because a content link is not that type. Its own affordances, the search in the URL field, a trigger character that opens the same search as a suggestion menu, and a caret-driven popover with its own edit and unlink controls, are a later change on the client side of this seam; this record states what they answer, not how they are drawn.
The demo recipe's basic format enables the content link with every node bundle permitted, so the maintainer can try the picker without installing anything beyond what the recipe already installs; the shipped optional format enables it in no site, because it cannot know which entity types a site will end up with. Amended 2026-11-01. The shipped blocknote format does enable it, for node with every bundle, and its optional configuration now depends on the Node module, so it arrives with Node, or later when Node is installed; nearly every site has Node, and a converted link then resolves without a configuration step. That dependency only holds the format back: saving it recalculates its dependencies, so the installed format does not name node and uninstalling Node does not take the format with it. The blocknote_snippet format stays without, since a snippet is a caption or a byline. A site that already has the format keeps its own.
References¶
- Core 11.4.6:
Drupal\filter\Plugin\Filter\EntityLinks,Drupal\Core\Entity\EntityLinkTargetInterface,Drupal\file\Entity\FileLinkTarget,Drupal\media\Entity\MediaLinkTarget,Drupal\media\Entity\MediaLinkTargetStandaloneWhenAvailable,Drupal\media\Plugin\media\Source\File,Drupal\ckeditor5\Controller\EntityLinkSuggestionsController,Drupal\ckeditor5\Plugin\CKEditor5Plugin\EntityLinkSuggestions,ckeditor5.routing.yml,Drupal\Core\Utility\Error. Change record https://www.drupal.org/node/3524296. Core issue #3607178. - Linkit 7.0.17:
linkit.info.yml,linkit.routing.yml,src/Controller/AutocompleteController.php,src/Plugin/Filter/LinkitFilter.php,src/Plugin/Linkit/Substitution/{Canonical,File,Media}.php. - This module:
src/Plugin/BlockNoteInlineContent/EntityLink.php,src/EntityLink/{EntityLinkResolver,ResolvedEntityLink}.php,src/Controller/EntityLinkSuggestionsController.php,blocknote.routing.yml,blocknote.services.yml,config/schema/blocknote.schema.yml,templates/blocknote-inline--entity-link.html.twig,src/File/{FileUrlResolver,ResolvedFileUrl,FileReferenceAccess}.php,src/Render/{InlineRenderContext,InlineContentRenderer,BlockRenderContext}.php. - 0002, 0006, 0007, 0008, 0010, 0016, 0023, 0027.