Skip to content

0029. A content link may carry a substitution the format permits, resolved by a decorated resolver

Status: Accepted

Date: 2026-09-21

Context

0028 considered and rejected a substitution prop in the base module, reasoning that its own file-versus-page rule already answered the one distinction that needs an access check, and that a general substitution vocabulary was linkit's own concept, tied to its own plugin type, so committing the document's stored shape to it would serve only a submodule that did not yet exist. Its own Consequences section, corrected the same day, narrowed what it had first promised a submodule: the seams that exist are 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 answer. Neither EntityLink::getPropSchema() nor the client's entityLinkPropSchema accepts an attribute a submodule did not declare, so a substitution prop was left as a seam the base module's code did not yet offer, needing a decision of its own before any submodule could add one. This record is that decision.

Linkit 7.0.17 answers the question 0028 left open with a vocabulary of its own. plugin.manager.linkit.substitution (SubstitutionManager::getApplicablePluginsOptionList($entity_type_id)) lists the substitution plugins that apply to an entity type; three ship with linkit itself: canonical, applicable whenever the type has a canonical link template; file, which answers a file entity's own direct URL; and media, which answers a media item's source field's file URL when the source holds one, else the entity's own canonical URL when that differs from its edit form, else nothing. SubstitutionInterface::getUrl(EntityInterface): ?Url is the one method each plugin implements. Amended 2026-09-22. The interface declares two methods, not one: getUrl(EntityInterface $entity), with no declared return type, answering a Url or NULL, and the static isApplicable(EntityTypeInterface $entity_type), which is what getApplicablePluginsOptionList() filters on and what a decorator asks before it asks a plugin for a URL at all. EntityMatcher::createSuggestion() sets substitution_id on a search result from the matcher's own configured substitution, so a suggestion already names which rule its own address followed. Drupal CMS's base recipe ships linkit.linkit_profile.default pointing entity:node at canonical and entity:media (bundle document) at media, not at canonical: Drupal CMS issue #3500041 records that a canonical link to a media item sent an anonymous visitor to a login page, because the media item's own page requires an account the visitor did not have, where the file itself is public. A substitution is therefore a choice linkit's own matchers already make per entity type and per profile, not one the module can fix once for every site the way 0028 fixes file and media for its own resolution.

BlockNote stores every prop a type declares, defaults included, so an optional prop has to be declared in the one shape that keeps it absent until set. nodeToCustomInlineContent() (node_modules/@blocknote/core/src/api/nodeConversions/nodeToBlock.ts:352-364) copies every node.attrs entry that is in the type's propSchema into the stored props, with no check for whether the value equals the prop's own default; the block path a few lines below it skips only a prop whose default and current value are both undefined, which a declared, always-present prop never is. Nothing on the server strips a default either: src/Document/DocumentNormalizer.php has no props handling at all. So a prop declared with a real default, { default: '' }, would be written onto every content link the editor saves, "substitution": "" included, which would make "stored only when non-empty" false the moment the editor wrote anything. BlockNote's own optional-prop shape avoids this: { default: undefined, type: 'string' } (node_modules/@blocknote/core/src/schema/propTypes.ts:8-21) declares a prop with no default at all, ProseMirror's node schema treats { default: undefined } as a declared default (hasDefault = Object.prototype.hasOwnProperty.call(options, "default") in node_modules/prosemirror-model/src/schema.ts), and JSON.stringify drops an attribute whose value is undefined. PropSpec::string('substitution', NULL, NULL, TRUE) is the server's matching shape: src/Document/PropSpec.php's string($name, $default = '', $values = NULL, $optional = FALSE) constructor and its matchesType(NULL) answering $this->optional mean an absent prop is never reported by PropSchemaValidator::validate() (src/Document/PropSchemaValidator.php:34-58).

Declaring a configuration dependency on a linkit profile would delete the format the moment the profile is deleted, which is the data loss 0006 already records for the same shape of mistake. ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval() (web/core/lib/Drupal/Core/Config/ConfigManager.php:318) calls callOnDependencyRemoval() (:438) for every dependent, and a dependent whose own onDependencyRemoval() answers FALSE lands in the delete list. FilterFormat::onDependencyRemoval() (web/core/modules/filter/src/Entity/FilterFormat.php:426) fixes only a disabled filter; ConfigEntityBase::onDependencyRemoval() (web/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php:484-505) fixes a third-party setting only when the module that owns it is being uninstalled (:486-489), never when the dependency is a named configuration entity such as a linkit profile. No seam lets a third module fix a configuration dependency it declared for anything but its own removal, so a format naming a deleted profile would be deleted along with every field whose allowed_formats named it. Core's own menu_ui module answers the equivalent problem without declaring a dependency at all: MenuUiHooks::menuDelete() (web/core/modules/menu_ui/src/Hook/MenuUiHooks.php:368-385), reacting to hook_menu_delete(), unsets available_menus on every node type that named the deleted menu, rather than declaring system.menu.* as a configuration dependency and relying on onDependencyRemoval() to fix it.

