diff --git a/.changeset/mermaid-conversion-parity.md b/.changeset/mermaid-conversion-parity.md new file mode 100644 index 0000000..4f7b8cb --- /dev/null +++ b/.changeset/mermaid-conversion-parity.md @@ -0,0 +1,5 @@ +--- +"@statelyai/graph": patch +--- + +Preserve quoted Mermaid state stereotypes and flowchart labels while expanding compact links and semicolon-separated statements correctly. diff --git a/src/formats/mermaid/README.md b/src/formats/mermaid/README.md index 9e1453f..3355c31 100644 --- a/src/formats/mermaid/README.md +++ b/src/formats/mermaid/README.md @@ -77,6 +77,9 @@ const mermaid = toMermaidSequence(graph); ### Flowchart Nodes with shapes, edges with arrows. Subgraphs map to compound nodes via `parentId`. +Compact links such as `A & B --> C & D` expand across supported arrows and +pipe edge labels. Semicolon-separated statements and quoted node labels are +split or decoded on import. ```ts const graph = fromMermaidFlowchart(`flowchart TD @@ -102,6 +105,8 @@ const mermaid = toMermaidFlowchart(graph); ### State Diagram State ID is the label. Descriptions go in `data.description`. `[*]` maps to start/end pseudo-nodes. +Quoted aliases with `<>`, `<>`, or `<>` retain both their +decoded description and state type across import and export. ```ts const graph = fromMermaidState(`stateDiagram-v2 diff --git a/src/formats/mermaid/flowchart.ts b/src/formats/mermaid/flowchart.ts index 5f85d23..e10bcb5 100644 --- a/src/formats/mermaid/flowchart.ts +++ b/src/formats/mermaid/flowchart.ts @@ -127,7 +127,7 @@ function parseNodeDecl(text: string): { else if (kv[1] === 'label') label = kv[2].trim(); } } - return { id, label, shape, ...(className && { className }) }; + return { id, label: getDecodedNodeLabel(label), shape, ...(className && { className }) }; } // Try each shape pattern @@ -139,7 +139,7 @@ function parseNodeDecl(text: string): { // Find matching closer from end if (!text.endsWith(closer)) continue; const label = text.slice(opIdx + opener.length, text.length - closer.length); - return { id, label: label.trim(), shape: shapeName, ...(className && { className }) }; + return { id, label: getDecodedNodeLabel(label.trim()), shape: shapeName, ...(className && { className }) }; } // Bare node ID (no brackets) if (/^[a-zA-Z_][\w]*$/.test(text)) { @@ -148,6 +148,93 @@ function parseNodeDecl(text: string): { return null; } +function getDecodedNodeLabel(label: string): string { + const unquoted = label.startsWith('"') && label.endsWith('"') + ? label.slice(1, -1) + : label; + return unescapeMermaidLabel(unquoted); +} + +/** Split delimiters outside quoted labels and node shapes. */ +function getTopLevelParts(input: string, separator: string): string[] { + const parts: string[] = []; + let start = 0; + let depth = 0; + let quoted = false; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + if (char === '"' && input[i - 1] !== '\\') quoted = !quoted; + if (!quoted) { + if ('[({'.includes(char)) depth++; + if ('])}'.includes(char)) depth = Math.max(0, depth - 1); + const entityTerminator = separator === ';' && /#(?:quot|\d+)$/i.test(input.slice(0, i)); + if (depth === 0 && !entityTerminator && input.startsWith(separator, i)) { + const part = input.slice(start, i).trim(); + if (part) parts.push(part); + i += separator.length - 1; + start = i + 1; + } + } + } + const last = input.slice(start).trim(); + if (last) parts.push(last); + return parts; +} + +function getTopLevelArrows(line: string): { start: number; end: number; arrow: string; label?: string }[] { + const arrows: { start: number; end: number; arrow: string; label?: string }[] = []; + let depth = 0; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"' && line[i - 1] !== '\\') quoted = !quoted; + if (quoted) continue; + if ('[({'.includes(char)) depth++; + if ('])}'.includes(char)) depth = Math.max(0, depth - 1); + if (depth !== 0) continue; + + let arrow = ''; + for (let length = Math.min(5, line.length - i); length >= 2; length--) { + const candidate = line.slice(i, i + length); + if (EDGE_ARROWS.some(([pattern]) => pattern.test(candidate))) { + arrow = candidate; + break; + } + } + if (!arrow) continue; + let end = i + arrow.length; + let label: string | undefined; + if (line[end] === '|') { + const labelEnd = line.indexOf('|', end + 1); + if (labelEnd < 0) return []; + label = line.slice(end + 1, labelEnd); + end = labelEnd + 1; + } + arrows.push({ start: i, end, arrow, label }); + i = end - 1; + } + return arrows; +} + +function getExpandedCompactLinks(line: string): string[] { + const arrows = getTopLevelArrows(line); + if (!arrows.length) return [line]; + const chain = arrows.map((arrow, i) => line.slice(i === 0 ? 0 : arrows[i - 1].end, arrow.start).trim()); + chain.push(line.slice(arrows[arrows.length - 1].end).trim()); + if (chain.some((part) => !part)) return [line]; + const groups = chain.map((part) => getTopLevelParts(part, '&')); + if (groups.every((group) => group.length === 1)) return [line]; + const links: string[] = []; + for (let i = 0; i < groups.length - 1; i++) { + for (const source of groups[i]) { + for (const target of groups[i + 1]) { + links.push(`${source} ${line.slice(arrows[i].start, arrows[i].end)} ${target}`); + } + } + } + return links; +} + // --- Edge parsing --- interface EdgeArrowInfo { @@ -199,17 +286,15 @@ function findEdge(line: string): { } | null { // Strategy: scan for arrow patterns in the line // First try the pipe-label form: A -->|label| B - const pipeMatch = line.match( - /^(.+?)\s*([-=.<>ox]+)\|([^|]*)\|\s*(.+)$/, - ); - if (pipeMatch) { - const arrowStr = pipeMatch[2]; + const pipeArrow = getTopLevelArrows(line).find((arrow) => arrow.label !== undefined); + if (pipeArrow) { + const arrowStr = pipeArrow.arrow; for (const [re, info] of EDGE_ARROWS) { if (re.test(arrowStr)) { return { - sourceText: pipeMatch[1].trim(), - targetText: pipeMatch[4].trim(), - info: { ...info, label: pipeMatch[3].trim() }, + sourceText: line.slice(0, pipeArrow.start).trim(), + targetText: line.slice(pipeArrow.end).trim(), + info: { ...info, label: pipeArrow.label?.trim() ?? '' }, rest: '', }; } @@ -362,8 +447,11 @@ export function fromMermaidFlowchart(input: string): MermaidFlowchartGraph { return node; } - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); + const statements = lines.slice(1) + .flatMap((line) => getTopLevelParts(line, ';')) + .flatMap(getExpandedCompactLinks); + for (const statement of statements) { + const line = statement.trim(); if (!line) continue; // subgraph diff --git a/src/formats/mermaid/shared.ts b/src/formats/mermaid/shared.ts index 369c796..e76a401 100644 --- a/src/formats/mermaid/shared.ts +++ b/src/formats/mermaid/shared.ts @@ -21,12 +21,14 @@ export const DIRECTION_TO_MERMAID: Record = { /** Escape a label for Mermaid output (quotes special chars). */ export function escapeMermaidLabel(s: string): string { - return s - .replace(/\\/g, '\\\\') - .replace(/"/g, '#quot;') - .replace(/;/g, '#59;') - .replace(/\|/g, '#124;') - .replace(/#(?!quot;|59;|35;|124;)/g, '#35;'); + const entities: Record = { + '\\': '\\\\', + '#': '#35;', + '"': '#quot;', + ';': '#59;', + '|': '#124;', + }; + return s.replace(/[\\#";|]/g, (char) => entities[char]!); } /** Unescape a Mermaid label back to plain text. */ diff --git a/src/formats/mermaid/state.ts b/src/formats/mermaid/state.ts index 72759f2..7222a8b 100644 --- a/src/formats/mermaid/state.ts +++ b/src/formats/mermaid/state.ts @@ -175,7 +175,7 @@ export function fromMermaidState(input: string): MermaidStateGraph { /^state\s+"([^"]+)"\s+as\s+(\S+)\s*\{\s*$/, ); if (compositeStateAsMatch) { - const description = compositeStateAsMatch[1]; + const description = unescapeMermaidLabel(compositeStateAsMatch[1]); const stateId = compositeStateAsMatch[2]; const node = ensureNode(stateId); node.data.description = description; @@ -184,6 +184,20 @@ export function fromMermaidState(input: string): MermaidStateGraph { continue; } + // The editor emits quoted aliases with the stereotype on the same line. + const aliasedStereotypeMatch = line.match( + /^state\s+"([^"]+)"\s+as\s+(\S+)\s+<<(choice|fork|join)>>\s*$/, + ); + if (aliasedStereotypeMatch) { + const [, description, stateId, stateType] = aliasedStereotypeMatch; + const node = ensureNode(stateId); + node.data.description = unescapeMermaidLabel(description); + node.label = node.data.description; + node.data.stateType = stateType as 'choice' | 'fork' | 'join'; + (node as any).shape = stateType; + continue; + } + // State with stereotype: state stateId <> const stereotypeMatch = line.match( /^state\s+(\S+)\s+<<(choice|fork|join)>>\s*$/, @@ -200,7 +214,7 @@ export function fromMermaidState(input: string): MermaidStateGraph { // State with description: state "description" as stateId const stateAsMatch = line.match(/^state\s+"([^"]+)"\s+as\s+(\S+)\s*$/); if (stateAsMatch) { - const description = stateAsMatch[1]; + const description = unescapeMermaidLabel(stateAsMatch[1]); const stateId = stateAsMatch[2]; const node = ensureNode(stateId); node.data.description = description; diff --git a/tests/formats/mermaid/flowchart.test.ts b/tests/formats/mermaid/flowchart.test.ts index 50e7f06..30fa585 100644 --- a/tests/formats/mermaid/flowchart.test.ts +++ b/tests/formats/mermaid/flowchart.test.ts @@ -6,6 +6,57 @@ import { describe('Mermaid Flowchart Converter', () => { describe('fromMermaidFlowchart()', () => { + it('expands compact source and target links', () => { + const graph = fromMermaidFlowchart(`flowchart LR +A & B --> C & D --> E`); + expect(graph.edges.map((edge) => [edge.sourceId, edge.targetId])).toEqual([ + ['A', 'C'], ['A', 'D'], ['B', 'C'], ['B', 'D'], ['C', 'E'], ['D', 'E'], + ]); + expect(graph.nodes.some((node) => node.id.includes('&'))).toBe(false); + }); + + it('expands labeled links and keeps pipes inside node labels', () => { + const graph = fromMermaidFlowchart(`flowchart LR +A["x|y"] & B -->|yes| C["a|b"] & D`); + expect(graph.nodes.map((node) => node.id)).toEqual(['A', 'C', 'D', 'B']); + expect(graph.nodes.find((node) => node.id === 'A')?.label).toBe('x|y'); + expect(graph.nodes.find((node) => node.id === 'C')?.label).toBe('a|b'); + expect(graph.edges.map((edge) => [edge.sourceId, edge.targetId, edge.label])).toEqual([ + ['A', 'C', 'yes'], ['A', 'D', 'yes'], + ['B', 'C', 'yes'], ['B', 'D', 'yes'], + ]); + }); + + it('expands links to quoted labels containing a pipe without an edge label', () => { + const graph = fromMermaidFlowchart('flowchart LR\nA & B --> C["x|y"]'); + expect(graph.edges.map((edge) => [edge.sourceId, edge.targetId])).toEqual([ + ['A', 'C'], ['B', 'C'], + ]); + expect(graph.nodes.find((node) => node.id === 'C')?.label).toBe('x|y'); + }); + + it('preserves thick and dotted arrows in mixed compact chains', () => { + const graph = fromMermaidFlowchart(`flowchart LR +A & B ==> C & D -.-> E & F`); + expect(graph.edges.map((edge) => [edge.sourceId, edge.targetId, edge.data.stroke])).toEqual([ + ['A', 'C', 'thick'], ['A', 'D', 'thick'], + ['B', 'C', 'thick'], ['B', 'D', 'thick'], + ['C', 'E', 'dotted'], ['C', 'F', 'dotted'], + ['D', 'E', 'dotted'], ['D', 'F', 'dotted'], + ]); + expect(graph.nodes.some((node) => node.id.includes('&'))).toBe(false); + }); + + it('splits statement semicolons but preserves them inside quoted labels', () => { + const graph = fromMermaidFlowchart(`flowchart LR +A["a;b"] --> B; B --> C`); + expect(graph.nodes.find((node) => node.id === 'A')?.label).toBe('a;b'); + expect(graph.edges.map((edge) => [edge.sourceId, edge.targetId])).toEqual([ + ['A', 'B'], ['B', 'C'], + ]); + expect(fromMermaidFlowchart(toMermaidFlowchart(graph)).nodes.find((node) => node.id === 'A')?.label).toBe('a;b'); + }); + it('parses basic flowchart with nodes and edges', () => { const graph = fromMermaidFlowchart(` graph TD diff --git a/tests/formats/mermaid/state.test.ts b/tests/formats/mermaid/state.test.ts index 2d56b77..63b5be6 100644 --- a/tests/formats/mermaid/state.test.ts +++ b/tests/formats/mermaid/state.test.ts @@ -7,6 +7,35 @@ import { getFormatSupportEntry } from '../../../src/formats/support'; describe('Mermaid State Diagram Converter', () => { describe('fromMermaidState()', () => { + it('keeps choice types on quoted state aliases', () => { + const graph = fromMermaidState(`stateDiagram-v2 +state "if_state" as if_state <> +IsPositive --> if_state +if_state --> False : if n < 0`); + expect(graph.nodes.find((node) => node.id === 'if_state')).toMatchObject({ + label: 'if_state', + data: { description: 'if_state', stateType: 'choice' }, + }); + expect(graph.edges).toHaveLength(2); + }); + + it('round-trips entity-escaped descriptions on stereotype aliases', () => { + const graph = fromMermaidState('stateDiagram-v2\nstate "Say #quot;yes#quot;" as q <>'); + const roundTrip = fromMermaidState(toMermaidState(graph)); + expect(roundTrip.nodes.find((node) => node.id === 'q')).toMatchObject({ + label: 'Say "yes"', + data: { description: 'Say "yes"', stateType: 'choice' }, + }); + }); + + it('decodes entity-escaped composite aliases', () => { + const graph = fromMermaidState('stateDiagram-v2\nstate "Say #quot;yes#quot;" as parent {\nchild\n}'); + expect(graph.nodes.find((node) => node.id === 'parent')).toMatchObject({ + label: 'Say "yes"', + data: { description: 'Say "yes"' }, + }); + }); + it('parses basic state diagram with transitions', () => { const graph = fromMermaidState(` stateDiagram-v2