@diagrammo/dgmo 0.8.3 → 0.8.4
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/.claude/commands/dgmo-diagram-this.md +60 -0
- package/.claude/commands/dgmo-document-project.md +128 -0
- package/.claude/commands/dgmo.md +185 -50
- package/.cursorrules +32 -37
- package/.github/copilot-instructions.md +35 -44
- package/.windsurfrules +32 -37
- package/README.md +4 -4
- package/dist/cli.cjs +153 -153
- package/dist/editor.cjs +336 -0
- package/dist/editor.cjs.map +1 -0
- package/dist/editor.d.cts +27 -0
- package/dist/editor.d.ts +27 -0
- package/dist/editor.js +305 -0
- package/dist/editor.js.map +1 -0
- package/dist/index.cjs +3336 -1055
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3336 -1055
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +30 -29
- package/gallery/fixtures/arc.dgmo +18 -0
- package/gallery/fixtures/area.dgmo +19 -0
- package/gallery/fixtures/bar-stacked.dgmo +10 -0
- package/gallery/fixtures/bar.dgmo +10 -0
- package/gallery/fixtures/c4-full.dgmo +52 -0
- package/gallery/fixtures/c4.dgmo +17 -0
- package/gallery/fixtures/chord.dgmo +12 -0
- package/gallery/fixtures/class-basic.dgmo +14 -0
- package/gallery/fixtures/class-full.dgmo +43 -0
- package/gallery/fixtures/doughnut.dgmo +8 -0
- package/gallery/fixtures/flowchart-basic.dgmo +3 -0
- package/gallery/fixtures/flowchart-colors.dgmo +5 -0
- package/gallery/fixtures/flowchart-complex.dgmo +17 -0
- package/gallery/fixtures/flowchart-decision.dgmo +5 -0
- package/gallery/fixtures/flowchart-full.dgmo +13 -0
- package/gallery/fixtures/flowchart-groups.dgmo +10 -0
- package/gallery/fixtures/flowchart-loop.dgmo +7 -0
- package/gallery/fixtures/flowchart-nested.dgmo +7 -0
- package/gallery/fixtures/flowchart-shapes.dgmo +5 -0
- package/gallery/fixtures/function.dgmo +8 -0
- package/gallery/fixtures/funnel.dgmo +7 -0
- package/gallery/fixtures/gantt-full.dgmo +49 -0
- package/gallery/fixtures/gantt.dgmo +42 -0
- package/gallery/fixtures/heatmap.dgmo +8 -0
- package/gallery/fixtures/infra-full.dgmo +78 -0
- package/gallery/fixtures/infra-overload.dgmo +25 -0
- package/gallery/fixtures/infra.dgmo +47 -0
- package/gallery/fixtures/initiative-status-full.dgmo +46 -0
- package/gallery/fixtures/initiative-status-phases.dgmo +29 -0
- package/gallery/fixtures/initiative-status.dgmo +9 -0
- package/gallery/fixtures/line.dgmo +19 -0
- package/gallery/fixtures/multi-line.dgmo +11 -0
- package/gallery/fixtures/org-basic.dgmo +16 -0
- package/gallery/fixtures/org-full.dgmo +69 -0
- package/gallery/fixtures/org-teams.dgmo +25 -0
- package/gallery/fixtures/pie.dgmo +9 -0
- package/gallery/fixtures/polar-area.dgmo +8 -0
- package/gallery/fixtures/quadrant.dgmo +18 -0
- package/gallery/fixtures/radar.dgmo +8 -0
- package/gallery/fixtures/sankey.dgmo +31 -0
- package/gallery/fixtures/scatter.dgmo +21 -0
- package/gallery/fixtures/sequence-tags-protocols.dgmo +45 -0
- package/gallery/fixtures/sequence-tags.dgmo +41 -0
- package/gallery/fixtures/sequence.dgmo +35 -0
- package/gallery/fixtures/sitemap-basic.dgmo +12 -0
- package/gallery/fixtures/sitemap-full.dgmo +156 -0
- package/gallery/fixtures/slope.dgmo +8 -0
- package/gallery/fixtures/spr-eras.dgmo +62 -0
- package/gallery/fixtures/state.dgmo +30 -0
- package/gallery/fixtures/timeline-intraday.dgmo +14 -0
- package/gallery/fixtures/timeline.dgmo +32 -0
- package/gallery/fixtures/venn.dgmo +10 -0
- package/gallery/fixtures/wordcloud.dgmo +24 -0
- package/package.json +51 -2
- package/src/c4/layout.ts +372 -90
- package/src/c4/parser.ts +100 -55
- package/src/chart.ts +91 -28
- package/src/class/parser.ts +41 -12
- package/src/cli.ts +168 -61
- package/src/completion.ts +378 -183
- package/src/d3.ts +887 -288
- package/src/dgmo-mermaid.ts +16 -13
- package/src/dgmo-router.ts +69 -23
- package/src/echarts.ts +646 -153
- package/src/editor/dgmo.grammar +69 -0
- package/src/editor/dgmo.grammar.d.ts +2 -0
- package/src/editor/dgmo.grammar.js +18 -0
- package/src/editor/dgmo.grammar.terms.d.ts +5 -0
- package/src/editor/dgmo.grammar.terms.js +35 -0
- package/src/editor/highlight.ts +36 -0
- package/src/editor/index.ts +28 -0
- package/src/editor/keywords.ts +220 -0
- package/src/editor/tokens.ts +30 -0
- package/src/er/parser.ts +48 -14
- package/src/er/renderer.ts +112 -53
- package/src/gantt/calculator.ts +91 -29
- package/src/gantt/parser.ts +197 -71
- package/src/gantt/renderer.ts +1120 -350
- package/src/graph/flowchart-parser.ts +46 -25
- package/src/graph/state-parser.ts +47 -17
- package/src/infra/parser.ts +157 -53
- package/src/infra/renderer.ts +723 -271
- package/src/initiative-status/parser.ts +138 -44
- package/src/kanban/parser.ts +25 -14
- package/src/org/layout.ts +111 -44
- package/src/org/parser.ts +69 -22
- package/src/palettes/index.ts +3 -2
- package/src/sequence/parser.ts +193 -61
- package/src/sitemap/parser.ts +65 -29
- package/src/utils/arrows.ts +2 -22
- package/src/utils/duration.ts +39 -21
- package/src/utils/legend-constants.ts +0 -2
- package/src/utils/parsing.ts +75 -31
|
@@ -9,13 +9,7 @@ import {
|
|
|
9
9
|
OPTION_NOCOLON_RE,
|
|
10
10
|
ALL_CHART_TYPES,
|
|
11
11
|
} from '../utils/parsing';
|
|
12
|
-
import type {
|
|
13
|
-
ParsedGraph,
|
|
14
|
-
GraphNode,
|
|
15
|
-
GraphEdge,
|
|
16
|
-
GraphShape,
|
|
17
|
-
GraphDirection,
|
|
18
|
-
} from './types';
|
|
12
|
+
import type { ParsedGraph, GraphNode, GraphEdge, GraphShape } from './types';
|
|
19
13
|
|
|
20
14
|
// ============================================================
|
|
21
15
|
// Helpers
|
|
@@ -36,10 +30,7 @@ interface NodeRef {
|
|
|
36
30
|
* Try to parse a node reference from a text fragment.
|
|
37
31
|
* Order matters: subroutine & document before process.
|
|
38
32
|
*/
|
|
39
|
-
function parseNodeRef(
|
|
40
|
-
text: string,
|
|
41
|
-
palette?: PaletteColors
|
|
42
|
-
): NodeRef | null {
|
|
33
|
+
function parseNodeRef(text: string, palette?: PaletteColors): NodeRef | null {
|
|
43
34
|
const t = text.trim();
|
|
44
35
|
if (!t) return null;
|
|
45
36
|
|
|
@@ -47,7 +38,12 @@ function parseNodeRef(
|
|
|
47
38
|
let m = t.match(/^\[\[([^\]]+)\]\]$/);
|
|
48
39
|
if (m) {
|
|
49
40
|
const { label, color } = extractColor(m[1].trim(), palette);
|
|
50
|
-
return {
|
|
41
|
+
return {
|
|
42
|
+
id: nodeId('subroutine', label),
|
|
43
|
+
label,
|
|
44
|
+
shape: 'subroutine',
|
|
45
|
+
color,
|
|
46
|
+
};
|
|
51
47
|
}
|
|
52
48
|
|
|
53
49
|
// Document: [Label~]
|
|
@@ -99,7 +95,12 @@ function splitArrows(line: string): string[] {
|
|
|
99
95
|
const segments: string[] = [];
|
|
100
96
|
let lastIndex = 0;
|
|
101
97
|
// Simpler approach: find all `->` positions, then determine if there's a label prefix
|
|
102
|
-
const arrowPositions: {
|
|
98
|
+
const arrowPositions: {
|
|
99
|
+
start: number;
|
|
100
|
+
end: number;
|
|
101
|
+
label?: string;
|
|
102
|
+
color?: string;
|
|
103
|
+
}[] = [];
|
|
103
104
|
|
|
104
105
|
// Find all -> occurrences
|
|
105
106
|
let searchFrom = 0;
|
|
@@ -124,10 +125,14 @@ function splitArrows(line: string): string[] {
|
|
|
124
125
|
scanBack--;
|
|
125
126
|
}
|
|
126
127
|
// Check if this `-` could be the start of the arrow
|
|
127
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
line[scanBack] === '-' &&
|
|
130
|
+
(scanBack === 0 || /\s/.test(line[scanBack - 1]))
|
|
131
|
+
) {
|
|
128
132
|
// Content between opening `-` and `->` (strip trailing `-` that is part of `->`)
|
|
129
133
|
let arrowContent = line.substring(scanBack + 1, idx);
|
|
130
|
-
if (arrowContent.endsWith('-'))
|
|
134
|
+
if (arrowContent.endsWith('-'))
|
|
135
|
+
arrowContent = arrowContent.slice(0, -1);
|
|
131
136
|
// Parse label and color from arrow content
|
|
132
137
|
const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
|
|
133
138
|
if (colorMatch) {
|
|
@@ -159,7 +164,8 @@ function splitArrows(line: string): string[] {
|
|
|
159
164
|
}
|
|
160
165
|
// Arrow marker
|
|
161
166
|
let arrowToken = '->';
|
|
162
|
-
if (arrow.label && arrow.color)
|
|
167
|
+
if (arrow.label && arrow.color)
|
|
168
|
+
arrowToken = `-${arrow.label}(${arrow.color})->`;
|
|
163
169
|
else if (arrow.label) arrowToken = `-${arrow.label}->`;
|
|
164
170
|
else if (arrow.color) arrowToken = `-(${arrow.color})->`;
|
|
165
171
|
segments.push(arrowToken);
|
|
@@ -190,7 +196,9 @@ function parseArrowToken(token: string, palette?: PaletteColors): ArrowInfo {
|
|
|
190
196
|
const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
|
|
191
197
|
if (m) {
|
|
192
198
|
const label = m[1]?.trim() || undefined;
|
|
193
|
-
let color = m[2]
|
|
199
|
+
let color = m[2]
|
|
200
|
+
? (resolveColor(m[2].trim(), palette) ?? undefined)
|
|
201
|
+
: undefined;
|
|
194
202
|
if (label && !color) {
|
|
195
203
|
color = inferArrowColor(label);
|
|
196
204
|
}
|
|
@@ -214,7 +222,7 @@ export function parseFlowchart(
|
|
|
214
222
|
const lines = content.split('\n');
|
|
215
223
|
const result: ParsedGraph = {
|
|
216
224
|
type: 'flowchart',
|
|
217
|
-
direction: '
|
|
225
|
+
direction: 'TB',
|
|
218
226
|
nodes: [],
|
|
219
227
|
edges: [],
|
|
220
228
|
options: {},
|
|
@@ -406,9 +414,9 @@ export function parseFlowchart(
|
|
|
406
414
|
|
|
407
415
|
// Options (space-separated, before content)
|
|
408
416
|
if (!contentStarted) {
|
|
409
|
-
// Bare boolean: direction-
|
|
410
|
-
if (/^direction-
|
|
411
|
-
result.direction = '
|
|
417
|
+
// Bare boolean: direction-lr
|
|
418
|
+
if (/^direction-lr$/i.test(trimmed)) {
|
|
419
|
+
result.direction = 'LR';
|
|
412
420
|
continue;
|
|
413
421
|
}
|
|
414
422
|
|
|
@@ -435,7 +443,10 @@ export function parseFlowchart(
|
|
|
435
443
|
|
|
436
444
|
// Validation: no nodes found
|
|
437
445
|
if (result.nodes.length === 0 && !result.error) {
|
|
438
|
-
const diag = makeDgmoError(
|
|
446
|
+
const diag = makeDgmoError(
|
|
447
|
+
1,
|
|
448
|
+
'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).'
|
|
449
|
+
);
|
|
439
450
|
result.diagnostics.push(diag);
|
|
440
451
|
result.error = formatDgmoError(diag);
|
|
441
452
|
}
|
|
@@ -449,7 +460,13 @@ export function parseFlowchart(
|
|
|
449
460
|
}
|
|
450
461
|
for (const node of result.nodes) {
|
|
451
462
|
if (!connectedIds.has(node.id)) {
|
|
452
|
-
result.diagnostics.push(
|
|
463
|
+
result.diagnostics.push(
|
|
464
|
+
makeDgmoError(
|
|
465
|
+
node.lineNumber,
|
|
466
|
+
`Node "${node.label}" is not connected to any other node`,
|
|
467
|
+
'warning'
|
|
468
|
+
)
|
|
469
|
+
);
|
|
453
470
|
}
|
|
454
471
|
}
|
|
455
472
|
}
|
|
@@ -485,7 +502,7 @@ export function looksLikeFlowchart(content: string): boolean {
|
|
|
485
502
|
// Look for patterns like `[X] ->` or `-> [X]` or `(X) ->` etc.
|
|
486
503
|
const shapeNearArrow =
|
|
487
504
|
/[\])][ \t]*-.*->/.test(content) || // shape ] or ) followed by arrow
|
|
488
|
-
/->[ \t]*[
|
|
505
|
+
/->[ \t]*[[(</]/.test(content); // arrow followed by shape opener
|
|
489
506
|
|
|
490
507
|
return shapeNearArrow;
|
|
491
508
|
}
|
|
@@ -509,7 +526,11 @@ export function extractSymbols(docText: string): DiagramSymbols {
|
|
|
509
526
|
for (const rawLine of docText.split('\n')) {
|
|
510
527
|
const line = rawLine.trim();
|
|
511
528
|
// Skip old-style colon metadata and new-style space-separated options
|
|
512
|
-
if (
|
|
529
|
+
if (
|
|
530
|
+
inMetadata &&
|
|
531
|
+
(/^[a-z-]+\s*:/i.test(line) || /^[a-z-]+\s+\S/i.test(line))
|
|
532
|
+
)
|
|
533
|
+
continue;
|
|
513
534
|
inMetadata = false;
|
|
514
535
|
if (line.length === 0 || /^\s/.test(rawLine)) continue;
|
|
515
536
|
const m = NODE_ID_RE.exec(line);
|
|
@@ -8,12 +8,7 @@ import {
|
|
|
8
8
|
OPTION_NOCOLON_RE,
|
|
9
9
|
ALL_CHART_TYPES,
|
|
10
10
|
} from '../utils/parsing';
|
|
11
|
-
import type {
|
|
12
|
-
ParsedGraph,
|
|
13
|
-
GraphNode,
|
|
14
|
-
GraphGroup,
|
|
15
|
-
GraphDirection,
|
|
16
|
-
} from './types';
|
|
11
|
+
import type { ParsedGraph, GraphNode, GraphGroup } from './types';
|
|
17
12
|
|
|
18
13
|
// ============================================================
|
|
19
14
|
// Constants
|
|
@@ -36,7 +31,12 @@ const GROUP_BRACKET_RE = /^\[([^\]]+)\](?:\(([^)]+)\))?\s*$/;
|
|
|
36
31
|
*/
|
|
37
32
|
function splitArrows(line: string): string[] {
|
|
38
33
|
const segments: string[] = [];
|
|
39
|
-
const arrowPositions: {
|
|
34
|
+
const arrowPositions: {
|
|
35
|
+
start: number;
|
|
36
|
+
end: number;
|
|
37
|
+
label?: string;
|
|
38
|
+
color?: string;
|
|
39
|
+
}[] = [];
|
|
40
40
|
|
|
41
41
|
let searchFrom = 0;
|
|
42
42
|
while (searchFrom < line.length) {
|
|
@@ -52,9 +52,13 @@ function splitArrows(line: string): string[] {
|
|
|
52
52
|
while (scanBack > 0 && line[scanBack] !== '-') {
|
|
53
53
|
scanBack--;
|
|
54
54
|
}
|
|
55
|
-
if (
|
|
55
|
+
if (
|
|
56
|
+
line[scanBack] === '-' &&
|
|
57
|
+
(scanBack === 0 || /\s/.test(line[scanBack - 1]))
|
|
58
|
+
) {
|
|
56
59
|
let arrowContent = line.substring(scanBack + 1, idx);
|
|
57
|
-
if (arrowContent.endsWith('-'))
|
|
60
|
+
if (arrowContent.endsWith('-'))
|
|
61
|
+
arrowContent = arrowContent.slice(0, -1);
|
|
58
62
|
const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
|
|
59
63
|
if (colorMatch) {
|
|
60
64
|
color = colorMatch[1].trim();
|
|
@@ -81,7 +85,8 @@ function splitArrows(line: string): string[] {
|
|
|
81
85
|
if (beforeText || i === 0) segments.push(beforeText);
|
|
82
86
|
|
|
83
87
|
let arrowToken = '->';
|
|
84
|
-
if (arrow.label && arrow.color)
|
|
88
|
+
if (arrow.label && arrow.color)
|
|
89
|
+
arrowToken = `-${arrow.label}(${arrow.color})->`;
|
|
85
90
|
else if (arrow.label) arrowToken = `-${arrow.label}->`;
|
|
86
91
|
else if (arrow.color) arrowToken = `-(${arrow.color})->`;
|
|
87
92
|
segments.push(arrowToken);
|
|
@@ -101,11 +106,14 @@ interface ArrowInfo {
|
|
|
101
106
|
function parseArrowToken(token: string, palette?: PaletteColors): ArrowInfo {
|
|
102
107
|
if (token === '->') return {};
|
|
103
108
|
const colorOnly = token.match(/^-\(([^)]+)\)->$/);
|
|
104
|
-
if (colorOnly)
|
|
109
|
+
if (colorOnly)
|
|
110
|
+
return { color: resolveColor(colorOnly[1].trim(), palette) ?? undefined };
|
|
105
111
|
const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
|
|
106
112
|
if (m) {
|
|
107
113
|
const label = m[1]?.trim() || undefined;
|
|
108
|
-
const color = m[2]
|
|
114
|
+
const color = m[2]
|
|
115
|
+
? (resolveColor(m[2].trim(), palette) ?? undefined)
|
|
116
|
+
: undefined;
|
|
109
117
|
return { label, color };
|
|
110
118
|
}
|
|
111
119
|
return {};
|
|
@@ -122,13 +130,20 @@ interface NodeRef {
|
|
|
122
130
|
color?: string;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
function parseStateNodeRef(
|
|
133
|
+
function parseStateNodeRef(
|
|
134
|
+
text: string,
|
|
135
|
+
palette?: PaletteColors
|
|
136
|
+
): NodeRef | null {
|
|
126
137
|
const t = text.trim();
|
|
127
138
|
if (!t) return null;
|
|
128
139
|
|
|
129
140
|
// Pseudostate: [*]
|
|
130
141
|
if (t === '[*]') {
|
|
131
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
id: PSEUDOSTATE_ID,
|
|
144
|
+
label: PSEUDOSTATE_LABEL,
|
|
145
|
+
shape: 'pseudostate',
|
|
146
|
+
};
|
|
132
147
|
}
|
|
133
148
|
|
|
134
149
|
// State: bare text with optional (color) suffix
|
|
@@ -350,7 +365,13 @@ export function parseState(
|
|
|
350
365
|
// Use explicit source if available, else implicit from indent
|
|
351
366
|
const sourceId = lastNodeId ?? implicitSourceId;
|
|
352
367
|
if (sourceId) {
|
|
353
|
-
addEdge(
|
|
368
|
+
addEdge(
|
|
369
|
+
sourceId,
|
|
370
|
+
node.id,
|
|
371
|
+
lineNumber,
|
|
372
|
+
pendingArrow.label,
|
|
373
|
+
pendingArrow.color
|
|
374
|
+
);
|
|
354
375
|
}
|
|
355
376
|
pendingArrow = null;
|
|
356
377
|
}
|
|
@@ -367,7 +388,10 @@ export function parseState(
|
|
|
367
388
|
|
|
368
389
|
// Validation: no nodes found
|
|
369
390
|
if (result.nodes.length === 0 && !result.error) {
|
|
370
|
-
const diag = makeDgmoError(
|
|
391
|
+
const diag = makeDgmoError(
|
|
392
|
+
1,
|
|
393
|
+
'No states found. Add state transitions like: Idle -> Active'
|
|
394
|
+
);
|
|
371
395
|
result.diagnostics.push(diag);
|
|
372
396
|
result.error = formatDgmoError(diag);
|
|
373
397
|
}
|
|
@@ -381,7 +405,13 @@ export function parseState(
|
|
|
381
405
|
}
|
|
382
406
|
for (const node of result.nodes) {
|
|
383
407
|
if (!connectedIds.has(node.id)) {
|
|
384
|
-
result.diagnostics.push(
|
|
408
|
+
result.diagnostics.push(
|
|
409
|
+
makeDgmoError(
|
|
410
|
+
node.lineNumber,
|
|
411
|
+
`State "${node.label}" is not connected to any other state`,
|
|
412
|
+
'warning'
|
|
413
|
+
)
|
|
414
|
+
);
|
|
385
415
|
}
|
|
386
416
|
}
|
|
387
417
|
}
|
package/src/infra/parser.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
// and connections, [Group] containers, tag groups, pipe metadata.
|
|
8
8
|
|
|
9
9
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
measureIndent,
|
|
12
|
+
parseFirstLine,
|
|
13
|
+
OPTION_NOCOLON_RE,
|
|
14
|
+
} from '../utils/parsing';
|
|
11
15
|
import { matchTagBlockHeading } from '../utils/tag-groups';
|
|
12
16
|
import type {
|
|
13
17
|
ParsedInfra,
|
|
@@ -21,21 +25,17 @@ import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
|
|
|
21
25
|
// Regex patterns
|
|
22
26
|
// ============================================================
|
|
23
27
|
|
|
24
|
-
// Connection: -label-> Target or -> Target (
|
|
25
|
-
const CONNECTION_RE =
|
|
26
|
-
/^-(?:([^-].*?))?->\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
|
|
28
|
+
// Connection: -label-> Target or -> Target (pipe metadata handled by extractPipeMetadata)
|
|
29
|
+
const CONNECTION_RE = /^-(?:([^-].*?))?->\s*(.+?)\s*$/;
|
|
27
30
|
|
|
28
31
|
// Simple connection shorthand: -> Target (no label, no dash prefix needed for edge)
|
|
29
|
-
const SIMPLE_CONNECTION_RE =
|
|
30
|
-
/^->\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
|
|
32
|
+
const SIMPLE_CONNECTION_RE = /^->\s*(.+?)\s*$/;
|
|
31
33
|
|
|
32
|
-
// Async connection: ~label~> Target or ~> Target
|
|
33
|
-
const ASYNC_CONNECTION_RE =
|
|
34
|
-
/^~(?:([^~].*?))?~>\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
|
|
34
|
+
// Async connection: ~label~> Target or ~> Target
|
|
35
|
+
const ASYNC_CONNECTION_RE = /^~(?:([^~].*?))?~>\s*(.+?)\s*$/;
|
|
35
36
|
|
|
36
37
|
// Async simple connection shorthand: ~> Target
|
|
37
|
-
const ASYNC_SIMPLE_CONNECTION_RE =
|
|
38
|
-
/^~>\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
|
|
38
|
+
const ASYNC_SIMPLE_CONNECTION_RE = /^~>\s*(.+?)\s*$/;
|
|
39
39
|
|
|
40
40
|
// Deprecated xN fanout suffix (e.g. "x5" at end of line)
|
|
41
41
|
const DEPRECATED_FANOUT_RE = /\bx(\d+)\s*$/;
|
|
@@ -68,8 +68,12 @@ const EDGE_NODE_NAMES = new Set(['edge', 'internet']);
|
|
|
68
68
|
|
|
69
69
|
// Known top-level option keys (space-separated, no colon)
|
|
70
70
|
const TOP_LEVEL_OPTIONS = new Set([
|
|
71
|
-
'slo-availability',
|
|
72
|
-
'
|
|
71
|
+
'slo-availability',
|
|
72
|
+
'slo-p90-latency-ms',
|
|
73
|
+
'slo-warning-margin',
|
|
74
|
+
'default-latency-ms',
|
|
75
|
+
'default-uptime',
|
|
76
|
+
'default-rps',
|
|
73
77
|
]);
|
|
74
78
|
|
|
75
79
|
// ============================================================
|
|
@@ -94,9 +98,10 @@ function parsePropertyValue(raw: string): string | number {
|
|
|
94
98
|
return raw.trim();
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
function extractPipeMetadata(
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
function extractPipeMetadata(rest: string): {
|
|
102
|
+
tags: Record<string, string>;
|
|
103
|
+
clean: string;
|
|
104
|
+
} {
|
|
100
105
|
const tags: Record<string, string> = {};
|
|
101
106
|
let clean = rest;
|
|
102
107
|
let match: RegExpExecArray | null;
|
|
@@ -108,6 +113,30 @@ function extractPipeMetadata(
|
|
|
108
113
|
return { tags, clean: clean.trim() };
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
// Detect unparsed pipe metadata left in a target name after extractPipeMetadata.
|
|
117
|
+
// Common case: `split 100%` without a colon isn't picked up by PIPE_META_RE.
|
|
118
|
+
const UNPARSED_SPLIT_RE = /\bsplit\s+(\d+)%/;
|
|
119
|
+
|
|
120
|
+
function warnUnparsedPipeMeta(
|
|
121
|
+
targetName: string,
|
|
122
|
+
lineNumber: number,
|
|
123
|
+
warnFn: (line: number, message: string) => void
|
|
124
|
+
): void {
|
|
125
|
+
if (!targetName.includes('|')) return;
|
|
126
|
+
const splitMatch = targetName.match(UNPARSED_SPLIT_RE);
|
|
127
|
+
if (splitMatch) {
|
|
128
|
+
warnFn(
|
|
129
|
+
lineNumber,
|
|
130
|
+
`'split ${splitMatch[1]}%' needs a colon — use 'split: ${splitMatch[1]}%'`
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
warnFn(
|
|
134
|
+
lineNumber,
|
|
135
|
+
`Unparsed pipe metadata in target — pipe values use 'key: value' syntax`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
111
140
|
// ============================================================
|
|
112
141
|
// Parser
|
|
113
142
|
// ============================================================
|
|
@@ -150,20 +179,26 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
150
179
|
if (currentNode && !nodeMap.has(currentNode.id)) {
|
|
151
180
|
// Validate mutual exclusion: concurrency vs instances/max-rps
|
|
152
181
|
const keys = new Set(currentNode.properties.map((p) => p.key));
|
|
153
|
-
if (
|
|
154
|
-
|
|
182
|
+
if (
|
|
183
|
+
keys.has('concurrency') &&
|
|
184
|
+
(keys.has('instances') || keys.has('max-rps'))
|
|
185
|
+
) {
|
|
186
|
+
const conflicting = [
|
|
187
|
+
keys.has('instances') ? 'instances' : '',
|
|
188
|
+
keys.has('max-rps') ? 'max-rps' : '',
|
|
189
|
+
]
|
|
155
190
|
.filter(Boolean)
|
|
156
191
|
.join(', ');
|
|
157
192
|
warn(
|
|
158
193
|
currentNode.lineNumber,
|
|
159
|
-
`'concurrency' (serverless) is mutually exclusive with ${conflicting}. Serverless nodes scale via concurrency, not instances
|
|
194
|
+
`'concurrency' (serverless) is mutually exclusive with ${conflicting}. Serverless nodes scale via concurrency, not instances.`
|
|
160
195
|
);
|
|
161
196
|
}
|
|
162
197
|
// Validate mutual exclusion: buffer (queue) vs max-rps (service)
|
|
163
198
|
if (keys.has('buffer') && keys.has('max-rps')) {
|
|
164
199
|
warn(
|
|
165
200
|
currentNode.lineNumber,
|
|
166
|
-
`'buffer' (queue) and 'max-rps' (service) represent different capacity models. A queue buffers messages; a service processes them
|
|
201
|
+
`'buffer' (queue) and 'max-rps' (service) represent different capacity models. A queue buffers messages; a service processes them.`
|
|
167
202
|
);
|
|
168
203
|
}
|
|
169
204
|
nodeMap.set(currentNode.id, currentNode);
|
|
@@ -202,7 +237,10 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
202
237
|
const firstLineResult = parseFirstLine(trimmed);
|
|
203
238
|
if (firstLineResult) {
|
|
204
239
|
if (firstLineResult.chartType !== 'infra') {
|
|
205
|
-
setError(
|
|
240
|
+
setError(
|
|
241
|
+
lineNumber,
|
|
242
|
+
`Expected chart type 'infra', got '${firstLineResult.chartType}'`
|
|
243
|
+
);
|
|
206
244
|
}
|
|
207
245
|
if (firstLineResult.title) {
|
|
208
246
|
result.title = firstLineResult.title;
|
|
@@ -255,11 +293,16 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
255
293
|
finishCurrentTagGroup();
|
|
256
294
|
const gLabel = groupMatch[1].trim();
|
|
257
295
|
const gId = groupId(gLabel);
|
|
258
|
-
const groupMeta = groupMatch[2]
|
|
296
|
+
const groupMeta = groupMatch[2]
|
|
297
|
+
? extractPipeMetadata('|' + groupMatch[2]).tags
|
|
298
|
+
: undefined;
|
|
259
299
|
currentGroup = {
|
|
260
300
|
id: gId,
|
|
261
301
|
label: gLabel,
|
|
262
|
-
metadata:
|
|
302
|
+
metadata:
|
|
303
|
+
groupMeta && Object.keys(groupMeta).length > 0
|
|
304
|
+
? groupMeta
|
|
305
|
+
: undefined,
|
|
263
306
|
lineNumber,
|
|
264
307
|
};
|
|
265
308
|
result.groups.push(currentGroup);
|
|
@@ -310,6 +353,11 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
310
353
|
}
|
|
311
354
|
continue;
|
|
312
355
|
}
|
|
356
|
+
warn(
|
|
357
|
+
lineNumber,
|
|
358
|
+
`Invalid tag value '${trimmed}' in tag group '${currentTagGroup.name}'.`
|
|
359
|
+
);
|
|
360
|
+
continue;
|
|
313
361
|
}
|
|
314
362
|
|
|
315
363
|
// Inside a [Group] but no current node — group properties or component declaration
|
|
@@ -333,6 +381,8 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
333
381
|
currentGroup.collapsed = val.toLowerCase() === 'true';
|
|
334
382
|
continue;
|
|
335
383
|
}
|
|
384
|
+
// Fall through to component matching — could be a component name
|
|
385
|
+
// that happens to match PROPERTY_RE (e.g., "MyService v2")
|
|
336
386
|
}
|
|
337
387
|
|
|
338
388
|
const compMatch = trimmed.match(COMPONENT_RE);
|
|
@@ -365,9 +415,17 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
365
415
|
if (currentNode && indent > baseIndent) {
|
|
366
416
|
// Detect deprecated xN fanout syntax
|
|
367
417
|
const deprecatedFanout = trimmed.match(DEPRECATED_FANOUT_RE);
|
|
368
|
-
if (
|
|
418
|
+
if (
|
|
419
|
+
deprecatedFanout &&
|
|
420
|
+
(trimmed.startsWith('->') ||
|
|
421
|
+
trimmed.startsWith('-') ||
|
|
422
|
+
trimmed.startsWith('~'))
|
|
423
|
+
) {
|
|
369
424
|
const n = deprecatedFanout[1];
|
|
370
|
-
setError(
|
|
425
|
+
setError(
|
|
426
|
+
lineNumber,
|
|
427
|
+
`'x${n}' fanout syntax is no longer supported — use '| fanout: ${n}' instead`
|
|
428
|
+
);
|
|
371
429
|
continue;
|
|
372
430
|
}
|
|
373
431
|
|
|
@@ -375,14 +433,20 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
375
433
|
const asyncSimpleConn = trimmed.match(ASYNC_SIMPLE_CONNECTION_RE);
|
|
376
434
|
if (asyncSimpleConn) {
|
|
377
435
|
const targetRaw = asyncSimpleConn[1].trim();
|
|
378
|
-
const splitStr = asyncSimpleConn[2];
|
|
379
436
|
const pipeMeta = extractPipeMetadata(targetRaw);
|
|
380
437
|
const targetName = pipeMeta.clean || targetRaw;
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
438
|
+
warnUnparsedPipeMeta(targetName, lineNumber, warn);
|
|
439
|
+
const split = pipeMeta.tags.split
|
|
440
|
+
? parseFloat(pipeMeta.tags.split)
|
|
441
|
+
: null;
|
|
442
|
+
const fanoutRaw = pipeMeta.tags.fanout
|
|
443
|
+
? parseInt(pipeMeta.tags.fanout, 10)
|
|
444
|
+
: null;
|
|
384
445
|
if (fanoutRaw !== null && fanoutRaw < 1) {
|
|
385
|
-
warn(
|
|
446
|
+
warn(
|
|
447
|
+
lineNumber,
|
|
448
|
+
`Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
|
|
449
|
+
);
|
|
386
450
|
}
|
|
387
451
|
const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
|
|
388
452
|
result.edges.push({
|
|
@@ -402,14 +466,20 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
402
466
|
if (asyncConnMatch) {
|
|
403
467
|
const label = asyncConnMatch[1]?.trim() || '';
|
|
404
468
|
const targetRaw = asyncConnMatch[2].trim();
|
|
405
|
-
const splitStr = asyncConnMatch[3];
|
|
406
469
|
const pipeMeta = extractPipeMetadata(targetRaw);
|
|
407
470
|
const targetName = pipeMeta.clean || targetRaw;
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
471
|
+
warnUnparsedPipeMeta(targetName, lineNumber, warn);
|
|
472
|
+
const split = pipeMeta.tags.split
|
|
473
|
+
? parseFloat(pipeMeta.tags.split)
|
|
474
|
+
: null;
|
|
475
|
+
const fanoutRaw = pipeMeta.tags.fanout
|
|
476
|
+
? parseInt(pipeMeta.tags.fanout, 10)
|
|
477
|
+
: null;
|
|
411
478
|
if (fanoutRaw !== null && fanoutRaw < 1) {
|
|
412
|
-
warn(
|
|
479
|
+
warn(
|
|
480
|
+
lineNumber,
|
|
481
|
+
`Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
|
|
482
|
+
);
|
|
413
483
|
}
|
|
414
484
|
const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
|
|
415
485
|
|
|
@@ -437,15 +507,20 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
437
507
|
const simpleConn = trimmed.match(SIMPLE_CONNECTION_RE);
|
|
438
508
|
if (simpleConn) {
|
|
439
509
|
const targetRaw = simpleConn[1].trim();
|
|
440
|
-
const splitStr = simpleConn[2];
|
|
441
|
-
// Parse pipe metadata for fanout/split (and clean target name)
|
|
442
510
|
const pipeMeta = extractPipeMetadata(targetRaw);
|
|
443
511
|
const targetName = pipeMeta.clean || targetRaw;
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
512
|
+
warnUnparsedPipeMeta(targetName, lineNumber, warn);
|
|
513
|
+
const split = pipeMeta.tags.split
|
|
514
|
+
? parseFloat(pipeMeta.tags.split)
|
|
515
|
+
: null;
|
|
516
|
+
const fanoutRaw = pipeMeta.tags.fanout
|
|
517
|
+
? parseInt(pipeMeta.tags.fanout, 10)
|
|
518
|
+
: null;
|
|
447
519
|
if (fanoutRaw !== null && fanoutRaw < 1) {
|
|
448
|
-
warn(
|
|
520
|
+
warn(
|
|
521
|
+
lineNumber,
|
|
522
|
+
`Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
|
|
523
|
+
);
|
|
449
524
|
}
|
|
450
525
|
const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
|
|
451
526
|
result.edges.push({
|
|
@@ -465,15 +540,20 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
465
540
|
if (connMatch) {
|
|
466
541
|
const label = connMatch[1]?.trim() || '';
|
|
467
542
|
const targetRaw = connMatch[2].trim();
|
|
468
|
-
const splitStr = connMatch[3];
|
|
469
|
-
// Parse pipe metadata for fanout/split (and clean target name)
|
|
470
543
|
const pipeMeta = extractPipeMetadata(targetRaw);
|
|
471
544
|
const targetName = pipeMeta.clean || targetRaw;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
545
|
+
warnUnparsedPipeMeta(targetName, lineNumber, warn);
|
|
546
|
+
const split = pipeMeta.tags.split
|
|
547
|
+
? parseFloat(pipeMeta.tags.split)
|
|
548
|
+
: null;
|
|
549
|
+
const fanoutRaw = pipeMeta.tags.fanout
|
|
550
|
+
? parseInt(pipeMeta.tags.fanout, 10)
|
|
551
|
+
: null;
|
|
475
552
|
if (fanoutRaw !== null && fanoutRaw < 1) {
|
|
476
|
-
warn(
|
|
553
|
+
warn(
|
|
554
|
+
lineNumber,
|
|
555
|
+
`Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
|
|
556
|
+
);
|
|
477
557
|
}
|
|
478
558
|
const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
|
|
479
559
|
|
|
@@ -525,7 +605,10 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
525
605
|
|
|
526
606
|
// Validate edge-only keys
|
|
527
607
|
if (EDGE_ONLY_KEYS.has(key) && !currentNode.isEdge) {
|
|
528
|
-
warn(
|
|
608
|
+
warn(
|
|
609
|
+
lineNumber,
|
|
610
|
+
`Property '${key}' is only valid on the entry point (Edge/Internet).`
|
|
611
|
+
);
|
|
529
612
|
}
|
|
530
613
|
|
|
531
614
|
const value = parsePropertyValue(rawVal);
|
|
@@ -534,7 +617,10 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
534
617
|
}
|
|
535
618
|
|
|
536
619
|
// Unknown indented line
|
|
537
|
-
warn(
|
|
620
|
+
warn(
|
|
621
|
+
lineNumber,
|
|
622
|
+
`Unexpected line inside component '${currentNode.label}'.`
|
|
623
|
+
);
|
|
538
624
|
continue;
|
|
539
625
|
}
|
|
540
626
|
|
|
@@ -592,6 +678,9 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
592
678
|
continue;
|
|
593
679
|
}
|
|
594
680
|
}
|
|
681
|
+
|
|
682
|
+
// Catch-all: nothing matched this line
|
|
683
|
+
warn(lineNumber, `Unexpected line: '${trimmed}'.`);
|
|
595
684
|
}
|
|
596
685
|
|
|
597
686
|
// Flush last open blocks
|
|
@@ -661,7 +750,8 @@ export function extractSymbols(docText: string): DiagramSymbols {
|
|
|
661
750
|
// Recognize new-style bare options (`key value`) and old-style (`key: value`)
|
|
662
751
|
const firstLine = parseFirstLine(line);
|
|
663
752
|
if (firstLine) continue; // chart type line
|
|
664
|
-
if (/^(?:direction-tb|animate|no-animate|slo-|default-)/i.test(line))
|
|
753
|
+
if (/^(?:direction-tb|animate|no-animate|slo-|default-)/i.test(line))
|
|
754
|
+
continue;
|
|
665
755
|
if (/^[a-z-]+\s*:/i.test(line)) continue; // legacy colon options
|
|
666
756
|
inMetadata = false;
|
|
667
757
|
} else {
|
|
@@ -671,8 +761,14 @@ export function extractSymbols(docText: string): DiagramSymbols {
|
|
|
671
761
|
|
|
672
762
|
if (!indented) {
|
|
673
763
|
// Root-level: tag group declaration, group header, or component
|
|
674
|
-
if (/^tag\s/i.test(line)) {
|
|
675
|
-
|
|
764
|
+
if (/^tag\s/i.test(line)) {
|
|
765
|
+
inTagGroup = true;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (/^tag\s*:/i.test(line)) {
|
|
769
|
+
inTagGroup = true;
|
|
770
|
+
continue;
|
|
771
|
+
} // legacy
|
|
676
772
|
inTagGroup = false;
|
|
677
773
|
if (/^\[/.test(line)) continue; // [Group] header
|
|
678
774
|
const m = COMPONENT_RE.exec(line);
|
|
@@ -687,7 +783,15 @@ export function extractSymbols(docText: string): DiagramSymbols {
|
|
|
687
783
|
if (/^\w[\w-]*\s*:/.test(line)) continue; // property (key: value) legacy
|
|
688
784
|
// New-style property: first token is a known behavior/property key
|
|
689
785
|
const firstToken = line.split(/\s/)[0].toLowerCase();
|
|
690
|
-
if (
|
|
786
|
+
if (
|
|
787
|
+
(INFRA_BEHAVIOR_KEYS.has(firstToken) ||
|
|
788
|
+
EDGE_ONLY_KEYS.has(firstToken) ||
|
|
789
|
+
firstToken === 'description' ||
|
|
790
|
+
firstToken === 'instances' ||
|
|
791
|
+
firstToken === 'collapsed') &&
|
|
792
|
+
/\s/.test(line)
|
|
793
|
+
)
|
|
794
|
+
continue;
|
|
691
795
|
const m = COMPONENT_RE.exec(line);
|
|
692
796
|
if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
|
|
693
797
|
}
|