@diagrammo/dgmo 0.2.27 → 0.3.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 (49) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +107 -0
  2. package/.claude/skills/dgmo-flowchart/SKILL.md +61 -0
  3. package/.claude/skills/dgmo-generate/SKILL.md +58 -0
  4. package/.claude/skills/dgmo-sequence/SKILL.md +83 -0
  5. package/.cursorrules +117 -0
  6. package/.github/copilot-instructions.md +117 -0
  7. package/.windsurfrules +117 -0
  8. package/README.md +10 -3
  9. package/dist/cli.cjs +366 -918
  10. package/dist/index.cjs +581 -396
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +39 -24
  13. package/dist/index.d.ts +39 -24
  14. package/dist/index.js +578 -395
  15. package/dist/index.js.map +1 -1
  16. package/docs/ai-integration.md +125 -0
  17. package/docs/language-reference.md +786 -0
  18. package/package.json +15 -8
  19. package/src/c4/parser.ts +90 -74
  20. package/src/c4/renderer.ts +13 -12
  21. package/src/c4/types.ts +6 -4
  22. package/src/chart.ts +3 -2
  23. package/src/class/layout.ts +17 -12
  24. package/src/class/parser.ts +22 -52
  25. package/src/class/renderer.ts +44 -46
  26. package/src/class/types.ts +1 -1
  27. package/src/cli.ts +130 -19
  28. package/src/d3.ts +1 -1
  29. package/src/dgmo-mermaid.ts +1 -1
  30. package/src/dgmo-router.ts +1 -1
  31. package/src/echarts.ts +33 -13
  32. package/src/er/parser.ts +34 -43
  33. package/src/er/types.ts +1 -1
  34. package/src/graph/flowchart-parser.ts +2 -25
  35. package/src/graph/types.ts +1 -1
  36. package/src/index.ts +5 -0
  37. package/src/initiative-status/parser.ts +36 -7
  38. package/src/initiative-status/types.ts +1 -1
  39. package/src/kanban/parser.ts +32 -53
  40. package/src/kanban/renderer.ts +9 -8
  41. package/src/kanban/types.ts +6 -14
  42. package/src/org/parser.ts +47 -87
  43. package/src/org/resolver.ts +11 -12
  44. package/src/sequence/parser.ts +97 -15
  45. package/src/sequence/renderer.ts +62 -69
  46. package/src/utils/arrows.ts +75 -0
  47. package/src/utils/inline-markdown.ts +75 -0
  48. package/src/utils/parsing.ts +67 -0
  49. package/src/utils/tag-groups.ts +76 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.2.27",
3
+ "version": "0.3.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,12 @@
24
24
  },
25
25
  "files": [
26
26
  "dist",
27
- "src"
27
+ "src",
28
+ "docs",
29
+ ".claude/skills",
30
+ ".github/copilot-instructions.md",
31
+ ".cursorrules",
32
+ ".windsurfrules"
28
33
  ],
29
34
  "sideEffects": false,
30
35
  "scripts": {
@@ -33,7 +38,8 @@
33
38
  "dev": "tsup --watch",
34
39
  "test": "vitest run",
35
40
  "test:watch": "vitest",
36
- "gallery": "pnpm build && node scripts/generate-gallery.mjs"
41
+ "gallery": "pnpm build && node scripts/generate-gallery.mjs",
42
+ "check:duplication": "jscpd ./src"
37
43
  },
38
44
  "dependencies": {
39
45
  "@dagrejs/dagre": "^2.0.4",
@@ -44,11 +50,11 @@
44
50
  "d3-scale": "^4.0.2",
45
51
  "d3-selection": "^3.0.0",
46
52
  "d3-shape": "^3.2.0",
47
- "echarts": "^5.6.0",
53
+ "echarts": "^6.0.0",
48
54
  "lz-string": "^1.5.0"
49
55
  },
50
56
  "optionalDependencies": {
51
- "jsdom": "^26.0.0"
57
+ "jsdom": "^28.1.0"
52
58
  },
