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), ![shot](docs/img/shot.png)) 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:

  1. RepositoryLinkFilter records the path as written in data-canonical-src before rewriting it, the way UploadLinkFilter and WikiLinkFilter already do; the helper moves into the shared BaseRelativeLinkFilter. Narrowing the parse rule alone is not enough: the rendered HTML carries only the resolved path, so the editor would save ![shot](/group/project/-/raw/master/docs/img/shot.png), 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 ([![x](docs/img/shot.png)](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 used Nokogiri::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.
  2. The reference extension claims only anchors with data-reference-type, the attribute every reference filter in lib/banzai/filter/references (CE and EE) emits through ReferenceFilter#data_attribute. Repository links and images then fall to the link and image extensions, which round-trip data-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 ![alt](path)
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) ![title](path), 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 ![alt](path), 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): ![shot](doc/img/shot.png) renders <a class="no-attachment-icon gfm" href=".../-/blob/master/doc/img/shot.png" ...><img class="lazy gfm" data-src="..."></a> with no data-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 as readme.
  • Writing the resolved path back does not survive a re-render: ![x](/gitlab-org/gitlab/-/raw/master/app/assets/images/logo.svg) renders with data-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-type the 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 new data-canonical-src examples 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 in spec/features/merge_request/user_edits_merge_request_spec.rb (the merge request form runs on a project with the test repository) types ![logo](files/images/logo-black.png) 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

Scenario Before After
Fix: repository image opened in rich text (the editor) 01_fix_repo_image_editor 01_fix_repo_image_editor
Fix: repository image, a word typed, saved (rendered description) 02_fix_repo_image_edit_saved 02_fix_repo_image_edit_saved
Fix: the same save, plain text source re-opened 03_fix_repo_image_source_after_edit_save 03_fix_repo_image_source_after_edit_save
Fix: two links to repository files, a word typed, saved 04_fix_repo_link_edit_saved 04_fix_repo_link_edit_saved
Fix: repository image in a table cell, a word typed, saved 05_fix_repo_image_in_table_edit_saved 05_fix_repo_image_in_table_edit_saved
Fix: repository image in a nested list item, a word typed, saved 06_fix_repo_image_in_nested_list_edit_saved 06_fix_repo_image_in_nested_list_edit_saved
Fix: linked repository image, a word typed, saved (before: saved with the resolved path, renders broken) 07_fix_linked_repo_image_edit_saved 07_fix_linked_repo_image_edit_saved
Edge: image with a title and markdown in the alt text, a word typed, saved (alt normalized as the backend renders it) 08_edge_title_and_markdown_alt_edit_saved 08_edge_title_and_markdown_alt_edit_saved
Edge: one paragraph with an issue reference, a repository link, a repository image and an upload, a word typed, saved 09_edge_mixed_paragraph_edit_saved 09_edge_mixed_paragraph_edit_saved
Edge: saved without typing anything (unchanged on both: the editor only re-serializes after a change) 10_edge_no_edit_save_keeps_source 10_edge_no_edit_save_keeps_source
Unchanged: uploaded image, a word typed, saved 11_same_upload_image_edit_saved 11_same_upload_image_edit_saved
Unchanged: image with an absolute URL, a word typed, saved 12_same_absolute_url_image_edit_saved 12_same_absolute_url_image_edit_saved
Unchanged: issue, user, label and merge request references, a word typed, saved 13_same_references_edit_saved 13_same_references_edit_saved

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)
rte_relative_image_before rte_relative_image_after
rte_linked_image_before rte_linked_image_after

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

  1. In a project with a repository (the gitlab-test repository used by create(:project, :repository) has files/images/logo-black.png), create an issue whose description is ![logo](files/images/logo-black.png) and [readme](README.md). The rendered description shows the image and the link.
  2. Select Edit title and description, then Switch to rich text editing. Before: no image, and readme renders as a reference chip. After: the image shows and readme is a link.
  3. 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 readme is plain text. After: both render, and the plain text source is the original markdown plus the typed word.
  4. Repeat with the image inside a table cell, inside a nested list item, and as a linked image [![logo](files/images/logo-black.png)](https://gitlab.com). Before, the linked image is saved with the resolved raw path and renders broken.
  5. Saving without an edit keeps the source on both versions: the editor only re-serializes the document after a change.
  6. 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.

Edited by Paul W

Merge request reports

Loading
Loading