fix: escape special chars in image alt and file-card filename (MUL-2899) (#3644)

* fix: escape special chars in image alt and file-card filename during Markdown serialization

Filenames containing Markdown label characters ([, ], \, (, )) broke
the ![alt](url) and !file[name](url) syntax, causing raw Markdown to
render instead of the image/file card.

- Add shared escapeMarkdownLabel utility
- Apply escaping in file-card renderMarkdown
- Add renderMarkdown to ImageExtension for alt text escaping
- Add regression tests

Closes #3616

Co-authored-by: multica-agent <github@multica.ai>

* fix: address review — fix tokenizer regex, unescape labels, add regression tests

- Remove unused tokenizeFn (TS6133)
- Change file-card regex to (?:\\.|[^\]])* to handle escaped brackets
- Unescape labels in tokenize() and preprocessFileCards()
- Export ImageExtension for testability
- Rewrite tests: 3 describe blocks covering ImageExtension.renderMarkdown,
  file-card tokenizer round-trip, and preprocessFileCards (6 tests total)
- typecheck and vitest both pass

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
LinYushen
2026-06-02 14:33:45 +08:00
committed by GitHub
parent 1aa742053b
commit d013a31db9
6 changed files with 137 additions and 6 deletions

View File

@@ -35,7 +35,7 @@ export function isAllowedFileCardHref(href: string): boolean {
/** New syntax: !file[name](url) — unambiguous, no hostname matching needed. */
const NEW_FILE_CARD_RE = new RegExp(
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)$`,
`^!file\\[((?:\\\\.|[^\\]])*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)$`,
)
/** Legacy syntax: [name](cdnUrl) on its own line — matched by CDN hostname. */
@@ -94,7 +94,8 @@ export function preprocessFileCards(markdown: string, cdnDomain: string): string
// New syntax: !file[name](url) — always a file card, no hostname check needed.
const newMatch = trimmed.match(NEW_FILE_CARD_RE)
if (newMatch) {
return toFileCardHtml(newMatch[1]!, newMatch[2]!)
const filename = newMatch[1]!.replace(/\\([[\]\\()])/g, '$1')
return toFileCardHtml(filename, newMatch[2]!)
}
// Legacy: [name](cdnUrl) on its own line — CDN hostname matching.

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { FileCardExtension } from "./file-card";
import { ImageExtension } from "./index";
import { preprocessFileCards } from "@multica/ui/markdown";
const fileCardRenderMarkdown = FileCardExtension.config.renderMarkdown as (
node: { attrs: Record<string, string> },
) => string;
const tokenizer = FileCardExtension.config.markdownTokenizer!;
const tokenize = tokenizer.tokenize as (
src: string,
) => { type: string; raw: string; attributes: Record<string, string> } | undefined;
const imageRenderMarkdown = ImageExtension.config.renderMarkdown as (
node: { attrs: Record<string, string> },
) => string;
// ---------------------------------------------------------------------------
// ImageExtension.renderMarkdown
// ---------------------------------------------------------------------------
describe("ImageExtension.renderMarkdown", () => {
it("escapes special chars in alt text", () => {
const md = imageRenderMarkdown({
attrs: { src: "https://cdn.example.com/img.png", alt: "6P4N\\`X[A~Z(S@XO}WE0FT_P.jpg" },
});
expect(md).toContain("\\\\");
expect(md).toContain("\\[");
expect(md).toContain("\\(");
expect(md).toMatch(/^!\[.*\]\(https:\/\/cdn\.example\.com\/img\.png\)\n\n$/);
});
it("leaves normal alt text unchanged", () => {
const md = imageRenderMarkdown({
attrs: { src: "https://cdn.example.com/img.png", alt: "screenshot" },
});
expect(md).toBe("![screenshot](https://cdn.example.com/img.png)\n\n");
});
});
// ---------------------------------------------------------------------------
// file-card tokenizer round-trip
// ---------------------------------------------------------------------------
describe("file-card tokenizer", () => {
it("round-trips a filename with all special chars", () => {
const filename = "report[final](v2)\\draft.pdf";
const md = fileCardRenderMarkdown({
attrs: { href: "https://cdn.example.com/f.pdf", filename },
});
const token = tokenize(md);
expect(token).toBeDefined();
expect(token!.attributes.filename).toBe(filename);
expect(token!.attributes.href).toBe("https://cdn.example.com/f.pdf");
});
it("round-trips a normal filename", () => {
const md = fileCardRenderMarkdown({
attrs: { href: "https://cdn.example.com/readme.md", filename: "readme.md" },
});
const token = tokenize(md);
expect(token).toBeDefined();
expect(token!.attributes.filename).toBe("readme.md");
});
});
// ---------------------------------------------------------------------------
// preprocessFileCards
// ---------------------------------------------------------------------------
describe("preprocessFileCards", () => {
it("converts escaped file-card syntax and unescapes the filename", () => {
const input = "!file[notes\\[v2\\]\\(draft\\).txt](https://cdn.example.com/notes.txt)";
const result = preprocessFileCards(input, "cdn.example.com");
expect(result).toContain('data-type="fileCard"');
expect(result).toContain('data-filename="notes[v2](draft).txt"');
expect(result).toContain('data-href="https://cdn.example.com/notes.txt"');
});
it("converts a normal file-card syntax", () => {
const input = "!file[readme.md](https://cdn.example.com/readme.md)";
const result = preprocessFileCards(input, "cdn.example.com");
expect(result).toContain('data-type="fileCard"');
expect(result).toContain('data-filename="readme.md"');
});
});

