diff --git a/package.json b/package.json index 7fb2b9cf2..35e171c35 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev --turbopack --port 3000", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:mermaid": "node tests/mermaid-preprocess.test.mjs" }, "dependencies": { "mermaid": "^11.4.1", diff --git a/src/components/Mermaid.tsx b/src/components/Mermaid.tsx index a201c98bb..30c5f2083 100644 --- a/src/components/Mermaid.tsx +++ b/src/components/Mermaid.tsx @@ -2,6 +2,104 @@ import React, { useEffect, useRef, useState } from 'react'; import mermaid from 'mermaid'; // We'll use dynamic import for svg-pan-zoom +function preprocessMermaidLabels(input: string): string { + const normalizeInner = (rawInner: string): string => { + const trimmedInner = rawInner.trim(); + const isAlreadyQuoted = + trimmedInner.length >= 2 && + trimmedInner.startsWith('"') && + trimmedInner.endsWith('"'); + + const quotedInnerMatch = rawInner.match(/^\s*"(.*)"\s*$/); + const unquotedInner = isAlreadyQuoted && quotedInnerMatch ? quotedInnerMatch[1] : rawInner; + return unquotedInner.replace(/"/g, '"'); + }; + + const processLine = (line: string): string => { + let out = ''; + let i = 0; + let inQuotes = false; + + const findMatchingClose = (openChar: '(' | '[' | '{', closeChar: ')' | ']' | '}'): number => { + let depth = 0; + let j = i; + let innerInQuotes = false; + + while (j < line.length) { + const ch = line[j]; + + if (ch === '"') { + innerInQuotes = !innerInQuotes; + j += 1; + continue; + } + + if (innerInQuotes) { + j += 1; + continue; + } + + if (ch === openChar) { + depth += 1; + } else if (ch === closeChar) { + depth -= 1; + if (depth === 0) return j; + } + + j += 1; + } + + return -1; + }; + + while (i < line.length) { + const ch = line[i]; + + if (ch === '"') { + inQuotes = !inQuotes; + out += ch; + i += 1; + continue; + } + + if (!inQuotes && ch === '|') { + const end = line.indexOf('|', i + 1); + if (end !== -1) { + const inner = line.slice(i + 1, end); + const escapedInner = normalizeInner(inner); + out += `|"${escapedInner}"|`; + i = end + 1; + continue; + } + } + + if (!inQuotes && (ch === '(' || ch === '[' || ch === '{')) { + const openChar = ch as '(' | '[' | '{'; + const closeChar: ')' | ']' | '}' = openChar === '(' ? ')' : openChar === '[' ? ']' : '}'; + const end = findMatchingClose(openChar, closeChar); + + if (end !== -1) { + const inner = line.slice(i + 1, end); + const escapedInner = normalizeInner(inner); + out += `${openChar}"${escapedInner}"${closeChar}`; + i = end + 1; + continue; + } + } + + out += ch; + i += 1; + } + + return out; + }; + + return input + .split('\n') + .map((line) => processLine(line)) + .join('\n'); +} + // Initialize mermaid with defaults - Japanese aesthetic mermaid.initialize({ startOnLoad: true, @@ -365,8 +463,10 @@ const Mermaid: React.FC = ({ chart, className = '', zoomingEnabled setError(null); setSvg(''); + const preprocessedChart = preprocessMermaidLabels(chart); + // Render the chart directly without preprocessing - const { svg: renderedSvg } = await mermaid.render(idRef.current, chart); + const { svg: renderedSvg } = await mermaid.render(idRef.current, preprocessedChart); if (!isMounted) return; @@ -392,7 +492,7 @@ const Mermaid: React.FC = ({ chart, className = '', zoomingEnabled if (mermaidRef.current) { mermaidRef.current.innerHTML = `
Syntax error in diagram
-
${chart}
+
${preprocessMermaidLabels(chart)}
`; } } diff --git a/tests/mermaid-preprocess.test.js b/tests/mermaid-preprocess.test.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/tests/mermaid-preprocess.test.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/tests/mermaid-preprocess.test.mjs b/tests/mermaid-preprocess.test.mjs new file mode 100644 index 000000000..21e8e673f --- /dev/null +++ b/tests/mermaid-preprocess.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { doubleWrap } = require('../temp/temp.js'); + +const input = ` +flowchart TD + Functioncall -->|GetMoney(Number amount) and| B(Go shopping) + Functioncall -->|"Already quoted"| B("Already quoted") + Functioncall -->|"Already quoted"| B("Function(string s) quoted") + Functioncall -->|"Already quoted"| B{"Function{string s} quoted"} + Functioncall -->|"Already quoted"| B["Function[string s] quoted"] + Functioncall -->|"Already quoted"| B(Function(string s) quoted) + Functioncall -->|"Already quoted"| B{Function{string s} quoted} + Functioncall -->|"Already quoted"| B[Function[string s] quoted] + A("Test") -->|"Already quoted"| B("Function(string s) quoted") + A("Test") -->|"Already quoted"| B{"Function{string s} quoted"} + A("Test") -->|"Already quoted"| B["Function[string s] quoted"] + A(Test) -->|"Already quoted"| B(Function(string s) quoted) + A(Test) -->|"Already quoted"| B{Function{string s} quoted} + A(Test) -->|"Already quoted"| B[Function[string s] quoted] + B --> C{Thinking(Number time)} + C -->|"One"| D[La"ptop] + C -->|Two| E["iPhone"] + C -->|Three| F[fa:fa-car Car] +`; + +const expected = ` +flowchart TD + Functioncall -->|"GetMoney(Number amount) and"| B("Go shopping") + Functioncall -->|"Already quoted"| B("Already quoted") + Functioncall -->|"Already quoted"| B("Function(string s) quoted") + Functioncall -->|"Already quoted"| B{"Function{string s} quoted"} + Functioncall -->|"Already quoted"| B["Function[string s] quoted"] + Functioncall -->|"Already quoted"| B("Function(string s) quoted") + Functioncall -->|"Already quoted"| B{"Function{string s} quoted"} + Functioncall -->|"Already quoted"| B["Function[string s] quoted"] + A("Test") -->|"Already quoted"| B("Function(string s) quoted") + A("Test") -->|"Already quoted"| B{"Function{string s} quoted"} + A("Test") -->|"Already quoted"| B["Function[string s] quoted"] + A("Test") -->|"Already quoted"| B("Function(string s) quoted") + A("Test") -->|"Already quoted"| B{"Function{string s} quoted"} + A("Test") -->|"Already quoted"| B["Function[string s] quoted"] + B --> C{"Thinking(Number time)"} + C -->|"One"| D[La"ptop] + C -->|"Two"| E["iPhone"] + C -->|"Three"| F["fa:fa-car Car"] +`; + +try { + const actual = doubleWrap(input); + assert.equal(actual, expected); + process.stdout.write('mermaid-preprocess: ok\n'); +} catch (err) { + process.stderr.write('mermaid-preprocess: failed\n'); + process.stderr.write(String(err) + '\n'); + process.exitCode = 1; +}