Keep images and links to repository files in the rich text editor
What does this MR do and why?
The rich text editor parses the HTML the markdown API renders into its document. The reference extension claimed every a.gfm anchor without data-link="true", at the highest parse priority. RepositoryLinkFilter adds the same gfm class to links and images whose path points at a file in the repository ([readme](README.md), ) without a data-reference-type, so those anchors became empty reference nodes: the editor showed nothing where the image was, a link to a file kept only its text, and the first save after an edit in rich text deleted the image markdown and flattened the link to plain text. (Loading a document does not re-serialize it, the load transaction carries preventUpdate, so saving without touching anything leaves the source as it was; the loss lands as soon as the author changes a word.) Uploads and images from other hosts were unaffected: the upload filter sets data-link="true" and asset-proxied images never get the class.
Two changes, one per commit:
RepositoryLinkFilterrecords the path as written indata-canonical-srcbefore rewriting it, the wayUploadLinkFilterandWikiLinkFilteralready do; the helper moves into the sharedBaseRelativeLinkFilter. Narrowing the parse rule alone is not enough: the rendered HTML carries only the resolved path, so the editor would save, and re-rendering that path treats it as relative to the repository root (/group/project/-/blob/master/group/project/-/raw/master/docs/img/shot.png), a broken image. Silent deletion would have become silent corruption. Recording the path fixes the same corruption for linked images ([](https://example.com)) and for repository videos and audio, which the editor already parsed but saved with the resolved path. The moved helper's guard usedNokogiri::XML::Node#value?, which compares attribute values, so it never matched; it now checks the attribute name. Nothing changes for uploads in practice, since an/uploads/path never carries an earlier canonical source.- The
referenceextension claims only anchors withdata-reference-type, the attribute every reference filter inlib/banzai/filter/references(CE and EE) emits throughReferenceFilter#data_attribute. Repository links and images then fall to thelinkandimageextensions, which round-tripdata-canonical-src.
Every producer of an anchor with the gfm class, and what the editor does with it:
| Producer | Anchor | Before | After |
|---|---|---|---|
| Reference filters (issue, work item, merge request, epic, user, project, label, milestone, snippet, commit, commit range, vulnerability, design, alert, feature flag, wiki page, external issue, iteration, iterations cadence) | class="gfm gfm-<type>" data-reference-type="<type>" |
reference node | reference node |
Reference filters, reference written as a link ([text](https://.../issues/1)) |
same plus data-link="true" |
link | link |
UploadLinkFilter |
class="gfm" data-link="true" data-canonical-src |
link or image | link or image |
RepositoryLinkFilter (link or image to a repository file) |
class="gfm", plus data-canonical-src with this MR |
empty reference node | link or image with the path as written |
WikiLinkGollumFilter ([[page]]) |
class="gfm gfm-gollum-wiki-page" data-link="true" data-reference-type="wiki_page" |
link | link |
ImageLinkFilter around an asset-proxied or wiki image |
a.no-attachment-icon without gfm |
image | image |
ColorFilter |
span.gfm-color_chip (not an anchor) |
color chip | color chip |
Wiki pages were never affected: WikiLinkFilter handles a:not(.gfm) and records data-canonical-src, and RepositoryLinkFilter skips wiki context.
The new attribute is emitted on every surface that renders markdown with a project context (descriptions and notes on issues, merge requests and other issuables, repository file and README views, commit messages, project snippets, releases and milestones, notification emails, the markdown and preview endpoints). It is a data-* attribute with no CSS or JavaScript reader outside the rich text editor, and no post-process filter that runs after RepositoryLinkFilter reads it (PlaceholdersPostFilter, PlayableLinkFilter, ExternalLinkFilter and ImageLinkFilter read it earlier in the pipeline and are untouched). Readers inside the editor, and what they do with a repository path:
| Reader | Behaviour with a repository path |
|---|---|
extensions/image.js and services/serializer/image.js |
image node keeps canonicalSrc; serialized as  |
extensions/link.js and services/serializer/link.js |
link mark keeps canonicalSrc; serialized as [text](path) |
extensions/playable.js (audio, video; serializer shared with image) |
, replacing the resolved raw path saved before |
extensions/drawio_diagram.js (a.no-attachment-icon[data-canonical-src$="drawio.svg"]) |
a repository .drawio.svg becomes a diagram node instead of an empty reference; it serializes as , and editing it in diagrams.net saves a new upload, as it does for wiki attachments |
extensions/iframe.js |
reads data-iframe-canonical-src first; iframe sources are absolute URLs, unaffected |
link and media bubble menus, asset_resolver.resolveUrl |
the URL field shows the path as written and resolves it through the render API, the same path uploads already take |
Resolves #628278 (closed)
References
- Reproduced against the production renderer (markdown API, project
gitlab-org/gitlab, 2026-09-08):renders<a class="no-attachment-icon gfm" href=".../-/blob/master/doc/img/shot.png" ...><img class="lazy gfm" data-src="..."></a>with nodata-link, and[readme](README.md)renders<a href=".../-/blob/master/README.md" class="gfm">readme</a>. Fed to the editor's own deserializer and serializer at the merge base, the image line comes back empty and the link comes back asreadme. - Writing the resolved path back does not survive a re-render:
renders withdata-src="/gitlab-org/gitlab/-/blob/master/gitlab-org/gitlab/-/raw/master/app/assets/images/logo.svg"(same API). That is why the filter has to record the path as written. - Jest, reference extension: two new examples built from the production HTML (a repository link, a repository image) fail at the merge base because the rule claims them, and pass here; 18 examples enumerate every
data-reference-typethe backend emits and keep parsing as references. Jest, deserializer round trips of backend HTML: 7 of 12 new examples fail at the merge base (repository image alone, in a nested list, in a table cell, with a title and markdown in the alt text, a repository link, a paragraph mixing both, and the fallback when no canonical source is present) and pass here; uploads, images from other hosts, a paragraph of five reference kinds, and a link whose target is an issue URL round-trip unchanged. Both specs pass 56/56 under Vue 2 and Vue 3. - RSpec,
RepositoryLinkFilter: the spec has 128 examples; the three newdata-canonical-srcexamples fail at the merge base in each of the three repository contexts (9 failures) and pass here, the upload filter spec is unchanged, and both specs pass together (150 examples). A new end-to-end example inspec/features/merge_request/user_edits_merge_request_spec.rb(the merge request form runs on a project with the test repository) types and [readme](README.md)into the plain text field, switches to rich text, expects the image and the link, types an edit (the editor only re-serializes after a change), switches back and expects the textarea to hold the original markdown plus the edit; it fails at the merge base with no image in the editor and passes here. The spec file itself changed, so predictive RSpec selection runs it in the MR pipeline (rspec:test-summary). - The reference rule comes from Render references in content editor (!68230); the image anchor rule from Use GFM Markdown fixtures in Content Editor (!63042). Recording the path as written on anchors is the direction the owning group set for link fidelity in kivikakk's note on the link escaping issue.
Screenshots or screen recordings
Every row seeds a description on a project with the test repository, opens it in rich text, types a word into the first line (the editor only re-serializes a document after a change) and saves; rows 1 and 3 show the editor and the plain text source, the others the re-rendered description. The no-edit row and the three Unchanged rows are byte-identical between before and after.
Recording
| Before (recording) | After (recording) |
|---|---|
![]() |
![]() |
![]() |
![]() |
Two load, edit and save stories cut into beats with identical captions per side. Repository image: rendered, opened in rich text, a word typed into the first line, saved, plain text source re-opened; before, the editor shows nothing where the image was and the save deletes the image line (the source ends up as Logo below: (edited)); after, the image stays and the source is the original markdown plus the typed word. Linked repository image: both versions show the image in the editor; before, the save writes the resolved raw path back and the re-render shows a broken image; after, the markdown is saved as written. Frames are byte-identical across the two versions until the beat where behaviour differs.
How to set up and validate locally
- In a project with a repository (the
gitlab-testrepository used bycreate(:project, :repository)hasfiles/images/logo-black.png), create an issue whose description is and [readme](README.md). The rendered description shows the image and the link. - Select Edit title and description, then Switch to rich text editing. Before: no image, and
readmerenders as a reference chip. After: the image shows andreadmeis a link. - Type any edit (a word at the end of the first line is enough), select Save changes and view the description. Before: the image is gone and
readmeis plain text. After: both render, and the plain text source is the original markdown plus the typed word. - Repeat with the image inside a table cell, inside a nested list item, and as a linked image
[](https://gitlab.com). Before, the linked image is saved with the resolved raw path and renders broken. - Saving without an edit keeps the source on both versions: the editor only re-serializes the document after a change.
- Regression checks: an uploaded image, an image with an absolute URL, references (
#1 @user ~label !1), a link whose target is an issue URL, and a wiki page with an attachment all render and save unchanged after the same edit.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.





























