@diagrammo/dgmo 0.7.3 → 0.8.0

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 (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3506 -1057
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3493 -1057
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +310 -73
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +726 -231
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +42 -23
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. package/src/utils/title-constants.ts +9 -0
package/src/chart.ts CHANGED
@@ -25,6 +25,7 @@ export interface ChartEra {
25
25
  end: string; // exact category label, e.g. "'81"
26
26
  label: string; // display name, e.g. "Carter"
27
27
  color: string | null; // resolved CSS color, or null → palette default
28
+ lineNumber: number;
28
29
  }
29
30
 
30
31
  import type { DgmoError } from './diagnostics';
@@ -34,9 +35,13 @@ export interface ParsedChart {
34
35
  title?: string;
35
36
  titleLineNumber?: number;
36
37
  series?: string;
38
+ seriesLineNumber?: number;
37
39
  xlabel?: string;
40
+ xlabelLineNumber?: number;
38
41
  ylabel?: string;
42
+ ylabelLineNumber?: number;
39
43
  seriesNames?: string[];
44
+ seriesNameLineNumbers?: number[];
40
45
  seriesNameColors?: (string | undefined)[];
41
46
  orientation?: 'horizontal' | 'vertical';
42
47
  color?: string;
@@ -55,7 +60,7 @@ export interface ParsedChart {
55
60
  import { resolveColor } from './colors';
56
61
  import type { PaletteColors } from './palettes';
57
62
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
58
- import { extractColor, parseSeriesNames } from './utils/parsing';
63
+ import { extractColor, normalizeDirection, normalizeGroupedNumber, parseFirstLine, parseSeriesNames } from './utils/parsing';
59
64
 
60
65
  // ============================================================
61
66
  // Parser
@@ -76,18 +81,23 @@ const TYPE_ALIASES: Record<string, ChartType> = {
76
81
  'multi-line': 'line',
77
82
  };
78
83
 
84
+ /** Known option keywords for the simple chart parser. */
85
+ const KNOWN_OPTIONS = new Set([
86
+ 'chart', 'title', 'series', 'xlabel', 'ylabel', 'label', 'labels',
87
+ 'orientation', 'direction', 'color',
88
+ ]);
89
+
79
90
  /**
80
91
  * Parses the simple chart text format into a structured object.
81
92
  *
82
- * Format:
93
+ * Format (colon-free):
83
94
  * ```
84
- * chart: bar
85
- * title: My Chart
86
- * series: Revenue
95
+ * bar My Chart
96
+ * series Revenue
87
97
  *
88
- * Jan: 120
89
- * Feb: 200
90
- * Mar: 150
98
+ * Jan 120
99
+ * Feb 200
100
+ * Mar 150
91
101
  * ```
92
102
  */
93
103
  export function parseChart(
@@ -96,6 +106,7 @@ export function parseChart(
96
106
  ): ParsedChart {
97
107
  const lines = content.split('\n');
98
108
  const parsedEras: ChartEra[] = [];
109
+ const rawEras: { start: string; afterArrow: string; color: string | null; lineNumber: number }[] = [];
99
110
  const result: ParsedChart = {
100
111
  type: 'bar',
101
112
  data: [],
@@ -111,6 +122,8 @@ export function parseChart(
111
122
  return result;
112
123
  };
113
124
 
125
+ let firstLineParsed = false;
126
+
114
127
  for (let i = 0; i < lines.length; i++) {
115
128
  const trimmed = lines[i].trim();
116
129
  const lineNumber = i + 1;
@@ -127,115 +140,198 @@ export function parseChart(
127
140
  // Skip comments
128
141
  if (trimmed.startsWith('//')) continue;
129
142
 
130
- // Era line: must be matched before colon-split (era '77 -> '81: Carter has colons inside)
131
- const eraMatch = trimmed.match(/^era\s+(.+?)\s*->\s*(.+?)\s*:\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/);
132
- if (eraMatch) {
133
- parsedEras.push({
134
- start: eraMatch[1].trim(),
135
- end: eraMatch[2].trim(),
136
- label: eraMatch[3].trim(),
137
- color: eraMatch[4] ? resolveColor(eraMatch[4].trim(), palette) : null,
138
- });
139
- continue;
140
- }
141
-
142
- // Parse key: value pairs
143
- const colonIndex = trimmed.indexOf(':');
144
- if (colonIndex === -1) continue;
145
-
146
- const key = trimmed.substring(0, colonIndex).trim().toLowerCase();
147
- const value = trimmed.substring(colonIndex + 1).trim();
148
-
149
- // Handle metadata
150
- if (key === 'chart') {
151
- const raw = value.toLowerCase();
152
- const chartType = (TYPE_ALIASES[raw] ?? raw) as ChartType;
153
- if (VALID_TYPES.has(chartType)) {
154
- result.type = chartType;
155
- } else {
156
- let msg = `Unsupported chart type: ${value}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
157
- const hint = suggest(raw, [...VALID_TYPES]);
143
+ // First non-empty, non-comment line: chart type + optional title
144
+ if (!firstLineParsed) {
145
+ firstLineParsed = true;
146
+ const firstLine = parseFirstLine(trimmed);
147
+ if (firstLine) {
148
+ const raw = firstLine.chartType.toLowerCase();
149
+ const chartType = (TYPE_ALIASES[raw] ?? raw) as ChartType;
150
+ if (VALID_TYPES.has(chartType)) {
151
+ result.type = chartType;
152
+ if (firstLine.title) {
153
+ result.title = firstLine.title;
154
+ result.titleLineNumber = lineNumber;
155
+ }
156
+ continue;
157
+ } else {
158
+ let msg = `Unsupported chart type: ${firstLine.chartType}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
159
+ const hint = suggest(raw, [...VALID_TYPES]);
160
+ if (hint) msg += ` ${hint}`;
161
+ return fail(lineNumber, msg);
162
+ }
163
+ }
164
+ // If the first line is a single word (no spaces, no colon, no numbers),
165
+ // treat it as an unrecognized chart type rather than falling through
166
+ if (!trimmed.includes(' ') && !trimmed.includes(':') && !/\d/.test(trimmed)) {
167
+ let msg = `Unsupported chart type: ${trimmed}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
168
+ const hint = suggest(trimmed.toLowerCase(), [...VALID_TYPES]);
158
169
  if (hint) msg += ` ${hint}`;
159
170
  return fail(lineNumber, msg);
160
171
  }
161
- continue;
172
+ // Fall through — first line might be a data row or option
162
173
  }
163
174
 
164
- if (key === 'title') {
165
- result.title = value;
166
- result.titleLineNumber = lineNumber;
175
+ // Era line: era Day 1 -> Day 3 Rough Seas (blue) — colon-free
176
+ const eraMatch = trimmed.match(/^era\s+(.+?)\s*->\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/);
177
+ if (eraMatch) {
178
+ // Store start and raw afterArrow — resolved against data labels after parsing
179
+ const afterArrow = eraMatch[2].trim();
180
+ const spaceIdx = afterArrow.indexOf(' ');
181
+ if (spaceIdx >= 0) {
182
+ rawEras.push({
183
+ start: eraMatch[1].trim(),
184
+ afterArrow,
185
+ color: eraMatch[3] ? resolveColor(eraMatch[3].trim(), palette) : null,
186
+ lineNumber,
187
+ });
188
+ }
167
189
  continue;
168
190
  }
169
191
 
170
- if (key === 'xlabel') {
171
- result.xlabel = value;
172
- continue;
173
- }
192
+ // Extract first token to check for known options
193
+ const spaceIdx = trimmed.indexOf(' ');
194
+ const firstToken = (spaceIdx >= 0 ? trimmed.substring(0, spaceIdx) : trimmed).toLowerCase();
195
+
196
+ // Known option with a value
197
+ if (KNOWN_OPTIONS.has(firstToken) && spaceIdx >= 0) {
198
+ const value = trimmed.substring(spaceIdx + 1).trim();
199
+
200
+ if (firstToken === 'chart') {
201
+ const raw = value.toLowerCase();
202
+ const chartType = (TYPE_ALIASES[raw] ?? raw) as ChartType;
203
+ if (VALID_TYPES.has(chartType)) {
204
+ result.type = chartType;
205
+ } else {
206
+ let msg = `Unsupported chart type: ${value}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
207
+ const hint = suggest(raw, [...VALID_TYPES]);
208
+ if (hint) msg += ` ${hint}`;
209
+ return fail(lineNumber, msg);
210
+ }
211
+ continue;
212
+ }
174
213
 
175
- if (key === 'ylabel') {
176
- result.ylabel = value;
177
- continue;
178
- }
214
+ if (firstToken === 'title') {
215
+ result.title = value;
216
+ result.titleLineNumber = lineNumber;
217
+ continue;
218
+ }
179
219
 
180
- if (key === 'label') {
181
- result.label = value;
182
- continue;
183
- }
220
+ if (firstToken === 'xlabel') {
221
+ result.xlabel = value;
222
+ result.xlabelLineNumber = lineNumber;
223
+ continue;
224
+ }
184
225
 
185
- if (key === 'labels') {
186
- const v = value.toLowerCase();
187
- if (v === 'name' || v === 'value' || v === 'percent' || v === 'full') {
188
- result.labels = v;
226
+ if (firstToken === 'ylabel') {
227
+ result.ylabel = value;
228
+ result.ylabelLineNumber = lineNumber;
229
+ continue;
189
230
  }
190
- continue;
191
- }
192
231
 
193
- if (key === 'orientation') {
194
- // Only bar and bar-stacked support orientation (axis swapping)
195
- if (result.type === 'bar' || result.type === 'bar-stacked') {
232
+ if (firstToken === 'label') {
233
+ result.label = value;
234
+ continue;
235
+ }
236
+
237
+ if (firstToken === 'labels') {
196
238
  const v = value.toLowerCase();
197
- if (v === 'horizontal' || v === 'vertical') {
198
- result.orientation = v;
239
+ if (v === 'name' || v === 'value' || v === 'percent' || v === 'full') {
240
+ result.labels = v;
199
241
  }
242
+ continue;
200
243
  }
201
- continue;
202
- }
203
244
 
204
- if (key === 'color') {
205
- result.color = resolveColor(value.trim(), palette);
206
- continue;
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
+ if (firstToken === 'color') {
261
+ result.color = resolveColor(value.trim(), palette) ?? undefined;
262
+ continue;
263
+ }
264
+
265
+ if (firstToken === 'series') {
266
+ const parsed = parseSeriesNames(value, lines, i, palette);
267
+ i = parsed.newIndex;
268
+ result.series = parsed.series;
269
+ result.seriesLineNumber = lineNumber;
270
+ if (parsed.names.length > 1) {
271
+ result.seriesNames = parsed.names;
272
+ result.seriesNameLineNumbers = parsed.nameLineNumbers;
273
+ }
274
+ if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
275
+ continue;
276
+ }
207
277
  }
208
278
 
209
- if (key === 'series') {
210
- const parsed = parseSeriesNames(value, lines, i, palette);
279
+ // Bare "series" keyword with no value — collect indented names
280
+ if (firstToken === 'series' && spaceIdx === -1) {
281
+ const parsed = parseSeriesNames('', lines, i, palette);
211
282
  i = parsed.newIndex;
212
283
  result.series = parsed.series;
284
+ result.seriesLineNumber = lineNumber;
213
285
  if (parsed.names.length > 1) {
214
286
  result.seriesNames = parsed.names;
287
+ result.seriesNameLineNumbers = parsed.nameLineNumbers;
215
288
  }
216
289
  if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
217
290
  continue;
218
291
  }
219
292
 
220
- // Data point: Label: value or Label: v1, v2, ...
221
- const parts = value.split(',').map((s) => s.trim());
222
- const numValue = parseFloat(parts[0]);
223
- if (!isNaN(numValue)) {
224
- const { label: rawLabel, color: pointColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
225
- const extra = parts
226
- .slice(1)
227
- .map((s) => parseFloat(s))
228
- .filter((n) => !isNaN(n));
293
+ // Data row: parse from the right — rightmost numeric token(s) = value(s), everything left = label
294
+ // Supports comma-separated multi-values: "Jan 100, 200, 300"
295
+ // Supports comma-grouped numbers: "Revenue 1,200, 1,500" → [1200, 1500]
296
+ const dataValues = parseDataRowValues(trimmed);
297
+ if (dataValues) {
298
+ const { label: rawLabel, color: pointColor } = extractColor(dataValues.label, palette);
299
+ const [first, ...rest] = dataValues.values;
229
300
  result.data.push({
230
301
  label: rawLabel,
231
- value: numValue,
232
- ...(extra.length > 0 && { extraValues: extra }),
302
+ value: first,
303
+ ...(rest.length > 0 && { extraValues: rest }),
233
304
  ...(pointColor && { color: pointColor }),
234
305
  lineNumber,
235
306
  });
236
307
  }
237
308
  }
238
309
 
310
+ // Resolve raw eras against known data labels (longest-prefix match for multi-word labels)
311
+ const knownLabels = new Set(result.data.map((d) => d.label));
312
+ for (const raw of rawEras) {
313
+ // Find the longest prefix of afterArrow that matches a known label
314
+ const words = raw.afterArrow.split(' ');
315
+ let end = '';
316
+ let label = '';
317
+ let matched = false;
318
+ for (let w = words.length - 1; w >= 1; w--) {
319
+ const candidateEnd = words.slice(0, w).join(' ');
320
+ if (knownLabels.has(candidateEnd)) {
321
+ end = candidateEnd;
322
+ label = words.slice(w).join(' ');
323
+ matched = true;
324
+ break;
325
+ }
326
+ }
327
+ if (!matched) {
328
+ // Fallback: first token = end, rest = label
329
+ end = words[0];
330
+ label = words.slice(1).join(' ');
331
+ }
332
+ parsedEras.push({ start: raw.start, end, label, color: raw.color, lineNumber: raw.lineNumber });
333
+ }
334
+
239
335
  // Eras are only valid for line, multi-line (aliased to 'line'), and area chart types
240
336
  if (result.type !== 'line' && result.type !== 'area') {
241
337
  result.eras = undefined;
@@ -253,11 +349,11 @@ export function parseChart(
253
349
  };
254
350
 
255
351
  if (!result.error && result.data.length === 0) {
256
- warn(1, 'No data points found. Add data in format: Label: 123');
352
+ warn(1, 'No data points found. Add data in format: Label 123');
257
353
  }
258
354
 
259
355
  if (!result.error && result.type === 'bar-stacked' && !result.seriesNames) {
260
- setChartError(1, 'Chart type "bar-stacked" requires multiple series names. Use: series: Name1, Name2, Name3');
356
+ setChartError(1, 'Chart type "bar-stacked" requires multiple series names. Use: series Name1, Name2, Name3');
261
357
  }
262
358
 
263
359
  if (!result.error && result.seriesNames) {
@@ -277,3 +373,120 @@ export function parseChart(
277
373
 
278
374
  return result;
279
375
  }
376
+
377
+ // ============================================================
378
+ // Data Row Parser
379
+ // ============================================================
380
+
381
+ /**
382
+ * 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").
385
+ *
386
+ * 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] }
392
+ *
393
+ * Returns null if the line has no numeric value at the end.
394
+ */
395
+ export function parseDataRowValues(
396
+ line: string,
397
+ ): { label: string; values: number[] } | null {
398
+ // First, normalize comma-grouped numbers: replace patterns like "1,087" with "1087"
399
+ // We need to be careful: commas also separate multi-values.
400
+ // Strategy: tokenize by commas, normalize grouped numbers, then re-parse.
401
+
402
+ // Split by comma to get segments
403
+ const segments = line.split(',');
404
+
405
+ // Normalize each segment: if a segment (trimmed) matches grouped number pattern,
406
+ // merge it with the previous segment
407
+ const normalized: string[] = [];
408
+ for (let i = 0; i < segments.length; i++) {
409
+ 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.
412
+ // Grouped numbers have NO space around the comma (e.g., "1,087"), so skip if
413
+ // 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])) {
415
+ const prevSeg = normalized[normalized.length - 1].trimEnd();
416
+ // Check if previous segment ends with a number (1-3 digits at the end of the last token)
417
+ if (/\d{1,3}$/.test(prevSeg)) {
418
+ // Check if the combined token would be a valid grouped number
419
+ // Extract the trailing number from prev
420
+ const prevMatch = prevSeg.match(/(\d{1,3})$/);
421
+ if (prevMatch) {
422
+ // Tentatively merge and validate
423
+ const mergedTail = prevMatch[1] + ',' + seg;
424
+ // Build full token by looking at what's left in normalized
425
+ // Simple approach: just merge
426
+ normalized[normalized.length - 1] = prevSeg + seg;
427
+ continue;
428
+ }
429
+ }
430
+ }
431
+ normalized.push(segments[i]);
432
+ }
433
+
434
+ const rebuilt = normalized.join(',');
435
+
436
+ // Now check for comma-separated values at the end
437
+ // Strategy: find where the label ends and values begin
438
+ // Values are comma-separated numeric tokens at the end of the line
439
+
440
+ // Try splitting by comma first — if the line has commas, the last comma-separated tokens
441
+ // that are all numeric form the values
442
+ const commaParts = rebuilt.split(',');
443
+ if (commaParts.length > 1) {
444
+ // Find how many trailing comma-separated parts are numeric
445
+ let numericCount = 0;
446
+ for (let j = commaParts.length - 1; j >= 0; j--) {
447
+ const part = commaParts[j].trim();
448
+ if (part && !isNaN(parseFloat(part)) && isFinite(Number(part))) {
449
+ numericCount++;
450
+ } else {
451
+ break;
452
+ }
453
+ }
454
+ if (numericCount > 0) {
455
+ // Pure numeric trailing comma-parts are extra values.
456
+ // Everything before them (joined by comma) contains "label firstValue".
457
+ const splitAt = commaParts.length - numericCount;
458
+ const extraValueParts = commaParts.slice(splitAt);
459
+ const firstPart = commaParts.slice(0, splitAt).join(',').trim();
460
+
461
+ // Split firstPart from the right: last space-separated token must be numeric
462
+ const lastSpaceIdx = firstPart.lastIndexOf(' ');
463
+ if (lastSpaceIdx >= 0) {
464
+ const possibleFirstVal = firstPart.substring(lastSpaceIdx + 1).trim();
465
+ if (possibleFirstVal && !isNaN(parseFloat(possibleFirstVal)) && isFinite(Number(possibleFirstVal))) {
466
+ const label = firstPart.substring(0, lastSpaceIdx).trim();
467
+ if (label) {
468
+ const values = [parseFloat(possibleFirstVal)];
469
+ for (const p of extraValueParts) {
470
+ values.push(parseFloat(p.trim()));
471
+ }
472
+ return { label, values };
473
+ }
474
+ }
475
+ }
476
+ }
477
+ }
478
+
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;
483
+
484
+ const possibleValue = rebuilt.substring(lastSpaceIdx + 1).trim();
485
+ const num = parseFloat(possibleValue);
486
+ if (isNaN(num) || !isFinite(Number(possibleValue))) return null;
487
+
488
+ const label = rebuilt.substring(0, lastSpaceIdx).trim();
489
+ if (!label) return null;
490
+
491
+ return { label, values: [num] };
492
+ }
@@ -1,7 +1,7 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
- import { measureIndent } from '../utils/parsing';
4
+ import { measureIndent, parseFirstLine, OPTION_NOCOLON_RE } from '../utils/parsing';
5
5
  import type {
6
6
  ParsedClassDiagram,
7
7
  ClassNode,
@@ -23,15 +23,19 @@ function classId(name: string): string {
23
23
  // Regex patterns
24
24
  // ============================================================
25
25
 
26
- // Class declaration: ClassName [extends|implements ParentClass] [modifier] (color)
26
+ // Class declaration: [modifier] ClassName [extends|implements ParentClass] (color)
27
+ // Supports both:
28
+ // New: `abstract Animal` or `interface Serializable`
29
+ // Old: `Animal [abstract]` (bracketed suffix, kept for transition)
27
30
  const CLASS_DECL_RE =
28
- /^([A-Z][A-Za-z0-9_]*)(?:\s+(extends|implements)\s+([A-Z][A-Za-z0-9_]*))?(?:\s+\[(abstract|interface|enum)\])?(?:\s+\(([^)]+)\))?\s*$/;
31
+ /^(?:(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*$/;
29
32
 
30
33
  // Relationship — arrow syntax:
31
- // ClassName --|> TargetClass : label
34
+ // ClassName --|> TargetClass label (new: space-separated)
35
+ // ClassName --|> TargetClass : label (old: colon-separated, kept for transition)
32
36
  // Arrows: --|> ..|> *-- o-- ..> ->
33
37
  const REL_ARROW_RE =
34
- /^([A-Z][A-Za-z0-9_]*)\s+(--\|>|\.\.\|>|\*--|o--|\.\.\>|->)\s+([A-Z][A-Za-z0-9_]*)(?:\s*:\s*(.+))?$/;
38
+ /^([A-Z][A-Za-z0-9_]*)\s*(--\|>|\.\.\|>|\*--|o--|\.\.\>|->)\s*([A-Z][A-Za-z0-9_]*)(?:\s+:?\s*(.+))?$/;
35
39
 
36
40
  // Member line patterns
37
41
  const VISIBILITY_RE = /^([+\-#])\s*/;
@@ -195,34 +199,30 @@ export function parseClassDiagram(
195
199
  // Skip comments
196
200
  if (trimmed.startsWith('//')) continue;
197
201
 
198
- // Metadata directives (before content) only simple keys (no spaces)
199
- if (!contentStarted && indent === 0 && /^[a-z][a-z0-9-]*\s*:/i.test(trimmed)) {
200
- const colonIdx = trimmed.indexOf(':');
201
- const key = trimmed.substring(0, colonIdx).trim().toLowerCase();
202
- const value = trimmed.substring(colonIdx + 1).trim();
203
-
204
- // Only recognize known metadata keys
205
- if (key === 'chart') {
206
- if (value.toLowerCase() !== 'class') {
207
- const allTypes = ['class', 'flowchart', 'sequence', 'er', 'org', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
208
- let msg = `Expected chart type "class", got "${value}"`;
209
- const hint = suggest(value.toLowerCase(), allTypes);
210
- if (hint) msg += `. ${hint}`;
211
- return fail(lineNumber, msg);
202
+ // First line: bare chart type + optional title (new syntax)
203
+ if (!contentStarted && indent === 0 && i === 0) {
204
+ const firstLine = parseFirstLine(trimmed);
205
+ if (firstLine && firstLine.chartType === 'class') {
206
+ if (firstLine.title) {
207
+ result.title = firstLine.title;
208
+ result.titleLineNumber = lineNumber;
212
209
  }
213
210
  continue;
214
211
  }
212
+ }
215
213
 
216
- if (key === 'title') {
217
- result.title = value;
218
- result.titleLineNumber = lineNumber;
219
- continue;
220
- }
221
-
222
- // Store diagram-level options (e.g., color: off)
223
- if (!/\s/.test(key)) {
224
- result.options[key] = value;
225
- continue;
214
+ // Space-separated options before content (new syntax): `color off`
215
+ // Only match lines starting with a lowercase token (options), not uppercase (class names)
216
+ if (!contentStarted && indent === 0 && /^[a-z]/.test(trimmed)) {
217
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
218
+ if (optMatch) {
219
+ const key = optMatch[1].toLowerCase();
220
+ const value = optMatch[2].trim();
221
+ // Don't swallow lines that look like class modifier keywords
222
+ if (key !== 'abstract' && key !== 'interface' && key !== 'enum') {
223
+ result.options[key] = value;
224
+ continue;
225
+ }
226
226
  }
227
227
  }
228
228
 
@@ -268,11 +268,13 @@ export function parseClassDiagram(
268
268
  // Try class declaration
269
269
  const classDecl = trimmed.match(CLASS_DECL_RE);
270
270
  if (classDecl) {
271
- const name = classDecl[1];
272
- const relKeyword = classDecl[2] as 'extends' | 'implements' | undefined;
273
- const parentName = classDecl[3];
274
- const modifier = classDecl[4] as ClassModifier | undefined;
275
- const colorName = classDecl[5]?.trim();
271
+ const prefixModifier = classDecl[1] as ClassModifier | undefined;
272
+ const name = classDecl[2];
273
+ const relKeyword = classDecl[3] as 'extends' | 'implements' | undefined;
274
+ const parentName = classDecl[4];
275
+ const bracketModifier = classDecl[5] as ClassModifier | undefined;
276
+ const modifier = prefixModifier ?? bracketModifier;
277
+ const colorName = classDecl[6]?.trim();
276
278
  const color = colorName ? resolveColor(colorName, palette) : undefined;
277
279
 
278
280
  const node = getOrCreateClass(name, lineNumber);
@@ -344,11 +346,18 @@ export function looksLikeClassDiagram(content: string): boolean {
344
346
 
345
347
  // Skip metadata
346
348
  if (/^(chart|title)\s*:/i.test(trimmed)) continue;
349
+ // Skip new-style first line (bare chart type)
350
+ if (/^class(\s|$)/i.test(trimmed)) continue;
347
351
 
348
352
  const indent = measureIndent(line);
349
353
 
350
354
  if (indent === 0) {
351
- // Check for modifier pattern: ClassName [abstract|interface|enum]
355
+ // Check for bare modifier keyword: `abstract ClassName`, `interface ClassName`, `enum ClassName`
356
+ if (/^(abstract|interface|enum)\s+[A-Z][A-Za-z0-9_]*/i.test(trimmed)) {
357
+ hasModifier = true;
358
+ hasClassDecl = true;
359
+ }
360
+ // Check for old modifier pattern: ClassName [abstract|interface|enum]
352
361
  if (/^[A-Z][A-Za-z0-9_]*\s+\[(abstract|interface|enum)\]/i.test(trimmed)) {
353
362
  hasModifier = true;
354
363
  hasClassDecl = true;
@@ -399,11 +408,19 @@ export function extractSymbols(docText: string): DiagramSymbols {
399
408
  let inMetadata = true;
400
409
  for (const rawLine of docText.split('\n')) {
401
410
  const line = rawLine.trim();
402
- if (inMetadata && /^[a-z-]+\s*:/i.test(line)) continue;
411
+ // 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;
413
+ if (inMetadata && /^[a-z]/.test(line) && OPTION_NOCOLON_RE.test(line)) {
414
+ const key = line.match(OPTION_NOCOLON_RE)![1].toLowerCase();
415
+ if (key !== 'abstract' && key !== 'interface' && key !== 'enum') continue;
416
+ }
403
417
  inMetadata = false;
404
418
  if (line.length === 0 || /^\s/.test(rawLine)) continue;
405
419
  const m = CLASS_DECL_RE.exec(line);
406
- if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
420
+ if (m) {
421
+ const name = m[2]!; // group 2 is the class name in the new regex
422
+ if (!entities.includes(name)) entities.push(name);
423
+ }
407
424
  }
408
425
  return {
409
426
  kind: 'class',