53
59
  "devDependencies": {
54
60
  "@types/d3-array": "^3.2.1",
@@ -57,10 +63,11 @@
57
63
  "@types/d3-scale": "^4.0.8",
58
64
  "@types/d3-selection": "^3.0.11",
59
65
  "@types/d3-shape": "^3.1.7",
60
- "@types/dagre": "^0.7.53",
61
- "@types/jsdom": "^21.1.7",
66
+ "@types/dagre": "^0.7.54",
67
+ "@types/jsdom": "^28.0.0",
68
+ "jscpd": "^4.0.8",
62
69
  "tsup": "^8.5.1",
63
70
  "typescript": "^5.7.3",
64
- "vitest": "^3.0.0"
71
+ "vitest": "^4.0.18"
65
72
  }
66
73
  }
package/src/c4/parser.ts CHANGED
@@ -2,12 +2,20 @@
2
2
  // C4 Architecture Diagram — Parser
3
3
  // ============================================================
4
4
 
5
- import { resolveColor } from '../colors';
6
5
  import type { PaletteColors } from '../palettes';
7
6
  import type { DgmoError } from '../diagnostics';
8
7
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
9
- import type { OrgTagGroup } from '../org/parser';
8
+ import type { TagGroup } from '../utils/tag-groups';
9
+ import { matchTagBlockHeading } from '../utils/tag-groups';
10
10
  import { inferParticipantType } from '../sequence/participant-inference';
