mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
@@ -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)";
|
||||
|
||||
@@ -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})`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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})`;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user