@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
package/src/chart.ts CHANGED
@@ -21,9 +21,9 @@ export interface ChartDataPoint {
21
21
  }
22
22
 
23
23
  export interface ChartEra {
24
- start: string; // exact category label, e.g. "'77"
25
- end: string; // exact category label, e.g. "'81"
26
- label: string; // display name, e.g. "Carter"
24
+ start: string; // exact category label, e.g. "'77"
25
+ end: string; // exact category label, e.g. "'81"
26
+ label: string; // display name, e.g. "Carter"
27
27
  color: string | null; // resolved CSS color, or null → palette default
28
28
  lineNumber: number;
29
29
  }
@@ -46,7 +46,9 @@ export interface ParsedChart {
46
46
  orientation?: 'horizontal' | 'vertical';
47
47
  color?: string;
48
48
  label?: string;
49
- labels?: 'name' | 'value' | 'percent' | 'full';
49
+ noLabelName?: boolean;
50
+ noLabelValue?: boolean;
51
+ noLabelPercent?: boolean;
50
52
  data: ChartDataPoint[];
51
53
  eras?: ChartEra[];
52
54
  diagnostics: DgmoError[];
@@ -60,7 +62,11 @@ export interface ParsedChart {
60
62
  import { resolveColor } from './colors';
61
63
  import type { PaletteColors } from './palettes';
62
64
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
63
- import { extractColor, normalizeDirection, normalizeGroupedNumber, parseFirstLine, parseSeriesNames } from './utils/parsing';
65
+ import {
66
+ extractColor,
67
+ parseFirstLine,
68
+ parseSeriesNames,
69
+ } from './utils/parsing';
64
70
 
65
71
  // ============================================================
66
72
  // Parser
@@ -83,10 +89,21 @@ const TYPE_ALIASES: Record<string, ChartType> = {
83
89
 
84
90
  /** Known option keywords for the simple chart parser. */
85
91
  const KNOWN_OPTIONS = new Set([
86
- 'chart', 'title', 'series', 'xlabel', 'ylabel', 'label', 'labels',
87
- 'orientation', 'direction', 'color',
92
+ 'chart',
93
+ 'title',
94
+ 'series',
95
+ 'x-label',
96
+ 'y-label',
97
+ 'label',
98
+ 'no-label-name',
99
+ 'no-label-value',
100
+ 'no-label-percent',
101
+ 'color',
88
102
  ]);
89
103
 
104
+ /** Known boolean options for the simple chart parser. */
105
+ const KNOWN_BOOLEANS = new Set(['orientation-horizontal']);
106
+
90
107
  /**
91
108
  * Parses the simple chart text format into a structured object.
92
109
  *
@@ -106,7 +123,12 @@ export function parseChart(
106
123
  ): ParsedChart {
107
124
  const lines = content.split('\n');
108
125
  const parsedEras: ChartEra[] = [];
109
- const rawEras: { start: string; afterArrow: string; color: string | null; lineNumber: number }[] = [];
126
+ const rawEras: {
127
+ start: string;
128
+ afterArrow: string;
129
+ color: string | null;
130
+ lineNumber: number;
131
+ }[] = [];
110
132
  const result: ParsedChart = {
111
133
  type: 'bar',
112
134
  data: [],
@@ -133,7 +155,12 @@ export function parseChart(
133
155
 
134
156
  // Reject legacy ## section headers
135
157
  if (/^#{2,}\s+/.test(trimmed)) {
136
- result.diagnostics.push(makeDgmoError(lineNumber, `'${trimmed}' — ## syntax is no longer supported. Use [Group] containers instead`));
158
+ result.diagnostics.push(
159
+ makeDgmoError(
160
+ lineNumber,
161
+ `'${trimmed}' — ## syntax is no longer supported. Use [Group] containers instead`
162
+ )
163
+ );
137
164
  continue;
138
165
  }
139
166
 
@@ -163,7 +190,11 @@ export function parseChart(
163
190
  }
164
191
  // If the first line is a single word (no spaces, no colon, no numbers),
165
192
  // treat it as an unrecognized chart type rather than falling through
166
- if (!trimmed.includes(' ') && !trimmed.includes(':') && !/\d/.test(trimmed)) {
193
+ if (
194
+ !trimmed.includes(' ') &&
195
+ !trimmed.includes(':') &&
196
+ !/\d/.test(trimmed)
197
+ ) {
167
198
  let msg = `Unsupported chart type: ${trimmed}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
168
199
  const hint = suggest(trimmed.toLowerCase(), [...VALID_TYPES]);
169
200
  if (hint) msg += ` ${hint}`;
@@ -173,7 +204,9 @@ export function parseChart(
173
204
  }
174
205
 
175
206
  // Era line: era Day 1 -> Day 3 Rough Seas (blue) — colon-free
176
- const eraMatch = trimmed.match(/^era\s+(.+?)\s*->\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/);
207
+ const eraMatch = trimmed.match(
208
+ /^era\s+(.+?)\s*->\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/
209
+ );
177
210
  if (eraMatch) {
178
211
  // Store start and raw afterArrow — resolved against data labels after parsing
179
212
  const afterArrow = eraMatch[2].trim();
@@ -191,7 +224,17 @@ export function parseChart(
191
224
 
192
225
  // Extract first token to check for known options
193
226
  const spaceIdx = trimmed.indexOf(' ');
194
- const firstToken = (spaceIdx >= 0 ? trimmed.substring(0, spaceIdx) : trimmed).toLowerCase();
227
+ const firstToken = (
228
+ spaceIdx >= 0 ? trimmed.substring(0, spaceIdx) : trimmed
229
+ ).toLowerCase();
230
+
231
+ // Bare boolean options (e.g. orientation-horizontal)
232
+ if (KNOWN_BOOLEANS.has(firstToken) && spaceIdx < 0) {
233
+ if (firstToken === 'orientation-horizontal') {
234
+ result.orientation = 'horizontal';
235
+ }
236
+ continue;
237
+ }
195
238
 
196
239
  // Known option with a value
197
240
  if (KNOWN_OPTIONS.has(firstToken) && spaceIdx >= 0) {
@@ -217,13 +260,13 @@ export function parseChart(
217
260
  continue;
218
261
  }
219
262
 
220
- if (firstToken === 'xlabel') {
263
+ if (firstToken === 'x-label') {
221
264
  result.xlabel = value;
222
265
  result.xlabelLineNumber = lineNumber;
223
266
  continue;
224
267
  }
225
268
 
226
- if (firstToken === 'ylabel') {
269
+ if (firstToken === 'y-label') {
227
270
  result.ylabel = value;
228
271
  result.ylabelLineNumber = lineNumber;
229
272
  continue;
@@ -234,29 +277,6 @@ export function parseChart(
234
277
  continue;
235
278
  }
236
279
 
237
- if (firstToken === 'labels') {
238
- const v = value.toLowerCase();
239
- if (v === 'name' || v === 'value' || v === 'percent' || v === 'full') {
240
- result.labels = v;
241
- }
242
- continue;
243
- }
244
-
245
- if (firstToken === 'orientation' || firstToken === 'direction') {
246
- // Only bar and bar-stacked support orientation (axis swapping)
247
- if (result.type === 'bar' || result.type === 'bar-stacked') {
248
- const vLower = value.toLowerCase();
249
- if (vLower === 'horizontal' || vLower === 'vertical') {
250
- result.orientation = vLower;
251
- } else {
252
- const dir = normalizeDirection(value);
253
- if (dir === 'LR') result.orientation = 'horizontal';
254
- else if (dir === 'TB') result.orientation = 'vertical';
255
- }
256
- }
257
- continue;
258
- }
259
-
260
280
  if (firstToken === 'color') {
261
281
  result.color = resolveColor(value.trim(), palette) ?? undefined;
262
282
  continue;
@@ -271,11 +291,26 @@ export function parseChart(
271
291
  result.seriesNames = parsed.names;
272
292
  result.seriesNameLineNumbers = parsed.nameLineNumbers;
273
293
  }
274
- if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
294
+ if (parsed.nameColors.some(Boolean))
295
+ result.seriesNameColors = parsed.nameColors;
275
296
  continue;
276
297
  }
277
298
  }
278
299
 
300
+ // Bare boolean options: no-label-name, no-label-value, no-label-percent
301
+ if (firstToken === 'no-label-name') {
302
+ result.noLabelName = true;
303
+ continue;
304
+ }
305
+ if (firstToken === 'no-label-value') {
306
+ result.noLabelValue = true;
307
+ continue;
308
+ }
309
+ if (firstToken === 'no-label-percent') {
310
+ result.noLabelPercent = true;
311
+ continue;
312
+ }
313
+
279
314
  // Bare "series" keyword with no value — collect indented names
280
315
  if (firstToken === 'series' && spaceIdx === -1) {
281
316
  const parsed = parseSeriesNames('', lines, i, palette);
@@ -286,16 +321,22 @@ export function parseChart(
286
321
  result.seriesNames = parsed.names;
287
322
  result.seriesNameLineNumbers = parsed.nameLineNumbers;
288
323
  }
289
- if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
324
+ if (parsed.nameColors.some(Boolean))
325
+ result.seriesNameColors = parsed.nameColors;
290
326
  continue;
291
327
  }
292
328
 
293
329
  // Data row: parse from the right — rightmost numeric token(s) = value(s), everything left = label
294
330
  // Supports comma-separated multi-values: "Jan 100, 200, 300"
331
+ // Supports space-separated multi-values when series are defined: "Jan 100 200 300"
295
332
  // Supports comma-grouped numbers: "Revenue 1,200, 1,500" → [1200, 1500]
296
- const dataValues = parseDataRowValues(trimmed);
333
+ const multiValue = (result.seriesNames?.length ?? 0) >= 2;
334
+ const dataValues = parseDataRowValues(trimmed, { multiValue });
297
335
  if (dataValues) {
298
- const { label: rawLabel, color: pointColor } = extractColor(dataValues.label, palette);
336
+ const { label: rawLabel, color: pointColor } = extractColor(
337
+ dataValues.label,
338
+ palette
339
+ );
299
340
  const [first, ...rest] = dataValues.values;
300
341
  result.data.push({
301
342
  label: rawLabel,
@@ -304,7 +345,14 @@ export function parseChart(
304
345
  ...(pointColor && { color: pointColor }),
305
346
  lineNumber,
306
347
  });
348
+ continue;
307
349
  }
350
+
351
+ // Catch-all: nothing matched this line
352
+ let msg = `Unexpected line: '${trimmed}'.`;
353
+ const hint = suggest(firstToken, [...KNOWN_OPTIONS, ...KNOWN_BOOLEANS]);
354
+ if (hint) msg += ` ${hint}`;
355
+ result.diagnostics.push(makeDgmoError(lineNumber, msg, 'warning'));
308
356
  }
309
357
 
310
358
  // Resolve raw eras against known data labels (longest-prefix match for multi-word labels)
@@ -329,7 +377,13 @@ export function parseChart(
329
377
  end = words[0];
330
378
  label = words.slice(1).join(' ');
331
379
  }
332
- parsedEras.push({ start: raw.start, end, label, color: raw.color, lineNumber: raw.lineNumber });
380
+ parsedEras.push({
381
+ start: raw.start,
382
+ end,
383
+ label,
384
+ color: raw.color,
385
+ lineNumber: raw.lineNumber,
386
+ });
333
387
  }
334
388
 
335
389
  // Eras are only valid for line, multi-line (aliased to 'line'), and area chart types
@@ -353,7 +407,10 @@ export function parseChart(
353
407
  }
354
408
 
355
409
  if (!result.error && result.type === 'bar-stacked' && !result.seriesNames) {
356
- setChartError(1, 'Chart type "bar-stacked" requires multiple series names. Use: series Name1, Name2, Name3');
410
+ setChartError(
411
+ 1,
412
+ 'Chart type "bar-stacked" requires multiple series names. Use: series Name1, Name2, Name3'
413
+ );
357
414
  }
358
415
 
359
416
  if (!result.error && result.seriesNames) {
@@ -361,7 +418,10 @@ export function parseChart(
361
418
  for (const dp of result.data) {
362
419
  const actualCount = 1 + (dp.extraValues?.length ?? 0);
363
420
  if (actualCount !== expectedCount) {
364
- warn(dp.lineNumber, `Data point "${dp.label}" has ${actualCount} value(s), but ${expectedCount} series defined. Each row must have ${expectedCount} comma-separated values.`);
421
+ warn(
422
+ dp.lineNumber,
423
+ `Data point "${dp.label}" has ${actualCount} value(s), but ${expectedCount} series defined. Each row must have ${expectedCount} values.`
424
+ );
365
425
  }
366
426
  }
367
427
  // Filter out mismatched data points so renderers get clean data
@@ -380,20 +440,22 @@ export function parseChart(
380
440
 
381
441
  /**
382
442
  * Parse a data row line: everything before the last numeric token(s) is the label,
383
- * numeric tokens at the end are the values. Supports comma-separated multi-values
384
- * and comma-grouped numbers (e.g., "1,087").
443
+ * numeric tokens at the end are the values. Supports comma-separated multi-values,
444
+ * space-separated multi-values, and comma-grouped numbers (e.g., "1,087").
385
445
  *
386
446
  * Examples:
387
- * "Jan 120" → { label: "Jan", values: [120] }
388
- * "North America 250" → { label: "North America", values: [250] }
389
- * "Region 5 300" → { label: "Region 5", values: [300] }
390
- * "Q1 10, 20, 30" → { label: "Q1", values: [10, 20, 30] }
391
- * "Revenue 1,200" → { label: "Revenue", values: [1200] }
447
+ * "Jan 120" → { label: "Jan", values: [120] }
448
+ * "North America 250" → { label: "North America", values: [250] }
449
+ * "Q1 10, 20, 30" → { label: "Q1", values: [10, 20, 30] }
450
+ * "Q1 10 20 30" → { label: "Q1", values: [10, 20, 30] }
451
+ * "Revenue 1,200" → { label: "Revenue", values: [1200] }
452
+ * "Revenue 3,984,078.65"→ { label: "Revenue", values: [3984078.65] }
392
453
  *
393
454
  * Returns null if the line has no numeric value at the end.
394
455
  */
395
456
  export function parseDataRowValues(
396
457
  line: string,
458
+ options?: { multiValue?: boolean }
397
459
  ): { label: string; values: number[] } | null {
398
460
  // First, normalize comma-grouped numbers: replace patterns like "1,087" with "1087"
399
461
  // We need to be careful: commas also separate multi-values.
@@ -407,11 +469,12 @@ export function parseDataRowValues(
407
469
  const normalized: string[] = [];
408
470
  for (let i = 0; i < segments.length; i++) {
409
471
  const seg = segments[i].trim();
410
- // Check if this segment is a continuation of a grouped number
411
- // A continuation looks like exactly 3 digits and follows a segment ending in digits.
472
+ // Check if this segment is a continuation of a grouped number.
473
+ // A continuation starts with exactly 3 digits (possibly followed by a decimal like ".65")
474
+ // and follows a segment ending in digits.
412
475
  // Grouped numbers have NO space around the comma (e.g., "1,087"), so skip if
413
476
  // the raw segment has leading whitespace (e.g., ", 350" is a value separator).
414
- if (i > 0 && /^\d{3}$/.test(seg) && !/^\s/.test(segments[i])) {
477
+ if (i > 0 && /^\d{3}(\.\d+)?$/.test(seg) && !/^\s/.test(segments[i])) {
415
478
  const prevSeg = normalized[normalized.length - 1].trimEnd();
416
479
  // Check if previous segment ends with a number (1-3 digits at the end of the last token)
417
480
  if (/\d{1,3}$/.test(prevSeg)) {
@@ -420,7 +483,6 @@ export function parseDataRowValues(
420
483
  const prevMatch = prevSeg.match(/(\d{1,3})$/);
421
484
  if (prevMatch) {
422
485
  // Tentatively merge and validate
423
- const mergedTail = prevMatch[1] + ',' + seg;
424
486
  // Build full token by looking at what's left in normalized
425
487
  // Simple approach: just merge
426
488
  normalized[normalized.length - 1] = prevSeg + seg;
@@ -462,7 +524,11 @@ export function parseDataRowValues(
462
524
  const lastSpaceIdx = firstPart.lastIndexOf(' ');
463
525
  if (lastSpaceIdx >= 0) {
464
526
  const possibleFirstVal = firstPart.substring(lastSpaceIdx + 1).trim();
465
- if (possibleFirstVal && !isNaN(parseFloat(possibleFirstVal)) && isFinite(Number(possibleFirstVal))) {
527
+ if (
528
+ possibleFirstVal &&
529
+ !isNaN(parseFloat(possibleFirstVal)) &&
530
+ isFinite(Number(possibleFirstVal))
531
+ ) {
466
532
  const label = firstPart.substring(0, lastSpaceIdx).trim();
467
533
  if (label) {
468
534
  const values = [parseFloat(possibleFirstVal)];
@@ -476,16 +542,35 @@ export function parseDataRowValues(
476
542
  }
477
543
  }
478
544
 
479
- // No commas or comma parsing didn't work — split by spaces from right
480
- // Last space-separated token that is numeric = the value
481
- const lastSpaceIdx = rebuilt.lastIndexOf(' ');
482
- if (lastSpaceIdx < 0) return null;
545
+ // No commas or comma parsing didn't work — split by spaces from right.
546
+ // When multiValue is enabled, walk backward collecting consecutive numeric tokens.
547
+ // Otherwise (default), take only the last token — preserving labels that contain
548
+ // numbers (e.g., "Region 5 300" → label "Region 5", value 300).
549
+ const tokens = rebuilt.split(/\s+/);
550
+ if (tokens.length < 2) return null;
551
+
552
+ if (options?.multiValue) {
553
+ const values: number[] = [];
554
+ let idx = tokens.length - 1;
555
+ while (idx >= 1) {
556
+ const tok = tokens[idx];
557
+ const num = parseFloat(tok);
558
+ if (isNaN(num) || !isFinite(Number(tok))) break;
559
+ values.unshift(num);
560
+ idx--;
561
+ }
562
+ if (values.length === 0) return null;
563
+ const label = tokens.slice(0, idx + 1).join(' ');
564
+ if (!label) return null;
565
+ return { label, values };
566
+ }
483
567
 
484
- const possibleValue = rebuilt.substring(lastSpaceIdx + 1).trim();
485
- const num = parseFloat(possibleValue);
486
- if (isNaN(num) || !isFinite(Number(possibleValue))) return null;
568
+ // Single-value mode: only the last space-separated token
569
+ const lastToken = tokens[tokens.length - 1];
570
+ const num = parseFloat(lastToken);
571
+ if (isNaN(num) || !isFinite(Number(lastToken))) return null;
487
572
 
488
- const label = rebuilt.substring(0, lastSpaceIdx).trim();
573
+ const label = tokens.slice(0, -1).join(' ');
489
574
  if (!label) return null;
490
575
 
491
576
  return { label, values: [num] };
@@ -1,7 +1,11 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
- import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
- import { measureIndent, parseFirstLine, OPTION_NOCOLON_RE } from '../utils/parsing';
3
+ import { makeDgmoError, formatDgmoError } from '../diagnostics';
4
+ import {
5
+ measureIndent,
6
+ parseFirstLine,
7
+ OPTION_NOCOLON_RE,
8
+ } from '../utils/parsing';
5
9
  import type {
6
10
  ParsedClassDiagram,
7
11
  ClassNode,
@@ -30,12 +34,16 @@ function classId(name: string): string {
30
34
  const CLASS_DECL_RE =
31
35
  /^(?:(abstract|interface|enum)\s+)?([A-Z][A-Za-z0-9_]*)(?:\s+(extends|implements)\s+([A-Z][A-Za-z0-9_]*))?(?:\s+\[(abstract|interface|enum)\])?(?:\s+\(([^)]+)\))?\s*$/;
32
36
 
33
- // Relationship — arrow syntax:
34
- // ClassName --|> TargetClass label (new: space-separated)
35
- // ClassName --|> TargetClass : label (old: colon-separated, kept for transition)
37
+ // Relationship — arrow syntax (indented under source class):
38
+ // --|> TargetClass label (space-separated)
39
+ // --|> TargetClass : label (colon-separated, kept for transition)
36
40
  // Arrows: --|> ..|> *-- o-- ..> ->
41
+ const INDENT_REL_ARROW_RE =
42
+ /^(--\|>|\.\.\|>|\*--|o--|\.\.>|->)\s*([A-Z][A-Za-z0-9_]*)(?:\s+:?\s*(.+))?$/;
43
+
44
+ // Legacy top-level relationship regex (used only for detection/rejection)
37
45
  const REL_ARROW_RE =
38
- /^([A-Z][A-Za-z0-9_]*)\s*(--\|>|\.\.\|>|\*--|o--|\.\.\>|->)\s*([A-Z][A-Za-z0-9_]*)(?:\s+:?\s*(.+))?$/;
46
+ /^([A-Z][A-Za-z0-9_]*)\s*(--\|>|\.\.\|>|\*--|o--|\.\.>|->)\s*([A-Z][A-Za-z0-9_]*)(?:\s+:?\s*(.+))?$/;
39
47
 
40
48
  // Member line patterns
41
49
  const VISIBILITY_RE = /^([+\-#])\s*/;
@@ -156,7 +164,8 @@ export function parseClassDiagram(
156
164
  error: null,
157
165
  };
158
166
 
159
- const fail = (line: number, message: string): ParsedClassDiagram => {
167
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
168
+ const _fail = (line: number, message: string): ParsedClassDiagram => {
160
169
  const diag = makeDgmoError(line, message);
161
170
  result.diagnostics.push(diag);
162
171
  result.error = formatDgmoError(diag);
@@ -211,9 +220,14 @@ export function parseClassDiagram(
211
220
  }
212
221
  }
213
222
 
214
- // Space-separated options before content (new syntax): `color off`
223
+ // Space-separated options before content (new syntax): `no-auto-color`
215
224
  // Only match lines starting with a lowercase token (options), not uppercase (class names)
216
225
  if (!contentStarted && indent === 0 && /^[a-z]/.test(trimmed)) {
226
+ // Bare boolean option (single keyword, no value)
227
+ if (trimmed.toLowerCase() === 'no-auto-color') {
228
+ result.options['no-auto-color'] = 'on';
229
+ continue;
230
+ }
217
231
  const optMatch = trimmed.match(OPTION_NOCOLON_RE);
218
232
  if (optMatch) {
219
233
  const key = optMatch[1].toLowerCase();
@@ -226,8 +240,27 @@ export function parseClassDiagram(
226
240
  }
227
241
  }
228
242
 
229
- // Indented lines = members of current class
243
+ // Indented lines = relationships or members of current class
230
244
  if (indent > 0 && currentClass) {
245
+ // Try indented relationship arrow: --|> TargetClass [label]
246
+ const indentRel = trimmed.match(INDENT_REL_ARROW_RE);
247
+ if (indentRel) {
248
+ const arrow = indentRel[1];
249
+ const targetName = indentRel[2];
250
+ const label = indentRel[3]?.trim();
251
+
252
+ getOrCreateClass(targetName, lineNumber);
253
+
254
+ result.relationships.push({
255
+ source: currentClass.id,
256
+ target: classId(targetName),
257
+ type: ARROW_TO_TYPE[arrow],
258
+ ...(label && { label }),
259
+ lineNumber,
260
+ });
261
+ continue;
262
+ }
263
+
231
264
  const member = parseMember(
232
265
  trimmed,
233
266
  lineNumber,
@@ -243,25 +276,19 @@ export function parseClassDiagram(
243
276
  currentClass = null;
244
277
  contentStarted = true;
245
278
 
246
- // Try relationship — arrow syntax
279
+ // Reject top-level relationship arrows must be indented under source class
247
280
  const relArrow = trimmed.match(REL_ARROW_RE);
248
281
  if (relArrow) {
249
282
  const sourceName = relArrow[1];
250
283
  const arrow = relArrow[2];
251
284
  const targetName = relArrow[3];
252
- const label = relArrow[4]?.trim();
253
-
254
- // Ensure both classes exist
255
- getOrCreateClass(sourceName, lineNumber);
256
- getOrCreateClass(targetName, lineNumber);
257
-
258
- result.relationships.push({
259
- source: classId(sourceName),
260
- target: classId(targetName),
261
- type: ARROW_TO_TYPE[arrow],
262
- ...(label && { label }),
263
- lineNumber,
264
- });
285
+ result.diagnostics.push(
286
+ makeDgmoError(
287
+ lineNumber,
288
+ `Relationship "${sourceName} ${arrow} ${targetName}" must be indented under the source class "${sourceName}"`,
289
+ 'warning'
290
+ )
291
+ );
265
292
  continue;
266
293
  }
267
294
 
@@ -297,17 +324,29 @@ export function parseClassDiagram(
297
324
  currentClass = node;
298
325
  continue;
299
326
  }
327
+
328
+ // Catch-all: nothing matched this line
329
+ result.diagnostics.push(
330
+ makeDgmoError(lineNumber, `Unexpected line: '${trimmed}'.`, 'warning')
331
+ );
300
332
  }
301
333
 
302
334
  // Validation
303
335
  if (result.classes.length === 0 && !result.error) {
304
- const diag = makeDgmoError(1, 'No classes found. Add class declarations like "ClassName" or "ClassName [interface]".');
336
+ const diag = makeDgmoError(
337
+ 1,
338
+ 'No classes found. Add class declarations like "ClassName" or "ClassName [interface]".'
339
+ );
305
340
  result.diagnostics.push(diag);
306
341
  result.error = formatDgmoError(diag);
307
342
  }
308
343
 
309
344
  // Warn about isolated classes (not in any relationship)
310
- if (result.classes.length >= 2 && result.relationships.length >= 1 && !result.error) {
345
+ if (
346
+ result.classes.length >= 2 &&
347
+ result.relationships.length >= 1 &&
348
+ !result.error
349
+ ) {
311
350
  const connectedIds = new Set<string>();
312
351
  for (const rel of result.relationships) {
313
352
  connectedIds.add(rel.source);
@@ -315,7 +354,13 @@ export function parseClassDiagram(
315
354
  }
316
355
  for (const cls of result.classes) {
317
356
  if (!connectedIds.has(cls.id)) {
318
- result.diagnostics.push(makeDgmoError(cls.lineNumber, `Class "${cls.name}" is not connected to any other class`, 'warning'));
357
+ result.diagnostics.push(
358
+ makeDgmoError(
359
+ cls.lineNumber,
360
+ `Class "${cls.name}" is not connected to any other class`,
361
+ 'warning'
362
+ )
363
+ );
319
364
  }
320
365
  }
321
366
  }
@@ -358,7 +403,9 @@ export function looksLikeClassDiagram(content: string): boolean {
358
403
  hasClassDecl = true;
359
404
  }
360
405
  // Check for old modifier pattern: ClassName [abstract|interface|enum]
361
- if (/^[A-Z][A-Za-z0-9_]*\s+\[(abstract|interface|enum)\]/i.test(trimmed)) {
406
+ if (
407
+ /^[A-Z][A-Za-z0-9_]*\s+\[(abstract|interface|enum)\]/i.test(trimmed)
408
+ ) {
362
409
  hasModifier = true;
363
410
  hasClassDecl = true;
364
411
  }
@@ -380,6 +427,10 @@ export function looksLikeClassDiagram(content: string): boolean {
380
427
  if (/^[+\-#]?\s*\w+.*[:(]/.test(trimmed)) {
381
428
  hasIndentedMember = true;
382
429
  }
430
+ // Indented relationship arrows
431
+ if (INDENT_REL_ARROW_RE.test(trimmed)) {
432
+ hasRelationship = true;
433
+ }
383
434
  }
384
435
  }
385
436
 
@@ -409,7 +460,12 @@ export function extractSymbols(docText: string): DiagramSymbols {
409
460
  for (const rawLine of docText.split('\n')) {
410
461
  const line = rawLine.trim();
411
462
  // Skip old-style colon metadata and new-style first line / space-separated options
412
- if (inMetadata && (/^[a-z-]+\s*:/i.test(line) || /^class(\s|$)/i.test(line))) continue;
463
+ if (
464
+ inMetadata &&
465
+ (/^[a-z-]+\s*:/i.test(line) || /^class(\s|$)/i.test(line))
466
+ )
467
+ continue;
468
+ if (inMetadata && line.toLowerCase() === 'no-auto-color') continue;
413
469
  if (inMetadata && /^[a-z]/.test(line) && OPTION_NOCOLON_RE.test(line)) {
414
470
  const key = line.match(OPTION_NOCOLON_RE)![1].toLowerCase();
415
471
  if (key !== 'abstract' && key !== 'interface' && key !== 'enum') continue;
@@ -82,7 +82,7 @@ const CLASS_TYPE_MAP: Record<string, ClassLegendEntry> = {
82
82
  const CLASS_TYPE_ORDER = ['class', 'abstract', 'interface', 'enum'];
83
83
 
84
84
  function collectClassTypes(parsed: ParsedClassDiagram): ClassLegendEntry[] {
85
- if (parsed.options?.color === 'off') return [];
85
+ if (parsed.options?.['no-auto-color']) return [];
86
86
 
87
87
  const present = new Set<string>();
88
88
  for (const c of parsed.classes) {
@@ -500,7 +500,7 @@ export function renderClassDiagram(
500
500
 
501
501
  const w = node.width;
502
502
  const h = node.height;
503
- const colorOff = parsed.options?.color === 'off';
503
+ const colorOff = !!parsed.options?.['no-auto-color'];
504
504
  // When legend is collapsed, use neutral color for nodes without explicit color
505
505
  const neutralize = hasLegend && !isLegendExpanded && !node.color;
506
506
  const effectiveColor = neutralize ? palette.primary : node.color;