@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/er/parser.ts CHANGED
@@ -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, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from '../utils/parsing';
4
+ import { measureIndent, extractColor, parsePipeMetadata, parseFirstLine, OPTION_NOCOLON_RE } from '../utils/parsing';
5
5
  import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
6
6
  import type { TagGroup } from '../utils/tag-groups';
7
7
  import type {
@@ -27,8 +27,10 @@ function tableId(name: string): string {
27
27
  // Allows lowercase, uppercase, underscores, digits — must start with letter or underscore
28
28
  const TABLE_DECL_RE = /^([a-zA-Z_]\w*)(?:\s*\(([^)]+)\))?(?:\s*\|(.+))?$/;
29
29
 
30
- // Column: name: type [constraints] or name [constraints] or name: type or name
31
- const COLUMN_RE = /^(\w+)(?:\s*:\s*(\w[\w()]*(?:\s*\[\])?))?(?:\s+\[([^\]]+)\])?\s*$/;
30
+ // Column: name [type] [constraints...] space-separated, no colon, no brackets
31
+ // First token is always the name. Second token is the type if it's not a constraint keyword.
32
+ // Remaining tokens are constraint keywords (pk, fk, unique, nullable).
33
+ // Handled programmatically, not with a single regex.
32
34
 
33
35
  // Indented relationship: 1-* target or 1-label-* target
34
36
  const INDENT_REL_RE = /^([1*?])-(?:(.+)-)?([1*?])\s+([a-zA-Z_]\w*)\s*$/;
@@ -41,6 +43,9 @@ const CONSTRAINT_MAP: Record<string, ERConstraint> = {
41
43
  nullable: 'nullable',
42
44
  };
43
45
 
46
+ // Known options (space-separated, no colon)
47
+ const KNOWN_OPTIONS = new Set(['notation']);
48
+
44
49
  // ============================================================
45
50
  // Cardinality parsing
46
51
  // ============================================================