Decision

The prop is substitution, optional, and absent unless it is set. entityType and uuid are unchanged. substitution is a string naming an id from the format's own permitted list; an absent prop, NULL and '' all mean the same thing, "follow the base rule", and nothing on the server or the client tells them apart.

The permitted list is a per-format setting, inline_content_settings.entityLink.substitutions, empty by default. A stored substitution is checked against it on save, refused with the 0023-worded fragment "follows an address rule this text format does not permit" when the format's list does not hold it. Amended 2026-10-31. The fragment is now "links to its content through an address this text format does not permit; choose the content again with Edit", from 0023's second pass. Amended 2026-10-31. It is now "it links to its content through an address this text format does not permit; choose the content again with Edit link", a sentence of its own after "cannot be saved:" that names the button by its text, as 0023 records. The settings pane shows the list as ticked checkboxes only when a resolver offers something to tick; when nothing is offered, the stored list is kept through a value element rather than shown, so a format's own choice survives a save made while the module that would have offered it is not installed. Amended 2026-09-22. The list is enforced on output as well, the same twice-enforced posture RenderPolicy's own docblock states for every other setting: EntityLink::build() blanks a stored substitution the format's current list does not hold to '' before resolving, so a document saved before the list narrowed resolves by the base rule instead of keeping an address kind the format has since withdrawn.

EntityLinkResolverInterface gains substitutions(): array and a trailing $substitution parameter on resolve(), describe() and resolveEntity(). The base resolver answers no substitutions and ignores the parameter on every method. A decorator that follows a further address rule offers it from substitutions(), resolves a non-empty id its own plugin can answer for the target, and falls through to the resolver it decorates for an empty id, for an id it does not recognize, and for an id whose plugin cannot answer for that particular target.

The search seam stays the suggestionsUrl settings key, swapped through core's own hook_editor_js_settings_alter(), and its answer gains one member. A route replacing the base module's own content search answers the same shape 0028 states, plus substitution_id on an entity result, emitted only when the format's own permitted list holds the id the search backend named; an id the format does not permit is dropped before the client ever sees it, so the client never has to know the difference between "no substitution" and "a substitution the format refused." Amended 2026-09-22. An entity result's row always carries the substitution_id key: it holds the id the search backend named when the format's own permitted list holds it, and '' otherwise, the same "absent, NULL and '' all mean the same thing" rule the Decision already states for a stored substitution; what is dropped is the id itself, never the key. Amended 2026-09-22 (second). A module implementing hook_editor_js_settings_alter() to swap this key sets no order: of its own on the implementation, so a later implementer's own alter, run after it with no ordering declared either, can overwrite suggestionsUrl again; a module that wants its own URL to be the one an author's browser actually uses declares an order against it. Amended 2026-09-24. There is no order: because core ships no implementation of hook_editor_js_settings_alter() outside its own test module, so there is no concrete implementer to order against, and OrderBefore and OrderAfter each need one to name, a module or a class and method; and because a second module that rewrites the same key for a format on a per-format setting of its own is two content search backends configured for one format, a conflict no ordering resolves correctly in either direction, while one that rewrites every format unconditionally can already declare new OrderAfter(modules: ['blocknote_linkit']). Order::Last and Order::First were both considered and refused: either claims a position against every implementer not yet written, and neither removes the declaration a module that wants its own URL to win still has to make.

The widget's prefetch is keyed by entityType:uuid[:substitution]. A substitution can change the resolved address, a media item's source file against its page for instance, so two links to the same entity following different rules need two distinct prefetch entries; a link naming none keeps the key it already had.

The access rule for a decorated answer is the one 0028 already draws for file and media. A substitution that answers a file, or a media item's own source file, goes through the same access-checked resolution FileUrlResolver already gives an uploaded file, because for those the address is the protected resource itself. Every other substitution is a page left unchecked with the target entity's own cacheability, the same boundary core's own link-target rule and a plain link both accept, because the page enforces its own access.

The linkit profile is a third-party setting of the format, and the format declares no configuration dependency on it. A module that offers a profile fixes a format naming a deleted one from the profile's own deletion hook, the way MenuUiHooks::menuDelete() fixes a content type naming a deleted menu, rather than from a declared dependency onDependencyRemoval() would have to answer for. Uninstalling the module that offers the profile needs no hook of its own: ConfigEntityBase::calculateDependencies() already adds that module as a dependency of the third-party setting, and ConfigEntityBase::onDependencyRemoval() already strips the setting and keeps the format when the module is what is being removed.

A document keeps saving after the module that offered a substitution is gone. A stored substitution a decorator no longer exists to follow is read by the base resolver, which ignores it and answers its own rule; the format's own permitted list stays exactly as it was stored, invisibly, until a site builder edits it again; nothing about the document or the format is refused or rewritten by the module's absence.

