From 9cbe8a97d716e8ce65de4156886cdcea1d89663d Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:38:06 +0800 Subject: [PATCH] fix(editor): escape backslash in mention/slash labels for round-trip (MUL-4016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the de-ambiguation fix, addressing Howard's PR review. The linear tokenizer now treats "\" as an escape lead (\\.), so a label whose serialized form contains a bare "\" adjacent to the closing "]" no longer parses back — the "\]" is consumed as an escaped bracket and swallows the boundary. The old ambiguous regex tolerated this by chance; the de-ambiguation exposes it. mention/slash renderMarkdown escaped only [ and ], not \. Switch both to the shared escapeMarkdownLabel() (escapes [ ] \ ( )) and mirror it on parse with replace(/\\([[\]\\()])/g, "$1"), matching what file-card already does. This also converges the three tokenizers on one escape contract. file-card was already correct and is unchanged. Adds parameterized round-trip tests for labels containing "\" / "\]" / parens (e.g. "A\\", "ends\\", "a\\]b"); these fail on the old serializer and pass now. Co-Authored-By: Claude Fable 5 Co-authored-by: multica-agent --- .../editor/extensions/mention-extension.test.ts | 17 +++++++++++++++++ .../editor/extensions/mention-extension.ts | 15 ++++++++++----- .../extensions/slash-command-extension.test.ts | 10 ++++++++++ .../extensions/slash-command-extension.ts | 12 ++++++++---- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/views/editor/extensions/mention-extension.test.ts b/packages/views/editor/extensions/mention-extension.test.ts index 13fcadc6a..478d09424 100644 --- a/packages/views/editor/extensions/mention-extension.test.ts +++ b/packages/views/editor/extensions/mention-extension.test.ts @@ -45,6 +45,23 @@ describe("mention tokenizer", () => { expect(token!.attributes.type).toBe("agent"); }); + it.each(["A\\", "ends\\", "a\\]b", "f(x)", "back\\slash"])( + "round-trips a label containing backslash/parens: %j", + (label) => { + // The linear tokenizer treats "\" as an escape lead, so renderMarkdown + // must escape "\" too — otherwise a trailing "\" swallows the closing "]" + // and the mention fails to parse back (regression guard for the + // de-ambiguation fix). + const md = renderMarkdown({ + attrs: { id: "aaa-bbb", label, type: "member" }, + }); + const token = tokenize(md); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe(label); + expect(token!.attributes.id).toBe("aaa-bbb"); + }, + ); + it("does not match an ordinary Markdown link before a mention", () => { const src = "Check [docs](https://example.com) - [@User](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)"; diff --git a/packages/views/editor/extensions/mention-extension.ts b/packages/views/editor/extensions/mention-extension.ts index 2f3b68e07..40f7519ae 100644 --- a/packages/views/editor/extensions/mention-extension.ts +++ b/packages/views/editor/extensions/mention-extension.ts @@ -2,6 +2,7 @@ import Mention from "@tiptap/extension-mention"; import { mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { MentionView } from "./mention-view"; +import { escapeMarkdownLabel } from "../utils/escape-markdown-label"; const MENTION_LINK_MARKER = "](mention://"; @@ -90,8 +91,10 @@ export const BaseMentionExtension = Mention.extend({ /^\[@?((?:\\.|[^\]\\])+)\]\(mention:\/\/(\w+)\/([^)]+)\)/, ); if (!match) return undefined; - // Unescape backslash-escaped brackets that renderMarkdown may produce. - const rawLabel = match[1]?.replace(/\\\[/g, "[").replace(/\\\]/g, "]"); + // Reverse escapeMarkdownLabel: unescape \[ \] \\ \( \) that + // renderMarkdown produced. Must mirror the escaped set exactly, or a + // label containing "\" fails to round-trip through the linear tokenizer. + const rawLabel = match[1]?.replace(/\\([[\]\\()])/g, "$1"); return { type: "mention", raw: match[0], @@ -105,9 +108,11 @@ export const BaseMentionExtension = Mention.extend({ renderMarkdown: (node: any) => { const { id, label, type = "member" } = node.attrs || {}; const prefix = type === "issue" || type === "project" ? "" : "@"; - // Escape square brackets in the label so the markdown link syntax - // is not broken when the name contains [ or ] (e.g. "David[TF]"). - const safeLabel = (label ?? id).replace(/\[/g, "\\[").replace(/\]/g, "\\]"); + // Escape [ ] \ ( ) in the label so the markdown link syntax is not broken + // and the label survives the linear tokenizer (which now treats "\" as an + // escape lead, not an ordinary char). Must stay in sync with the unescape + // in tokenize() above. Shared with file-card/slash via escapeMarkdownLabel. + const safeLabel = escapeMarkdownLabel(label ?? id); return `[${prefix}${safeLabel}](mention://${type}/${id})`; }, }); diff --git a/packages/views/editor/extensions/slash-command-extension.test.ts b/packages/views/editor/extensions/slash-command-extension.test.ts index 43e17e6d0..a83c5b5d0 100644 --- a/packages/views/editor/extensions/slash-command-extension.test.ts +++ b/packages/views/editor/extensions/slash-command-extension.test.ts @@ -79,6 +79,16 @@ describe("slash command tokenizer", () => { expect(tokenize(md)?.attributes.label).toBe("deploy[prod]"); }); + it.each(["A\\", "ends\\", "a\\]b", "f(x)", "back\\slash"])( + "round-trips a label containing backslash/parens: %j", + (label) => { + // renderMarkdown must escape "\" so a trailing "\" does not swallow the + // closing "]" under the linear tokenizer (regression guard). + const md = renderMarkdown({ attrs: { id: "skill-1", label } }); + expect(tokenize(md)?.attributes.label).toBe(label); + }, + ); + it("does not match ordinary markdown links", () => { expect(tokenize("[docs](https://example.com)")).toBeUndefined(); }); diff --git a/packages/views/editor/extensions/slash-command-extension.ts b/packages/views/editor/extensions/slash-command-extension.ts index b742bfb98..b232d1892 100644 --- a/packages/views/editor/extensions/slash-command-extension.ts +++ b/packages/views/editor/extensions/slash-command-extension.ts @@ -3,6 +3,7 @@ import { mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { SlashCommandView } from "./slash-command-view"; import { formatSlashCommandLabel } from "./slash-command-utils"; +import { escapeMarkdownLabel } from "../utils/escape-markdown-label"; export const SlashCommandExtension = Mention.extend({ name: "slashCommand", @@ -45,7 +46,10 @@ export const SlashCommandExtension = Mention.extend({ /^\[\/((?:\\.|[^\]\\])+)\]\(slash:\/\/skill\/([^)]+)\)/, ); if (!match) return undefined; - const rawLabel = match[1]?.replace(/\\\[/g, "[").replace(/\\\]/g, "]"); + // Reverse escapeMarkdownLabel: unescape \[ \] \\ \( \). Must mirror the + // escaped set exactly, or a label containing "\" fails to round-trip + // through the linear tokenizer. + const rawLabel = match[1]?.replace(/\\([[\]\\()])/g, "$1"); return { type: "slashCommand", raw: match[0], @@ -60,9 +64,9 @@ export const SlashCommandExtension = Mention.extend({ renderMarkdown: (node: any) => { const { id, label } = node.attrs || {}; - const safeLabel = formatSlashCommandLabel(label) - .replace(/\[/g, "\\[") - .replace(/\]/g, "\\]"); + // Escape [ ] \ ( ) so the label survives the linear tokenizer; must stay in + // sync with the unescape in tokenize() above. + const safeLabel = escapeMarkdownLabel(formatSlashCommandLabel(label)); return `[/${safeLabel}](slash://skill/${id})`; }, });