Draft: Fix reference spacing, intraword emphasis and quoted references on rich text editor save

What does this MR do and why?

Saving a description from the rich text editor after any edit rewrites three kinds of markdown anywhere in the document. An untouched save is safe, because the editor only serializes once something changes.

  1. Adjacent references gain a space. #1/#2 saves as #1/ #2, and so do #1-#2, @root/@user and see #1/#2, and !1.. The space shows in the rendered text too. ensureSpace added a space before every reference unless the preceding character was whitespace, a bracket, a quote or a mark delimiter. The parser only needs it where the neighbouring text would otherwise be absorbed into the reference, so the rule is inverted to exactly those cases: after a letter, digit or underscore (abc#1), or a path-like word ending in a hyphen or dot (foo-#1, v1.#1) unless that punctuation follows another reference, and after a reference when the following text would extend it (#1abc, ~bugx, @root.x, %17.5x).
  2. Intraword emphasis stops rendering. a*b*c saves as a_b_c, which CommonMark renders literally, because an underscore cannot open or close emphasis next to a letter or digit. The italic serializer now writes * when the run touches a letter or digit on either side and keeps _ elsewhere. The check is Unicode-aware (é_b_c is literal too), and the delimiter chosen at open is carried on serializer state for the close, as the link serializer does with inAutolink.
  3. Quoted label and milestone references save as HTML entities. ~"UX Paper Cuts" becomes ~&quot;UX Paper Cuts&quot;. It still renders, but the source is unreadable in the plain text editor, in diffs and in history. data_attributes_for stores data-original HTML-escaped and the extension read it raw. It now decodes it, and the writer escapes < and > the way the text serializer does, so a name with angle brackets writes as ~"a \<b\> c" rather than becoming a tag.

Four commits: one per defect, plus a fourth hardening the spacing rule on both sides of a reference. Each carries the specs that fail at the merge base.