Alternatives considered

A substitution prop with a real default, stored as '' on every content link. Rejected. BlockNote stores every declared prop, defaults included, so a prop declared this way would write "substitution": "" onto every content link the editor saves, making "stored only when non-empty" false from the first save onward. The optional-prop shape, absent unless set, is the one declaration that keeps that intent true.

A declared configuration dependency on the linkit profile. Rejected. Core deletes a dependent it cannot fix, and no seam here lets a decorating module fix a dependency the base plugin did not declare; a format naming a deleted profile would be deleted along with every field that used it. 0008's own third amendment records that its earlier text assumed this dependency would exist and be handled from inside the filter's own settings collection; it does not exist under this decision.

A per-format substitution with no per-link choice. Rejected. Drupal CMS's own default profile answers canonical for one entity type and media for another inside one profile, and a hand-picked search result already names which substitution its own match used; fixing one substitution per format would either ignore what the search already knows or force a second, narrower search per substitution, neither of which is what a curated profile is for.

Refusing a stored substitution once the module that offered it is uninstalled. Rejected. A document an author has never touched since would stop saving through no choice of the author's, which is the same failure 0027 already rejected for an inline carrier that reached the server. Letting the base resolver quietly answer its own rule instead keeps every document saveable.

Consequences

The stored shape gains one more optional prop, read by the base module and ignored unless a decorator claims it; a document written before this record and a document written after it are both valid, because an absent substitution was always the whole of what existed before.

The permitted list is a second gate beside whatever a linkit profile's own matcher configuration already decides, and the two can disagree: a profile's matcher may name a substitution the format has not permitted, in which case the value is dropped at the search stage and never reaches the document, or a format may permit a substitution no installed resolver currently offers, in which case a stored value degrades silently to the base rule rather than being refused.

Amended 2026-09-22. The access-checked boundary the Decision states for "a substitution that answers a file, or a media item's own source file" is keyed on linkit's own plugin ids, file and media, not on the shape of the URL a substitution answers. A substitution plugin from a module other than linkit that answers a file's own URL for some entity is emitted through the unchecked page branch instead; the bytes stay protected by the file access hook that already gates every resolved file address, but the anchor is drawn where the base rule would render an unchecked page. A later plugin author who wants the checked branch for a new substitution extends or names it alongside file and media, the same boundary 0028 already draws.

The widget's prefetch attribute grows one entry per distinct substitution a document uses on a given entity, rather than one entry per distinct entity.

A hand-written content link may name any permitted substitution id for any entity type, the same posture 0028 already takes for entityType and uuid themselves: whether the id means anything for that particular entity is answered when the page is built, by falling through to the base rule, not refused on save.

References

  • Linkit 7.0.17 (paths and lines read at that tag from git.drupalcode.org, not from an installed copy): linkit.services.yml:5-8 (plugin.manager.linkit.substitution), src/SubstitutionManager.php:56-63 (getApplicablePluginsOptionList()), src/SubstitutionInterface.php:23 (getUrl()), src/Plugin/Linkit/Substitution/Canonical.php:15-33, src/Plugin/Linkit/Substitution/File.php:18-56, src/Plugin/Linkit/Substitution/Media.php:19-70, src/Plugin/Linkit/Matcher/EntityMatcher.php:435-447 (createSuggestion() sets substitution_id).
  • Drupal CMS 2.x: recipes/drupal_cms_site_template_base/recipe.yml and its config/linkit.linkit_profile.default.yml; Drupal CMS issue #3500041.
  • Core 11.4.6: web/core/lib/Drupal/Core/Config/ConfigManager.php:318, :438, web/core/modules/filter/src/Entity/FilterFormat.php:426, web/core/lib/Drupal/Core/Config/Entity/ConfigEntityBase.php:484-505, web/core/modules/editor/src/Plugin/EditorManager.php:70-98 (getAttachments(), the editor_js_settings alter), web/core/modules/menu_ui/src/Hook/MenuUiHooks.php:302-385, web/core/modules/menu_ui/config/schema/menu_ui.schema.yml:13.
  • BlockNote 0.54.2: node_modules/@blocknote/core/src/api/nodeConversions/nodeToBlock.ts:352-364, node_modules/@blocknote/core/src/schema/propTypes.ts:8-21.
  • This module: src/Plugin/BlockNoteInlineContent/EntityLink.php, src/EntityLink/EntityLinkResolverInterface.php, src/EntityLink/EntityLinkResolver.php, src/Document/PropSpec.php, src/Document/PropSchemaValidator.php, src/Document/DocumentNormalizer.php, src/Plugin/Field/FieldWidget/BlockNoteEditorWidget.php, config/schema/blocknote.schema.yml, js/src/entity-link.tsx, js/src/entity-link-search.ts.
  • 0006, 0007, 0008, 0023, 0027, 0028.