@diagrammo/dgmo 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -57
- package/dist/cli.cjs +91 -85
- package/dist/index.cjs +1362 -194
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +108 -2
- package/dist/index.d.ts +108 -2
- package/dist/index.js +1357 -193
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/src/d3.ts +59 -1
- package/src/dgmo-router.ts +5 -1
- package/src/echarts.ts +2 -2
- package/src/graph/flowchart-parser.ts +499 -0
- package/src/graph/flowchart-renderer.ts +503 -0
- package/src/graph/layout.ts +222 -0
- package/src/graph/types.ts +44 -0
- package/src/index.ts +24 -0
- package/src/sequence/parser.ts +221 -37
- package/src/sequence/renderer.ts +342 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diagrammo/dgmo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "DGMO diagram markup language — parser, renderer, and color system",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"gallery": "pnpm build && node scripts/generate-gallery.mjs"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@dagrejs/dagre": "^2.0.4",
|
|
39
40
|
"@resvg/resvg-js": "^2.6.2",
|
|
40
41
|
"d3-array": "^3.2.4",
|
|
41
42
|
"d3-cloud": "^1.2.7",
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"@types/d3-scale": "^4.0.8",
|
|
52
53
|
"@types/d3-selection": "^3.0.11",
|
|
53
54
|
"@types/d3-shape": "^3.1.7",
|
|
55
|
+
"@types/dagre": "^0.7.53",
|
|
54
56
|
"@types/jsdom": "^21.1.7",
|
|
55
57
|
"tsup": "^8.5.1",
|
|
56
58
|
"typescript": "^5.7.3",
|
package/src/d3.ts
CHANGED
|
@@ -2146,7 +2146,17 @@ export function computeTimeTicks(
|
|
|
2146
2146
|
const firstYear = Math.ceil(domainMin);
|
|
2147
2147
|
const lastYear = Math.floor(domainMax);
|
|
2148
2148
|
if (lastYear >= firstYear + 1) {
|
|
2149
|
-
|
|
2149
|
+
// Decimate ticks for long spans so labels don't overlap
|
|
2150
|
+
const yearSpan = lastYear - firstYear;
|
|
2151
|
+
let step = 1;
|
|
2152
|
+
if (yearSpan > 80) step = 20;
|
|
2153
|
+
else if (yearSpan > 40) step = 10;
|
|
2154
|
+
else if (yearSpan > 20) step = 5;
|
|
2155
|
+
else if (yearSpan > 10) step = 2;
|
|
2156
|
+
|
|
2157
|
+
// Align to step boundary so ticks land on round years (1700, 1710, …)
|
|
2158
|
+
const alignedFirst = Math.ceil(firstYear / step) * step;
|
|
2159
|
+
for (let y = alignedFirst; y <= lastYear; y += step) {
|
|
2150
2160
|
ticks.push({ pos: scale(y), label: String(y) });
|
|
2151
2161
|
}
|
|
2152
2162
|
} else if (span > 0.25) {
|
|
@@ -4972,6 +4982,54 @@ export async function renderD3ForExport(
|
|
|
4972
4982
|
theme: 'light' | 'dark' | 'transparent',
|
|
4973
4983
|
palette?: PaletteColors
|
|
4974
4984
|
): Promise<string> {
|
|
4985
|
+
// Flowchart uses its own parser pipeline — intercept before parseD3()
|
|
4986
|
+
const { parseDgmoChartType } = await import('./dgmo-router');
|
|
4987
|
+
const detectedType = parseDgmoChartType(content);
|
|
4988
|
+
if (detectedType === 'flowchart') {
|
|
4989
|
+
const { parseFlowchart } = await import('./graph/flowchart-parser');
|
|
4990
|
+
const { layoutGraph } = await import('./graph/layout');
|
|
4991
|
+
const { renderFlowchart } = await import('./graph/flowchart-renderer');
|
|
4992
|
+
|
|
4993
|
+
const isDark = theme === 'dark';
|
|
4994
|
+
const { getPalette } = await import('./palettes');
|
|
4995
|
+
const effectivePalette =
|
|
4996
|
+
palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
|
|
4997
|
+
|
|
4998
|
+
const fcParsed = parseFlowchart(content, effectivePalette);
|
|
4999
|
+
if (fcParsed.error || fcParsed.nodes.length === 0) return '';
|
|
5000
|
+
|
|
5001
|
+
const layout = layoutGraph(fcParsed);
|
|
5002
|
+
const container = document.createElement('div');
|
|
5003
|
+
container.style.width = `${EXPORT_WIDTH}px`;
|
|
5004
|
+
container.style.height = `${EXPORT_HEIGHT}px`;
|
|
5005
|
+
container.style.position = 'absolute';
|
|
5006
|
+
container.style.left = '-9999px';
|
|
5007
|
+
document.body.appendChild(container);
|
|
5008
|
+
|
|
5009
|
+
try {
|
|
5010
|
+
renderFlowchart(container, fcParsed, layout, effectivePalette, isDark, undefined, {
|
|
5011
|
+
width: EXPORT_WIDTH,
|
|
5012
|
+
height: EXPORT_HEIGHT,
|
|
5013
|
+
});
|
|
5014
|
+
|
|
5015
|
+
const svgEl = container.querySelector('svg');
|
|
5016
|
+
if (!svgEl) return '';
|
|
5017
|
+
|
|
5018
|
+
if (theme === 'transparent') {
|
|
5019
|
+
svgEl.style.background = 'none';
|
|
5020
|
+
} else if (!svgEl.style.background) {
|
|
5021
|
+
svgEl.style.background = effectivePalette.bg;
|
|
5022
|
+
}
|
|
5023
|
+
|
|
5024
|
+
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
5025
|
+
svgEl.style.fontFamily = FONT_FAMILY;
|
|
5026
|
+
|
|
5027
|
+
return svgEl.outerHTML;
|
|
5028
|
+
} finally {
|
|
5029
|
+
document.body.removeChild(container);
|
|
5030
|
+
}
|
|
5031
|
+
}
|
|
5032
|
+
|
|
4975
5033
|
const parsed = parseD3(content, palette);
|
|
4976
5034
|
// Allow sequence diagrams through even if parseD3 errors —
|
|
4977
5035
|
// sequence is parsed by its own dedicated parser (parseSequenceDgmo)
|
package/src/dgmo-router.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
5
|
import { looksLikeSequence } from './sequence/parser';
|
|
6
|
+
import { looksLikeFlowchart } from './graph/flowchart-parser';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Framework identifiers used by the .dgmo router.
|
|
@@ -44,6 +45,7 @@ export const DGMO_CHART_TYPE_MAP: Record<string, DgmoFramework> = {
|
|
|
44
45
|
venn: 'd3',
|
|
45
46
|
quadrant: 'd3',
|
|
46
47
|
sequence: 'd3',
|
|
48
|
+
flowchart: 'd3',
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
/**
|
|
@@ -69,8 +71,10 @@ export function parseDgmoChartType(content: string): string | null {
|
|
|
69
71
|
if (match) return match[1].trim().toLowerCase();
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
// Infer
|
|
74
|
+
// Infer chart type from content patterns (sequence before flowchart —
|
|
75
|
+
// both use `->` but sequence uses bare names while flowchart uses shape delimiters)
|
|
73
76
|
if (looksLikeSequence(content)) return 'sequence';
|
|
77
|
+
if (looksLikeFlowchart(content)) return 'flowchart';
|
|
74
78
|
|
|
75
79
|
return null;
|
|
76
80
|
}
|
package/src/echarts.ts
CHANGED
|
@@ -395,7 +395,7 @@ export function buildEChartsOption(
|
|
|
395
395
|
): EChartsOption {
|
|
396
396
|
const textColor = palette.text;
|
|
397
397
|
const axisLineColor = palette.border;
|
|
398
|
-
const gridOpacity = isDark ? 0.7 : 0.
|
|
398
|
+
const gridOpacity = isDark ? 0.7 : 0.55;
|
|
399
399
|
const colors = getSeriesColors(palette);
|
|
400
400
|
|
|
401
401
|
if (parsed.error) {
|
|
@@ -1300,7 +1300,7 @@ export function buildEChartsOptionFromChart(
|
|
|
1300
1300
|
const textColor = palette.text;
|
|
1301
1301
|
const axisLineColor = palette.border;
|
|
1302
1302
|
const splitLineColor = palette.border;
|
|
1303
|
-
const gridOpacity = isDark ? 0.7 : 0.
|
|
1303
|
+
const gridOpacity = isDark ? 0.7 : 0.55;
|
|
1304
1304
|
const colors = getSeriesColors(palette);
|
|
1305
1305
|
|
|
1306
1306
|
const titleConfig = parsed.title
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { resolveColor } from '../colors';
|
|
2
|
+
import type { PaletteColors } from '../palettes';
|
|
3
|
+
import type {
|
|
4
|
+
ParsedGraph,
|
|
5
|
+
GraphNode,
|
|
6
|
+
GraphEdge,
|
|
7
|
+
GraphGroup,
|
|
8
|
+
GraphShape,
|
|
9
|
+
GraphDirection,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
// ============================================================
|
|
13
|
+
// Helpers
|
|
14
|
+
// ============================================================
|
|
15
|
+
|
|
16
|
+
function measureIndent(line: string): number {
|
|
17
|
+
let indent = 0;
|
|
18
|
+
for (const ch of line) {
|
|
19
|
+
if (ch === ' ') indent++;
|
|
20
|
+
else if (ch === '\t') indent += 4;
|
|
21
|
+
else break;
|
|
22
|
+
}
|
|
23
|
+
return indent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nodeId(shape: GraphShape, label: string): string {
|
|
27
|
+
return `${shape}:${label.toLowerCase().trim()}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface NodeRef {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
shape: GraphShape;
|
|
34
|
+
color?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
|
|
38
|
+
|
|
39
|
+
function extractColor(
|
|
40
|
+
label: string,
|
|
41
|
+
palette?: PaletteColors
|
|
42
|
+
): { label: string; color?: string } {
|
|
43
|
+
const m = label.match(COLOR_SUFFIX_RE);
|
|
44
|
+
if (!m) return { label };
|
|
45
|
+
const colorName = m[1].trim();
|
|
46
|
+
return {
|
|
47
|
+
label: label.substring(0, m.index!).trim(),
|
|
48
|
+
color: resolveColor(colorName, palette),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Try to parse a node reference from a text fragment.
|
|
54
|
+
* Order matters: subroutine & document before process.
|
|
55
|
+
*/
|
|
56
|
+
function parseNodeRef(
|
|
57
|
+
text: string,
|
|
58
|
+
palette?: PaletteColors
|
|
59
|
+
): NodeRef | null {
|
|
60
|
+
const t = text.trim();
|
|
61
|
+
if (!t) return null;
|
|
62
|
+
|
|
63
|
+
// Subroutine: [[Label]]
|
|
64
|
+
let m = t.match(/^\[\[([^\]]+)\]\]$/);
|
|
65
|
+
if (m) {
|
|
66
|
+
const { label, color } = extractColor(m[1].trim(), palette);
|
|
67
|
+
return { id: nodeId('subroutine', label), label, shape: 'subroutine', color };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Document: [Label~]
|
|
71
|
+
m = t.match(/^\[([^\]]+)~\]$/);
|
|
72
|
+
if (m) {
|
|
73
|
+
const { label, color } = extractColor(m[1].trim(), palette);
|
|
74
|
+
return { id: nodeId('document', label), label, shape: 'document', color };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Process: [Label]
|
|
78
|
+
m = t.match(/^\[([^\]]+)\]$/);
|
|
79
|
+
if (m) {
|
|
80
|
+
const { label, color } = extractColor(m[1].trim(), palette);
|
|
81
|
+
return { id: nodeId('process', label), label, shape: 'process', color };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Terminal: (Label) — use .+ (greedy) so (Label(color)) matches outermost parens
|
|
85
|
+
m = t.match(/^\((.+)\)$/);
|
|
86
|
+
if (m) {
|
|
87
|
+
const { label, color } = extractColor(m[1].trim(), palette);
|
|
88
|
+
return { id: nodeId('terminal', label), label, shape: 'terminal', color };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Decision: <Label>
|
|
92
|
+
m = t.match(/^<([^>]+)>$/);
|
|
93
|
+
if (m) {
|
|
94
|
+
const { label, color } = extractColor(m[1].trim(), palette);
|
|
95
|
+
return { id: nodeId('decision', label), label, shape: 'decision', color };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// I/O: /Label/
|
|
99
|
+
m = t.match(/^\/([^/]+)\/$/);
|
|
100
|
+
if (m) {
|
|
101
|
+
const { label, color } = extractColor(m[1].trim(), palette);
|
|
102
|
+
return { id: nodeId('io', label), label, shape: 'io', color };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Split a line into segments around arrow tokens.
|
|
110
|
+
* Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`
|
|
111
|
+
*
|
|
112
|
+
* Returns alternating: [nodeText, arrowText, nodeText, arrowText, nodeText, ...]
|
|
113
|
+
* Where arrowText is the full arrow token like `-yes->` or `->`.
|
|
114
|
+
*/
|
|
115
|
+
function splitArrows(line: string): string[] {
|
|
116
|
+
const segments: string[] = [];
|
|
117
|
+
// Match: optional `-label(color)->` or just `->`
|
|
118
|
+
// We scan left to right looking for `->` and work backwards to find the `-` start.
|
|
119
|
+
const arrowRe = /(?:^|\s)-([^>\s(][^(>]*?)?\s*(?:\(([^)]+)\))?\s*->|(?:^|\s)->/g;
|
|
120
|
+
|
|
121
|
+
let lastIndex = 0;
|
|
122
|
+
// Simpler approach: find all `->` positions, then determine if there's a label prefix
|
|
123
|
+
const arrowPositions: { start: number; end: number; label?: string; color?: string }[] = [];
|
|
124
|
+
|
|
125
|
+
// Find all -> occurrences
|
|
126
|
+
let searchFrom = 0;
|
|
127
|
+
while (searchFrom < line.length) {
|
|
128
|
+
const idx = line.indexOf('->', searchFrom);
|
|
129
|
+
if (idx === -1) break;
|
|
130
|
+
|
|
131
|
+
// Look backwards from idx to find the start of the arrow (the `-` that starts the label)
|
|
132
|
+
let arrowStart = idx;
|
|
133
|
+
let label: string | undefined;
|
|
134
|
+
let color: string | undefined;
|
|
135
|
+
|
|
136
|
+
// Check if there's content between a preceding `-` and this `->` (e.g., `-yes->`)
|
|
137
|
+
// Walk backwards from idx-1 to find another `-` that could be the arrow start
|
|
138
|
+
if (idx > 0 && line[idx - 1] !== ' ' && line[idx - 1] !== '\t') {
|
|
139
|
+
// There might be label/color content attached: e.g. `-yes->` or `-(blue)->`
|
|
140
|
+
// The arrow token starts with `-` followed by optional label, optional (color), then `->`
|
|
141
|
+
// We need to find the opening `-` before any label text
|
|
142
|
+
// Scan backwards to find a `-` preceded by whitespace or start-of-line
|
|
143
|
+
let scanBack = idx - 1;
|
|
144
|
+
while (scanBack > 0 && line[scanBack] !== '-') {
|
|
145
|
+
scanBack--;
|
|
146
|
+
}
|
|
147
|
+
// Check if this `-` could be the start of the arrow
|
|
148
|
+
if (line[scanBack] === '-' && (scanBack === 0 || /\s/.test(line[scanBack - 1]))) {
|
|
149
|
+
// Content between opening `-` and `->` (strip trailing `-` that is part of `->`)
|
|
150
|
+
let arrowContent = line.substring(scanBack + 1, idx);
|
|
151
|
+
if (arrowContent.endsWith('-')) arrowContent = arrowContent.slice(0, -1);
|
|
152
|
+
// Parse label and color from arrow content
|
|
153
|
+
const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
|
|
154
|
+
if (colorMatch) {
|
|
155
|
+
color = colorMatch[1].trim();
|
|
156
|
+
const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
|
|
157
|
+
if (labelPart) label = labelPart;
|
|
158
|
+
} else {
|
|
159
|
+
const labelPart = arrowContent.trim();
|
|
160
|
+
if (labelPart) label = labelPart;
|
|
161
|
+
}
|
|
162
|
+
arrowStart = scanBack;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
arrowPositions.push({ start: arrowStart, end: idx + 2, label, color });
|
|
167
|
+
searchFrom = idx + 2;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (arrowPositions.length === 0) {
|
|
171
|
+
return [line];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Build segments
|
|
175
|
+
for (let i = 0; i < arrowPositions.length; i++) {
|
|
176
|
+
const arrow = arrowPositions[i];
|
|
177
|
+
const beforeText = line.substring(lastIndex, arrow.start).trim();
|
|
178
|
+
if (beforeText || i === 0) {
|
|
179
|
+
segments.push(beforeText);
|
|
180
|
+
}
|
|
181
|
+
// Arrow marker
|
|
182
|
+
let arrowToken = '->';
|
|
183
|
+
if (arrow.label && arrow.color) arrowToken = `-${arrow.label}(${arrow.color})->`;
|
|
184
|
+
else if (arrow.label) arrowToken = `-${arrow.label}->`;
|
|
185
|
+
else if (arrow.color) arrowToken = `-(${arrow.color})->`;
|
|
186
|
+
segments.push(arrowToken);
|
|
187
|
+
lastIndex = arrow.end;
|
|
188
|
+
}
|
|
189
|
+
// Remaining text after last arrow
|
|
190
|
+
const remaining = line.substring(lastIndex).trim();
|
|
191
|
+
if (remaining) {
|
|
192
|
+
segments.push(remaining);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return segments;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface ArrowInfo {
|
|
199
|
+
label?: string;
|
|
200
|
+
color?: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseArrowToken(token: string, palette?: PaletteColors): ArrowInfo {
|
|
204
|
+
if (token === '->') return {};
|
|
205
|
+
// Color-only: -(color)->
|
|
206
|
+
const colorOnly = token.match(/^-\(([^)]+)\)->$/);
|
|
207
|
+
if (colorOnly) {
|
|
208
|
+
return { color: resolveColor(colorOnly[1].trim(), palette) };
|
|
209
|
+
}
|
|
210
|
+
// -label(color)-> or -label->
|
|
211
|
+
const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
|
|
212
|
+
if (m) {
|
|
213
|
+
const label = m[1]?.trim() || undefined;
|
|
214
|
+
const color = m[2] ? resolveColor(m[2].trim(), palette) : undefined;
|
|
215
|
+
return { label, color };
|
|
216
|
+
}
|
|
217
|
+
return {};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ============================================================
|
|
221
|
+
// Group heading pattern
|
|
222
|
+
// ============================================================
|
|
223
|
+
const GROUP_HEADING_RE = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
|
|
224
|
+
|
|
225
|
+
// ============================================================
|
|
226
|
+
// Main parser
|
|
227
|
+
// ============================================================
|
|
228
|
+
|
|
229
|
+
export function parseFlowchart(
|
|
230
|
+
content: string,
|
|
231
|
+
palette?: PaletteColors
|
|
232
|
+
): ParsedGraph {
|
|
233
|
+
const lines = content.split('\n');
|
|
234
|
+
const result: ParsedGraph = {
|
|
235
|
+
type: 'flowchart',
|
|
236
|
+
direction: 'TB',
|
|
237
|
+
nodes: [],
|
|
238
|
+
edges: [],
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const nodeMap = new Map<string, GraphNode>();
|
|
242
|
+
const indentStack: { nodeId: string; indent: number }[] = [];
|
|
243
|
+
let currentGroup: GraphGroup | null = null;
|
|
244
|
+
const groups: GraphGroup[] = [];
|
|
245
|
+
let contentStarted = false;
|
|
246
|
+
|
|
247
|
+
function getOrCreateNode(ref: NodeRef, lineNumber: number): GraphNode {
|
|
248
|
+
const existing = nodeMap.get(ref.id);
|
|
249
|
+
if (existing) return existing;
|
|
250
|
+
|
|
251
|
+
const node: GraphNode = {
|
|
252
|
+
id: ref.id,
|
|
253
|
+
label: ref.label,
|
|
254
|
+
shape: ref.shape,
|
|
255
|
+
lineNumber,
|
|
256
|
+
...(ref.color && { color: ref.color }),
|
|
257
|
+
...(currentGroup && { group: currentGroup.id }),
|
|
258
|
+
};
|
|
259
|
+
nodeMap.set(ref.id, node);
|
|
260
|
+
result.nodes.push(node);
|
|
261
|
+
|
|
262
|
+
// Add to current group
|
|
263
|
+
if (currentGroup && !currentGroup.nodeIds.includes(ref.id)) {
|
|
264
|
+
currentGroup.nodeIds.push(ref.id);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return node;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function addEdge(
|
|
271
|
+
sourceId: string,
|
|
272
|
+
targetId: string,
|
|
273
|
+
lineNumber: number,
|
|
274
|
+
label?: string,
|
|
275
|
+
color?: string
|
|
276
|
+
): void {
|
|
277
|
+
const edge: GraphEdge = {
|
|
278
|
+
source: sourceId,
|
|
279
|
+
target: targetId,
|
|
280
|
+
lineNumber,
|
|
281
|
+
...(label && { label }),
|
|
282
|
+
...(color && { color }),
|
|
283
|
+
};
|
|
284
|
+
result.edges.push(edge);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Process a content line that may contain nodes and arrows.
|
|
289
|
+
* Returns the last node ID encountered (for indent stack tracking).
|
|
290
|
+
*/
|
|
291
|
+
function processContentLine(
|
|
292
|
+
trimmed: string,
|
|
293
|
+
lineNumber: number,
|
|
294
|
+
indent: number
|
|
295
|
+
): string | null {
|
|
296
|
+
contentStarted = true;
|
|
297
|
+
|
|
298
|
+
// Determine implicit source from indent stack
|
|
299
|
+
// Pop stack entries that are at same or deeper indent level
|
|
300
|
+
while (indentStack.length > 0) {
|
|
301
|
+
const top = indentStack[indentStack.length - 1];
|
|
302
|
+
if (top.indent >= indent) {
|
|
303
|
+
indentStack.pop();
|
|
304
|
+
} else {
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const implicitSourceId =
|
|
310
|
+
indentStack.length > 0
|
|
311
|
+
? indentStack[indentStack.length - 1].nodeId
|
|
312
|
+
: null;
|
|
313
|
+
|
|
314
|
+
// Split line into segments around arrows
|
|
315
|
+
const segments = splitArrows(trimmed);
|
|
316
|
+
|
|
317
|
+
if (segments.length === 1) {
|
|
318
|
+
// Single node reference, no arrows
|
|
319
|
+
const ref = parseNodeRef(segments[0], palette);
|
|
320
|
+
if (ref) {
|
|
321
|
+
const node = getOrCreateNode(ref, lineNumber);
|
|
322
|
+
indentStack.push({ nodeId: node.id, indent });
|
|
323
|
+
return node.id;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Process chain: alternating nodeText / arrowToken / nodeText / ...
|
|
329
|
+
let lastNodeId: string | null = null;
|
|
330
|
+
let pendingArrow: ArrowInfo | null = null;
|
|
331
|
+
|
|
332
|
+
for (let i = 0; i < segments.length; i++) {
|
|
333
|
+
const seg = segments[i];
|
|
334
|
+
|
|
335
|
+
// Check if this is an arrow token
|
|
336
|
+
if (seg === '->' || /^-.+->$/.test(seg)) {
|
|
337
|
+
pendingArrow = parseArrowToken(seg, palette);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// This is a node text segment
|
|
342
|
+
const ref = parseNodeRef(seg, palette);
|
|
343
|
+
if (!ref) continue;
|
|
344
|
+
|
|
345
|
+
const node = getOrCreateNode(ref, lineNumber);
|
|
346
|
+
|
|
347
|
+
if (pendingArrow !== null) {
|
|
348
|
+
const sourceId = lastNodeId ?? implicitSourceId;
|
|
349
|
+
if (sourceId) {
|
|
350
|
+
addEdge(
|
|
351
|
+
sourceId,
|
|
352
|
+
node.id,
|
|
353
|
+
lineNumber,
|
|
354
|
+
pendingArrow.label,
|
|
355
|
+
pendingArrow.color
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
pendingArrow = null;
|
|
359
|
+
} else if (lastNodeId === null && implicitSourceId === null) {
|
|
360
|
+
// First node in chain, no arrow yet — just register
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
lastNodeId = node.id;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// If we ended with a pending arrow but no target node, that's an edge-only line
|
|
367
|
+
// handled by: the arrow was at the start with implicit source
|
|
368
|
+
if (pendingArrow !== null && lastNodeId === null && implicitSourceId) {
|
|
369
|
+
// Edge-only line like ` -> ` with no target — ignore
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// If line started with an arrow and we have an implicit source
|
|
373
|
+
// but no explicit first node, the first segment was empty
|
|
374
|
+
if (
|
|
375
|
+
segments.length >= 2 &&
|
|
376
|
+
segments[0] === '' &&
|
|
377
|
+
implicitSourceId &&
|
|
378
|
+
lastNodeId
|
|
379
|
+
) {
|
|
380
|
+
// Already handled above — the implicit source was used
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (lastNodeId) {
|
|
384
|
+
indentStack.push({ nodeId: lastNodeId, indent });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return lastNodeId;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// === Main loop ===
|
|
391
|
+
for (let i = 0; i < lines.length; i++) {
|
|
392
|
+
const raw = lines[i];
|
|
393
|
+
const trimmed = raw.trim();
|
|
394
|
+
const lineNumber = i + 1;
|
|
395
|
+
const indent = measureIndent(raw);
|
|
396
|
+
|
|
397
|
+
// Skip empty lines
|
|
398
|
+
if (!trimmed) continue;
|
|
399
|
+
|
|
400
|
+
// Skip comments
|
|
401
|
+
if (trimmed.startsWith('//')) continue;
|
|
402
|
+
|
|
403
|
+
// Group headings
|
|
404
|
+
const groupMatch = trimmed.match(GROUP_HEADING_RE);
|
|
405
|
+
if (groupMatch) {
|
|
406
|
+
const groupLabel = groupMatch[1].trim();
|
|
407
|
+
const groupColorName = groupMatch[2]?.trim();
|
|
408
|
+
const groupColor = groupColorName
|
|
409
|
+
? resolveColor(groupColorName, palette)
|
|
410
|
+
: undefined;
|
|
411
|
+
|
|
412
|
+
currentGroup = {
|
|
413
|
+
id: `group:${groupLabel.toLowerCase()}`,
|
|
414
|
+
label: groupLabel,
|
|
415
|
+
nodeIds: [],
|
|
416
|
+
lineNumber,
|
|
417
|
+
...(groupColor && { color: groupColor }),
|
|
418
|
+
};
|
|
419
|
+
groups.push(currentGroup);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Metadata directives (before content)
|
|
424
|
+
if (!contentStarted && trimmed.includes(':') && !trimmed.includes('->')) {
|
|
425
|
+
const colonIdx = trimmed.indexOf(':');
|
|
426
|
+
const key = trimmed.substring(0, colonIdx).trim().toLowerCase();
|
|
427
|
+
const value = trimmed.substring(colonIdx + 1).trim();
|
|
428
|
+
|
|
429
|
+
if (key === 'chart') {
|
|
430
|
+
if (value.toLowerCase() !== 'flowchart') {
|
|
431
|
+
result.error = `Line ${lineNumber}: Expected chart type "flowchart", got "${value}"`;
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (key === 'title') {
|
|
438
|
+
result.title = value;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (key === 'direction') {
|
|
443
|
+
const dir = value.toUpperCase() as GraphDirection;
|
|
444
|
+
if (dir === 'TB' || dir === 'LR') {
|
|
445
|
+
result.direction = dir;
|
|
446
|
+
}
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Unknown metadata — skip
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Content line (nodes and edges)
|
|
455
|
+
processContentLine(trimmed, lineNumber, indent);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (groups.length > 0) result.groups = groups;
|
|
459
|
+
|
|
460
|
+
// Validation: no nodes found
|
|
461
|
+
if (result.nodes.length === 0 && !result.error) {
|
|
462
|
+
result.error = 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ============================================================
|
|
469
|
+
// Detection helper
|
|
470
|
+
// ============================================================
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Detect if content looks like a flowchart (without explicit `chart: flowchart` header).
|
|
474
|
+
* Checks for shape delimiters combined with `->` arrows.
|
|
475
|
+
* Avoids false-positives on sequence diagrams (which use bare names with `->`)
|
|
476
|
+
*/
|
|
477
|
+
export function looksLikeFlowchart(content: string): boolean {
|
|
478
|
+
// Must have -> arrows
|
|
479
|
+
if (!content.includes('->')) return false;
|
|
480
|
+
|
|
481
|
+
// Must have at least one shape delimiter pattern
|
|
482
|
+
// Shape delimiters: [...], (...), <...>, /.../, [[...]], [...~]
|
|
483
|
+
// Sequence diagrams use bare names like "Alice -> Bob: msg" — no delimiters around names
|
|
484
|
+
const hasShapeDelimiter =
|
|
485
|
+
/\[[^\]]+\]/.test(content) ||
|
|
486
|
+
/\([^)]+\)/.test(content) ||
|
|
487
|
+
/<[^>]+>/.test(content) ||
|
|
488
|
+
/\/[^/]+\//.test(content);
|
|
489
|
+
|
|
490
|
+
if (!hasShapeDelimiter) return false;
|
|
491
|
+
|
|
492
|
+
// Check that shape delimiters appear near arrows (not just random brackets)
|
|
493
|
+
// Look for patterns like `[X] ->` or `-> [X]` or `(X) ->` etc.
|
|
494
|
+
const shapeNearArrow =
|
|
495
|
+
/[\])][ \t]*-.*->/.test(content) || // shape ] or ) followed by arrow
|
|
496
|
+
/->[ \t]*[\[(<\/]/.test(content); // arrow followed by shape opener
|
|
497
|
+
|
|
498
|
+
return shapeNearArrow;
|
|
499
|
+
}
|