Skip to content

0041. Converted references to files and content resolve through two more kinds of rule

Status: Accepted

Date: 2026-10-31

Context

0038 converts formatted text into a document through a walker and rules that are tagged services, and left resolving files and content to a record of its own. Until now an image kept its source as written and a link its address, and an element naming a file or content by its data-entity-* attributes was reported as kept as its URL.

A document names a file by its identity, file:<uuid>, resolved when the page is built (0007), and serves a private file it names to whoever may view the document, through the usage row the file gets and the module's hook_file_download(). Core's editor module gives a file such a row of its own, under the module name editor and the entity, and serves the file to whoever may view that entity, when the entity's formatted text, as the entity is saved, names the file on an element whose data-entity-type is exactly file, by its data-entity-uuid exactly as written (EditorHooks::parseFileUuids()), which the database may match in any case. A file only an address names gets none, and neither does one named another way, nor one whose markup no save of the entity read, such as text saved before the editor module was enabled or changed without a save. A document names content by a content link, entityLink, whose props are entityType, uuid and an optional substitution (0028, 0029). A content link holds no query and no fragment, follows the language of the document it is in, and a page building it leaves a page target to enforce its own access while it checks access to a file and to a media item's file. A substitution is valid only where the format's inline_content_settings.entityLink.substitutions holds it. The entity hooks of 0007 give every file a document names a usage row, a content link to a file included, and a save into the same revision adds what it newly names.

What sites store: core's editor writes data-entity-type="file" and data-entity-uuid on an image it uploaded, and its editor_file_reference filter gave the page the address of the file with that uuid, whatever the src said. Core's entity link plugin and linkit write data-entity-type and data-entity-uuid on a link to content, linkit data-entity-substitution as well, and their filters gave the page the address of the content with that uuid, keeping the query and the fragment of the stored href. An image copied from a page of the site names an image style's derivative, <files>/styles/<style>/<scheme>/<path> with an extension added where the style converts the image (ImageStyle::buildUri()), and an itok in its query. A site that moved names its pages and files at a former domain, its files at a former path such as /files, and its pages at addresses no route of the new site answers. The early adopter's notes count 5,390 bodies with images, nearly all carrying a file's uuid, 27 derivatives of a style since deleted, /files addresses, and absolute links to the old site's articles in 49,000 bodies.

Core resolves an address to a route with the router, whose route provider runs the inbound path processors first: the language module's takes off a path prefix, the alias processor looks the alias up in the current URL language, which under Drush is the site's default, and the front page processor turns / into whatever the front page shows. The route provider caches the route collection of each path it matches in cache_data, a row for each distinct path; AliasManager::getPathByAlias() keeps every path it looks up for as long as it lives and searches every alias it found, with array_search(), for each new one; AliasRepository::lookupByAlias() keeps nothing. Router::match() builds a request from the path, which decodes it again. An entity type's canonical route is built from its canonical link template, such as /node/{node}, and the router matches a path without its trailing slash, and in any case when nothing matches it as written.

Core's filter_html_image_secure shows an image on the site only when getimagesize() reads the path after the base path, query included, under the site's root. 0038 reads the path a browser requests instead, which ends at the query, so a derivative with itok is kept where its file is here, though the filter dropped it, and an image whose file is not on the converting server is kept unchecked when it is under the public files directory or named as an image.

Decision

Two more rule interfaces, on the same tag. FileResolverInterface::resolveFile(FileReference, ConversionContext): ?FileTarget and LinkResolverInterface::resolveLink(LinkReference, ConversionContext): ?LinkTarget join the six of 0038; ConversionRuleRegistry splits them out like the others. Resolvers are asked highest priority first and the first answer wins. ConversionContext::resolveFile() and resolveLink() run the two chains, so every rule finds a file or content the way the module's own rules do. Amended 2026-10-31. The media submodule adds a third kind, a media resolver, on the same tag (0042). A reference holds what the markup says: an address, a uuid, an entity type and a substitution from the data-entity-* attributes, and the element. A target holds what it resolves to: a FileTarget the file and the image style whose derivative the address named, so that the caller reports it; a LinkTarget a content entity, the substitution and the language the address named the content in.

The attributes count only where a filter read them. editor_file_reference, entity_links and linkit's filter gave the page the address of what the data-entity-* attributes name; a format running none of them showed the address as written. So where the conversion names its source format, ConversionContext::sourceFormatEnables() says whether the format enables the filter, and the media rule reads an element's uuid only under editor_file_reference, and the walker a link's attributes only under entity_links or linkit. Where it does not, the address is read instead and the attributes are reported as entity_attributes_not_filtered, ignored, keyed by the type they name. A conversion naming no source format reads them, as one converting markup on its own has no filter to ask about, and a format that does not exist enables none.

