@diagrammo/dgmo 0.7.2 → 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 +3529 -1061
  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 +3516 -1061
  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 +312 -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 +82 -31
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/c4/layout.ts CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  import dagre from '@dagrejs/dagre';
6
6
  import type { ParsedC4, C4Element, C4Relationship, C4ArrowType, C4Shape, C4DeploymentNode } from './types';
7
- import type { OrgTagGroup } from '../org/parser';
7
+ import type { TagGroup } from '../utils/tag-groups';
8
+ import { LEGEND_PILL_FONT_SIZE, LEGEND_ENTRY_FONT_SIZE, measureLegendText } from '../utils/legend-constants';
8
9
 
9
10
  // ============================================================
10
11
  // Types
@@ -94,12 +95,8 @@ const GROUP_BOUNDARY_PAD = 24;
94
95
 
95
96
  // Legend constants (match org)
96
97
  const LEGEND_HEIGHT = 28;
97
- const LEGEND_PILL_FONT_SIZE = 11;
98
- const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
99
98
  const LEGEND_PILL_PAD = 16;
100
99
  const LEGEND_DOT_R = 4;
101
- const LEGEND_ENTRY_FONT_SIZE = 10;
102
- const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
103
100
  const LEGEND_ENTRY_DOT_GAP = 4;
104
101
  const LEGEND_ENTRY_TRAIL = 8;
105
102
  const LEGEND_CAPSULE_PAD = 4;
@@ -530,7 +527,7 @@ export function rollUpContextRelationships(parsed: ParsedC4): ContextRelationshi
530
527
 
