@diagrammo/dgmo 0.8.2 → 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.
Files changed (120) hide show
  1. package/.claude/commands/dgmo-diagram-this.md +60 -0
  2. package/.claude/commands/dgmo-document-project.md +128 -0
  3. package/.claude/commands/dgmo.md +185 -50
  4. package/.cursorrules +32 -37
  5. package/.github/copilot-instructions.md +35 -44
  6. package/.windsurfrules +32 -37
  7. package/README.md +4 -4
  8. package/dist/cli.cjs +189 -194
  9. package/dist/editor.cjs +336 -0
  10. package/dist/editor.cjs.map +1 -0
  11. package/dist/editor.d.cts +27 -0
  12. package/dist/editor.d.ts +27 -0
  13. package/dist/editor.js +305 -0
  14. package/dist/editor.js.map +1 -0
  15. package/dist/index.cjs +3699 -1564
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +7 -6
  18. package/dist/index.d.ts +7 -6
  19. package/dist/index.js +3699 -1564
  20. package/dist/index.js.map +1 -1
  21. package/docs/language-reference.md +822 -1060
  22. package/gallery/fixtures/arc.dgmo +18 -0
  23. package/gallery/fixtures/area.dgmo +19 -0
  24. package/gallery/fixtures/bar-stacked.dgmo +10 -0
  25. package/gallery/fixtures/bar.dgmo +10 -0
  26. package/gallery/fixtures/c4-full.dgmo +52 -0
  27. package/gallery/fixtures/c4.dgmo +17 -0
  28. package/gallery/fixtures/chord.dgmo +12 -0
  29. package/gallery/fixtures/class-basic.dgmo +14 -0
  30. package/gallery/fixtures/class-full.dgmo +43 -0
  31. package/gallery/fixtures/doughnut.dgmo +8 -0
  32. package/gallery/fixtures/flowchart-basic.dgmo +3 -0
  33. package/gallery/fixtures/flowchart-colors.dgmo +5 -0
  34. package/gallery/fixtures/flowchart-complex.dgmo +17 -0
  35. package/gallery/fixtures/flowchart-decision.dgmo +5 -0
  36. package/gallery/fixtures/flowchart-full.dgmo +13 -0
  37. package/gallery/fixtures/flowchart-groups.dgmo +10 -0
  38. package/gallery/fixtures/flowchart-loop.dgmo +7 -0
  39. package/gallery/fixtures/flowchart-nested.dgmo +7 -0
  40. package/gallery/fixtures/flowchart-shapes.dgmo +5 -0
  41. package/gallery/fixtures/function.dgmo +8 -0
  42. package/gallery/fixtures/funnel.dgmo +7 -0
  43. package/gallery/fixtures/gantt-full.dgmo +49 -0
  44. package/gallery/fixtures/gantt.dgmo +42 -0
  45. package/gallery/fixtures/heatmap.dgmo +8 -0
  46. package/gallery/fixtures/infra-full.dgmo +78 -0
  47. package/gallery/fixtures/infra-overload.dgmo +25 -0
  48. package/gallery/fixtures/infra.dgmo +47 -0
  49. package/gallery/fixtures/initiative-status-full.dgmo +46 -0
  50. package/gallery/fixtures/initiative-status-phases.dgmo +29 -0
  51. package/gallery/fixtures/initiative-status.dgmo +9 -0
  52. package/gallery/fixtures/line.dgmo +19 -0
  53. package/gallery/fixtures/multi-line.dgmo +11 -0
  54. package/gallery/fixtures/org-basic.dgmo +16 -0
  55. package/gallery/fixtures/org-full.dgmo +69 -0
  56. package/gallery/fixtures/org-teams.dgmo +25 -0
  57. package/gallery/fixtures/pie.dgmo +9 -0
  58. package/gallery/fixtures/polar-area.dgmo +8 -0
  59. package/gallery/fixtures/quadrant.dgmo +18 -0
  60. package/gallery/fixtures/radar.dgmo +8 -0
  61. package/gallery/fixtures/sankey.dgmo +31 -0
  62. package/gallery/fixtures/scatter.dgmo +21 -0
  63. package/gallery/fixtures/sequence-tags-protocols.dgmo +45 -0
  64. package/gallery/fixtures/sequence-tags.dgmo +41 -0
  65. package/gallery/fixtures/sequence.dgmo +35 -0
  66. package/gallery/fixtures/sitemap-basic.dgmo +12 -0
  67. package/gallery/fixtures/sitemap-full.dgmo +156 -0
  68. package/gallery/fixtures/slope.dgmo +8 -0
  69. package/gallery/fixtures/spr-eras.dgmo +62 -0
  70. package/gallery/fixtures/state.dgmo +30 -0
  71. package/gallery/fixtures/timeline-intraday.dgmo +14 -0
  72. package/gallery/fixtures/timeline.dgmo +32 -0
  73. package/gallery/fixtures/venn.dgmo +10 -0
  74. package/gallery/fixtures/wordcloud.dgmo +24 -0
  75. package/package.json +51 -2
  76. package/src/c4/layout.ts +372 -90
  77. package/src/c4/parser.ts +113 -62
  78. package/src/chart.ts +149 -64
  79. package/src/class/parser.ts +84 -28
  80. package/src/class/renderer.ts +2 -2
  81. package/src/cli.ts +179 -77
  82. package/src/completion.ts +381 -182
  83. package/src/d3.ts +1026 -428
  84. package/src/dgmo-mermaid.ts +16 -13
  85. package/src/dgmo-router.ts +70 -24
  86. package/src/echarts.ts +682 -169
  87. package/src/editor/dgmo.grammar +69 -0
  88. package/src/editor/dgmo.grammar.d.ts +2 -0
  89. package/src/editor/dgmo.grammar.js +18 -0
  90. package/src/editor/dgmo.grammar.terms.d.ts +5 -0
  91. package/src/editor/dgmo.grammar.terms.js +35 -0
  92. package/src/editor/highlight.ts +36 -0
  93. package/src/editor/index.ts +28 -0
  94. package/src/editor/keywords.ts +220 -0
  95. package/src/editor/tokens.ts +30 -0
  96. package/src/er/parser.ts +55 -29
  97. package/src/er/renderer.ts +112 -53
  98. package/src/gantt/calculator.ts +91 -29
  99. package/src/gantt/parser.ts +291 -97
  100. package/src/gantt/renderer.ts +1120 -350
  101. package/src/graph/flowchart-parser.ts +48 -75
  102. package/src/graph/state-parser.ts +54 -27
  103. package/src/infra/parser.ts +161 -177
  104. package/src/infra/renderer.ts +723 -271
  105. package/src/infra/types.ts +0 -1
  106. package/src/initiative-status/parser.ts +144 -56
  107. package/src/kanban/parser.ts +27 -19
  108. package/src/org/layout.ts +111 -44
  109. package/src/org/parser.ts +71 -27
  110. package/src/org/resolver.ts +3 -3
  111. package/src/palettes/index.ts +3 -2
  112. package/src/render.ts +1 -2
  113. package/src/sequence/parser.ts +209 -100
  114. package/src/sitemap/parser.ts +73 -44
  115. package/src/utils/arrows.ts +2 -22
  116. package/src/utils/duration.ts +39 -21
  117. package/src/utils/legend-constants.ts +0 -2
  118. package/src/utils/parsing.ts +82 -72
  119. package/src/utils/tag-groups.ts +4 -41
  120. package/src/infra/serialize.ts +0 -67
