Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mermaid-conversion-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@statelyai/graph": patch
---

Preserve quoted Mermaid state stereotypes and flowchart labels while expanding compact links and semicolon-separated statements correctly.
5 changes: 5 additions & 0 deletions src/formats/mermaid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<<choice>>`, `<<fork>>`, or `<<join>>` retain both their
decoded description and state type across import and export.

```ts
const graph = fromMermaidState(`stateDiagram-v2
Expand Down
112 changes: 100 additions & 12 deletions src/formats/mermaid/flowchart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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: '',
};
}
Expand Down Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions src/formats/mermaid/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ export const DIRECTION_TO_MERMAID: Record<string, string> = {

/** 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<string, string> = {
'\\': '\\\\',
'#': '#35;',
'"': '#quot;',
';': '#59;',
'|': '#124;',
};
return s.replace(/[\\#";|]/g, (char) => entities[char]!);
}

/** Unescape a Mermaid label back to plain text. */
Expand Down
18 changes: 16 additions & 2 deletions src/formats/mermaid/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Comment thread
davidkpiano marked this conversation as resolved.
node.data.stateType = stateType as 'choice' | 'fork' | 'join';
(node as any).shape = stateType;
continue;
}

// State with stereotype: state stateId <<choice>>
const stereotypeMatch = line.match(
/^state\s+(\S+)\s+<<(choice|fork|join)>>\s*$/,
Expand All @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions tests/formats/mermaid/flowchart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions tests/formats/mermaid/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<choice>>
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 <<choice>>');
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
Expand Down
Loading