mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(flowchart): mermaid translator (flowchart TD, all 5 shapes, 3 edge kinds)
Pure Mermaid translator: graph (nodes + edges) -> flowchart TD source.
5 node kinds (process/decision/terminator/subroutine/document) ->
Mermaid syntax ([..], {..}, ([..]), [[..]], [/../]).
3 edge kinds (solid/dotted/thick) -> Mermaid arrows (-->, -.->, ==>).
Label escaping: double quotes become #quot;, newlines become literal \n.
Also moves tests/fixtures per-test dirs to fixtures-epub / fixtures-css
so the shared tests/fixtures/ stays available for snapshot fixtures.
Amit Haridas
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Pure translator: graph → Mermaid `flowchart TD` source.
|
||||||
|
*
|
||||||
|
* Output format:
|
||||||
|
* flowchart TD
|
||||||
|
* A[Step 1]
|
||||||
|
* B{Valid?}
|
||||||
|
* C([Start])
|
||||||
|
* D[[Do thing]]
|
||||||
|
* E[/Report/]
|
||||||
|
* A --> B
|
||||||
|
* B -->|yes| C
|
||||||
|
*
|
||||||
|
* IDs are 1-3 chars (A..Z, AA..ZZ, …). Labels are escaped: double quotes
|
||||||
|
* become `#quot;`, newlines become literal `\n` (Mermaid's escape).
|
||||||
|
*
|
||||||
|
* @module flowchart-mermaid
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const SHAPE_SYNTAX = {
|
||||||
|
process: (id, label) => `${id}[${label}]`,
|
||||||
|
decision: (id, label) => `${id}{${label}}`,
|
||||||
|
terminator: (id, label) => `${id}([${label}])`,
|
||||||
|
subroutine: (id, label) => `${id}[[${label}]]`,
|
||||||
|
document: (id, label) => `${id}[/${label}/]`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDGE_ARROW = {
|
||||||
|
solid: '-->',
|
||||||
|
dotted: '-.->',
|
||||||
|
thick: '==>',
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeLabel(label) {
|
||||||
|
return String(label || '')
|
||||||
|
.replace(/"/g, '#quot;')
|
||||||
|
.replace(/\r?\n/g, '\\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignIds(nodes) {
|
||||||
|
// A, B, C, …, Z, AA, AB, … (matches Mermaid's preferred short ids).
|
||||||
|
const map = new Map();
|
||||||
|
let n = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
let id;
|
||||||
|
if (n < 26) id = String.fromCharCode(65 + n);
|
||||||
|
else {
|
||||||
|
const first = Math.floor(n / 26) - 1;
|
||||||
|
const second = n % 26;
|
||||||
|
id = String.fromCharCode(65 + first) + String.fromCharCode(65 + second);
|
||||||
|
}
|
||||||
|
map.set(node.id, id);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeDeclaration(node) {
|
||||||
|
const fn = SHAPE_SYNTAX[node.kind];
|
||||||
|
if (!fn) throw new Error(`flowchart-mermaid: unknown node kind "${node.kind}"`);
|
||||||
|
return fn(node.id, escapeLabel(node.label));
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeDeclaration(edge, fromId, toId) {
|
||||||
|
const arrow = EDGE_ARROW[edge.kind];
|
||||||
|
if (!arrow) throw new Error(`flowchart-mermaid: unknown edge kind "${edge.kind}"`);
|
||||||
|
if (edge.label && edge.label.length > 0) {
|
||||||
|
return `${fromId} ${arrow}|${escapeLabel(edge.label)}| ${toId}`;
|
||||||
|
}
|
||||||
|
return `${fromId} ${arrow} ${toId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMermaid(graph) {
|
||||||
|
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
||||||
|
throw new Error('flowchart-mermaid: graph must have nodes[] and edges[]');
|
||||||
|
}
|
||||||
|
const ids = assignIds(graph.nodes);
|
||||||
|
const lines = ['flowchart TD'];
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
lines.push(nodeDeclaration({ ...node, id: ids.get(node.id) }));
|
||||||
|
}
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
const fromId = ids.get(edge.fromNodeId);
|
||||||
|
const toId = ids.get(edge.toNodeId);
|
||||||
|
if (!fromId || !toId) continue; // skip dangling edges (defensive)
|
||||||
|
lines.push(edgeDeclaration(edge, fromId, toId));
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration };
|
||||||
@@ -4,7 +4,8 @@ const JSZip = require('jszip');
|
|||||||
const EpubFontEmbedder = require('../src/main/EpubFontEmbedder');
|
const EpubFontEmbedder = require('../src/main/EpubFontEmbedder');
|
||||||
|
|
||||||
describe('EpubFontEmbedder.patchManifest', () => {
|
describe('EpubFontEmbedder.patchManifest', () => {
|
||||||
const fixturesDir = path.join(__dirname, 'fixtures');
|
// Per-test fixture dir (not the shared tests/fixtures/ used by snapshot fixtures).
|
||||||
|
const fixturesDir = path.join(__dirname, 'fixtures-epub');
|
||||||
const epubPath = path.join(fixturesDir, 'fake.epub');
|
const epubPath = path.join(fixturesDir, 'fake.epub');
|
||||||
const fontPath = path.join(fixturesDir, 'fake.ttf');
|
const fontPath = path.join(fixturesDir, 'fake.ttf');
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ const path = require('path');
|
|||||||
const ExportCss = require('../src/main/ExportCss');
|
const ExportCss = require('../src/main/ExportCss');
|
||||||
|
|
||||||
describe('ExportCss.build', () => {
|
describe('ExportCss.build', () => {
|
||||||
const fakeFontPath = path.join(__dirname, 'fixtures', 'fake.woff2');
|
// Per-test fixture dir (not the shared tests/fixtures/ used by snapshot fixtures).
|
||||||
|
const fakeFontPath = path.join(__dirname, 'fixtures-css', 'fake.woff2');
|
||||||
const fixture = Buffer.from('woff2-binary-fake-data');
|
const fixture = Buffer.from('woff2-binary-fake-data');
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixture graphs for flowchart-mermaid snapshot tests.
|
||||||
|
* Mermaid IDs are 1-3 chars; here we use semantic names that match the
|
||||||
|
* store's auto-generated ids but normalized to A/B/C form for readability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function normalizeIds(graph) {
|
||||||
|
const map = new Map();
|
||||||
|
const normNode = (n, i) => {
|
||||||
|
const id = String.fromCharCode(65 + i);
|
||||||
|
map.set(n.id, id);
|
||||||
|
return { ...n, id };
|
||||||
|
};
|
||||||
|
const normEdge = (e) => ({
|
||||||
|
...e,
|
||||||
|
fromNodeId: map.get(e.fromNodeId),
|
||||||
|
toNodeId: map.get(e.toNodeId),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
nodes: graph.nodes.map(normNode),
|
||||||
|
edges: graph.edges.map(normEdge),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const linearChain = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'n1', kind: 'terminator', x: 0, y: 0, label: 'Start' },
|
||||||
|
{ id: 'n2', kind: 'process', x: 0, y: 0, label: 'Step 1' },
|
||||||
|
{ id: 'n3', kind: 'process', x: 0, y: 0, label: 'Step 2' },
|
||||||
|
{ id: 'n4', kind: 'terminator', x: 0, y: 0, label: 'End' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' },
|
||||||
|
{ id: 'e2', fromNodeId: 'n2', toNodeId: 'n3', kind: 'solid' },
|
||||||
|
{ id: 'e3', fromNodeId: 'n3', toNodeId: 'n4', kind: 'solid' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const decisionDiamond = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'n1', kind: 'terminator', x: 0, y: 0, label: 'Start' },
|
||||||
|
{ id: 'n2', kind: 'process', x: 0, y: 0, label: 'Get input' },
|
||||||
|
{ id: 'n3', kind: 'decision', x: 0, y: 0, label: 'Valid?' },
|
||||||
|
{ id: 'n4', kind: 'process', x: 0, y: 0, label: 'Process' },
|
||||||
|
{ id: 'n5', kind: 'process', x: 0, y: 0, label: 'Show error' },
|
||||||
|
{ id: 'n6', kind: 'terminator', x: 0, y: 0, label: 'End' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' },
|
||||||
|
{ id: 'e2', fromNodeId: 'n2', toNodeId: 'n3', kind: 'solid' },
|
||||||
|
{ id: 'e3', fromNodeId: 'n3', toNodeId: 'n4', kind: 'solid', label: 'yes' },
|
||||||
|
{ id: 'e4', fromNodeId: 'n3', toNodeId: 'n5', kind: 'solid', label: 'no' },
|
||||||
|
{ id: 'e5', fromNodeId: 'n4', toNodeId: 'n6', kind: 'solid' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const parallelBranches = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'n1', kind: 'terminator', x: 0, y: 0, label: 'Start' },
|
||||||
|
{ id: 'n2', kind: 'process', x: 0, y: 0, label: 'Fork' },
|
||||||
|
{ id: 'n3', kind: 'process', x: 0, y: 0, label: 'Branch A' },
|
||||||
|
{ id: 'n4', kind: 'process', x: 0, y: 0, label: 'Branch B' },
|
||||||
|
{ id: 'n5', kind: 'terminator', x: 0, y: 0, label: 'End' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' },
|
||||||
|
{ id: 'e2', fromNodeId: 'n2', toNodeId: 'n3', kind: 'solid' },
|
||||||
|
{ id: 'e3', fromNodeId: 'n2', toNodeId: 'n4', kind: 'solid' },
|
||||||
|
{ id: 'e4', fromNodeId: 'n3', toNodeId: 'n5', kind: 'solid' },
|
||||||
|
{ id: 'e5', fromNodeId: 'n4', toNodeId: 'n5', kind: 'solid' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycle = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'A' },
|
||||||
|
{ id: 'n2', kind: 'process', x: 0, y: 0, label: 'B' },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'e1', fromNodeId: 'n1', toNodeId: 'n2', kind: 'solid' },
|
||||||
|
{ id: 'e2', fromNodeId: 'n2', toNodeId: 'n1', kind: 'dotted' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function largeGraph(n) {
|
||||||
|
const nodes = [];
|
||||||
|
const edges = [];
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
nodes.push({ id: `n${i}`, kind: 'process', x: 0, y: 0, label: `Step ${i}` });
|
||||||
|
if (i > 0) {
|
||||||
|
edges.push({
|
||||||
|
id: `e${i}`,
|
||||||
|
fromNodeId: `n${i - 1}`,
|
||||||
|
toNodeId: `n${i}`,
|
||||||
|
kind: 'solid',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeIds,
|
||||||
|
linearChain,
|
||||||
|
decisionDiamond,
|
||||||
|
parallelBranches,
|
||||||
|
cycle,
|
||||||
|
largeGraph,
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
const {
|
||||||
|
toMermaid,
|
||||||
|
escapeLabel,
|
||||||
|
nodeDeclaration,
|
||||||
|
edgeDeclaration,
|
||||||
|
} = require('../src/flowchart/flowchart-mermaid');
|
||||||
|
const {
|
||||||
|
normalizeIds,
|
||||||
|
linearChain,
|
||||||
|
decisionDiamond,
|
||||||
|
parallelBranches,
|
||||||
|
cycle,
|
||||||
|
largeGraph,
|
||||||
|
} = require('./fixtures/flowchart-snapshots');
|
||||||
|
|
||||||
|
describe('flowchart-mermaid: escapeLabel', () => {
|
||||||
|
test('escapes double quotes', () => {
|
||||||
|
expect(escapeLabel('say "hi"')).toBe('say #quot;hi#quot;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes newlines to literal \\n', () => {
|
||||||
|
expect(escapeLabel('line1\nline2')).toBe('line1\\nline2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes plain text through', () => {
|
||||||
|
expect(escapeLabel('Hello world')).toBe('Hello world');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flowchart-mermaid: nodeDeclaration', () => {
|
||||||
|
test('process → A[Label]', () => {
|
||||||
|
expect(nodeDeclaration({ id: 'A', kind: 'process', label: 'Step' })).toBe('A[Step]');
|
||||||
|
});
|
||||||
|
test('decision → A{Label}', () => {
|
||||||
|
expect(nodeDeclaration({ id: 'A', kind: 'decision', label: 'Yes?' })).toBe('A{Yes?}');
|
||||||
|
});
|
||||||
|
test('terminator → A([Label])', () => {
|
||||||
|
expect(nodeDeclaration({ id: 'A', kind: 'terminator', label: 'Start' })).toBe('A([Start])');
|
||||||
|
});
|
||||||
|
test('subroutine → A[[Label]]', () => {
|
||||||
|
expect(nodeDeclaration({ id: 'A', kind: 'subroutine', label: 'Do thing' })).toBe(
|
||||||
|
'A[[Do thing]]'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('document → A[/Label/]', () => {
|
||||||
|
expect(nodeDeclaration({ id: 'A', kind: 'document', label: 'Report' })).toBe('A[/Report/]');
|
||||||
|
});
|
||||||
|
test('unknown kind throws', () => {
|
||||||
|
expect(() => nodeDeclaration({ id: 'A', kind: 'hexagon', label: 'x' })).toThrow(/hexagon/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flowchart-mermaid: edgeDeclaration', () => {
|
||||||
|
test('solid → A --> B', () => {
|
||||||
|
expect(edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'solid' }, 'A', 'B')).toBe(
|
||||||
|
'A --> B'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('dotted → A -.-> B', () => {
|
||||||
|
expect(edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'dotted' }, 'A', 'B')).toBe(
|
||||||
|
'A -.-> B'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('thick → A ==> B', () => {
|
||||||
|
expect(edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'thick' }, 'A', 'B')).toBe(
|
||||||
|
'A ==> B'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('with label → A -->|yes| B', () => {
|
||||||
|
expect(
|
||||||
|
edgeDeclaration({ fromNodeId: 'A', toNodeId: 'B', kind: 'solid', label: 'yes' }, 'A', 'B')
|
||||||
|
).toBe('A -->|yes| B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flowchart-mermaid: toMermaid — full graphs', () => {
|
||||||
|
test('linearChain', () => {
|
||||||
|
const out = toMermaid(normalizeIds(linearChain));
|
||||||
|
expect(out).toMatch(/^flowchart TD/);
|
||||||
|
expect(out).toMatch(/A\(\[Start\]\)/);
|
||||||
|
expect(out).toMatch(/B\[Step 1\]/);
|
||||||
|
expect(out).toMatch(/C\[Step 2\]/);
|
||||||
|
expect(out).toMatch(/D\(\[End\]\)/);
|
||||||
|
expect(out).toMatch(/A --> B/);
|
||||||
|
expect(out).toMatch(/B --> C/);
|
||||||
|
expect(out).toMatch(/C --> D/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decisionDiamond includes labeled branches', () => {
|
||||||
|
const out = toMermaid(normalizeIds(decisionDiamond));
|
||||||
|
expect(out).toMatch(/C\{Valid\?\}/);
|
||||||
|
expect(out).toMatch(/C -->\|yes\| D/);
|
||||||
|
expect(out).toMatch(/C -->\|no\| E/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parallelBranches', () => {
|
||||||
|
const out = toMermaid(normalizeIds(parallelBranches));
|
||||||
|
expect(out).toMatch(/B --> C/);
|
||||||
|
expect(out).toMatch(/B --> D/);
|
||||||
|
expect(out).toMatch(/C --> E/);
|
||||||
|
expect(out).toMatch(/D --> E/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cycle uses dotted for the back-edge', () => {
|
||||||
|
const out = toMermaid(normalizeIds(cycle));
|
||||||
|
expect(out).toMatch(/A --> B/);
|
||||||
|
expect(out).toMatch(/B -\.-> A/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('largeGraph(20) emits 20 nodes and 19 edges in order', () => {
|
||||||
|
const out = toMermaid(normalizeIds(largeGraph(20)));
|
||||||
|
const nodeCount = (out.match(/^[A-Z]\[/gm) || []).length;
|
||||||
|
const edgeCount = (out.match(/ --> /g) || []).length;
|
||||||
|
expect(nodeCount).toBe(20);
|
||||||
|
expect(edgeCount).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty graph still emits the header', () => {
|
||||||
|
expect(toMermaid({ nodes: [], edges: [] })).toBe('flowchart TD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label with embedded double-quote is escaped', () => {
|
||||||
|
const out = toMermaid(
|
||||||
|
normalizeIds({
|
||||||
|
nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'say "hi"' }],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(out).toMatch(/A\[say #quot;hi#quot;\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label with newline uses \\n escape', () => {
|
||||||
|
const out = toMermaid(
|
||||||
|
normalizeIds({
|
||||||
|
nodes: [{ id: 'n1', kind: 'process', x: 0, y: 0, label: 'line1\nline2' }],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(out).toMatch(/A\[line1\\nline2\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user