From 06934706e77e87e51d4644f9c4dd98ba7bafb681 Mon Sep 17 00:00:00 2001 From: danielfrey63 Date: Fri, 26 Dec 2025 23:56:45 +0100 Subject: [PATCH 1/3] fix: Mermaid display bug fix --- src/components/Mermaid.tsx | 46 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/components/Mermaid.tsx b/src/components/Mermaid.tsx index a201c98bb..fe3df31b3 100644 --- a/src/components/Mermaid.tsx +++ b/src/components/Mermaid.tsx @@ -2,6 +2,46 @@ import React, { useEffect, useRef, useState } from 'react'; import mermaid from 'mermaid'; // We'll use dynamic import for svg-pan-zoom +const MERMAID_LABELS_RE = /(\|)([^|\n]+)(\|)|(\()([^()\n]+)(\))|(\[)([^\[\]\n]+)(\])|(\{)([^{}\n]+)(\})/g; + +function preprocessMermaidLabels(input: string): string { + return input.replace( + MERMAID_LABELS_RE, + ( + match, + o1: string | undefined, + i1: string | undefined, + c1: string | undefined, + o2: string | undefined, + i2: string | undefined, + c2: string | undefined, + o3: string | undefined, + i3: string | undefined, + c3: string | undefined, + o4: string | undefined, + i4: string | undefined, + c4: string | undefined + ) => { + const open = o1 ?? o2 ?? o3 ?? o4; + const inner = i1 ?? i2 ?? i3 ?? i4; + const close = c1 ?? c2 ?? c3 ?? c4; + + if (!open || !close || inner == null) return match; + + const trimmedInner = inner.trim(); + const isAlreadyQuoted = + trimmedInner.length >= 2 && + trimmedInner.startsWith('"') && + trimmedInner.endsWith('"'); + + const quotedInnerMatch = inner.match(/^\s*"(.*)"\s*$/); + const unquotedInner = isAlreadyQuoted && quotedInnerMatch ? quotedInnerMatch[1] : inner; + const escapedInner = unquotedInner.replace(/"/g, '"'); + return `${open}"${escapedInner}"${close}`; + } + ); +} + // Initialize mermaid with defaults - Japanese aesthetic mermaid.initialize({ startOnLoad: true, @@ -365,8 +405,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 +434,7 @@ const Mermaid: React.FC = ({ chart, className = '', zoomingEnabled if (mermaidRef.current) { mermaidRef.current.innerHTML = `
Syntax error in diagram
-
${chart}
+
${preprocessMermaidLabels(chart)}
`; } } From 0f3505cb0e09eaa71c56c7e906ced0786aa60adf Mon Sep 17 00:00:00 2001 From: danielfrey63 Date: Sat, 27 Dec 2025 11:32:28 +0100 Subject: [PATCH 2/3] fix(mermaid): normalize labels and escape quotes --- src/components/Mermaid.tsx | 130 +++++++++++++++++++++++++++---------- 1 file changed, 94 insertions(+), 36 deletions(-) diff --git a/src/components/Mermaid.tsx b/src/components/Mermaid.tsx index fe3df31b3..30c5f2083 100644 --- a/src/components/Mermaid.tsx +++ b/src/components/Mermaid.tsx @@ -2,44 +2,102 @@ import React, { useEffect, useRef, useState } from 'react'; import mermaid from 'mermaid'; // We'll use dynamic import for svg-pan-zoom -const MERMAID_LABELS_RE = /(\|)([^|\n]+)(\|)|(\()([^()\n]+)(\))|(\[)([^\[\]\n]+)(\])|(\{)([^{}\n]+)(\})/g; - function preprocessMermaidLabels(input: string): string { - return input.replace( - MERMAID_LABELS_RE, - ( - match, - o1: string | undefined, - i1: string | undefined, - c1: string | undefined, - o2: string | undefined, - i2: string | undefined, - c2: string | undefined, - o3: string | undefined, - i3: string | undefined, - c3: string | undefined, - o4: string | undefined, - i4: string | undefined, - c4: string | undefined - ) => { - const open = o1 ?? o2 ?? o3 ?? o4; - const inner = i1 ?? i2 ?? i3 ?? i4; - const close = c1 ?? c2 ?? c3 ?? c4; - - if (!open || !close || inner == null) return match; - - const trimmedInner = inner.trim(); - const isAlreadyQuoted = - trimmedInner.length >= 2 && - trimmedInner.startsWith('"') && - trimmedInner.endsWith('"'); - - const quotedInnerMatch = inner.match(/^\s*"(.*)"\s*$/); - const unquotedInner = isAlreadyQuoted && quotedInnerMatch ? quotedInnerMatch[1] : inner; - const escapedInner = unquotedInner.replace(/"/g, '"'); - return `${open}"${escapedInner}"${close}`; + 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 From 4ae86d9180bd06ee524871333f991788c62c19ed Mon Sep 17 00:00:00 2001 From: danielfrey63 Date: Sat, 27 Dec 2025 17:20:02 +0100 Subject: [PATCH 3/3] fix(mermaid): added tests --- package.json | 3 +- tests/mermaid-preprocess.test.js | 1 + tests/mermaid-preprocess.test.mjs | 59 +++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 tests/mermaid-preprocess.test.js create mode 100644 tests/mermaid-preprocess.test.mjs 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/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; +}