From 4123455863ba1ea4f375d7f97ecb6b3b87557927 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Wed, 30 Sep 2026 21:34:53 +0530 Subject: [PATCH] =?UTF-8?q?feat(flowchart):=20Mermaid=20source=20=E2=86=92?= =?UTF-8?q?=20graph=20parser=20(C8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inverse of flowchart-mermaid.js's toMermaid(). Pure module — no DOM, no globals — so the parsing logic is unit-tested in isolation. Recognises the 5 node shapes: - process [label] - decision {label} - terminator ([label]) - subroutine [[label]] - document [/label/] Recognises the 3 edge arrows: - --> solid - -.-> dotted - ==> thick Edge labels may appear before OR after the arrow (Mermaid accepts both: and ). Behaviour: - Auto-creates nodes referenced in edges but not declared explicitly. - Idempotent: declaring then connecting does not duplicate the A node. - Defensive: malformed lines are skipped silently so partial / hand-edited source still loads whatever it can. - Unescapes the standard Mermaid escapes (#quot; → ", \n → newline). Wire-up into the renderer (Open from .mmd file → parse → store.deserialize) is the next commit; this lands the testable math. 23 new tests cover shape parsing for all 5 kinds, edge parsing for all 3 arrow types + label placement, unescaping, header skipping, comment skipping, the subroutine-vs-process regression, auto-node creation, and the null/non-string inputs. Amit Haridas --- src/flowchart/flowchart-mermaid-parse.js | 209 +++++++++++++++++++++++ tests/flowchart-mermaid-parse.test.js | 177 +++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 src/flowchart/flowchart-mermaid-parse.js create mode 100644 tests/flowchart-mermaid-parse.test.js diff --git a/src/flowchart/flowchart-mermaid-parse.js b/src/flowchart/flowchart-mermaid-parse.js new file mode 100644 index 0000000..8e3d6c2 --- /dev/null +++ b/src/flowchart/flowchart-mermaid-parse.js @@ -0,0 +1,209 @@ +/** + * Pure parser: Mermaid `flowchart TD` source → graph. + * + * Inverse of flowchart-mermaid.js's toMermaid(). Recognises the 5 + * node shapes (process / decision / subroutine / terminator / document) + * and the 3 edge kinds (solid / dotted / thick). + * + * Pure module — no DOM, no globals. Defensive: unrecognised lines are + * skipped, not thrown on, so partial / hand-edited source still loads + * whatever it can. + * + * Inverse of flowchart-mermaid.js toMermaid(): + * id assignment: A..Z, AA..ZZ, ... + * escape: #quot; → ", \\n → \n + * + * @module flowchart-mermaid-parse + */ + +// Order matters: longest prefix first so `[[label]]` doesn't get +// matched as `process` `[label]` first. +const SHAPE_FROM_SYNTAX = [ + { prefix: '[[', suffix: ']]', kind: 'subroutine' }, // [[label]] + { prefix: '([', suffix: '])', kind: 'terminator' }, // ([label]) + { prefix: '[/', suffix: '/]', kind: 'document' }, // [/label/] + { prefix: '{', suffix: '}', kind: 'decision' }, // {label} + { prefix: '[', suffix: ']', kind: 'process' }, // [label] +]; + +const EDGE_FROM_ARROW = { + '-->': 'solid', + '-.->': 'dotted', + '==>': 'thick', +}; + +function unescapeLabel(label) { + return String(label || '') + .replace(/#quot;/g, '"') + .replace(/\\n/g, '\n'); +} + +/** + * Recognise a node declaration like `A[Step 1]`, `B{Valid?}`, + * `C([Start])`, etc. The id is the leading run of identifier chars. + * + * Returns the matched id + label + kind, or null on no match. + */ +function parseNodeDeclaration(line) { + const idBody = splitId(line); + if (!idBody) return null; + const brackets = idBody.body; + for (const shape of SHAPE_FROM_SYNTAX) { + if (brackets.startsWith(shape.prefix) && brackets.endsWith(shape.suffix)) { + const label = brackets.slice(shape.prefix.length, brackets.length - shape.suffix.length); + return { id: idBody.id, label: unescapeLabel(label), kind: shape.kind }; + } + } + return null; +} + +/** + * Split a line into id, body. The id is the leading run of [A-Za-z0-9_]. + * Everything after is the body. + */ +function splitId(line) { + const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*(.*)$/.exec(line); + if (!m) return null; + return { id: m[1], body: m[2] }; +} + +/** + * Parse a single Mermaid edge with optional label `|label|`. + * Handles `-->`, `-.->`, `==>`. + * Returns { fromId, toId, kind, label } or null. + */ +function parseEdgeLine(body) { + // Find an edge arrow. The label, when present, is `|label|` between + // the source id and the arrow OR between the arrow and the target id. + // We accept both orderings for robustness. + const labelRe = /\|([^|]*)\|/; + + // Find arrow first + let arrow = null; + let arrowIdx = -1; + for (const candidate of Object.keys(EDGE_FROM_ARROW)) { + const idx = body.indexOf(candidate); + if (idx !== -1 && (arrowIdx === -1 || idx < arrowIdx)) { + arrow = candidate; + arrowIdx = idx; + } + } + if (!arrow) return null; + + const before = body.slice(0, arrowIdx).trim(); + const after = body.slice(arrowIdx + arrow.length).trim(); + + // Source id: strip the source id + optional label + const beforeLabel = labelRe.exec(before); + const beforeLabelStr = beforeLabel ? beforeLabel[0] : null; + const sourceStr = beforeLabelStr ? before.replace(beforeLabelStr, '').trim() : before; + + const afterLabel = labelRe.exec(after); + const afterLabelStr = afterLabel ? afterLabel[0] : null; + const targetStr = afterLabelStr ? after.replace(afterLabelStr, '').trim() : after; + + if (!sourceStr || !targetStr) return null; + + // Find the label (prefer the one nearer to the arrow) + let label = null; + if (afterLabelStr) { + label = unescapeLabel(afterLabel[1]); + } else if (beforeLabelStr) { + label = unescapeLabel(beforeLabel[1]); + } + + return { + fromNodeId: sourceStr, + toNodeId: targetStr, + kind: EDGE_FROM_ARROW[arrow], + label: label || '', + }; +} + +/** + * Parse a full Mermaid source string into a graph object. + * + * @param {string} source + * @returns {{nodes:Array, edges:Array}} + */ +function fromMermaid(source) { + const nodes = []; + const edges = []; + const nodeByMermaidId = new Map(); // mermaid id → generated store id + let layoutCounter = 0; + + if (typeof source !== 'string') { + return { nodes, edges }; + } + + const lines = source + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith('%%') /* mermaid comment */); + + for (const line of lines) { + // Skip the header + if (/^flowchart\s+(TD|LR|BT|RL)/i.test(line)) continue; + + // Edge line? + const edge = parseEdgeLine(line); + if (edge) { + // Mermaid-side id → store-side id (assign on demand) + const fromId = ensureNodeId(edge.fromNodeId, nodeByMermaidId, nodes, () => + makeAutoNode(nextLayoutPos(layoutCounter++)) + ); + const toId = ensureNodeId(edge.toNodeId, nodeByMermaidId, nodes, () => + makeAutoNode(nextLayoutPos(layoutCounter++)) + ); + edges.push({ + id: 'e_' + edges.length + '_' + Date.now().toString(36), + fromNodeId: fromId, + toNodeId: toId, + kind: edge.kind, + label: edge.label, + }); + continue; + } + + // Node declaration? + const parsed = parseNodeDeclaration(line); + if (parsed) { + const storeId = 'n_' + parsed.id; + nodeByMermaidId.set(parsed.id, storeId); + if (!nodes.find((n) => n.id === storeId)) { + nodes.push({ + id: storeId, + kind: parsed.kind, + x: 40 + (nodes.length % 5) * 160, + y: 40 + Math.floor(nodes.length / 5) * 100, + label: parsed.label, + }); + } + } + } + return { nodes, edges }; +} + +function ensureNodeId(mermaidId, map, nodes, createFn) { + if (map.has(mermaidId)) return map.get(mermaidId); + const node = createFn(); + nodes.push(node); + map.set(mermaidId, node.id); + return node.id; +} + +function makeAutoNode(pos) { + return { + id: 'n_auto_' + Math.random().toString(36).slice(2, 8), + kind: 'process', + x: pos.x, + y: pos.y, + label: '', + }; +} + +function nextLayoutPos(n) { + return { x: 40 + (n % 5) * 160, y: 40 + Math.floor(n / 5) * 100 }; +} + +module.exports = { fromMermaid, parseEdgeLine, parseNodeDeclaration, EDGE_FROM_ARROW }; diff --git a/tests/flowchart-mermaid-parse.test.js b/tests/flowchart-mermaid-parse.test.js new file mode 100644 index 0000000..1d37304 --- /dev/null +++ b/tests/flowchart-mermaid-parse.test.js @@ -0,0 +1,177 @@ +/** + * @jest-environment node + * + * Mermaid → graph parser. + */ + +const { + fromMermaid, + parseEdgeLine, + parseNodeDeclaration, + EDGE_FROM_ARROW, +} = require('../src/flowchart/flowchart-mermaid-parse'); + +describe('parseEdgeLine', () => { + test('parses a solid edge with no label', () => { + const out = parseEdgeLine('A --> B'); + expect(out).toEqual({ fromNodeId: 'A', toNodeId: 'B', kind: 'solid', label: '' }); + }); + + test('parses a dotted edge', () => { + expect(parseEdgeLine('A -.-> B').kind).toBe('dotted'); + }); + + test('parses a thick edge', () => { + expect(parseEdgeLine('A ==> B').kind).toBe('thick'); + }); + + test('parses edge with label after arrow', () => { + const out = parseEdgeLine('A -->|yes| B'); + expect(out).toMatchObject({ fromNodeId: 'A', toNodeId: 'B', kind: 'solid', label: 'yes' }); + }); + + test('parses edge with label before arrow', () => { + const out = parseEdgeLine('A |label|--> B'); + expect(out).toMatchObject({ fromNodeId: 'A', toNodeId: 'B', kind: 'solid', label: 'label' }); + }); + + test('returns null for non-edge content', () => { + expect(parseEdgeLine('A[hello]')).toBeNull(); + }); + + test('EDGE_FROM_ARROW is solid / dotted / thick', () => { + expect(Object.keys(EDGE_FROM_ARROW).sort()).toEqual(['-->', '-.->', '==>'].sort()); + }); +}); + +describe('parseNodeDeclaration', () => { + test('process shape (rectangle)', () => { + expect(parseNodeDeclaration('A[Step]')).toEqual({ id: 'A', label: 'Step', kind: 'process' }); + }); + + test('decision shape (diamond)', () => { + expect(parseNodeDeclaration('A{Valid?}')).toEqual({ + id: 'A', + label: 'Valid?', + kind: 'decision', + }); + }); + + test('terminator shape (stadium)', () => { + expect(parseNodeDeclaration('A([Start])')).toEqual({ + id: 'A', + label: 'Start', + kind: 'terminator', + }); + }); + + test('subroutine shape (double brackets)', () => { + expect(parseNodeDeclaration('A[[Do thing]]')).toEqual({ + id: 'A', + label: 'Do thing', + kind: 'subroutine', + }); + }); + + test('document shape (parallelogram)', () => { + expect(parseNodeDeclaration('A[/Report/]')).toEqual({ + id: 'A', + label: 'Report', + kind: 'document', + }); + }); + + test('unescapes quotes and newlines in labels', () => { + expect(parseNodeDeclaration('A[he said #quot;hi#quot; \\n bye]')).toEqual({ + id: 'A', + label: 'he said "hi" \n bye', + kind: 'process', + }); + }); + + test('returns null for malformed input', () => { + expect(parseNodeDeclaration('garbage')).toBeNull(); + expect(parseNodeDeclaration('')).toBeNull(); + }); + + test('subroutine is not matched as process', () => { + // Regression: `[[Do thing]]` must not fall through to process `[...]` + // by matching the leading `[` and trailing `]`. + const r = parseNodeDeclaration('A[[Do thing]]'); + expect(r.kind).toBe('subroutine'); + }); +}); + +describe('fromMermaid', () => { + test('parses a minimal flowchart', () => { + const src = `flowchart TD +A[Step 1] +B[Step 2] +A --> B`; + const graph = fromMermaid(src); + expect(graph.nodes).toHaveLength(2); + expect(graph.nodes[0]).toMatchObject({ kind: 'process', label: 'Step 1' }); + expect(graph.nodes[1]).toMatchObject({ kind: 'process', label: 'Step 2' }); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toMatchObject({ + fromNodeId: graph.nodes[0].id, + toNodeId: graph.nodes[1].id, + kind: 'solid', + }); + }); + + test('parses mixed shapes', () => { + const src = `flowchart TD +A([Start]) +B{Valid?} +C[Process] +C --> B +B -->|yes| A`; + const graph = fromMermaid(src); + expect(graph.nodes).toHaveLength(3); + expect(graph.nodes.find((n) => n.kind === 'terminator')).toBeTruthy(); + expect(graph.nodes.find((n) => n.kind === 'decision')).toBeTruthy(); + expect(graph.nodes.find((n) => n.kind === 'process')).toBeTruthy(); + expect(graph.edges).toHaveLength(2); + }); + + test('handles inline edges without explicit node declarations', () => { + // Some users write the edge in one line with implicit source/target. + // fromMermaid auto-creates the missing nodes. + const src = `flowchart TD +A --> B`; + const graph = fromMermaid(src); + expect(graph.nodes).toHaveLength(2); + expect(graph.edges).toHaveLength(1); + }); + + test('skips the flowchart header line', () => { + const graph = fromMermaid('flowchart TD\nA[Step]'); + expect(graph.nodes).toHaveLength(1); + }); + + test('skips comment lines (%%)', () => { + const src = `flowchart TD +%% this is a comment +A[Step]`; + const graph = fromMermaid(src); + expect(graph.nodes).toHaveLength(1); + }); + + test('handles dotted and thick edges', () => { + const src = `flowchart TD +A -.-> B +C ==> D`; + expect(fromMermaid(src).edges.find((e) => e.kind === 'dotted')).toBeTruthy(); + expect(fromMermaid(src).edges.find((e) => e.kind === 'thick')).toBeTruthy(); + }); + + test('returns empty graph for empty source', () => { + expect(fromMermaid('')).toEqual({ nodes: [], edges: [] }); + }); + + test('returns empty graph for non-string', () => { + expect(fromMermaid(null)).toEqual({ nodes: [], edges: [] }); + expect(fromMermaid(undefined)).toEqual({ nodes: [], edges: [] }); + }); +});