@@ -56,17 +61,17 @@ function parseCardSide(token: string): ERCardinality | null {
56
61
  /**
57
62
  * Try to parse a relationship line with symbolic cardinality.
58
63
  *
59
- * Supported form:
60
- * tableName 1--* tableName : label
61
- * tableName 1-* tableName : label
62
- * tableName ?--1 tableName : label
64
+ * Supported form (no colon before label):
65
+ * tableName 1--* tableName label
66
+ * tableName 1-* tableName label
67
+ * tableName ?--1 tableName
63
68
  */
64
69
  const REL_SYMBOLIC_RE =
65
- /^([a-zA-Z_]\w*)\s+([1*?])\s*-{1,2}\s*([1*?])\s+([a-zA-Z_]\w*)(?:\s*:\s*(.+))?$/;
70
+ /^([a-zA-Z_]\w*)\s+([1*?])\s*-{1,2}\s*([1*?])\s+([a-zA-Z_]\w*)(?:\s+(.+))?$/;
66
71
 
67
72
  /** Detects keyword cardinality forms to emit helpful error */
68
73
  const REL_KEYWORD_RE =
69
- /^([a-zA-Z_]\w*)\s+(one|many|zero)[- ]to[- ](one|many|zero)\s+([a-zA-Z_]\w*)(?:\s*:\s*(.+))?$/i;
74
+ /^([a-zA-Z_]\w*)\s+(one|many|zero)[- ]to[- ](one|many|zero)\s+([a-zA-Z_]\w*)(?:\s+(.+))?$/i;
70
75
 
71
76
  const KEYWORD_TO_SYMBOL: Record<string, string> = {
72
77
  one: '1',
@@ -117,17 +122,39 @@ function parseRelationship(
117
122
  }
118
123
 
119
124
  // ============================================================
120
- // Constraint parser
125
+ // Column parser (space-separated: name [type] [constraints...])
121
126
  // ============================================================
122
127
 
123
- function parseConstraints(raw: string): ERConstraint[] {
124
- const parts = raw.split(',').map((s) => s.trim().toLowerCase());
125
- const result: ERConstraint[] = [];
126
- for (const part of parts) {
127
- const c = CONSTRAINT_MAP[part];
128
- if (c) result.push(c);
128
+ function parseColumn(trimmed: string): {
129
+ name: string;
130
+ type?: string;
131
+ constraints: ERConstraint[];
132
+ } | null {
133
+ const tokens = trimmed.split(/\s+/);
134
+ if (tokens.length === 0) return null;
135
+
136
+ // First token must look like a column name (word chars)
137
+ const name = tokens[0];
138
+ if (!/^\w+$/.test(name)) return null;
139
+
140
+ const constraints: ERConstraint[] = [];
141
+ let type: string | undefined;
142
+
143
+ for (let i = 1; i < tokens.length; i++) {
144
+ const lower = tokens[i].toLowerCase();
145
+ const constraint = CONSTRAINT_MAP[lower];
146
+ if (constraint) {
147
+ constraints.push(constraint);
148
+ } else if (type === undefined) {
149
+ // First non-constraint token after name is the type
150
+ type = tokens[i];
151
+ } else {
152
+ // Unknown token after type — not a valid column line
153
+ return null;
154
+ }
129
155
  }
130
- return result;
156
+
157
+ return { name, type, constraints };
131
158
  }
132
159
 
133
160
  // ============================================================
@@ -167,6 +194,7 @@ export function parseERDiagram(
167
194
  let contentStarted = false;
168
195
  let currentTagGroup: TagGroup | null = null;
169
196
  const aliasMap = new Map<string, string>();
197
+ let firstLineParsed = false;
170
198
 
171
199
  function getOrCreateTable(name: string, lineNumber: number): ERTable {
172
200
  const id = tableId(name);
@@ -200,13 +228,29 @@ export function parseERDiagram(
200
228
  // Skip comments
201
229
  if (trimmed.startsWith('//')) continue;
202
230
 
203
- // Tag group heading — `tag: Name` or deprecated `## Name`
231
+ // First line: chart type + optional title
232
+ if (!firstLineParsed && indent === 0) {
233
+ const firstLineResult = parseFirstLine(trimmed);
234
+ if (firstLineResult && firstLineResult.chartType === 'er') {
235
+ firstLineParsed = true;
236
+ if (firstLineResult.title) {
237
+ result.title = firstLineResult.title;
238
+ result.titleLineNumber = lineNumber;
239
+ }
240
+ continue;
241
+ }
242
+ // Not an explicit `er` first line — that's OK, treat as implicit
243
+ firstLineParsed = true;
244
+ }
245
+
246
+ // Tag group heading — `tag Name` or deprecated `## Name`
204
247
  if (!contentStarted && indent === 0) {
205
248
  const tagBlockMatch = matchTagBlockHeading(trimmed);
206
249
  if (tagBlockMatch) {
207
250
  if (tagBlockMatch.deprecated) {
208
251
  result.diagnostics.push(makeDgmoError(lineNumber,
209
- `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`, 'warning'));
252
+ `'## ${tagBlockMatch.name}' is no longer supported — use 'tag: ${tagBlockMatch.name}' instead`));
253
+ continue;
210
254
  }
211
255
  currentTagGroup = {
212
256
  name: tagBlockMatch.name,
@@ -222,19 +266,16 @@ export function parseERDiagram(
222
266
  }
223
267
  }
224
268
 
225
- // Tag group entries (indented under tag: heading)
269
+ // Tag group entries (indented under tag heading)
226
270
  if (currentTagGroup && !contentStarted && indent > 0) {
227
- const isDefault = /\bdefault\s*$/.test(trimmed);
228
- const entryText = isDefault
229
- ? trimmed.replace(/\s+default\s*$/, '').trim()
230
- : trimmed;
231
- const { label, color } = extractColor(entryText, palette);
271
+ const { label, color } = extractColor(trimmed, palette);
232
272
  if (!color) {
233
273
  result.diagnostics.push(makeDgmoError(lineNumber,
234
274
  `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`, 'warning'));
235
275
  continue;
236
276
  }
237
- if (isDefault) {
277
+ // First entry becomes the default
278
+ if (currentTagGroup.entries.length === 0) {
238
279
  currentTagGroup.defaultValue = label;
239
280
  }
240
281
  currentTagGroup.entries.push({ value: label, color, lineNumber });
@@ -246,36 +287,17 @@ export function parseERDiagram(
246
287
  currentTagGroup = null;
247
288
  }
248
289
 
249
- // Metadata directives (before content)
250
- if (!contentStarted && indent === 0 && /^[a-z][a-z0-9-]*\s*:/i.test(trimmed)) {
251
- const colonIdx = trimmed.indexOf(':');
252
- const key = trimmed.substring(0, colonIdx).trim().toLowerCase();
253
- const value = trimmed.substring(colonIdx + 1).trim();
254
-
255
- if (key === 'chart') {
256
- if (value.toLowerCase() !== 'er') {
257
- const allTypes = ['er', 'class', 'flowchart', 'sequence', 'org', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
258
- let msg = `Expected chart type "er", got "${value}"`;
259
- const hint = suggest(value.toLowerCase(), allTypes);
260
- if (hint) msg += `. ${hint}`;
261
- return fail(lineNumber, msg);
290
+ // Options (space-separated, no colon) — before content
291
+ if (!contentStarted && indent === 0) {
292
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
293
+ if (optMatch) {
294
+ const key = optMatch[1].toLowerCase();
295
+ const value = optMatch[2].trim();
296
+ if (KNOWN_OPTIONS.has(key)) {
297
+ result.options[key] = value.toLowerCase();
298
+ continue;
262
299
  }
263
- continue;
264
- }
265
-
266
- if (key === 'title') {
267
- result.title = value;
268
- result.titleLineNumber = lineNumber;
269
- continue;
270
300
  }
271
-
272
- if (key === 'notation') {
273
- result.options.notation = value.toLowerCase();
274
- continue;
275
- }
276
-
277
- // Unknown single-word keys are metadata — skip
278
- if (!/\s/.test(key)) continue;
279
301
  }
280
302
 
281
303
  // Indented lines = columns or relationships of current table
@@ -299,17 +321,12 @@ export function parseERDiagram(
299
321
  continue;
300
322
  }
301
323
 
302
- const colMatch = trimmed.match(COLUMN_RE);
303
- if (colMatch) {
304
- const colName = colMatch[1];
305
- const colType = colMatch[2]?.trim();
306
- const constraintRaw = colMatch[3];
307
- const constraints = constraintRaw ? parseConstraints(constraintRaw) : [];
308
-
324
+ const colResult = parseColumn(trimmed);
325
+ if (colResult) {
309
326
  currentTable.columns.push({
310
- name: colName,
311
- ...(colType && { type: colType }),
312
- constraints,
327
+ name: colResult.name,
328
+ ...(colResult.type && { type: colResult.type }),
329
+ constraints: colResult.constraints,
313
330
  lineNumber,
314
331
  });
315
332
  }
@@ -350,10 +367,8 @@ export function parseERDiagram(
350
367
  // Parse pipe metadata: TableName(color) | key: value, key2: value2
351
368
  const pipeStr = tableDecl[3]?.trim();
352
369
  if (pipeStr) {
353
- // Split on additional pipes (treated as commas) and warn if found
354
370
  const pipeSegments = pipeStr.split('|');
355
- const meta = parsePipeMetadata(['', ...pipeSegments], aliasMap,
356
- () => result.diagnostics.push(makeDgmoError(lineNumber, MULTIPLE_PIPE_WARNING, 'warning')));
371
+ const meta = parsePipeMetadata(['', ...pipeSegments], aliasMap);
357
372
  Object.assign(table.metadata, meta);
358
373
  }
359
374
 
@@ -415,9 +430,12 @@ export function parseERDiagram(
415
430
  // Detection helper
416
431
  // ============================================================
417
432
 
433
+ // Column detection for looksLikeERDiagram: space-separated with constraint keywords
434
+ const CONSTRAINT_KEYWORD_RE = /\b(pk|fk)\b/i;
435
+
418
436
  /**
419
- * Detect if content looks like an ER diagram without explicit `chart: er`.
420
- * Looks for indented lines with [pk] or [fk] constraint patterns.
437
+ * Detect if content looks like an ER diagram without explicit `er` first line.
438
+ * Looks for indented lines with pk or fk constraint keywords.
421
439
  */
422
440
  export function looksLikeERDiagram(content: string): boolean {
423
441
  const lines = content.split('\n');
@@ -430,14 +448,14 @@ export function looksLikeERDiagram(content: string): boolean {
430
448
  const trimmed = line.trim();
431
449
  if (!trimmed || trimmed.startsWith('//')) continue;
432
450
 
433
- // Skip metadata
434
- if (/^(chart|title|notation)\s*:/i.test(trimmed)) continue;
451
+ // Skip metadata (both old colon and new space-separated)
452
+ if (/^(er|notation)\s/i.test(trimmed) || /^er$/i.test(trimmed)) continue;
435
453
 
436
454
  const indent = measureIndent(line);
437
455
 
438
456
  if (indent > 0) {
439
- // Indented line with [pk] or [fk] is strong ER signal
440
- if (/\[(pk|fk)\]/i.test(trimmed)) {
457
+ // Indented line with pk or fk is strong ER signal
458
+ if (CONSTRAINT_KEYWORD_RE.test(trimmed)) {
441
459
  hasConstraint = true;
442
460
  }
443
461
  // Indented relationship is a strong ER signal
@@ -456,7 +474,7 @@ export function looksLikeERDiagram(content: string): boolean {
456
474
  }
457
475
  }
458
476
 
459
- // [pk]/[fk] constraint is a strong enough signal
477
+ // pk/fk constraint is a strong enough signal
460
478
  if (hasConstraint && hasTableDecl) return true;
461
479
 
462
480
  // Relationship with table declarations is sufficient
@@ -480,8 +498,8 @@ export function extractSymbols(docText: string): DiagramSymbols {
480
498
  let inMetadata = true;
481
499
  for (const rawLine of docText.split('\n')) {
482
500
  const line = rawLine.trim();
483
- if (inMetadata && /^chart\s*:/i.test(line)) continue;
484
- if (inMetadata && /^[a-z-]+\s*:/i.test(line)) continue; // metadata key
501
+ if (inMetadata && /^er(\s|$)/i.test(line)) continue;
502
+ if (inMetadata && OPTION_NOCOLON_RE.test(line)) continue; // option line
485
503
  inMetadata = false;
486
504
  if (line.length === 0) continue;
487
505
  if (/^\s/.test(rawLine)) continue; // indented = column definition, not table
@@ -13,14 +13,15 @@ import {
13
13
  LEGEND_HEIGHT,
14
14
  LEGEND_PILL_PAD,
15
15
  LEGEND_PILL_FONT_SIZE,
16
- LEGEND_PILL_FONT_W,
17
16
  LEGEND_CAPSULE_PAD,
18
17
  LEGEND_DOT_R,
19
18
  LEGEND_ENTRY_FONT_SIZE,
20
19
  LEGEND_ENTRY_DOT_GAP,
21
20
  LEGEND_ENTRY_TRAIL,
22
21
  LEGEND_GROUP_GAP,
22
+ measureLegendText,
23
23
  } from '../utils/legend-constants';
24
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y } from '../utils/title-constants';
24
25
  import type { ParsedERDiagram, ERConstraint } from './types';
25
26
  import type { ERLayoutResult } from './layout';
26
27
  import { parseERDiagram } from './parser';
@@ -286,11 +287,11 @@ export function renderERDiagram(
286
287
  .append('text')
287
288
  .attr('class', 'chart-title')
288
289
  .attr('x', viewW / 2)
289
- .attr('y', 30)
290
+ .attr('y', TITLE_Y)
290
291
  .attr('text-anchor', 'middle')
291
292
  .attr('fill', palette.text)
292
- .attr('font-size', '20px')
293
- .attr('font-weight', '700')
293
+ .attr('font-size', TITLE_FONT_SIZE)
294
+ .attr('font-weight', TITLE_FONT_WEIGHT)
294
295
  .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
295
296
  .text(parsed.title);
296
297
 
@@ -626,7 +627,7 @@ export function renderERDiagram(
626
627
  : mix(palette.surface, palette.bg, 30);
627
628
 
628
629
  const groupName = 'Role';
629
- const pillWidth = groupName.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
630
+ const pillWidth = measureLegendText(groupName, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
630
631
  const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
631
632
 
632
633
  let totalWidth: number;