mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(flowchart): Mermaid source → graph parser (C8)
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
This commit is contained in:
@@ -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 };
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user