Unchanged: references separated by spaces, in brackets or quotes, or alone inside a mark (**#1**, _#1_); a word typed directly before a chip still saves as abc #1; ordinary italics and italics next to spaces or punctuation; bold followed by italics (**a**_b_); unquoted labels (~bug); references without data-original keep a null original text.

Resolves #628280

Evidence

  • Verified on the production renderer, not on fixtures. Every claim above was reproduced by feeding POST /api/v4/markdown output through the editor's own deserializer and serializer, then rendering the result back: #1/#2 gives #1/ #2, a*b*c gives a_b_c which renders as literal text, ~"UX Paper Cuts" gives the entity form.
  • The parser's real boundaries were mapped against the same renderer rather than assumed, roughly 50 shapes on both sides of the rule. Full list in the collapsed block below; it is what the spacing rule is derived from.
  • Jest: 21 new examples fail at the merge base and pass with their commit (5 reference, 8 italic, 8 across the extension and label serializer specs). The fourth commit adds 17 spacing cases, 10 of which fail at the merge base. Touched specs 75/75 under Vue 2 and Vue 3; content_editor tree 1247/1247 under Vue 2.
  • Twelve matrix rows and two GIF stories below. Two of the fourteen planned rows have no master-side still and are not shown; see the caption.
Parser boundaries as measured on the production renderer

Parser boundaries checked with the same renderer: both references render for #1#2, #1-#2, #1/#2, #1,#2, #1.#2, #1;#2, #1:#2, (#1)#2, #1|#2, #1=#2, #1+#2, %"17.5"#1, ~"UX Paper Cuts"#1 and @root+#1; the second reference is lost after a letter, digit or underscore (abc#1, 1#1, _#1, ~bug#1, @root#1, !1#1); é#1 and 日本#1 render. é_b_c stays literal while é*b*c renders. Rendered on 2026-09-09 for the spacing rule: foo-#1, v1.#1, x-y-#1, a.b#1, x_#1, #1abc, #1x, #1_x, ~bugx, ~bug?x, @root.x, @root-x and %17.5x lose the reference (@rootx resolves to a different user), while foo- #1, #1 abc, #1.x, #1-x, #1/x, #1é, #1**x**, #1*x*, ~bug -#1, ~"UX Paper Cuts"abc and #1.#2 keep it. a**_b_**c renders without the bold, a***b***c renders both. ~"a <b> c" written raw is parsed as an HTML tag, ~"a \<b\> c" renders as text.

Origins, and the two shapes the first version missed

Origins: the exception list comes from Fix reference serialization in RTE (!165017), which narrowed an earlier always-add-a-space rule; the underscore delimiter from Preserve bold, italic, code, and link syntax in the Content Editor (!87157); data-original has been read undecoded since Render references in content editor (!68230).

The fourth commit covers two shapes the first version left unhandled: a hyphenated word typed directly before a chip (foo- then #1) saved as foo-#1, which the renderer reads as a project path; and a word typed directly after a chip saved as #1abc, which the renderer does not read as a reference. Both now save with the protecting space. The italic form of an edge shared with Keep formatting marks in one span around links in the rich text editor (!254236) is tracked in issue 628260.

Screenshots or screen recordings

Scenario Before After
Fix: adjacent references #1/#2 (saved markdown) 01_fix_adjacent_references_source 01_fix_adjacent_references_source
Fix: adjacent references #1/#2 (rendered) 02_fix_adjacent_references_rendered 02_fix_adjacent_references_rendered
Fix: references before punctuation see #1/#2, and !1. (saved markdown) 03_fix_references_before_punctuation_source 03_fix_references_before_punctuation_source
Fix: emphasis inside a word (saved markdown) 05_fix_intraword_italics_source 05_fix_intraword_italics_source
Fix: quoted label ~"UX Paper Cuts" (saved markdown) 06_fix_quoted_label_source 06_fix_quoted_label_source
Fix: quoted milestone %"Sprint 1" (saved markdown) 07_fix_quoted_milestone_source 07_fix_quoted_milestone_source
Edge: abc typed directly before the #1 chip keeps its separating space on both sides (saved markdown) 08_edge_word_typed_before_reference_source 08_edge_word_typed_before_reference_source
Edge: label with an ampersand ~"Docs & Guides" (saved markdown) 09_edge_label_with_ampersand_source 09_edge_label_with_ampersand_source
Edge: the same three cases in a nested list item and a table cell (rendered) 10_edge_nested_list_and_table_rendered 10_edge_nested_list_and_table_rendered
Edge: nested list item and table cell (saved markdown) 11_edge_nested_list_and_table_source 11_edge_nested_list_and_table_source
Unchanged: spaced references, brackets, quotes and an unquoted label ~bug (saved markdown) 13_same_spaced_references_source 13_same_spaced_references_source
Unchanged: the same, rendered 14_same_spaced_references_rendered 14_same_spaced_references_rendered

Every row is a description edited and saved in the rich text editor in real headless Chrome (a word typed into the plain first line, or for row 7 typed directly before the chip; the editor serializes only after an edit), on a project seeded with the issues, merge request, labels and milestone the references resolve against. Saved-markdown rows show the description re-opened in the plain text editor after the save. The two Unchanged rows are byte-identical between before and after. Two rows of the fourteen-row matrix have no master-side still yet and are not shown: the rendered intraword emphasis row (a*b*c and 2*3*4; its saved markdown is row 4 here, and the nested row shows the rendered loss) and the unchanged ordinary emphasis row (_word_ and **bold** and _two words_ here; covered by the jest examples). Their master-side captures timed out waiting for the editor to mount under machine load in the only complete run and will be re-captured. Head 7e97cf33 against master 26225e7e.

Recordings

Story Before After
Emphasis inside a word a*b*c and 2*3*4: identical until the save, then the before renders literal underscores and saves a_b_c and 2_3_4 intraword_italics_before intraword_italics_after
Quoted label ~"UX Paper Cuts": identical through the save (the entity form still renders as a chip), then the before shows ~&quot;UX Paper Cuts&quot; in the plain text editor quoted_label_before quoted_label_after

Four beats per side: rendered from plain text, the editor open with a word typed into its first line, rendered after the save, and the saved markdown reopened in plain text. Frames 1 and 2 are byte-identical between the sides; the quoted label story stays identical through frame 3.

How to set up and validate locally

  1. In a project with two issues, a merge request, a label with a space in its name and a milestone, set an issue description in the plain text editor to:

    Either #1/#2 fixes it, see !1.
    
    a*b*c and 2*3*4
    
    Tracked under ~"UX Paper Cuts" for %"Sprint 1".
  2. Edit it, switch to rich text, type a word anywhere, and save.

  3. Before: the description reads Either #1/ #2 fixes it, shows a_b_c and 2_3_4 as literal text, and the plain text source shows ~&quot;UX Paper Cuts&quot;. After: the description and its source are what was typed.

  4. Regressions: #1 and #2 with ~bug, (#1) or "#2" and _word_ and **bold** and _two words_ here save unchanged after the same edit, and typing a word directly before a chip still saves with a separating space (abc #1).

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