@@ -4,10 +4,14 @@
4
4
  //
5
5
  // Parses `infra [Title]` syntax into a structured InfraModel.
6
6
  // Handles: chart metadata, component blocks with indented properties
7
- // and connections, [Group] / # Group containers, tag groups, pipe metadata.
7
+ // and connections, [Group] containers, tag groups, pipe metadata.
8
8
 
9
9
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
10
- import { measureIndent, normalizeDirection, parseFirstLine, GROUP_HASH_RE, OPTION_NOCOLON_RE } from '../utils/parsing';
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,31 +25,21 @@ import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
21
25
  // Regex patterns
22
26
  // ============================================================
23
27
 
24
- // Connection: -label-> Target or -> Target (with optional | split: N% or pipe metadata)
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 (with optional | split: N% or pipe metadata)
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*$/;
42
42
 
43
- // "is a" type declaration: NodeName is a <type>
44
- const IS_A_RE = /^(.+?)\s+is\s+an?\s+(database|cache|queue|service|gateway|storage|function|network)\s*$/i;
45
-
46
- // Valid node types for "is a" declarations
47
- const VALID_NODE_TYPES = new Set(['database', 'cache', 'queue', 'service', 'gateway', 'storage', 'function', 'network']);
48
-
49
43
  // Group declaration: [Group Name] with optional pipe metadata