What an address names is the context's to say. ConversionContext::$sites, a SiteAddresses, reads an address relative to the site's root, at the host the conversion runs under unless it is Drush's placeholder default, or at one of the addresses ConversionOptions::SITE_URLS lists, each a domain or an address with the path the site was served under; the scheme does not matter, a default port is no port, and where an address is under two paths at one host, or under two legacy file paths, the longest wins, so the order the options give them in changes nothing. The address is read as a browser requests it: control characters and whitespace removed, a backslash a slash, the path ending at a question mark or a number sign, dot segments resolved, written with periods or as %2e; a path that leaves the base path is not the site's. address() answers the path under the base path, still percent-encoded, the query and the fragment. ConversionOptions::LEGACY_FILE_PATHS lists paths the public files were once served at. Both options are lists of strings, and the converter throws an \InvalidArgumentException for an entry that is not a site address or a path; drush blocknote:convert-html and drush blocknote:convert take them as --site-url and --legacy-file-path, one address or path to each, repeated for more, and refuse a malformed one as a usage error, and one holding a comma: a comma may belong to an address or a path, so splitting a value at its commas could read one address as two, and reading a value whole would read a list written with commas as one strange address. A job's identity holds the addresses as SiteAddresses::identity() reads them, the host and base path of the request the run is served under, which Drush's --uri gives, included, so a run resuming at another host or base path, or under site URLs or legacy file paths that name other things, is refused, and one naming the same ones spelled another way resumes.

A file is found by its uuid, then by its address, and by its address only when it is public. The module's own resolvers, at priority 0, are FileByUuidResolver, for an element whose data-entity-type is exactly file, reading data-entity-uuid exactly as written and finding a file only by its own uuid, in lower case, so that no database matching it in any case or ignoring a trailing space finds a file the editor module did not read, and FileByUrlResolver: an address on the site in the directory the public files are served at, as core's file URL generator gives it, /sites/default/files/ on most sites, or in a legacy file path, which stands for it too, names the file with that URI, decoded; of two files with one URI, the first saved. An image style's derivative names the public file it was made from, its query left aside, and where its name has two extensions, the name without the last. A private file is never found by its address: naming it would open it to every reader of the converted document, where the old page's link led to an address its access guarded. The media rule writes the file's identity, file:<uuid>, in place of the source, and reports a derivative as image_style_not_carried, degraded, keyed by the style. Where no resolver finds a file, the source is kept as written and reported as file_reference_kept_as_url when the element named a uuid or the source is on the site; a source elsewhere is an address and is not reported. Amended 2026-11-03. A conversion asking for it with ConversionOptions::REGISTER_FILES, --register-files, registers instead a file in the public files directory that is on the server and that no file entity names: FileByUrlResolver::register() plans a permanent file entity, owned by the owner of the entity the markup belongs to and with a uuid derived from its URI, through the createOnWrite() of 0042, and the media rule names it. A link to such a file stays a plain link. A link to a file of the site is a content link to the file, which 0028 offers because a link to a file is the file, except a link to a derivative, whose content link would name another file.

Amended 2026-11-03. The amendment above overstated how a registered file is known to be a public file. The address's dot segments were resolved before its path was decoded, so an encoded slash could lead out of the public files directory, or name a file another URI names; core's public stream checks containment as a prefix of the real path, which a sibling directory whose name starts with the directory's passes; a private or a temporary directory inside the public one was registered as public; and any file was, a hidden one, one Drupal generated or one of any extension. register() now refuses a source whose path encodes a slash or a backslash, or decodes to a backslash, a NUL byte or a dot segment; a file whose real path, its links followed, is not inside the real path of the public files directory, a slash added, or is inside the private or the temporary directory; a hidden file, one in a directory whose name starts with a period, and one in styles, css, js, php or translations at the top of the public files; a file whose extension is not one of what the element shows, an image's, a video's or an audio file's; and a URI longer than the file entity's field holds. Each refusal is reported in the detail of file_reference_kept_as_url, as the others are.

A file outside the public files directory is named only where the editor module recorded it. ConversionContext::mayNameFile() allows a file of the public stream, served to anyone already, and any other only where the file's usage rows hold one of the editor module for the entity the markup was read from, which serves the file to whoever may view that entity already, so the document opens it to nobody new. Markup read from no entity, or from one not yet saved, names no such file. The media rule and the walker ask it of every file a resolver finds, a site's resolver included, by its uuid or by its address, and keep the source or the link's address where it says no, reported as private_file_kept_as_url, unresolved, keyed file. A rule may write a file's identity without the resolvers, so ConversionGate asks it too of every file the finished document would give a usage row, found as the save finds them, through ReferenceExtractor::extract(), and refuses a document naming one it does not allow as private_file_named, keyed by the file's identity.

