mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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  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:
@@ -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.
|
||||
|
||||
84
packages/views/editor/extensions/file-card-markdown.test.ts
Normal file
84
packages/views/editor/extensions/file-card-markdown.test.ts
Normal 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("\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"');
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 `\n\n`;
|
||||
}
|
||||
return `\n\n`;
|
||||
},
|
||||
}).configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
|
||||
26
packages/views/editor/utils/escape-markdown-label.test.ts
Normal file
26
packages/views/editor/utils/escape-markdown-label.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
8
packages/views/editor/utils/escape-markdown-label.ts
Normal file
8
packages/views/editor/utils/escape-markdown-label.ts
Normal 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 `` / `!file[name](url)` output.
|
||||
*/
|
||||
export function escapeMarkdownLabel(text: string): string {
|
||||
return text.replace(/[[\]\\()]/g, (ch) => `\\${ch}`);
|
||||
}
|
||||
Reference in New Issue
Block a user