50
44
  const GROUP_RE = /^\[([^\]]+)\]\s*(?:\|\s*(.+))?$/;
51
45
 
@@ -74,8 +68,12 @@ const EDGE_NODE_NAMES = new Set(['edge', 'internet']);
74
68
 
75
69
  // Known top-level option keys (space-separated, no colon)
76
70
  const TOP_LEVEL_OPTIONS = new Set([
77
- 'slo-availability', 'slo-p90-latency-ms', 'slo-warning-margin',
78
- 'default-latency-ms', 'default-uptime', 'default-rps',
71
+ 'slo-availability',
72
+ 'slo-p90-latency-ms',
73
+ 'slo-warning-margin',
74
+ 'default-latency-ms',
75
+ 'default-uptime',
76
+ 'default-rps',
79
77
  ]);
80
78
 
81
79
  // ============================================================
@@ -100,9 +98,10 @@ function parsePropertyValue(raw: string): string | number {
100
98
  return raw.trim();
101
99
  }
102
100
 
103
- function extractPipeMetadata(
104
- rest: string,
105
- ): { tags: Record<string, string>; clean: string } {
101
+ function extractPipeMetadata(rest: string): {
102
+ tags: Record<string, string>;
103
+ clean: string;
104
+ } {
106
105
  const tags: Record<string, string> = {};
107
106
  let clean = rest;
108
107
  let match: RegExpExecArray | null;
@@ -114,6 +113,30 @@ function extractPipeMetadata(
114
113
  return { tags, clean: clean.trim() };
115
114
  }
116
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
+
117
140
  // ============================================================
118
141
  // Parser
119
142
  // ============================================================
@@ -156,20 +179,26 @@ export function parseInfra(content: string): ParsedInfra {
156
179
  if (currentNode && !nodeMap.has(currentNode.id)) {
157
180
  // Validate mutual exclusion: concurrency vs instances/max-rps
158
181
  const keys = new Set(currentNode.properties.map((p) => p.key));
159
- if (keys.has('concurrency') && (keys.has('instances') || keys.has('max-rps'))) {
160
- const conflicting = [keys.has('instances') ? 'instances' : '', keys.has('max-rps') ? 'max-rps' : '']
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
+ ]
161
190
  .filter(Boolean)
162
191
  .join(', ');
163
192
  warn(
164
193
  currentNode.lineNumber,
165
- `'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.`
166
195
  );
167
196
  }
168
197
  // Validate mutual exclusion: buffer (queue) vs max-rps (service)
169
198
  if (keys.has('buffer') && keys.has('max-rps')) {
170
199
  warn(
171
200
  currentNode.lineNumber,
172
- `'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.`
173
202
  );
174
203
  }
175
204
  nodeMap.set(currentNode.id, currentNode);
@@ -208,7 +237,10 @@ export function parseInfra(content: string): ParsedInfra {
208
237
  const firstLineResult = parseFirstLine(trimmed);
209
238
  if (firstLineResult) {
210
239
  if (firstLineResult.chartType !== 'infra') {
211
- setError(lineNumber, `Expected chart type 'infra', got '${firstLineResult.chartType}'`);
240
+ setError(
241
+ lineNumber,
242
+ `Expected chart type 'infra', got '${firstLineResult.chartType}'`
243
+ );
212
244
  }
213
245
  if (firstLineResult.title) {
214
246
  result.title = firstLineResult.title;
@@ -217,16 +249,9 @@ export function parseInfra(content: string): ParsedInfra {
217
249
  continue;
218
250
  }
219
251
 
220
- // direction LR | TB (also accepts orientation as alias)
221
- // Supports both `direction LR` (new) and `direction: LR` (legacy)
222
- if (/^(?:direction|orientation)\s/i.test(trimmed)) {
223
- const raw = trimmed.replace(/^(?:direction|orientation)\s+/i, '').trim();
224
- const dir = normalizeDirection(raw);
225
- if (dir) {
226
- result.direction = dir;
227
- } else {
228
- warn(lineNumber, `Unknown direction '${raw}'. Expected 'LR', 'TB', 'horizontal', or 'vertical'.`);
229
- }
252
+ // direction-tb bare boolean to switch to top-to-bottom (default is LR)
253
+ if (/^direction-tb$/i.test(trimmed)) {
254
+ result.direction = 'TB';
230
255
  continue;
231
256
  }
232
257
 
@@ -247,23 +272,6 @@ export function parseInfra(content: string): ParsedInfra {
247
272
  continue;
248
273
  }
249
274
 
250
- // scenario: Name — no longer supported
251
- if (/^scenario\s*:/i.test(trimmed)) {
252
- setError(lineNumber, `'scenario:' syntax is no longer supported`);
253
- // Skip indented block
254
- let si = i + 1;
255
- while (si < lines.length) {
256
- const sLine = lines[si];
257
- const sTrimmed = sLine.trim();
258
- if (!sTrimmed || sTrimmed.startsWith('#')) { si++; continue; }
259
- const sIndent = sLine.length - sLine.trimStart().length;
260
- if (sIndent === 0) break;
261
- si++;
262
- }
263
- i = si - 1;
264
- continue;
265
- }
266
-
267
275
  // Tag group: `tag Name [alias]` (via shared matchTagBlockHeading)
268
276
  const tagMatch = matchTagBlockHeading(trimmed);
269
277
  if (tagMatch) {
@@ -278,23 +286,6 @@ export function parseInfra(content: string): ParsedInfra {
278
286
  continue;
279
287
  }
280
288
 
281
- // # GroupName (alternate group notation)
282
- const hashGroupMatch = trimmed.match(GROUP_HASH_RE);
283
- if (hashGroupMatch) {
284
- finishCurrentNode();
285
- finishCurrentTagGroup();
286
- const gLabel = hashGroupMatch[1].trim();
287
- const gId = groupId(gLabel);
288
- currentGroup = {
289
- id: gId,
290
- label: gLabel,
291
- metadata: undefined,
292
- lineNumber,
293
- };
294
- result.groups.push(currentGroup);
295
- continue;
296
- }
297
-
298
289
  // [Group Name] or [Group Name] | t: Engineering
299
290
  const groupMatch = trimmed.match(GROUP_RE);
300
291
  if (groupMatch) {
@@ -302,43 +293,22 @@ export function parseInfra(content: string): ParsedInfra {
302
293
  finishCurrentTagGroup();
303
294
  const gLabel = groupMatch[1].trim();
304
295
  const gId = groupId(gLabel);
305
- const groupMeta = groupMatch[2] ? extractPipeMetadata('|' + groupMatch[2]).tags : undefined;
296
+ const groupMeta = groupMatch[2]
297
+ ? extractPipeMetadata('|' + groupMatch[2]).tags
298
+ : undefined;
306
299
  currentGroup = {
307
300
  id: gId,
308
301
  label: gLabel,
309
- metadata: groupMeta && Object.keys(groupMeta).length > 0 ? groupMeta : undefined,
302
+ metadata:
303
+ groupMeta && Object.keys(groupMeta).length > 0
304
+ ? groupMeta
305
+ : undefined,
310
306
  lineNumber,
311
307
  };
312
308
  result.groups.push(currentGroup);
313
309
  continue;
314
310
  }
315
311
 
316
- // "is a" type declaration: NodeName is a <type>
317
- const isaMatch = trimmed.match(IS_A_RE);
318
- if (isaMatch) {
319
- finishCurrentNode();
320
- finishCurrentTagGroup();
321
-
322
- const name = isaMatch[1].trim();
323
- const nType = isaMatch[2].toLowerCase();
324
- const id = nodeId(name);
325
- const isEdge = EDGE_NODE_NAMES.has(id.toLowerCase());
326
-
327
- currentNode = {
328
- id,
329
- label: name,
330
- properties: [],
331
- groupId: null,
332
- tags: {},
333
- isEdge,
334
- nodeType: nType,
335
- lineNumber,
336
- };
337
- currentGroup = null;
338
- baseIndent = 0;
339
- continue;
340
- }
341
-
342
312
  // Component at top level (no indent)
343
313
  const compMatch = trimmed.match(COMPONENT_RE);
344
314
  if (compMatch) {
@@ -383,6 +353,11 @@ export function parseInfra(content: string): ParsedInfra {
383
353
  }
384
354
  continue;
385
355
  }
356
+ warn(
357
+ lineNumber,
358
+ `Invalid tag value '${trimmed}' in tag group '${currentTagGroup.name}'.`
359
+ );
360
+ continue;
386
361
  }
387
362
 
388
363
  // Inside a [Group] but no current node — group properties or component declaration
@@ -406,30 +381,8 @@ export function parseInfra(content: string): ParsedInfra {
406
381
  currentGroup.collapsed = val.toLowerCase() === 'true';
407
382
  continue;
408
383
  }
409
- }
410
-
411
- // "is a" type declaration inside group
412
- const isaMatchG = trimmed.match(IS_A_RE);
413
- if (isaMatchG) {
414
- finishCurrentTagGroup();
415
- const name = isaMatchG[1].trim();
416
- const nType = isaMatchG[2].toLowerCase();
417
- const id = nodeId(name);
418
- // Cascade group metadata into node tags (node-level overrides later)
419
- const tags: Record<string, string> = currentGroup.metadata ? { ...currentGroup.metadata } : {};
420
-
421
- currentNode = {
422
- id,
423
- label: name,
424
- properties: [],
425
- groupId: currentGroup.id,
426
- tags,
427
- isEdge: false,
428
- nodeType: nType,
429
- lineNumber,
430
- };
431
- baseIndent = indent;
432
- continue;
384
+ // Fall through to component matching — could be a component name
385
+ // that happens to match PROPERTY_RE (e.g., "MyService v2")
433
386
  }
434
387
 
435
388
  const compMatch = trimmed.match(COMPONENT_RE);
@@ -462,9 +415,17 @@ export function parseInfra(content: string): ParsedInfra {
462
415
  if (currentNode && indent > baseIndent) {
463
416
  // Detect deprecated xN fanout syntax
464
417
  const deprecatedFanout = trimmed.match(DEPRECATED_FANOUT_RE);
465
- if (deprecatedFanout && (trimmed.startsWith('->') || trimmed.startsWith('-') || trimmed.startsWith('~'))) {
418
+ if (
419
+ deprecatedFanout &&
420
+ (trimmed.startsWith('->') ||
421
+ trimmed.startsWith('-') ||
422
+ trimmed.startsWith('~'))
423
+ ) {
466
424
  const n = deprecatedFanout[1];
467
- setError(lineNumber, `'x${n}' fanout syntax is no longer supported — use '| fanout: ${n}' instead`);
425
+ setError(
426
+ lineNumber,
427
+ `'x${n}' fanout syntax is no longer supported — use '| fanout: ${n}' instead`
428
+ );
468
429
  continue;
469
430
  }
470
431
 
@@ -472,14 +433,20 @@ export function parseInfra(content: string): ParsedInfra {
472
433
  const asyncSimpleConn = trimmed.match(ASYNC_SIMPLE_CONNECTION_RE);
473
434
  if (asyncSimpleConn) {
474
435
  const targetRaw = asyncSimpleConn[1].trim();
475
- const splitStr = asyncSimpleConn[2];
476
436
  const pipeMeta = extractPipeMetadata(targetRaw);
477
437
  const targetName = pipeMeta.clean || targetRaw;
478
- const split = splitStr ? parseFloat(splitStr)
479
- : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
480
- const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
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;
481
445
  if (fanoutRaw !== null && fanoutRaw < 1) {
482
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
446
+ warn(
447
+ lineNumber,
448
+ `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
449
+ );
483
450
  }
484
451
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
485
452
  result.edges.push({
@@ -499,14 +466,20 @@ export function parseInfra(content: string): ParsedInfra {
499
466
  if (asyncConnMatch) {
500
467
  const label = asyncConnMatch[1]?.trim() || '';
501
468
  const targetRaw = asyncConnMatch[2].trim();
502
- const splitStr = asyncConnMatch[3];
503
469
  const pipeMeta = extractPipeMetadata(targetRaw);
504
470
  const targetName = pipeMeta.clean || targetRaw;
505
- const split = splitStr ? parseFloat(splitStr)
506
- : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
507
- const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
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;
508
478
  if (fanoutRaw !== null && fanoutRaw < 1) {
509
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
479
+ warn(
480
+ lineNumber,
481
+ `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
482
+ );
510
483
  }
511
484
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
512
485
 
@@ -534,15 +507,20 @@ export function parseInfra(content: string): ParsedInfra {
534
507
  const simpleConn = trimmed.match(SIMPLE_CONNECTION_RE);
535
508
  if (simpleConn) {
536
509
  const targetRaw = simpleConn[1].trim();
537
- const splitStr = simpleConn[2];
538
- // Parse pipe metadata for fanout/split (and clean target name)
539
510
  const pipeMeta = extractPipeMetadata(targetRaw);
540
511
  const targetName = pipeMeta.clean || targetRaw;
541
- const split = splitStr ? parseFloat(splitStr)
542
- : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
543
- const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
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;
544
519
  if (fanoutRaw !== null && fanoutRaw < 1) {
545
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
520
+ warn(
521
+ lineNumber,
522
+ `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
523
+ );
546
524
  }
547
525
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
548
526
  result.edges.push({
@@ -562,15 +540,20 @@ export function parseInfra(content: string): ParsedInfra {
562
540
  if (connMatch) {
563
541
  const label = connMatch[1]?.trim() || '';
564
542
  const targetRaw = connMatch[2].trim();
565
- const splitStr = connMatch[3];
566
- // Parse pipe metadata for fanout/split (and clean target name)
567
543
  const pipeMeta = extractPipeMetadata(targetRaw);
568
544
  const targetName = pipeMeta.clean || targetRaw;
569
- const split = splitStr ? parseFloat(splitStr)
570
- : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
571
- const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
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;
572
552
  if (fanoutRaw !== null && fanoutRaw < 1) {
573
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
553
+ warn(
554
+ lineNumber,
555
+ `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
556
+ );
574
557
  }
575
558
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
576
559
 
@@ -622,7 +605,10 @@ export function parseInfra(content: string): ParsedInfra {
622
605
 
623
606
  // Validate edge-only keys
624
607
  if (EDGE_ONLY_KEYS.has(key) && !currentNode.isEdge) {
625
- warn(lineNumber, `Property '${key}' is only valid on the entry point (Edge/Internet).`);
608
+ warn(
609
+ lineNumber,
610
+ `Property '${key}' is only valid on the entry point (Edge/Internet).`
611
+ );
626
612
  }
627
613
 
628
614
  const value = parsePropertyValue(rawVal);
@@ -631,7 +617,10 @@ export function parseInfra(content: string): ParsedInfra {
631
617
  }
632
618
 
633
619
  // Unknown indented line
634
- warn(lineNumber, `Unexpected line inside component '${currentNode.label}'.`);
620
+ warn(
621
+ lineNumber,
622
+ `Unexpected line inside component '${currentNode.label}'.`
623
+ );
635
624
  continue;
636
625
  }
637
626
 
@@ -639,28 +628,6 @@ export function parseInfra(content: string): ParsedInfra {
639
628
  if (currentGroup && indent > 0) {
640
629
  finishCurrentNode();
641
630
 
642
- // "is a" type declaration inside group
643
- const isaMatchG2 = trimmed.match(IS_A_RE);
644
- if (isaMatchG2) {
645
- const name = isaMatchG2[1].trim();
646
- const nType = isaMatchG2[2].toLowerCase();
647
- const id = nodeId(name);
648
- const tags: Record<string, string> = currentGroup.metadata ? { ...currentGroup.metadata } : {};
649
-
650
- currentNode = {
651
- id,
652
- label: name,
653
- properties: [],
654
- groupId: currentGroup.id,
655
- tags,
656
- isEdge: false,
657
- nodeType: nType,
658
- lineNumber,
659
- };
660
- baseIndent = indent;
661
- continue;
662
- }
663
-
664
631
  const compMatch = trimmed.match(COMPONENT_RE);
665
632
  if (compMatch) {
666
633
  const name = compMatch[1];
@@ -711,6 +678,9 @@ export function parseInfra(content: string): ParsedInfra {
711
678
  continue;
712
679
  }
713
680
  }
681
+
682
+ // Catch-all: nothing matched this line
683
+ warn(lineNumber, `Unexpected line: '${trimmed}'.`);
714
684
  }
715
685
 
716
686
  // Flush last open blocks
@@ -780,7 +750,8 @@ export function extractSymbols(docText: string): DiagramSymbols {
780
750
  // Recognize new-style bare options (`key value`) and old-style (`key: value`)
781
751
  const firstLine = parseFirstLine(line);
782
752
  if (firstLine) continue; // chart type line
783
- if (/^(?:direction|orientation|animate|no-animate|slo-|default-)/i.test(line)) continue;
753
+ if (/^(?:direction-tb|animate|no-animate|slo-|default-)/i.test(line))
754
+ continue;
784
755
  if (/^[a-z-]+\s*:/i.test(line)) continue; // legacy colon options
785
756
  inMetadata = false;
786
757
  } else {
@@ -790,11 +761,16 @@ export function extractSymbols(docText: string): DiagramSymbols {
790
761
 
791
762
  if (!indented) {
792
763
  // Root-level: tag group declaration, group header, or component
793
- if (/^tag\s/i.test(line)) { inTagGroup = true; continue; }
794
- if (/^tag\s*:/i.test(line)) { inTagGroup = true; continue; } // legacy
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
795
772
  inTagGroup = false;
796
773
  if (/^\[/.test(line)) continue; // [Group] header
797
- if (/^#\s/.test(line)) continue; // # Group header
798
774
  const m = COMPONENT_RE.exec(line);
799
775
  if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
800
776
  } else {
@@ -807,7 +783,15 @@ export function extractSymbols(docText: string): DiagramSymbols {
807
783
  if (/^\w[\w-]*\s*:/.test(line)) continue; // property (key: value) legacy
808
784
  // New-style property: first token is a known behavior/property key
809
785
  const firstToken = line.split(/\s/)[0].toLowerCase();
810
- if ((INFRA_BEHAVIOR_KEYS.has(firstToken) || EDGE_ONLY_KEYS.has(firstToken) || firstToken === 'description' || firstToken === 'instances' || firstToken === 'collapsed') && /\s/.test(line)) continue;
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;
811
795
  const m = COMPONENT_RE.exec(line);
812
796
  if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
813
797
  }