The secure image check asks the file resolvers. Under filter_html_image_secure, an image whose file is not on the server running the conversion is checked by the file the resolvers find for its source, a derivative by the file it was made from: by getimagesize() where that file is here; dropped where it is not and the site records it as no image, by its MIME type and its name; and otherwise kept and reported as unchecked, since a file record does not say that the file was there to show. Where the resolvers find nothing, the image is kept unchecked or dropped as before. A source at an address the site was served at is the site's; without --uri, an absolute source no site URL names is still kept and reported, as before.

Content is found by its attributes, then by its address, and only where the target format permits content links. EntityLinkAttributeResolver loads the content a link's data-entity-type and data-entity-uuid name, read exactly as written, as core's entity_links filter and linkit's read them, the uuid only as the content's own, in lower case, of any content entity type with a uuid key, and passes the substitution on. SameSiteLinkResolver reads an address on the site: a file, as above; otherwise it takes off the language a path prefix names, by the language module's own rule where the site negotiates the language by path prefix, the path without one naming the language whose prefix is empty; decodes it once, and refuses a path that decoding makes another host's, or gives a query, a fragment or bytes that are not UTF-8; looks the alias up in that language, or the default one, through the alias repository, except where the site negotiates the language by domain, where an alias names content in the language of the host a page was served at, which the address does not say, so a link through an alias stays plain and is reported as link_alias_not_resolved, unresolved, keyed domain, and a site writes a resolver that reads its domains; reads the language settings once, for the first link; and matches the path against the canonical link template of each content entity type with a uuid, as the router matches the route built from it, a trailing slash and the case of its letters aside, and an id that is an integer as digits only, as core's route provider requires it, loading the entity by the id it holds. Any other route, the front page whatever it shows, and an address no template answers name nothing. In a format that does not permit content links nothing is resolved.

A link stays plain where a content link cannot say what it said. A link to content becomes a content link except when its address has a query or a fragment, which 0028 refuses, and when its address names a translation in another language than the one the content link would show, the document's, or the content's own where it has none. Both stay plain links and are reported as entity_link_kept_as_url, unresolved, keyed by the entity type, and so is a link that names content by its attributes that nothing resolves, or that a format without content links holds. An address on the site that names no content is a link to the site, such as a view, and is not reported. A substitution the format permits is kept; any other is left off, the content link following the base rule, and reported as substitution_not_permitted, degraded, keyed by the substitution.

A content link is written as the editor holds it. The walker gives the link it resolved the content's props, and InlineNormalizer makes it an item of its own, as the editor holds any custom inline content: its runs collapse whitespace with the text around them and merge inside it, it merges with nothing beside it, not even a content link to the same content, a line break after it is a run of its own, and one left with no text is dropped. Its runs keep the styles the format permits, the new-tab flag included; code formatting is no link's mark inside it, so it gives way only to another mark on its run.

Resolution never asks access. The conversion runs as whoever runs Drush, so content is loaded directly, by its uuid or by the id its canonical address holds, and nothing asks whether that account may see it: an unpublished target, or one that account may not see, resolves as any other. The page decides access for each reader, as 0028 has it: a page target enforces its own, and a file or a media item's file is checked when the page is built. Amended 2026-10-31. A media item found for an image's file or a video's address (0042) is still not checked for access, but an unpublished one is named only where the items the conversion creates are unpublished too, since the image or the player it replaces was shown to every reader of the page, and the block shows nothing to a reader who may not view it.

Usage follows from the save. The bulk converter writes through the entity save, so the hooks of 0007 give each revision it writes one use of every file its documents name, through an image or a content link, and make a temporary file permanent. A dry run writes nothing. The old field's own usage, which core's editor module records, is left to that field.

Alternatives considered

Resolving inside the media rule and the walker, with no seam. Rejected: a site whose files moved, or whose links follow a pattern of its own, would replace the media rule or the walker's link handling to change where a file or content is found, and lose the rest of what they do. Finding is a chain of its own, as the design for the converter had it.

resolveFile() answering a FileInterface. Rejected: only the resolver knows it read a derivative, and reporting from the resolver would report it twice, from the secure image check and from the media rule.

Core's path validator, or the router. Rejected: both look the alias up in the current URL language, the default one under Drush, so an alias in another language is not found; / is the front page's content; the route provider writes a row to cache_data for each distinct address, and the alias manager keeps every path it looks up and searches every alias it found for each new one, which over a run converting a field's every value is hundreds of thousands of rows and a search growing with each link; and the request the router builds decodes the path again, so that /%252F or /node/1%3Fa reach other routes than a browser does. Only canonical routes name content, and their templates are what the router builds them from.