531
528
  function resolveNodeColor(
532
529
  el: C4Element,
533
- tagGroups: OrgTagGroup[],
530
+ tagGroups: TagGroup[],
534
531
  activeGroupName: string | null,
535
532
  ancestors?: C4Element[]
536
533
  ): string | undefined {
@@ -661,7 +658,7 @@ export function computeC4NodeDimensions(
661
658
  // Legend Helpers
662
659
  // ============================================================
663
660
 
664
- function computeLegendGroups(tagGroups: OrgTagGroup[]): C4LegendGroup[] {
661
+ function computeLegendGroups(tagGroups: TagGroup[]): C4LegendGroup[] {
665
662
  const result: C4LegendGroup[] = [];
666
663
 
667
664
  for (const group of tagGroups) {
@@ -672,13 +669,13 @@ function computeLegendGroups(tagGroups: OrgTagGroup[]): C4LegendGroup[] {
672
669
  if (entries.length === 0) continue;
673
670
 
674
671
  // Compute pill width: group name + entries
675
- const nameW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD * 2;
672
+ const nameW = measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD * 2;
676
673
  let capsuleW = LEGEND_CAPSULE_PAD;
677
674
  for (const e of entries) {
678
675
  capsuleW +=
679
676
  LEGEND_DOT_R * 2 +
680
677
  LEGEND_ENTRY_DOT_GAP +
681
- e.value.length * LEGEND_ENTRY_FONT_W +
678
+ measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
682
679
  LEGEND_ENTRY_TRAIL;
683
680
  }
684
681
  capsuleW += LEGEND_CAPSULE_PAD;
package/src/c4/parser.ts CHANGED
@@ -12,9 +12,8 @@ import {
12
12
  extractColor,
13
13
  parsePipeMetadata,
14
14
  MULTIPLE_PIPE_WARNING,
15
- CHART_TYPE_RE,
16
- TITLE_RE,
17
- OPTION_RE,
15
+ parseFirstLine,
16
+ OPTION_NOCOLON_RE,
18
17
  } from '../utils/parsing';
19
18
  import type {
20
19
  ParsedC4,
@@ -39,23 +38,26 @@ const ELEMENT_RE = /^(person|system|container|component)\s+(.+)$/i;
39
38
  /** Matches `is a <shape>` in the element name portion */
40
39
  const IS_A_RE = /\s+is\s+a(?:n)?\s+(\w+)\s*$/i;
41
40
 
41
+ /** Matches `Name is a <type>` declarations (new preferred syntax) */
42
+ const C4_IS_A_RE = /^([^:]+?)\s+is\s+an?\s+(person|system|container|component|external|database)\b(.*)$/i;
43
+
42
44
  /** Matches relationship arrows: `->`, `~>`, `<->`, `<~>` */
43
- const RELATIONSHIP_RE = /^(<?-?>|<?~?>)\s+(.+)$/;
45
+ const RELATIONSHIP_RE = /^(<?-?>|<?~?>)\s*(.+)$/;
44
46
 
45
47
  /** 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+(.+)$/;
48
+ const C4_LABELED_SYNC_RE = /^-(.+)->\s*(.+)$/;
49
+ const C4_LABELED_ASYNC_RE = /^~(.+)~>\s*(.+)$/;
50
+ const C4_LABELED_BIDI_SYNC_RE = /^<-(.+)->\s*(.+)$/;
51
+ const C4_LABELED_BIDI_ASYNC_RE = /^<~(.+)~>\s*(.+)$/;
50
52
 
51
- /** Matches section headers: `containers:`, `components:`, `deployment:` */
52
- const SECTION_HEADER_RE = /^(containers|components|deployment)\s*:\s*$/i;
53
+ /** Matches section headers: `containers`, `components`, `deployment` (bare keyword) */
54
+ const SECTION_HEADER_RE = /^(containers|components|deployment)\s*$/i;
53
55
 
54
56
  /** Matches `container X` references inside deployment nodes */
55
57
  const CONTAINER_REF_RE = /^container\s+(.+)$/i;
56
58
 
57
- /** Matches indented metadata: `key: value` */
58
- const METADATA_RE = /^([^:]+):\s*(.+)$/;
59
+ /** Matches indented metadata: `key value` (space-separated, no colon) */
60
+ const METADATA_RE = /^([a-z][a-z0-9-]*)\s+(.+)$/i;
59
61
 
60
62
  // ============================================================
61
63
  // Helpers
@@ -78,6 +80,12 @@ const VALID_SHAPES = new Set<string>([
78
80
  'external',
79
81
  ]);
80
82
 
83
+ /** Known top-level option keys for C4 diagrams. */
84
+ const KNOWN_C4_OPTIONS = new Set<string>([
85
+ 'layout',
86
+ 'direction',
87
+ ]);
88
+
81
89
  const ALL_CHART_TYPES = [
82
90
  'c4',
83
91
  'org',
@@ -143,35 +151,6 @@ function parseArrowType(arrow: string): C4ArrowType | null {
143
151
  }
144
152
  }
145
153
 
146
- /** Parse relationship label and optional [technology] annotation. */
147
- function parseRelationshipBody(
148
- body: string,
149
- ): { target: string; label?: string; technology?: string } {
150
- // Format: `Target: label [tech]` or `Target: label` or `Target`
151
- const colonIdx = body.indexOf(':');
152
- let target: string;
153
- let rest: string;
154
-
155
- if (colonIdx > 0) {
156
- target = body.substring(0, colonIdx).trim();
157
- rest = body.substring(colonIdx + 1).trim();
158
- } else {
159
- target = body.trim();
160
- rest = '';
161
- }
162
-
163
- if (!rest) return { target };
164
-
165
- // Extract [technology] from end of rest
166
- const techMatch = rest.match(/\[([^\]]+)\]\s*$/);
167
- if (techMatch) {
168
- const label = rest.substring(0, techMatch.index!).trim() || undefined;
169
- return { target, label, technology: techMatch[1].trim() };
170
- }
171
-
172
- return { target, label: rest };
173
- }
174
-
175
154
 
176
155
  // ============================================================
177
156
  // Stack entry types
@@ -280,28 +259,21 @@ export function parseC4(
280
259
 
281
260
  // --- Header phase ---
282
261
 
283
- // chart: type
284
- if (!contentStarted) {
285
- const chartMatch = trimmed.match(CHART_TYPE_RE);
286
- if (chartMatch) {
287
- const chartType = chartMatch[1].trim().toLowerCase();
288
- if (chartType !== 'c4') {
289
- let msg = `Expected chart type "c4", got "${chartType}"`;
290
- const hint = suggest(chartType, ALL_CHART_TYPES);
262
+ // First line: `c4` or `c4 My Title`
263
+ if (!contentStarted && !sawChartType) {
264
+ const firstLine = parseFirstLine(trimmed);
265
+ if (firstLine) {
266
+ if (firstLine.chartType !== 'c4') {
267
+ let msg = `Expected chart type "c4", got "${firstLine.chartType}"`;
268
+ const hint = suggest(firstLine.chartType, ALL_CHART_TYPES);
291
269
  if (hint) msg += `. ${hint}`;
292
270
  return fail(lineNumber, msg);
293
271
  }
294
272
  sawChartType = true;
295
- continue;
296
- }
297
- }
298
-
299
- // title: value
300
- if (!contentStarted) {
301
- const titleMatch = trimmed.match(TITLE_RE);
302
- if (titleMatch) {
303
- result.title = titleMatch[1].trim();
304
- result.titleLineNumber = lineNumber;
273
+ if (firstLine.title) {
274
+ result.title = firstLine.title;
275
+ result.titleLineNumber = lineNumber;
276
+ }
305
277
  continue;
306
278
  }
307
279
  }
@@ -315,7 +287,8 @@ export function parseC4(
315
287
  continue;
316
288
  }
317
289
  if (tagBlockMatch.deprecated) {
318
- pushError(lineNumber, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`, 'warning');
290
+ pushError(lineNumber, `'## ${tagBlockMatch.name}' is no longer supported — use 'tag: ${tagBlockMatch.name}' instead`);
291
+ continue;
319
292
  }
320
293
  currentTagGroup = {
321
294
  name: tagBlockMatch.name,
@@ -330,27 +303,23 @@ export function parseC4(
330
303
  continue;
331
304
  }
332
305
 
333
- // Generic header options
306
+ // Generic header options (space-separated: `key value`)
334
307
  if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
335
- const optMatch = trimmed.match(OPTION_RE);
308
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
336
309
  if (optMatch) {
337
310
  const key = optMatch[1].trim().toLowerCase();
338
- if (key !== 'chart' && key !== 'title') {
311
+ if (KNOWN_C4_OPTIONS.has(key)) {
339
312
  result.options[key] = optMatch[2].trim();
340
313
  continue;
341
314
  }
342
315
  }
343
316
  }
344
317
 
345
- // Tag group entries
318
+ // Tag group entries — first entry is the default (no `default` keyword)
346
319
  if (currentTagGroup && !contentStarted) {
347
320
  const indent = measureIndent(line);
348
321
  if (indent > 0) {
349
- const isDefault = /\bdefault\s*$/.test(trimmed);
350
- const entryText = isDefault
351
- ? trimmed.replace(/\s+default\s*$/, '').trim()
352
- : trimmed;
353
- const { label, color } = extractColor(entryText, palette);
322
+ const { label, color } = extractColor(trimmed, palette);
354
323
  if (!color) {
355
324
  pushError(
356
325
  lineNumber,
@@ -358,7 +327,8 @@ export function parseC4(
358
327
  );
359
328
  continue;
360
329
  }
361
- if (isDefault) {
330
+ // First entry becomes the default
331
+ if (currentTagGroup.entries.length === 0) {
362
332
  currentTagGroup.defaultValue = label;
363
333
  }
364
334
  currentTagGroup.entries.push({
@@ -376,7 +346,7 @@ export function parseC4(
376
346
  currentTagGroup = null;
377
347
 
378
348
  if (!sawChartType) {
379
- return fail(lineNumber, 'Missing "chart: c4" header');
349
+ return fail(lineNumber, 'Missing "c4" header');
380
350
  }
381
351
 
382
352
  const indent = measureIndent(line);
@@ -391,7 +361,7 @@ export function parseC4(
391
361
  }
392
362
 
393
363
  // Check for top-level non-deployment content (section ended)
394
- if (indent === 0 && ELEMENT_RE.test(trimmed)) {
364
+ if (indent === 0 && (C4_IS_A_RE.test(trimmed) || ELEMENT_RE.test(trimmed))) {
395
365
  inDeployment = false;
396
366
  // Fall through to element parsing below
397
367
  } else {
@@ -444,7 +414,7 @@ export function parseC4(
444
414
  continue;
445
415
  }
446
416
 
447
- // containers: / components: must be inside an element
417
+ // containers / components must be inside an element
448
418
  const parentEntry = findParentElement(indent, stack);
449
419
  if (parentEntry) {
450
420
  parentEntry.element.sectionHeader =
@@ -459,7 +429,7 @@ export function parseC4(
459
429
  } else {
460
430
  pushError(
461
431
  lineNumber,
462
- `"${sectionType}:" must be inside an element`,
432
+ `"${sectionType}" must be inside an element`,
463
433
  );
464
434
  }
465
435
  continue;
@@ -516,6 +486,14 @@ export function parseC4(
516
486
  const targetBody = m[2].trim();
517
487
  if (!rawLabel) break; // empty label — fall through to plain arrow
518
488
 
489
+ // Reject bidirectional arrows
490
+ if (arrowType === 'bidirectional' || arrowType === 'bidirectional-async') {
491
+ const source = findParentElement(indent, stack)?.element.name ?? '?';
492
+ pushError(lineNumber, `Bidirectional arrows are no longer supported. Replace with two separate arrows:\n -${rawLabel}-> ${targetBody}\n ${targetBody} -${rawLabel}-> ${source}`);
493
+ labeledHandled = true;
494
+ break;
495
+ }
496
+
519
497
  // Extract [technology] from end of label
520
498
  let label: string | undefined = rawLabel;
521
499
  let technology: string | undefined;
@@ -525,8 +503,25 @@ export function parseC4(
525
503
  technology = techMatch[1].trim();
526
504
  }
527
505
 
506
+ // Extract pipe metadata from target body (e.g. "Database | tech: SQL")
507
+ let target = targetBody;
508
+ const pipeIdx = targetBody.indexOf('|');
509
+ if (pipeIdx !== -1) {
510
+ target = targetBody.substring(0, pipeIdx).trim();
511
+ const metaPart = targetBody.substring(pipeIdx + 1).trim();
512
+ // parsePipeMetadata expects segments split by |; first segment is pre-pipe
513
+ const meta = parsePipeMetadata(['', metaPart], aliasMap);
514
+ // tech/technology on pipe overrides [tech] in label
515
+ if (meta.tech) {
516
+ technology = meta.tech;
517
+ }
518
+ if (meta.technology) {
519
+ technology = meta.technology;
520
+ }
521
+ }
522
+
528
523
  const rel: C4Relationship = {
529
- target: targetBody,
524
+ target,
530
525
  label,
531
526
  technology,
532
527
  arrowType,
@@ -545,18 +540,24 @@ export function parseC4(
545
540
  if (labeledHandled) continue;
546
541
  }
547
542
 
548
- // ── Relationships ───────────────────────────────────────
543
+ // ── Relationships (plain arrows: ->, ~>) ─────────────────
549
544
  const relMatch = trimmed.match(RELATIONSHIP_RE);
550
545
  if (relMatch) {
551
546
  const arrowType = parseArrowType(relMatch[1]);
552
547
  if (arrowType) {
553
- const { target, label, technology } = parseRelationshipBody(
554
- relMatch[2],
555
- );
548
+ // Reject bidirectional arrows
549
+ if (arrowType === 'bidirectional' || arrowType === 'bidirectional-async') {
550
+ const arrow = relMatch[1];
551
+ const target = relMatch[2].trim();
552
+ const source = findParentElement(indent, stack)?.element.name ?? '?';
553
+ pushError(lineNumber, `'${arrow}' bidirectional arrows are no longer supported. Replace with two separate arrows:\n -> ${target}\n ${target} -> ${source}`);
554
+ continue;
555
+ }
556
+
557
+ // Plain arrow: entire body is the target (no colon label, no technology)
558
+ const target = relMatch[2].trim();
556
559
  const rel: C4Relationship = {
557
560
  target,
558
- label,
559
- technology,
560
561
  arrowType,
561
562
  lineNumber,
562
563
  };
@@ -573,7 +574,106 @@ export function parseC4(
573
574
  }
574
575
  }
575
576
 
576
- // ── Element declarations ────────────────────────────────
577
+ // ── "Name is a type" declarations (preferred syntax) ────
578
+ const isATypeMatch = trimmed.match(C4_IS_A_RE);
579
+ if (isATypeMatch) {
580
+ let namePart = isATypeMatch[1].trim();
581
+ const rawType = isATypeMatch[2].toLowerCase();
582
+ const remainder = isATypeMatch[3];
583
+
584
+ // Map external/database to shape overrides with default element type
585
+ let elementType: C4ElementType;
586
+ let explicitShape: C4Shape | null = null;
587
+ if (rawType === 'external') {
588
+ elementType = 'system';
589
+ explicitShape = 'external';
590
+ } else if (rawType === 'database') {
591
+ elementType = 'container';
592
+ explicitShape = 'database';
593
+ } else {
594
+ elementType = rawType as C4ElementType;
595
+ }
596
+
597
+ // Parse pipe metadata from remainder
598
+ const remainderTrimmed = remainder.trim();
599
+ let segments: string[];
600
+ if (remainderTrimmed.startsWith('|')) {
601
+ // remainder has pipe metadata: "| tech: PostgreSQL, team: Data"
602
+ segments = ['', ...remainderTrimmed.substring(1).split('|').map((s) => s.trim())];
603
+ } else {
604
+ segments = [remainderTrimmed];
605
+ }
606
+
607
+ // Check for additional `is a <shape>` in the name (e.g., already stripped by C4_IS_A_RE won't happen,
608
+ // but handle remainder like "is a cylinder" after type)
609
+ const remainderIsA = remainderTrimmed.match(/^\s*is\s+a(?:n)?\s+(\w+)\s*(.*)$/i);
610
+ if (remainderIsA) {
611
+ const shapeName = remainderIsA[1].toLowerCase();
612
+ if (VALID_SHAPES.has(shapeName)) {
613
+ explicitShape = shapeName as C4Shape;
614
+ } else {
615
+ pushError(
616
+ lineNumber,
617
+ `Unknown shape "${remainderIsA[1]}". Valid shapes: ${[...VALID_SHAPES].join(', ')}`,
618
+ );
619
+ }
620
+ // Re-parse remainder after shape
621
+ const afterShape = remainderIsA[2].trim();
622
+ if (afterShape.startsWith('|')) {
623
+ segments = ['', ...afterShape.substring(1).split('|').map((s) => s.trim())];
624
+ } else {
625
+ segments = [afterShape];
626
+ }
627
+ }
628
+
629
+ // Also check for `is a <shape>` within the name part itself
630
+ const nameIsAMatch = namePart.match(IS_A_RE);
631
+ if (nameIsAMatch) {
632
+ const shapeName = nameIsAMatch[1].toLowerCase();
633
+ if (VALID_SHAPES.has(shapeName)) {
634
+ explicitShape = shapeName as C4Shape;
635
+ } else {
636
+ pushError(
637
+ lineNumber,
638
+ `Unknown shape "${nameIsAMatch[1]}". Valid shapes: ${[...VALID_SHAPES].join(', ')}`,
639
+ );
640
+ }
641
+ namePart = namePart.substring(0, nameIsAMatch.index!).trim();
642
+ }
643
+
644
+ const metadata = parsePipeMetadata(segments, aliasMap, () => pushError(lineNumber, MULTIPLE_PIPE_WARNING, 'warning'));
645
+
646
+ const shape =
647
+ explicitShape ??
648
+ inferC4Shape(namePart, metadata.tech ?? metadata.technology);
649
+
650
+ const element: C4Element = {
651
+ name: namePart,
652
+ type: elementType,
653
+ shape,
654
+ metadata,
655
+ children: [],
656
+ groups: [],
657
+ relationships: [],
658
+ lineNumber,
659
+ };
660
+
661
+ // Check for duplicate name
662
+ const existingLine = knownNames.get(namePart.toLowerCase());
663
+ if (existingLine !== undefined) {
664
+ pushError(
665
+ lineNumber,
666
+ `Duplicate element name "${namePart}" (first defined on line ${existingLine})`,
667
+ );
668
+ } else {
669
+ knownNames.set(namePart.toLowerCase(), lineNumber);
670
+ }
671
+
672
+ attachElement(element, indent, stack, result);
673
+ continue;
674
+ }
675
+
676
+ // ── Element declarations (deprecated prefix syntax) ─────
577
677
  const elementMatch = trimmed.match(ELEMENT_RE);
578
678
  if (elementMatch) {
579
679
  const elementType = elementMatch[1].toLowerCase() as C4ElementType;
@@ -599,6 +699,12 @@ export function parseC4(
599
699
  namePart = namePart.substring(0, isAMatch.index!).trim();
600
700
  }
601
701
 
702
+ // Emit deprecation error with migration hint
703
+ pushError(
704
+ lineNumber,
705
+ `'${elementMatch[1]} ${namePart}' prefix syntax is no longer supported — use '${namePart} is a ${elementType}' instead`,
706
+ );
707
+
602
708
  const metadata = parsePipeMetadata(segments, aliasMap, () => pushError(lineNumber, MULTIPLE_PIPE_WARNING, 'warning'));
603
709
 
604
710
  // Determine shape: explicit > inference
@@ -641,7 +747,7 @@ export function parseC4(
641
747
  if (parentEntry) {
642
748
  const rawKey = metadataMatch[1].trim().toLowerCase();
643
749
 
644
- // Special case: `import: file.dgmo`
750
+ // Special case: `import file.dgmo`
645
751
  if (rawKey === 'import') {
646
752
  parentEntry.element.importPath = metadataMatch[2].trim();
647
753
  continue;
@@ -15,16 +15,16 @@ import { layoutC4Context, layoutC4Containers, layoutC4Components, layoutC4Deploy
15
15
  import {
16
16
  LEGEND_HEIGHT,
17
17
  LEGEND_PILL_FONT_SIZE,
18
- LEGEND_PILL_FONT_W,
19
18
  LEGEND_PILL_PAD,
20
19
  LEGEND_DOT_R,
21
20
  LEGEND_ENTRY_FONT_SIZE,
22
- LEGEND_ENTRY_FONT_W,
23
21
  LEGEND_ENTRY_DOT_GAP,
24
22
  LEGEND_ENTRY_TRAIL,
25
23
  LEGEND_CAPSULE_PAD,
26
24
  LEGEND_GROUP_GAP,
25
+ measureLegendText,
27
26
  } from '../utils/legend-constants';
27
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
28
28
 
29
29
  // ============================================================
30
30
  // Constants
@@ -33,7 +33,6 @@ import {
33
33
  const DIAGRAM_PADDING = 20;
34
34
  const MAX_SCALE = 3;
35
35
  const TITLE_HEIGHT = 30;
36
- const TITLE_FONT_SIZE = 20;
37
36
  const TYPE_FONT_SIZE = 10;
38
37
  const NAME_FONT_SIZE = 14;
39
38
  const DESC_FONT_SIZE = 11;
@@ -307,8 +306,8 @@ export function renderC4Context(
307
306
  .attr('y', 30)
308
307
  .attr('text-anchor', 'middle')
309
308
  .attr('fill', palette.text)
310
- .attr('font-size', `${TITLE_FONT_SIZE}px`)
311
- .attr('font-weight', '700')
309
+ .attr('font-size', TITLE_FONT_SIZE)
310
+ .attr('font-weight', TITLE_FONT_WEIGHT)
312
311
  .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
313
312
  .text(parsed.title);
314
313
 
@@ -1143,7 +1142,7 @@ function renderLegend(
1143
1142
  ? layout.legend.filter((g) => g.name.toLowerCase() === (activeTagGroup ?? '').toLowerCase())
1144
1143
  : layout.legend;
1145
1144
 
1146
- const pillWidthOf = (g: C4LegendGroup) => g.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1145
+ const pillWidthOf = (g: C4LegendGroup) => measureLegendText(g.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1147
1146
  const effectiveW = (g: C4LegendGroup) => activeTagGroup != null ? g.width : pillWidthOf(g);
1148
1147
 
1149
1148
  // In fixed mode, compute centered x-positions
@@ -1250,7 +1249,7 @@ function renderLegend(
1250
1249
  .attr('fill', palette.textMuted)
1251
1250
  .text(entry.value);
1252
1251
 
1253
- entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1252
+ entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1254
1253
  }
1255
1254
  }
1256
1255
  }
@@ -1347,8 +1346,8 @@ export function renderC4Containers(
1347
1346
  .attr('y', 30)
1348
1347
  .attr('text-anchor', 'middle')
1349
1348
  .attr('fill', palette.text)
1350
- .attr('font-size', `${TITLE_FONT_SIZE}px`)
1351
- .attr('font-weight', '700')
1349
+ .attr('font-size', TITLE_FONT_SIZE)
1350
+ .attr('font-weight', TITLE_FONT_WEIGHT)
1352
1351
  .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
1353
1352
  .text(parsed.title);
1354
1353