View File

@@ -18,10 +18,11 @@ import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { FILE_CARD_URL_PATTERN } from "@multica/ui/markdown";
import { escapeMarkdownLabel } from "../utils/escape-markdown-label";
import { Attachment } from "../attachment";
const FILE_CARD_MARKDOWN_RE = new RegExp(
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)`,
`^!file\\[((?:\\\\.|[^\\]])*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)`,
);
@@ -117,10 +118,11 @@ export const FileCardExtension = Node.create({
tokenize(src: string) {
const match = src.match(FILE_CARD_MARKDOWN_RE);
if (!match) return undefined;
const filename = (match[1] ?? "").replace(/\\([[\]\\()])/g, "$1");
return {
type: "fileCard",
raw: match[0],
attributes: { filename: match[1], href: match[2] },
attributes: { filename, href: match[2] },
};
},
},
@@ -129,7 +131,7 @@ export const FileCardExtension = Node.create({
},
renderMarkdown: (node: any) => {
const { href, filename } = node.attrs || {};
return `!file[${filename || "file"}](${href})`;
return `!file[${escapeMarkdownLabel(filename || "file")}](${href})`;
},
addNodeView() {

View File

@@ -35,6 +35,7 @@ import { Markdown } from "@tiptap/markdown";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { AnyExtension } from "@tiptap/core";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { escapeMarkdownLabel } from "../utils/escape-markdown-label";
import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
@@ -57,7 +58,7 @@ const LinkExtension = Link.extend({ inclusive: false }).configure({
defaultProtocol: "https",
});
const ImageExtension = Image.extend({
export const ImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
@@ -72,6 +73,15 @@ const ImageExtension = Image.extend({
addNodeView() {
return ReactNodeViewRenderer(ImageView);
},
renderMarkdown: (node: any) => {
const src = node.attrs?.src || "";
const alt = escapeMarkdownLabel(node.attrs?.alt || "");
const title = node.attrs?.title;
if (title) {
return `![${alt}](${src} "${title}")\n\n`;
}
return `![${alt}](${src})\n\n`;
},
}).configure({
inline: false,
allowBase64: false,

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { escapeMarkdownLabel } from "./escape-markdown-label";
describe("escapeMarkdownLabel", () => {
it("escapes [ and ]", () => {
expect(escapeMarkdownLabel("photo[1].png")).toBe("photo\\[1\\].png");
});
it("escapes backslash", () => {
expect(escapeMarkdownLabel("a\\b")).toBe("a\\\\b");
});
it("escapes ( and )", () => {
expect(escapeMarkdownLabel("file(1).txt")).toBe("file\\(1\\).txt");
});
it("escapes all special chars together", () => {
expect(escapeMarkdownLabel("6P4N\\`X[A~Z(S@XO}WE0FT_P.jpg")).toBe(
"6P4N\\\\`X\\[A~Z\\(S@XO}WE0FT_P.jpg",
);
});
it("leaves normal text unchanged", () => {
expect(escapeMarkdownLabel("hello world.png")).toBe("hello world.png");
});
});

View File

@@ -0,0 +1,8 @@
/**
* Escape characters that break Markdown link/image label syntax: [ ] \ ( )
* Used by image and file-card renderMarkdown to prevent raw filenames from
* corrupting the `![alt](url)` / `!file[name](url)` output.
*/
export function escapeMarkdownLabel(text: string): string {
return text.replace(/[[\]\\()]/g, (ch) => `\\${ch}`);
}