Finding any file by its address. Rejected: a private file named in a document is served to every reader of the document, so converting a link to /system/files/… would open a file the old page guarded.

Naming any file found by its uuid. Rejected: a uuid in the markup does not show that the editor module recorded the file. The resolver read the attributes more leniently than the module, a type in any case, a uuid with spaces around it or in capitals, and markup the module never read, or converted from no entity, names a file the module recorded for nobody, so a document naming it would have served it to readers the old page never served it to.

Refusing every file outside the public files directory. Rejected: an image core's editor uploaded into private files is the common case, and the editor module's row shows the file was served to the entity's readers already.

Checking only in the module's own resolvers. Rejected: a site's resolver finding a private file by its address, and a rule writing an identity itself, would still open it. The media rule and the walker check whatever the resolvers answer, and the gate checks the document.

Keeping a link with a query or a fragment as a content link without them. Rejected: an anchor or a page number would go without a word. The backlog row on fragments decides whether a content link holds them.

A content link for a link to another translation. Rejected: a content link has no language, so it would lead to the document's translation, another page than the old one led to.

Dropping a derivative with itok under the secure image filter, as the filter did. Rejected: the filter dropped it by reading the query as part of a file name, while the address a browser requests names a file of the site, and the document now names that file rather than the address. The image an author placed is kept and the style reported.

Resolving links in every format. Rejected: a format without content links holds none, and each link would cost an alias lookup and an entity load for nothing.

Reporting every link to the site that names no content. Rejected: views, forms and search pages would fill the report.

Checking access as the account running the conversion. Rejected: Drush runs as the anonymous user unless told otherwise, so every unpublished target would stay a plain link, and the page decides access per reader anyway. The one access a conversion could widen, a private file's, is kept to the readers the editor module already served the file to, as above.

Consequences

A converted image or file link names its file by identity, so it survives the file moving, and gets a usage row; that needs the site's file entities, so a copy without them reports every file as kept as its URL. A derivative shows its original at the block's preview width, and a document shows an image the secure filter hid for its itok; both are reported or recorded here.

A link to content follows it wherever its alias moves, and a page checks a file target's access per reader: a link to a private file a reader may not see shows its text without an address, where the old page's link was refused on click.

Each address on the site that a link names costs an alias lookup and an entity load, and nothing kept for the run. A route a module altered away from its entity type's canonical template is not read, nor an alias on a site that negotiates the language by domain; such a site writes a resolver. A private file named only by its address stays an address, reported, until an author names it again, and so does one named by its uuid that the editor module did not record the entity as using, and every private file in markup converted without an entity.

A site's own resolver, at a priority above 0, answers what the module's cannot, such as the addresses of the site it migrated from; the test module's ExampleArticleLinkResolver is one. The conversion API stays experimental until the converter has run on a real site's content.

References

  • src/Convert/ (SiteAddresses, SiteAddress, FileLocations, FileReference, FileTarget, LinkReference, LinkTarget, ConversionContext, ConversionOptions, PrivateFileCheck, ConversionGate, HtmlWalker, InlineNormalizer, TargetPolicy, SourceFormatFilters), src/Convert/Rule/ (FileResolverInterface, LinkResolverInterface, FileByUuidResolver, FileByUrlResolver, EntityLinkAttributeResolver, SameSiteLinkResolver, MediaRule, ConversionRuleRegistry), src/Drush/Commands/BlockNoteConvertCommands.php, blocknote.services.yml.
  • tests/src/Unit/Convert/SiteAddressesTest.php, tests/src/Kernel/Convert/FileReferenceConversionTest.php, PrivateFileConversionTest.php, ContentLinkConversionTest.php, SourceFormatFilterTest.php, BulkConversionTest.php, tests/fixtures/conversion/content-links/, tests/modules/blocknote_convert_test/src/Conversion/ExampleArticleLinkResolver.php and ExampleOldPrivateFileResolver.php, js/src/__tests__/conversion-roundtrip.test.ts.
  • Drupal core 11.4: Drupal\Core\Routing\Router::match(), RouteProvider::getRouteCollectionForRequest(), Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider, Drupal\Core\Path\PathValidator::getUrl(), Drupal\path_alias\AliasManager::getPathByAlias(), AliasRepository::lookupByAlias(), Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationUrl::processInbound(), Drupal\image\Entity\ImageStyle::buildUri(), FilterHtmlImageSecure::process(), EditorFileReference, EntityLinks, EditorHooks::entityInsert(), parseFileUuids() and fileDownload().
  • 0007, 0028, 0029, 0038, 0039.
  • docs/converting.md, docs/extending.md section 14.