From d013a31db94386b47fd00549b2094dabcbd8460d Mon Sep 17 00:00:00 2001 From: LinYushen Date: Tue, 2 Jun 2026 14:33:45 +0800 Subject: [PATCH] fix: escape special chars in image alt and file-card filename (MUL-2899) (#3644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: multica-agent --- packages/ui/markdown/file-cards.ts | 5 +- .../extensions/file-card-markdown.test.ts | 84 +++++++++++++++++++ .../views/editor/extensions/file-card.tsx | 8 +- packages/views/editor/extensions/index.ts | 12 ++- .../utils/escape-markdown-label.test.ts | 26 ++++++ .../editor/utils/escape-markdown-label.ts | 8 ++ 6 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 packages/views/editor/extensions/file-card-markdown.test.ts create mode 100644 packages/views/editor/utils/escape-markdown-label.test.ts create mode 100644 packages/views/editor/utils/escape-markdown-label.ts diff --git a/packages/ui/markdown/file-cards.ts b/packages/ui/markdown/file-cards.ts index f33e05dc8..ed4863389 100644 --- a/packages/ui/markdown/file-cards.ts +++ b/packages/ui/markdown/file-cards.ts @@ -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. diff --git a/packages/views/editor/extensions/file-card-markdown.test.ts b/packages/views/editor/extensions/file-card-markdown.test.ts new file mode 100644 index 000000000..1689c192c --- /dev/null +++ b/packages/views/editor/extensions/file-card-markdown.test.ts @@ -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; + +const tokenizer = FileCardExtension.config.markdownTokenizer!; +const tokenize = tokenizer.tokenize as ( + src: string, +) => { type: string; raw: string; attributes: Record } | undefined; + +const imageRenderMarkdown = ImageExtension.config.renderMarkdown as ( + node: { attrs: Record }, +) => 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"'); + }); +}); diff --git a/packages/views/editor/extensions/file-card.tsx b/packages/views/editor/extensions/file-card.tsx index 2999a451c..acdf7b398 100644 --- a/packages/views/editor/extensions/file-card.tsx +++ b/packages/views/editor/extensions/file-card.tsx @@ -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() { diff --git a/packages/views/editor/extensions/index.ts b/packages/views/editor/extensions/index.ts index ae50a1284..557978141 100644 --- a/packages/views/editor/extensions/index.ts +++ b/packages/views/editor/extensions/index.ts @@ -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, diff --git a/packages/views/editor/utils/escape-markdown-label.test.ts b/packages/views/editor/utils/escape-markdown-label.test.ts new file mode 100644 index 000000000..19a20333e --- /dev/null +++ b/packages/views/editor/utils/escape-markdown-label.test.ts @@ -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"); + }); +}); diff --git a/packages/views/editor/utils/escape-markdown-label.ts b/packages/views/editor/utils/escape-markdown-label.ts new file mode 100644 index 000000000..ec7c08d1d --- /dev/null +++ b/packages/views/editor/utils/escape-markdown-label.ts @@ -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}`); +}