0008. An entity link is custom inline content, not a link mark¶
Status: Accepted; the placement of the type in blocknote_linkit and the linkit search in the URL field are superseded in part by 0028 (2026-09-21); the substitution prop's placement and its permitted list are superseded in part by 0029 (2026-09-22)
Date: 2026-09-22
Context¶
A Drupal site links to its own content, and it expects those links to survive an alias change, to invalidate the pages that carry them when the target is renamed, and to say something honest when the target is gone. Linkit is how contrib answers that: 143,724 reported sites in the week of 2026-08-30, core_version_requirement: '^10.2 || ^11 || ^12', and Drupal CMS installs it from its base recipe. Everything below linkit's client is editor-agnostic. A linkit_profile config entity holds a weighted collection of matcher plugins; one route, linkit.autocomplete at /linkit/autocomplete/{linkit_profile_id}, takes ?q= and answers {"suggestions": [...]}, where an entity hit carries entity_uuid, entity_type_id and substitution_id beside the label and the path; and three substitution plugins, canonical, file and media, turn a chosen entity into a Url. Only the CKEditor 5 bridge is editor-specific, and not even all of it: linkit's autocomplete widget is a standalone jQuery function with no CKEditor import, and js/linkit.autocomplete.js in the same tree is a second CKEditor-free implementation driving linkit's own form element and field widget.
Three facts decide how that reaches a BlockNote document.
A BlockNote link is a TipTap mark with exactly one attribute. href is the only attribute the mark declares (@blocknote/core src/extensions/tiptap-extensions/Link/link.ts:91-100), ProseMirror drops anything undeclared, and both document converters hard-code it: nodeToBlock.ts:248-258 builds {type: 'link', href, content} and blockToNode.ts:101-110 writes the mark back with href alone. A link can therefore carry no second piece of data through a save. That is structurally the same constraint that put file:<uuid> inside a file block's url property, recorded in 0007. Upstream makes it worse in one specific way: EditLinkMenuItems.validateUrl() prefixes https:// to any value that does not start with one of ten hard-coded protocols, on Enter and on submit (@blocknote/react src/components/LinkToolbar/EditLinkMenuItems.tsx:19-27, @blocknote/core src/extensions/LinkToolbar/protocols.ts:1-12), so any non-URL identity in an href is corrupted the moment an author re-submits the default toolbar. There is no resolveLinkUrl counterpart to resolveFileUrl anywhere in 0.54.2, so nothing upstream resolves a link for display either.
A BlockNote format may enable no other filter, and the output vocabulary is closed. The single-filter rule is not prose: BlockNoteFormatConstraint enforces it on the filter_format config entity, 0002 records it, and that record explicitly declined to reopen it for the first release. docs/rendering.md section 6 lists "any data-* attribute built from a property" among what is never emitted, and 0004 makes the renderer the sanitizer, so the module does not put markup it does not own on a public page and does not hand its output to a second filter.
Every settings mapping the module owns is closed, and block_settings is the only dynamic seam. filter_settings.blocknote, editor.settings.blocknote and field.field_settings.blocknote_document are each FullyValidatable mappings in config/schema/blocknote.schema.yml, which means no submodule can add a key to any of them. The one extension point is block_settings, a sequence resolved through the dynamic type blocknote.block_settings.[%key], and it is keyed by block plugin id. An integration that is not a block has nowhere to put a per-format setting today, so any linkit work needs a base-module seam whatever submodule ships it.
Two further facts bound the answer. This module knows two inline content types and refuses the rest: DocumentValidator::validateInlineContent() accepts text and link and reports InvalidInlineItem for anything else, InlineContentRenderer::build() sends anything else to refuseItem(), which drops it with a debug log, and js/src/runtime.ts publishes registerBlock() with no counterpart for inline content. And upstream's own maintainers, asked how to distinguish an internal link, answered that it should be a different kind of inline content rather than an extension of the link mark (BlockNote discussion #2479), which is the cross-framework convention: the TipTap pattern for the same problem is a Mention node whose renderHTML emits an anchor.
Decision¶
An entity link is a custom inline content type. It is declared with createReactInlineContentSpec and content: 'styled', so the author's own text is the link text and stays editable in place, and its props hold the entity type, the entity uuid and the linkit substitution id. Amended 2026-09-21. The props are entityType and uuid; a substitution prop is optional and belongs to blocknote_linkit (0028). There is no label prop, and no identity is written into an href anywhere. The stored shape is {type, props, content: StyledText[]}, which is the only shape in BlockNote 0.54.2 that keeps props and styled text together through a save. Amended 2026-09-22. The substitution prop is the base module's after all, optional and absent unless set, checked against a per-format permitted list (0029); no prop belongs to blocknote_linkit.
The linkit-specific code lives in modules/blocknote_linkit, which hard-depends on drupal:linkit. Amended 2026-09-21. The type itself now lives in the base module and resolves through core's link_target rule; blocknote_linkit holds the profile search and the optional substitution prop (0028). Amended 2026-09-22. blocknote_linkit as landed holds the profile search and a decorator of EntityLinkResolverInterface that follows linkit's substitution plugins; the substitution prop itself is the base module's (0029). The base module gains only generic seams: an inline-content plugin type and its client registration, validator and renderer dispatch, a carrier for an unknown inline type, an inline pass on ReferenceExtractor, a per-format settings seam for something that is not a block, and its own link toolbar. Linkit is contrib, and this is 0006's reasoning applied again: media went to a submodule because it needs modules the base module will not depend on, and files stayed in the base module because file arrives anyway.
A document holding entity links stays saveable when blocknote_linkit is uninstalled. The unknown-inline carrier keeps the text and the props, the public page renders the text with no anchor around it, and reinstalling the submodule restores every link. Content is never silently dropped and a document is never made unsaveable, which is the same rule the blocknote_unsupported block carrier exists to keep (0002).
The editor shows the entity's current title, and nothing stored goes stale. The title in the toolbar and on hover comes from a prefetch attribute the widget builds from the stored document, the way data-blocknote-files is built by BlockNoteEditorWidget::filesAttribute() and read by js/src/upload.tsx. Because no label is stored, there is no second source of truth to go stale, and a renamed entity is right on the next page build. Amended 2026-09-21. The attribute is data-blocknote-entity-links, built by BlockNoteEditorWidget::entityLinksAttribute() (0028).
The base module owns the link toolbar. BlockNote's own toolbar is replaced through LinkToolbarController's linkToolbar prop, the seam the formatting toolbar already uses, so the format's link policy is enforced as the author types rather than only on save. The URL field searches the format's linkit profile as the author types. Picking an entity result while a selection is made produces an entity link; a typed URL, or a suggestion that carries no entity, which is what linkit's email, front_page and external matchers and its no-result fallback return, produces a plain link under the existing link policy. A trigger character opens the same search as a suggestion menu and inserts an entity link at the cursor, which is the mentions pattern. Both surfaces use one search component. Amended 2026-09-21. The URL field searches the module's own route, blocknote.entity_link_suggestions, built on core's selection plugins; blocknote_linkit replaces the search backend with a linkit profile (0028).
Configuration is per format, in the filter's own settings. Amended 2026-09-21. The seam 0027 built is keyed by inline content type, not by integration: the sequence is inline_content_settings, resolved through blocknote.inline_content_settings.[%key], and a format enables inline types through its own inline_content list, so blocknote_linkit stores its settings under the entity link type's id. filter_settings.blocknote gains an integration_settings sequence keyed by integration id, resolved through a dynamic schema type blocknote.integration_settings.[%key], mirroring block_settings. blocknote_linkit stores there whether entity links are permitted, the linkit profile id and the permitted substitutions. The format declares a configuration dependency on linkit.linkit_profile.<id> and handles its removal, through the same RemovableDependentPluginInterface implementation BlockNoteFilter already carries for block settings. Without the handler, deleting a profile would delete every format that named it and every field whose allowed_formats named that format. Amended 2026-09-21 (second). The base module's entity link stores the entity types and bundles the picker offers under inline_content_settings.entityLink; the profile id and the permitted substitutions stay the submodule's (0028). Amended 2026-09-21 (third). No configuration dependency on the profile is declared after all: 0029 records why core would delete the format rather than fix it, and how the profile's own deletion hook edits the format instead. Amended 2026-09-22 (fourth). The permitted substitutions are the base module's, inline_content_settings.entityLink.substitutions; only the profile id is the submodule's, as the third-party setting blocknote_linkit.profile (0029).
Substitutions are a per-format list, defaulting to canonical only. Amended 2026-09-21. Superseded in part by 0028: the base module has no substitution list, canonical remains unchecked, and a file or a media item's source file is access-checked through FileUrlResolver as this paragraph says. file and media are available to enable and are off until a site builder says otherwise, because for those two the URL is the protected resource rather than a page that enforces its own access. At render, canonical mirrors linkit and core menu links: the URL is emitted, the target enforces its own access, the entity's cache tags bubble into the filter result, and no user.permissions cache context is added. file and media go through the same access answer FileUrlResolver gives, and a denied answer renders the link's text alone carrying the access result's cacheability. A target that no longer exists renders the text alone with the entity type's list cache tag, so the page recovers when an entity with that uuid next exists. Amended 2026-09-22. The base module does hold a permitted substitutions list since 0029, inline_content_settings.entityLink.substitutions, empty by default; canonical, file and media are linkit's own ids, offered by blocknote_linkit's decorator once it is installed, enforced on save and on output, and file and media access-checked through FileUrlResolver as this paragraph says.
A linked entity is not a usage row. Usage tracking beyond file usage was excluded from 1.0 and stays a separate product decision. The inline pass on ReferenceExtractor exists so that a later entity_usage track plugin has something to ride on, and nothing writes a usage row for a link. Amended 2026-09-21. A link to a file is the exception: it gets a usage row through the module's existing recorder, the same as an uploaded file, because core's own editor module already records file usage for a data-entity-type="file" reference inside a text field and a file nothing tracks usage for can be deleted out from under a document that still names it (0028).
Alternatives considered¶
A. Autocomplete only: write the resolved path into href. Rejected. The toolbar queries linkit's autocomplete and stores the suggestion's path, which EntityMatcher::buildPath() builds as the unaliased system path, so a stored link is /node/12. Nothing about the document contract changes and no Drupal code runs at render, which is the whole of its appeal. It also gives up the three things linkit is installed for: the path does not follow an alias change or an entity id change across a content sync, the filtered text carries no cache tag for the linked entity so nothing invalidates when the target is renamed or deleted, and a deleted target leaves a link to a 404 in a module whose file and media design is built around explicit missing states (0006, 0007). It survives as the fallback inside the chosen design rather than as a design: linkit's email, front_page and external matchers and its no-result fallback return a label and a path and no entity, so a plain link has to be written for them whatever else is built.
B. The identity inside the href, resolved at render. Considered and not chosen. It is 0007 applied to links: an exact grammar such as entity:<type>/<uuid>/<substitution>, a resolver running before the sanitiser and never instead of it, three cacheable answers, a permanent scheme refusal so an unresolved identity can never reach a page, and a prefetch attribute so the editor shows what the server resolved. Every piece of machinery it needs already exists in a proven shape, and it keeps BlockNote's link type, so the validator and the renderer need dispatch changes at most. What it costs is that the identity lives in the one property a browser reads: copy out of the editor and any external HTML export emit <a href="entity:node/...">, which is dead everywhere but this site; the module-private grammar deliberately shadows core's entity: URI, where entity:node/1 already means an entity id rather than a uuid, so the two-segment form has to be refused and documented rather than discovered; the identity can hold only what a string grammar holds, so a fragment, a title override or a view mode is another grammar change rather than another prop; and the change reaches the client policy, the sanitiser, the render policy, the validator, the filter's policy array and a closed FullyValidatable config mapping. The chosen shape keeps the identity out of the href entirely, is what upstream recommends for exactly this case, and separates entity links from plain links in the document, in the validator and in the renderer instead of telling them apart by a scheme.
C. Emit linkit's data-entity-* attributes and let linkit's own filter substitute. Rejected on the record. It is not an alternative storage at all: the document still has to carry the entity type, the uuid and the substitution somewhere, so C only moves resolution out of the module and into LinkitFilter::process(). To get there, two accepted records and one shipped contract have to be reopened. BlockNoteFormatConstraint forbids any other filter in a BlockNote format and 0002 declined to reopen that for the first release, because core runs every prepare() before any process() and an HTML-restricting filter placed after the renderer strips the markup the renderer just proved safe. docs/rendering.md section 6 forbids any data-* attribute built from a property, and 0004 makes the renderer the sanitizer. What is bought in exchange is weaker than what the module already ships: linkit's filter leaves a stale href untouched when the uuid no longer resolves and records no cacheable dependency for the miss, it never checks view access before rewriting an href, and every per-element failure is swallowed and logged to linkit's own channel. Every failure mode, uninstalling linkit included, would leave an unresolved identity and its attributes on a public page, which is the outcome UrlSanitizer::mediaSrc()'s permanent file: refusal was designed to make impossible. The sub-variant where the BlockNote filter instantiates linkit's filter itself and runs it over its own output re-parses a render array's markup into a DOM to rewrite it, which is the thing 0004 exists to avoid.
Consequences¶
The implementation comes in two later stages, each with its own review: the generic inline-content seam in the base module, then blocknote_linkit on top of it. The seam gets its own record, because it is a base-module contract rather than a decision about linkit, and this record is amended only where either stage changes something it states.
A document can hold two link vocabularies: BlockNote's link for plain URLs and an entity link beside it. The format's policy has to speak about both, the validator and the renderer dispatch on both, and docs/rendering.md has to describe both.
An entity link gets none of BlockNote's built-in link affordances, because it is not the link type: Ctrl-K, autolink, the paste-to-link rule and getSelectedLinkUrl() all ignore it. Creation is a suggestion menu and the module's own toolbar, and editing is whatever the type's own render function offers. Each of those is rebuilt or explicitly given up.
Copying an entity link out of the editor degrades to whatever toExternalHTML emits, and BlockNote's HTML parser drops arbitrary data-* attributes on the way back in (BlockNote issue #1308), so a paste into another editor and back cannot restore the props. The round trip inside one editor is lossless, because the parse rule matches the type's own wrapper. Amended 2026-11-02. Neither held for the content link 0028 built. The link mark's a[href] rule read its anchor before its own rule was asked, because prosemirror-model asks a mark's rules first at the same priority, so even a paste inside one editor arrived as a plain link to the target's current address; and prosemirror-view opens pasted HTML that names no slice down to its edges, so a content link copied as text/html alone arrived as its words. Its parse rule now has priority 51, and a paste or a drop closes the slice at a content link on either edge, so a content link copied from this module's editor arrives as a content link with its props and its new-tab flag, from blocknote/html or from text/html alone: upstream wraps the type's exported anchor in an element carrying its declared props as data-entity-type and data-uuid, which its own rule reads back. Selecting only some of a content link's words copies a content link holding them, to text/html and text/plain as well, where upstream wrote the words alone, so the words keep their link as a link mark's do. A selection that ends at a content link's first word, or starts after its last, takes the content link holding none of its words, and the paste leaves it out rather than closing the slice at it. The editor also deletes a content link left holding no words, by a paste, a cut or a deletion, and the page renders nothing for one: an anchor with no words is invisible in the editor and has no accessible name on the page.
Most of the cost is base-module API surface that has nothing to do with linkit: an inline-content plugin type, client registration, validator and renderer dispatch, an unknown-inline carrier, a policy key and an HTML contract change. That surface is a commitment the module owes anyway, because a module adding inline content needs the same contract a module adding a block has; 0005 records that the block contract does not yet cover it.
Every entity link costs one uuid lookup and one entity load when the page is built, and a repeat of the same uuid in one request costs nothing, because core's entity storage keeps a uuid lookup in its memory cache. There is no batch resolver. 0007 measured the same cost for files and found no reason for one.
A hand-written document can name any entity uuid with any substitution. For canonical that discloses a URL whose target enforces its own access, which is the boundary core accepts for editor_file_reference's data-entity-uuid and the one 0007 names for file:<uuid>. For file and media the URL is the resource, which is why those two are access-checked and off by default.
Linkit's autocomplete route is open to any authenticated account for any profile: _access: 'TRUE' plus _user_is_logged_in: 'TRUE', no permission and no CSRF token, with results filtered to what the account may view because every entity matcher access-checks its own query and re-checks each candidate. That is linkit's boundary, inherited rather than created, and it is documented rather than silently relied on.
Linkit's media substitution resolves to the source file's direct URL rather than to the media item's page. Drupal CMS chose it deliberately, because a canonical media link sent anonymous visitors to a login wall (Drupal CMS issue #3500041), and a site that assumed /media/{id} gets something else. That is why the permitted substitutions are a conscious per-format choice rather than whatever the matcher recorded.
The module now depends on two pre-1.0 upstream surfaces rather than one: LinkToolbarController's linkToolbar prop and the custom inline content API. The mitigation is the same in both cases, which is to depend on the seam and own everything inside it.
References¶
- Linkit 7.0.16 (tag
7.0.16, commit2dfe3344):linkit.info.yml,linkit.routing.yml,src/Entity/Profile.php,src/Plugin/Linkit/Matcher/EntityMatcher.php,src/Plugin/Derivative/EntityMatcherDeriver.php,src/Plugin/Linkit/Substitution/{Canonical,File,Media}.php,src/Controller/AutocompleteController.php,src/Suggestion/{SuggestionCollection,SimpleSuggestion,DescriptionSuggestion,EntitySuggestion}.php,src/Plugin/Filter/LinkitFilter.php,js/ckeditor5_plugins/linkit/src/autocomplete.js,js/linkit.autocomplete.js. - Drupal CMS
2.xand1.x:recipes/drupal_cms_site_template_base/recipe.ymland itsconfig/linkit.linkit_profile.default.yml,config/editor.editor.content_format.yml,config/filter.format.content_format.yml; Drupal CMS issue #3500041. - BlockNote 0.54.2:
@blocknote/coresrc/extensions/tiptap-extensions/Link/link.ts,src/api/nodeConversions/{nodeToBlock,blockToNode}.ts,src/schema/inlineContent/{types,createSpec}.ts,src/extensions/LinkToolbar/protocols.ts;@blocknote/reactsrc/schema/ReactInlineContentSpec.tsx,src/components/LinkToolbar/{LinkToolbarController,EditLinkMenuItems}.tsx,src/components/SuggestionMenu/SuggestionMenuController.tsx. Upstream discussion #2479 and issue #1308. src/Document/DocumentValidator.php,src/Render/{InlineContentRenderer,UrlSanitizer,RenderPolicy,BlockRenderContext}.php,src/File/{FileUrlResolver,ResolvedFileUrl,ReferenceExtractor}.php,src/Plugin/Filter/BlockNoteFilter.php,src/Plugin/Editor/BlockNote.php,src/Plugin/Field/FieldWidget/BlockNoteEditorWidget.php,src/Plugin/Validation/Constraint/BlockNoteFormatConstraint.php,config/schema/blocknote.schema.yml,js/src/{runtime.ts,schema.tsx,editor.tsx,policy.ts,upload.tsx,toolbar.tsx}.- 0002, 0004, 0005, 0006, 0007.