diff --git a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts index ba7c77c9b8..092c530b85 100644 --- a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts +++ b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts @@ -14,6 +14,7 @@ import { MIN_SCALE, MAX_SCALE, getSvgDimensions, + sanitizeMermaidCode, } from "@/components/shared/mermaid-utils"; /** @@ -239,8 +240,9 @@ class MermaidNodeView implements NodeView { if (!mermaidInitialized) initMermaid(); if (!code.trim()) return; const id = `mermaid-${++mermaidIdCounter}`; + const sanitizedCode = sanitizeMermaidCode(code); mermaid - .render(id, code) + .render(id, sanitizedCode) .then(({ svg }) => { this.svgContainer.innerHTML = svg; this.updateSizeWrapper(); diff --git a/apps/web/components/shared/mermaid-block.tsx b/apps/web/components/shared/mermaid-block.tsx index e85c3d9cc5..0c70f803fe 100644 --- a/apps/web/components/shared/mermaid-block.tsx +++ b/apps/web/components/shared/mermaid-block.tsx @@ -3,7 +3,14 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { useTheme } from "next-themes"; import { IconZoomIn, IconZoomOut, IconCode } from "@tabler/icons-react"; -import { DEFAULT_SCALE, SCALE_STEP, MIN_SCALE, MAX_SCALE, getSvgDimensions } from "./mermaid-utils"; +import { + DEFAULT_SCALE, + SCALE_STEP, + MIN_SCALE, + MAX_SCALE, + getSvgDimensions, + sanitizeMermaidCode, +} from "./mermaid-utils"; type MermaidAPI = typeof import("mermaid").default; @@ -36,10 +43,12 @@ export function MermaidBlock({ code }: MermaidBlockProps) { const theme = resolvedTheme === "dark" ? "dark" : "default"; const id = `mermaid-md-${++mermaidIdCounter}`; + const sanitizedCode = sanitizeMermaidCode(code); + getMermaid() .then((mermaid) => { mermaid.initialize({ startOnLoad: false, theme, securityLevel: "loose" }); - return mermaid.render(id, code); + return mermaid.render(id, sanitizedCode); }) .then(({ svg }) => { if (!cancelled && containerRef.current) { diff --git a/apps/web/components/shared/mermaid-utils.ts b/apps/web/components/shared/mermaid-utils.ts index 14dd084841..b00c205d6b 100644 --- a/apps/web/components/shared/mermaid-utils.ts +++ b/apps/web/components/shared/mermaid-utils.ts @@ -21,3 +21,51 @@ export function getSvgDimensions(container: HTMLElement): { w: number; h: number if (w > 0 && h > 0) return { w, h }; return null; } + +/** + * Characters that require quoting in mermaid node/edge labels. + * Includes: $, #, &, /, and other special chars that cause lexical errors. + */ +const SPECIAL_CHARS_RE = /[$#&/\\<>{}]/; + +/** + * Preprocesses mermaid code to quote text containing special characters. + * Mermaid requires quotes around text with special chars like $, /, etc. + * + * Handles: + * - Node labels: A[text] -> A["text"] if text contains special chars + * - Edge labels: -->|text| -> -->|"text"| if text contains special chars + * - Subgraph titles: subgraph text -> subgraph "text" if text contains special chars + */ +export function sanitizeMermaidCode(code: string): string { + // Quote node labels: [text] or (text) or {text} or ([text]) or [[text]] etc. + // Match brackets that aren't already quoted + let result = code.replace(/(\[+)([^\]"]+?)(\]+)/g, (match, open, text, close) => { + if (SPECIAL_CHARS_RE.test(text) && !text.startsWith('"')) { + return `${open}"${text}"${close}`; + } + return match; + }); + + // Quote edge labels: |text| + result = result.replace(/\|([^|"]+?)\|/g, (match, text) => { + if (SPECIAL_CHARS_RE.test(text) && !text.startsWith('"')) { + return `|"${text}"|`; + } + return match; + }); + + // Quote parentheses labels: (text) for stadium/circle nodes + result = result.replace(/(\(+)([^)"]+?)(\)+)/g, (match, open, text, close) => { + // Skip if it looks like a subgraph or keyword + if (/^(subgraph|end|graph|flowchart|sequenceDiagram)\b/.test(text.trim())) { + return match; + } + if (SPECIAL_CHARS_RE.test(text) && !text.startsWith('"')) { + return `${open}"${text}"${close}`; + } + return match; + }); + + return result; +}