11
+ import {
12
+ measureIndent,
13
+ extractColor,
14
+ parsePipeMetadata,
15
+ CHART_TYPE_RE,
16
+ TITLE_RE,
17
+ OPTION_RE,
18
+ } from '../utils/parsing';
11
19
  import type {
12
20
  ParsedC4,
13
21
  C4Element,
@@ -23,12 +31,6 @@ import type {
23
31
  // Regex patterns
24
32
  // ============================================================
25
33
 
26
- const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
27
- const TITLE_RE = /^title\s*:\s*(.+)/i;
28
- const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
29
- const GROUP_HEADING_RE =
30
- /^##\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/;
31
- const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
32
34
  const CONTAINER_RE = /^\[([^\]]+)\]$/;
33
35
 
34
36
  /** Matches element declarations: `person Name`, `system Name | k: v` */
@@ -40,6 +42,12 @@ const IS_A_RE = /\s+is\s+a(?:n)?\s+(\w+)\s*$/i;
40
42
  /** Matches relationship arrows: `->`, `~>`, `<->`, `<~>` */
41
43
  const RELATIONSHIP_RE = /^(<?-?>|<?~?>)\s+(.+)$/;
42
44
 
45
+ /** Labeled arrow relationships: -label->, ~label~>, <-label->, <~label~> */
46
+ const C4_LABELED_SYNC_RE = /^-(.+)->\s+(.+)$/;
47
+ const C4_LABELED_ASYNC_RE = /^~(.+)~>\s+(.+)$/;
48
+ const C4_LABELED_BIDI_SYNC_RE = /^<-(.+)->\s+(.+)$/;
49
+ const C4_LABELED_BIDI_ASYNC_RE = /^<~(.+)~>\s+(.+)$/;
50
+
43
51
  /** Matches section headers: `containers:`, `components:`, `deployment:` */
44
52
  const SECTION_HEADER_RE = /^(containers|components|deployment)\s*:\s*$/i;
45
53
 
@@ -53,28 +61,6 @@ const METADATA_RE = /^([^:]+):\s*(.+)$/;
53
61
  // Helpers
54
62
  // ============================================================
55
63
 
56
- function measureIndent(line: string): number {
57
- let indent = 0;
58
- for (const ch of line) {
59
- if (ch === ' ') indent++;
60
- else if (ch === '\t') indent += 4;
61
- else break;
62
- }
63
- return indent;
64
- }
65
-
66
- function extractColor(
67
- label: string,
68
- palette?: PaletteColors,
69
- ): { label: string; color?: string } {
70
- const m = label.match(COLOR_SUFFIX_RE);
71
- if (!m) return { label };
72
- const colorName = m[1].trim();
73
- return {
74
- label: label.substring(0, m.index!).trim(),
75
- color: resolveColor(colorName, palette),
76
- };
77
- }
78
64
 
79
65
  const VALID_ELEMENT_TYPES = new Set<string>([
80
66
  'person',
@@ -186,27 +172,6 @@ function parseRelationshipBody(
186
172
  return { target, label: rest };
187
173
  }
188
174
 
189
- /** Parse pipe-delimited metadata from segments after the first (name) segment. */
190
- function parsePipeMetadata(
191
- segments: string[],
192
- aliasMap: Map<string, string>,
193
- ): Record<string, string> {
194
- const metadata: Record<string, string> = {};
195
- for (let j = 1; j < segments.length; j++) {
196
- for (const part of segments[j].split(',')) {
197
- const trimmedPart = part.trim();
198
- if (!trimmedPart) continue;
199
- const colonIdx = trimmedPart.indexOf(':');
200
- if (colonIdx > 0) {
201
- const rawKey = trimmedPart.substring(0, colonIdx).trim().toLowerCase();
202
- const key = aliasMap.get(rawKey) ?? rawKey;
203
- const value = trimmedPart.substring(colonIdx + 1).trim();
204
- metadata[key] = value;
205
- }
206
- }
207
- }
208
- return metadata;
209
- }
210
175
 
211
176
  // ============================================================
212
177
  // Stack entry types
@@ -287,7 +252,7 @@ export function parseC4(
287
252
  let inDeployment = false;
288
253
 
289
254
  // Tag group parsing state
290
- let currentTagGroup: OrgTagGroup | null = null;
255
+ let currentTagGroup: TagGroup | null = null;
291
256
  const aliasMap = new Map<string, string>();
292
257
 
293
258
  // Name uniqueness tracking
@@ -341,40 +306,42 @@ export function parseC4(
341
306
  }
342
307
  }
343
308
 
344
- // Generic header options
345
- if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
346
- const optMatch = trimmed.match(OPTION_RE);
347
- if (optMatch && !trimmed.startsWith('##')) {
348
- const key = optMatch[1].trim().toLowerCase();
349
- if (key !== 'chart' && key !== 'title') {
350
- result.options[key] = optMatch[2].trim();
351
- continue;
352
- }
353
- }
354
- }
355
-
356
- // ## Tag group heading
357
- const groupMatch = trimmed.match(GROUP_HEADING_RE);
358
- if (groupMatch) {
309
+ // Tag group heading — `tag: Name` (new) or `## Name` (deprecated)
310
+ // Must be checked BEFORE OPTION_RE to prevent `tag: Rank` being swallowed as option
311
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
312
+ if (tagBlockMatch) {
359
313
  if (contentStarted) {
360
- pushError(lineNumber, 'Tag groups (##) must appear before content');
314
+ pushError(lineNumber, 'Tag groups must appear before content');
361
315
  continue;
362
316
  }
363
- const groupName = groupMatch[1].trim();
364
- const alias = groupMatch[2] || undefined;
317
+ if (tagBlockMatch.deprecated) {
318
+ pushError(lineNumber, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`, 'warning');
319
+ }
365
320
  currentTagGroup = {
366
- name: groupName,
367
- alias,
321
+ name: tagBlockMatch.name,
322
+ alias: tagBlockMatch.alias,
368
323
  entries: [],
369
324
  lineNumber,
370
325
  };
371
- if (alias) {
372
- aliasMap.set(alias.toLowerCase(), groupName.toLowerCase());
326
+ if (tagBlockMatch.alias) {
327
+ aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
373
328
  }
374
329
  result.tagGroups.push(currentTagGroup);
375
330
  continue;
376
331
  }
377
332
 
333
+ // Generic header options
334
+ if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
335
+ const optMatch = trimmed.match(OPTION_RE);
336
+ if (optMatch) {
337
+ const key = optMatch[1].trim().toLowerCase();
338
+ if (key !== 'chart' && key !== 'title') {
339
+ result.options[key] = optMatch[2].trim();
340
+ continue;
341
+ }
342
+ }
343
+ }
344
+
378
345
  // Tag group entries
379
346
  if (currentTagGroup && !contentStarted) {
380
347
  const indent = measureIndent(line);
@@ -529,6 +496,55 @@ export function parseC4(
529
496
  continue;
530
497
  }
531
498
 
499
+ // ── Labeled arrow relationships: -label->, ~label~>, <-label->, <~label~> ──
500
+ // Must be checked BEFORE plain RELATIONSHIP_RE to avoid partial matches
501
+ {
502
+ const labeledPatterns: {
503
+ re: RegExp;
504
+ arrowType: C4ArrowType;
505
+ }[] = [
506
+ { re: C4_LABELED_BIDI_SYNC_RE, arrowType: 'bidirectional' },
507
+ { re: C4_LABELED_BIDI_ASYNC_RE, arrowType: 'bidirectional-async' },
508
+ { re: C4_LABELED_SYNC_RE, arrowType: 'sync' },
509
+ { re: C4_LABELED_ASYNC_RE, arrowType: 'async' },
510
+ ];
511
+ let labeledHandled = false;
512
+ for (const { re, arrowType } of labeledPatterns) {
513
+ const m = trimmed.match(re);
514
+ if (!m) continue;
515
+ const rawLabel = m[1].trim();
516
+ const targetBody = m[2].trim();
517
+ if (!rawLabel) break; // empty label — fall through to plain arrow
518
+
519
+ // Extract [technology] from end of label
520
+ let label: string | undefined = rawLabel;
521
+ let technology: string | undefined;
522
+ const techMatch = rawLabel.match(/\[([^\]]+)\]\s*$/);
523
+ if (techMatch) {
524
+ label = rawLabel.substring(0, techMatch.index!).trim() || undefined;
525
+ technology = techMatch[1].trim();
526
+ }
527
+
528
+ const rel: C4Relationship = {
529
+ target: targetBody,
530
+ label,
531
+ technology,
532
+ arrowType,
533
+ lineNumber,
534
+ };
535
+
536
+ const parentEntry = findParentElement(indent, stack);
537
+ if (parentEntry) {
538
+ parentEntry.element.relationships.push(rel);
539
+ } else {
540
+ result.relationships.push(rel);
541
+ }
542
+ labeledHandled = true;
543
+ break;
544
+ }
545
+ if (labeledHandled) continue;
546
+ }
547
+
532
548
  // ── Relationships ───────────────────────────────────────
533
549
  const relMatch = trimmed.match(RELATIONSHIP_RE);
534
550
  if (relMatch) {
@@ -6,6 +6,7 @@ import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
8
  import type { PaletteColors } from '../palettes';
9
+ import { renderInlineText } from '../utils/inline-markdown';
9
10
  import type { ParsedC4 } from './types';
10
11
  import type { C4Shape } from './types';
11
12
  import type { C4LayoutResult, C4LayoutNode, C4LayoutEdge, C4LayoutBoundary } from './layout';
@@ -546,20 +547,20 @@ export function renderC4Context(
546
547
 
547
548
  yPos += DIVIDER_GAP;
548
549
 
549
- // Description (wrapping, muted)
550
+ // Description (wrapping, muted, inline markdown)
550
551
  if (node.description) {
551
552
  const contentWidth = w - CARD_H_PAD * 2;
552
553
  const lines = wrapText(node.description, contentWidth, DESC_CHAR_WIDTH);
553
554
  for (const line of lines) {
554
- nodeG
555
+ const textEl = nodeG
555
556
  .append('text')
556
557
  .attr('x', 0)
557
558
  .attr('y', yPos + DESC_FONT_SIZE / 2)
558
559
  .attr('text-anchor', 'middle')
559
560
  .attr('dominant-baseline', 'central')
560
561
  .attr('fill', palette.textMuted)
561
- .attr('font-size', DESC_FONT_SIZE)
562
- .text(line);
562
+ .attr('font-size', DESC_FONT_SIZE);
563
+ renderInlineText(textEl, line, palette, DESC_FONT_SIZE);
563
564
  yPos += DESC_LINE_HEIGHT;
564
565
  }
565
566
  }
@@ -1617,20 +1618,20 @@ export function renderC4Containers(
1617
1618
  if (node.type === 'container') {
1618
1619
  // Container cards: description above divider, metadata below
1619
1620
 
1620
- // Description (above divider)
1621
+ // Description (above divider, inline markdown)
1621
1622
  if (node.description) {
1622
1623
  const contentWidth = w - CARD_H_PAD * 2;
1623
1624
  const lines = wrapText(node.description, contentWidth, DESC_CHAR_WIDTH);
1624
1625
  for (const line of lines) {
1625
- nodeG
1626
+ const textEl = nodeG
1626
1627
  .append('text')
1627
1628
  .attr('x', 0)
1628
1629
  .attr('y', yPos + DESC_FONT_SIZE / 2)
1629
1630
  .attr('text-anchor', 'middle')
1630
1631
  .attr('dominant-baseline', 'central')
1631
1632
  .attr('fill', palette.textMuted)
1632
- .attr('font-size', DESC_FONT_SIZE)
1633
- .text(line);
1633
+ .attr('font-size', DESC_FONT_SIZE);
1634
+ renderInlineText(textEl, line, palette, DESC_FONT_SIZE);
1634
1635
  yPos += DESC_LINE_HEIGHT;
1635
1636
  }
1636
1637
  }
@@ -1696,20 +1697,20 @@ export function renderC4Containers(
1696
1697
 
1697
1698
  yPos += DIVIDER_GAP;
1698
1699
 
1699
- // Description
1700
+ // Description (inline markdown)
1700
1701
  if (node.description) {
1701
1702
  const contentWidth = w - CARD_H_PAD * 2;
1702
1703
  const lines = wrapText(node.description, contentWidth, DESC_CHAR_WIDTH);
1703
1704
  for (const line of lines) {
1704
- nodeG
1705
+ const textEl = nodeG
1705
1706
  .append('text')
1706
1707
  .attr('x', 0)
1707
1708
  .attr('y', yPos + DESC_FONT_SIZE / 2)
1708
1709
  .attr('text-anchor', 'middle')
1709
1710
  .attr('dominant-baseline', 'central')
1710
1711
  .attr('fill', palette.textMuted)
1711
- .attr('font-size', DESC_FONT_SIZE)
1712
- .text(line);
1712
+ .attr('font-size', DESC_FONT_SIZE);
1713
+ renderInlineText(textEl, line, palette, DESC_FONT_SIZE);
1713
1714
  yPos += DESC_LINE_HEIGHT;
1714
1715
  }
1715
1716
  }
package/src/c4/types.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  // C4 Architecture Diagram — Types
3
3
  // ============================================================
4
4
 
5
- import type { OrgTagGroup, OrgTagEntry } from '../org/parser';
5
+ import type { TagGroup, TagEntry } from '../utils/tag-groups';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
 
8
- // Re-export tag types for convenience
9
- export type { OrgTagGroup as C4TagGroup, OrgTagEntry as C4TagEntry };
8
+ /** @deprecated Use `TagEntry` from `utils/tag-groups` */
9
+ export type C4TagEntry = TagEntry;
10
+ /** @deprecated Use `TagGroup` from `utils/tag-groups` */
11
+ export type C4TagGroup = TagGroup;
10
12
 
11
13
  // ── String unions ────────────────────────────────────────────
12
14
 
@@ -77,7 +79,7 @@ export interface ParsedC4 {
77
79
  title: string | null;
78
80
  titleLineNumber: number | null;
79
81
  options: Record<string, string>;
80
- tagGroups: OrgTagGroup[];
82
+ tagGroups: TagGroup[];
81
83
  elements: C4Element[];
82
84
  relationships: C4Relationship[];
83
85
  deployment: C4DeploymentNode[];
package/src/chart.ts CHANGED
@@ -37,7 +37,7 @@ export interface ParsedChart {
37
37
  labels?: 'name' | 'value' | 'percent' | 'full';
38
38
  data: ChartDataPoint[];
39
39
  diagnostics: DgmoError[];
40
- error?: string;
40
+ error: string | null;
41
41
  }
42
42
 
43
43
  // ============================================================
@@ -90,6 +90,7 @@ export function parseChart(
90
90
  type: 'bar',
91
91
  data: [],
92
92
  diagnostics: [],
93
+ error: null,
93
94
  };
94
95
 
95
96
  const fail = (line: number, message: string): ParsedChart => {
@@ -110,7 +111,7 @@ export function parseChart(
110
111
  if (/^#{2,}\s+/.test(trimmed)) continue;
111
112
 
112
113
  // Skip comments
113
- if (trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
114
+ if (trimmed.startsWith('//')) continue;
114
115
 
115
116
  // Parse key: value pairs
116
117
  const colonIndex = trimmed.indexOf(':');
@@ -87,7 +87,7 @@ function computeNodeDimensions(node: ClassNode): {
87
87
  const headerHeight = HEADER_BASE + (node.modifier ? MODIFIER_BADGE : 0);
88
88
 
89
89
  // Fields compartment
90
- let fieldsHeight = 0;
90
+ let fieldsHeight: number;
91
91
  if (isEnum) {
92
92
  // Enum values go in fields compartment
93
93
  const enumValues = node.members; // all members are enum values
@@ -96,6 +96,8 @@ function computeNodeDimensions(node: ClassNode): {
96
96
  COMPARTMENT_PADDING_Y * 2 +
97
97
  enumValues.length * MEMBER_LINE_HEIGHT +
98
98
  SEPARATOR_HEIGHT;
99
+ } else {
100
+ fieldsHeight = SEPARATOR_HEIGHT + COMPARTMENT_PADDING_Y;
99
101
  }
100
102
  } else {
101
103
  if (fields.length > 0) {
@@ -103,24 +105,27 @@ function computeNodeDimensions(node: ClassNode): {
103
105
  COMPARTMENT_PADDING_Y * 2 +
104
106
  fields.length * MEMBER_LINE_HEIGHT +
105
107
  SEPARATOR_HEIGHT;
108
+ } else {
109
+ // UML: always show attributes compartment
110
+ fieldsHeight = SEPARATOR_HEIGHT + COMPARTMENT_PADDING_Y;
106
111
  }
107
112
  }
108
113
 
109
114
  // Methods compartment (not for enums)
110
115
  let methodsHeight = 0;
111
- if (!isEnum && methods.length > 0) {
112
- methodsHeight =
113
- COMPARTMENT_PADDING_Y * 2 +
114
- methods.length * MEMBER_LINE_HEIGHT +
115
- SEPARATOR_HEIGHT;
116
+ if (!isEnum) {
117
+ if (methods.length > 0) {
118
+ methodsHeight =
119
+ COMPARTMENT_PADDING_Y * 2 +
120
+ methods.length * MEMBER_LINE_HEIGHT +
121
+ SEPARATOR_HEIGHT;
122
+ } else {
123
+ // UML: always show methods compartment
124
+ methodsHeight = SEPARATOR_HEIGHT + COMPARTMENT_PADDING_Y;
125
+ }
116
126
  }
117
127
 
118
- // If no members at all, add minimal padding
119
- const height =
120
- headerHeight +
121
- fieldsHeight +
122
- methodsHeight +
123
- (fieldsHeight === 0 && methodsHeight === 0 ? 4 : 0);
128
+ const height = headerHeight + fieldsHeight + methodsHeight;
124
129
 
125
130
  return { width, height, headerHeight, fieldsHeight, methodsHeight };
126
131
  }
@@ -1,11 +1,11 @@
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
5
  import type {
5
6
  ParsedClassDiagram,
6
7
  ClassNode,
7
8
  ClassMember,
8
- ClassRelationship,
9
9
  ClassModifier,
10
10
  MemberVisibility,
11
11
  RelationshipType,
@@ -15,16 +15,6 @@ import type {
15
15
  // Helpers
16
16
  // ============================================================
17
17
 
18
- function measureIndent(line: string): number {
19
- let indent = 0;
20
- for (const ch of line) {
21
- if (ch === ' ') indent++;
22
- else if (ch === '\t') indent += 4;
23
- else break;
24
- }
25
- return indent;
26
- }
27
-
28
18
  function classId(name: string): string {
29
19
  return name.toLowerCase().trim();
30
20
  }
@@ -33,14 +23,9 @@ function classId(name: string): string {
33
23
  // Regex patterns
34
24
  // ============================================================
35
25
 
36
- // Class declaration: ClassName [modifier] (color)
26
+ // Class declaration: ClassName [extends|implements ParentClass] [modifier] (color)
37
27
  const CLASS_DECL_RE =
38
- /^([A-Z][A-Za-z0-9_]*)(?:\s+\[(abstract|interface|enum)\])?(?:\s+\(([^)]+)\))?\s*$/;
39
-
40
- // Relationship — keyword syntax:
41
- // ClassName extends|implements|contains|has|uses TargetClass : label
42
- const REL_KEYWORD_RE =
43
- /^([A-Z][A-Za-z0-9_]*)\s+(extends|implements|contains|has|uses)\s+([A-Z][A-Za-z0-9_]*)(?:\s*:\s*(.+))?$/;
28
+ /^([A-Z][A-Za-z0-9_]*)(?:\s+(extends|implements)\s+([A-Z][A-Za-z0-9_]*))?(?:\s+\[(abstract|interface|enum)\])?(?:\s+\(([^)]+)\))?\s*$/;
44
29
 
45
30
  // Relationship — arrow syntax:
46
31
  // ClassName --|> TargetClass : label
@@ -54,14 +39,6 @@ const STATIC_SUFFIX_RE = /\{static\}\s*$/;
54
39
  const METHOD_RE = /^(.+?)\(([^)]*)\)(?:\s*:\s*(.+))?$/;
55
40
  const FIELD_RE = /^(.+?)\s*:\s*(.+)$/;
56
41
 
57
- const KEYWORD_TO_TYPE: Record<string, RelationshipType> = {
58
- extends: 'extends',
59
- implements: 'implements',
60
- contains: 'composes',
61
- has: 'aggregates',
62
- uses: 'depends',
63
- };
64
-
65
42
  const ARROW_TO_TYPE: Record<string, RelationshipType> = {
66
43
  '--|>': 'extends',
67
44
  '..|>': 'implements',
@@ -172,6 +149,7 @@ export function parseClassDiagram(
172
149
  relationships: [],
173
150
  options: {},
174
151
  diagnostics: [],
152
+ error: null,
175
153
  };
176
154
 
177
155
  const fail = (line: number, message: string): ParsedClassDiagram => {
@@ -265,28 +243,6 @@ export function parseClassDiagram(
265
243
  currentClass = null;
266
244
  contentStarted = true;
267
245
 
268
- // Try relationship — keyword syntax
269
- const relKeyword = trimmed.match(REL_KEYWORD_RE);
270
- if (relKeyword) {
271
- const sourceName = relKeyword[1];
272
- const keyword = relKeyword[2].toLowerCase();
273
- const targetName = relKeyword[3];
274
- const label = relKeyword[4]?.trim();
275
-
276
- // Ensure both classes exist
277
- getOrCreateClass(sourceName, lineNumber);
278
- getOrCreateClass(targetName, lineNumber);
279
-
280
- result.relationships.push({
281
- source: classId(sourceName),
282
- target: classId(targetName),
283
- type: KEYWORD_TO_TYPE[keyword],
284
- ...(label && { label }),
285
- lineNumber,
286
- });
287
- continue;
288
- }
289
-
290
246
  // Try relationship — arrow syntax
291
247
  const relArrow = trimmed.match(REL_ARROW_RE);
292
248
  if (relArrow) {
@@ -313,8 +269,10 @@ export function parseClassDiagram(
313
269
  const classDecl = trimmed.match(CLASS_DECL_RE);
314
270
  if (classDecl) {
315
271
  const name = classDecl[1];
316
- const modifier = classDecl[2] as ClassModifier | undefined;
317
- const colorName = classDecl[3]?.trim();
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();
318
276
  const color = colorName ? resolveColor(colorName, palette) : undefined;
319
277
 
320
278
  const node = getOrCreateClass(name, lineNumber);
@@ -323,6 +281,17 @@ export function parseClassDiagram(
323
281
  // Update line number to the declaration line (may have been created by relationship)
324
282
  node.lineNumber = lineNumber;
325
283
 
284
+ // Inline extends/implements creates a relationship
285
+ if (relKeyword && parentName) {
286
+ getOrCreateClass(parentName, lineNumber);
287
+ result.relationships.push({
288
+ source: classId(name),
289
+ target: classId(parentName),
290
+ type: relKeyword as RelationshipType,
291
+ lineNumber,
292
+ });
293
+ }
294
+
326
295
  currentClass = node;
327
296
  continue;
328
297
  }
@@ -384,9 +353,10 @@ export function looksLikeClassDiagram(content: string): boolean {
384
353
  hasModifier = true;
385
354
  hasClassDecl = true;
386
355
  }
387
- // Check for relationship keywords
388
- if (REL_KEYWORD_RE.test(trimmed)) {
356
+ // Check for inline extends/implements in class declaration
357
+ if (/^[A-Z][A-Za-z0-9_]*\s+(extends|implements)\s+[A-Z]/.test(trimmed)) {
389
358
  hasRelationship = true;
359
+ hasClassDecl = true;
390
360
  }
391
361
  // Check for relationship arrows
392
362
  if (REL_ARROW_RE.test(trimmed)) {