fix(editor): escape backslash in mention/slash labels for round-trip (MUL-4016)

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 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-03 18:38:06 +08:00
parent 63893cc41f
commit 9cbe8a97d7
4 changed files with 45 additions and 9 deletions

View File

@@ -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)";

View File

@@ -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})`;
},
});

View File

@@ -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();
});

View File

@@ -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})`;
},
});