@diagrammo/dgmo 0.2.27 → 0.2.28

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 (47) 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 +116 -108
  10. package/dist/index.cjs +543 -351
  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 +540 -350
  15. package/dist/index.js.map +1 -1
  16. package/docs/ai-integration.md +125 -0
  17. package/docs/language-reference.md +784 -0
  18. package/package.json +10 -3
  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/parser.ts +2 -10
  24. package/src/class/types.ts +1 -1
  25. package/src/cli.ts +130 -19
  26. package/src/d3.ts +1 -1
  27. package/src/dgmo-mermaid.ts +1 -1
  28. package/src/dgmo-router.ts +1 -1
  29. package/src/echarts.ts +33 -13
  30. package/src/er/parser.ts +34 -43
  31. package/src/er/types.ts +1 -1
  32. package/src/graph/flowchart-parser.ts +2 -25
  33. package/src/graph/types.ts +1 -1
  34. package/src/index.ts +5 -0
  35. package/src/initiative-status/parser.ts +36 -7
  36. package/src/initiative-status/types.ts +1 -1
  37. package/src/kanban/parser.ts +32 -53
  38. package/src/kanban/renderer.ts +9 -8
  39. package/src/kanban/types.ts +6 -14
  40. package/src/org/parser.ts +47 -87
  41. package/src/org/resolver.ts +11 -12
  42. package/src/sequence/parser.ts +97 -15
  43. package/src/sequence/renderer.ts +62 -69
  44. package/src/utils/arrows.ts +75 -0
  45. package/src/utils/inline-markdown.ts +75 -0
  46. package/src/utils/parsing.ts +67 -0
  47. package/src/utils/tag-groups.ts +76 -0
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@ import { execSync } from 'node:child_process';
3
3
  import { resolve, basename, extname } from 'node:path';
4
4
  import { Resvg } from '@resvg/resvg-js';
5
5
  import { render } from './render';
6
- import { parseDgmo } from './dgmo-router';
6
+ import { parseDgmo, DGMO_CHART_TYPE_MAP } from './dgmo-router';
7
7
  import { parseDgmoChartType } from './dgmo-router';
8
8
  import { formatDgmoError } from './diagnostics';
9
9
  import { getPalette } from './palettes/registry';
@@ -24,6 +24,38 @@ const PALETTES = [
24
24
 
25
25
  const THEMES = ['light', 'dark', 'transparent'] as const;
26
26
 
27
+ const CHART_TYPE_DESCRIPTIONS: Record<string, string> = {
28
+ bar: 'Bar chart — categorical comparisons',
29
+ line: 'Line chart — trends over time',
30
+ 'multi-line': 'Multi-line chart — multiple series trends',
31
+ area: 'Area chart — filled line chart',
32
+ pie: 'Pie chart — part-to-whole proportions',
33
+ doughnut: 'Doughnut chart — ring-style pie chart',
34
+ radar: 'Radar chart — multi-dimensional metrics',
35
+ 'polar-area': 'Polar area chart — radial bar chart',
36
+ 'bar-stacked': 'Stacked bar chart — multi-series categorical',
37
+ scatter: 'Scatter plot — 2D data points or bubble chart',
38
+ sankey: 'Sankey diagram — flow/allocation visualization',
39
+ chord: 'Chord diagram — circular flow relationships',
40
+ function: 'Function plot — mathematical expressions',
41
+ heatmap: 'Heatmap — matrix intensity visualization',
42
+ funnel: 'Funnel chart — conversion pipeline',
43
+ slope: 'Slope chart — change between two periods',
44
+ wordcloud: 'Word cloud — term frequency visualization',
45
+ arc: 'Arc diagram — network relationships',
46
+ timeline: 'Timeline — events, eras, and date ranges',
47
+ venn: 'Venn diagram — set overlaps',
48
+ quadrant: 'Quadrant chart — 2x2 positioning matrix',
49
+ sequence: 'Sequence diagram — message/interaction flows',
50
+ flowchart: 'Flowchart — decision trees and process flows',
51
+ class: 'Class diagram — UML class hierarchies',
52
+ er: 'ER diagram — database schemas and relationships',
53
+ org: 'Org chart — hierarchical tree structures',
54
+ kanban: 'Kanban board — task/workflow columns',
55
+ c4: 'C4 diagram — system architecture (context, container, component, deployment)',
56
+ 'initiative-status': 'Initiative status — project roadmap with dependency tracking',
57
+ };
58
+
27
59
  function printHelp(): void {
28
60
  console.log(`Usage: dgmo <input> [options]
29
61
  cat input.dgmo | dgmo [options]
@@ -42,6 +74,8 @@ Options:
42
74
  --c4-container <name> Container to drill into (with --c4-level components)
43
75
  --no-branding Omit diagrammo.app branding from exports
44
76
  --copy Copy URL to clipboard (only with -o url)
77
+ --json Output structured JSON to stdout
78
+ --chart-types List all supported chart types
45
79
  --help Show this help
46
80
  --version Show version`);
47
81
  }
@@ -62,6 +96,8 @@ function parseArgs(argv: string[]): {
62
96
  version: boolean;
63
97
  noBranding: boolean;
64
98
  copy: boolean;
99
+ json: boolean;
100
+ chartTypes: boolean;
65
101
  c4Level: 'context' | 'containers' | 'components' | 'deployment';
66
102
  c4System: string | undefined;
67
103
  c4Container: string | undefined;
@@ -75,6 +111,8 @@ function parseArgs(argv: string[]): {
75
111
  version: false,
76
112
  noBranding: false,
77
113
  copy: false,
114
+ json: false,
115
+ chartTypes: false,
78
116
  c4Level: 'context' as 'context' | 'containers' | 'components' | 'deployment',
79
117
  c4System: undefined as string | undefined,
80
118
  c4Container: undefined as string | undefined,
@@ -134,6 +172,12 @@ function parseArgs(argv: string[]): {
134
172
  } else if (arg === '--no-branding') {
135
173
  result.noBranding = true;
136
174
  i++;
175
+ } else if (arg === '--json') {
176
+ result.json = true;
177
+ i++;
178
+ } else if (arg === '--chart-types') {
179
+ result.chartTypes = true;
180
+ i++;
137
181
  } else if (arg === '--copy') {
138
182
  result.copy = true;
139
183
  i++;
@@ -220,6 +264,23 @@ async function main(): Promise<void> {
220
264
  return;
221
265
  }
222
266
 
267
+ if (opts.chartTypes) {
268
+ const types = Object.keys(DGMO_CHART_TYPE_MAP);
269
+ if (opts.json) {
270
+ const chartTypes = types.map((id) => ({
271
+ id,
272
+ description: CHART_TYPE_DESCRIPTIONS[id] ?? id,
273
+ }));
274
+ process.stdout.write(JSON.stringify({ chartTypes }, null, 2) + '\n');
275
+ } else {
276
+ for (const id of types) {
277
+ const desc = CHART_TYPE_DESCRIPTIONS[id];
278
+ console.log(desc ? `${id} — ${desc.split(' — ')[1]}` : id);
279
+ }
280
+ }
281
+ return;
282
+ }
283
+
223
284
  // Determine input source
224
285
  let content: string;
225
286
  let inputBasename: string | undefined;
@@ -277,14 +338,30 @@ async function main(): Promise<void> {
277
338
  process.exit(1);
278
339
  }
279
340
 
341
+ const chartType = parseDgmoChartType(content);
342
+
343
+ // Helper for JSON error output
344
+ function exitWithJsonError(error: string, line?: number): never {
345
+ if (opts.json) {
346
+ process.stdout.write(JSON.stringify({
347
+ success: false,
348
+ error,
349
+ ...(line != null ? { line } : {}),
350
+ ...(chartType ? { chartType } : {}),
351
+ }, null, 2) + '\n');
352
+ } else {
353
+ console.error(error);
354
+ }
355
+ process.exit(1);
356
+ }
357
+
280
358
  // URL output — encode DSL directly, no rendering needed
281
359
  if (format === 'url') {
282
360
  const result = encodeDiagramUrl(content);
283
361
  if (result.error) {
284
- console.error(
362
+ exitWithJsonError(
285
363
  `Error: Diagram too large for URL sharing (${result.compressedSize} bytes, limit ${result.limit} bytes)`
286
364
  );
287
- process.exit(1);
288
365
  }
289
366
 
290
367
  if (opts.copy) {
@@ -303,7 +380,15 @@ async function main(): Promise<void> {
303
380
  }
304
381
  }
305
382
 
306
- process.stdout.write(result.url + '\n');
383
+ if (opts.json) {
384
+ process.stdout.write(JSON.stringify({
385
+ success: true,
386
+ url: result.url,
387
+ ...(chartType ? { chartType } : {}),
388
+ }, null, 2) + '\n');
389
+ } else {
390
+ process.stdout.write(result.url + '\n');
391
+ }
307
392
  return;
308
393
  }
309
394
 
@@ -313,10 +398,9 @@ async function main(): Promise<void> {
313
398
  // which are unavailable in Node.js — check before attempting render.
314
399
  const wordcloudRe = /^\s*chart\s*:\s*wordcloud\b/im;
315
400
  if (wordcloudRe.test(content)) {
316
- console.error(
401
+ exitWithJsonError(
317
402
  'Error: Word clouds are not supported in the CLI (requires Canvas). Use the desktop app or browser instead.'
318
403
  );
319
- process.exit(1);
320
404
  }
321
405
 
322
406
  // Parse first to collect diagnostics
@@ -325,28 +409,36 @@ async function main(): Promise<void> {
325
409
  const warnings = diagnostics.filter((d) => d.severity === 'warning');
326
410
 
327
411
  // Print warnings even if rendering succeeds
328
- for (const w of warnings) {
329
- console.error(`\u26A0 ${formatDgmoError(w)}`);
412
+ if (!opts.json) {
413
+ for (const w of warnings) {
414
+ console.error(`\u26A0 ${formatDgmoError(w)}`);
415
+ }
330
416
  }
331
417
 
332
- // Print errors
333
- for (const e of errors) {
334
- console.error(`\u2716 ${formatDgmoError(e)}`);
418
+ // Print errors and exit
419
+ if (errors.length > 0) {
420
+ if (opts.json) {
421
+ const firstError = errors[0];
422
+ exitWithJsonError(
423
+ formatDgmoError(firstError),
424
+ firstError.line,
425
+ );
426
+ }
427
+ for (const e of errors) {
428
+ console.error(`\u2716 ${formatDgmoError(e)}`);
429
+ }
335
430
  }
336
431
 
337
432
  // Validate C4 options
338
433
  if (opts.c4Level === 'containers' && !opts.c4System) {
339
- console.error('Error: --c4-system is required when --c4-level is containers');
340
- process.exit(1);
434
+ exitWithJsonError('Error: --c4-system is required when --c4-level is containers');
341
435
  }
342
436
  if (opts.c4Level === 'components') {
343
437
  if (!opts.c4System) {
344
- console.error('Error: --c4-system is required when --c4-level is components');
345
- process.exit(1);
438
+ exitWithJsonError('Error: --c4-system is required when --c4-level is components');
346
439
  }
347
440
  if (!opts.c4Container) {
348
- console.error('Error: --c4-container is required when --c4-level is components');
349
- process.exit(1);
441
+ exitWithJsonError('Error: --c4-container is required when --c4-level is components');
350
442
  }
351
443
  }
352
444
 
@@ -361,7 +453,7 @@ async function main(): Promise<void> {
361
453
 
362
454
  if (!svg) {
363
455
  if (errors.length === 0) {
364
- console.error(
456
+ exitWithJsonError(
365
457
  'Error: Failed to render diagram. The input may be empty, invalid, or use an unsupported chart type.'
366
458
  );
367
459
  }
@@ -371,7 +463,26 @@ async function main(): Promise<void> {
371
463
  // Determine output destination
372
464
  const pngBg = opts.theme === 'transparent' ? undefined : paletteColors.bg;
373
465
 
374
- if (opts.output) {
466
+ if (opts.json) {
467
+ // JSON mode: write file as normal but output JSON result to stdout
468
+ let outputPath: string | undefined;
469
+ if (opts.output) {
470
+ outputPath = resolve(opts.output);
471
+ if (format === 'svg') {
472
+ writeFileSync(outputPath, svg, 'utf-8');
473
+ } else {
474
+ writeFileSync(outputPath, svgToPng(svg, pngBg));
475
+ }
476
+ } else if (inputBasename) {
477
+ outputPath = resolve(`${inputBasename}.png`);
478
+ writeFileSync(outputPath, svgToPng(svg, pngBg));
479
+ }
480
+ process.stdout.write(JSON.stringify({
481
+ success: true,
482
+ ...(outputPath ? { output: outputPath } : {}),
483
+ ...(chartType ? { chartType } : {}),
484
+ }, null, 2) + '\n');
485
+ } else if (opts.output) {
375
486
  // Explicit output path
376
487
  const outputPath = resolve(opts.output);
377
488
  if (format === 'svg') {
package/src/d3.ts CHANGED
@@ -350,7 +350,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
350
350
  }
351
351
 
352
352
  // Skip comments
353
- if (line.startsWith('#') || line.startsWith('//')) {
353
+ if (line.startsWith('//')) {
354
354
  continue;
355
355
  }
356
356
 
@@ -82,7 +82,7 @@ export function parseQuadrant(content: string): ParsedQuadrant {
82
82
  const lineNumber = i + 1; // 1-indexed for editor
83
83
 
84
84
  // Skip empty lines and comments
85
- if (!line || line.startsWith('#') || line.startsWith('//')) continue;
85
+ if (!line || line.startsWith('//')) continue;
86
86
 
87
87
  // Skip the chart: directive (already consumed by router)
88
88
  if (/^chart\s*:/i.test(line)) continue;
@@ -81,7 +81,7 @@ export function parseDgmoChartType(content: string): string | null {
81
81
  for (const line of lines) {
82
82
  const trimmed = line.trim();
83
83
  // Skip empty lines and comments
84
- if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//'))
84
+ if (!trimmed || trimmed.startsWith('//'))
85
85
  continue;
86
86
  const match = trimmed.match(/^chart\s*:\s*(.+)/i);
87
87
  if (match) return match[1].trim().toLowerCase();
package/src/echarts.ts CHANGED
@@ -75,7 +75,7 @@ export interface ParsedEChart {
75
75
  showLabels?: boolean;
76
76
  categoryColors?: Record<string, string>;
77
77
  diagnostics: DgmoError[];
78
- error?: string;
78
+ error: string | null;
79
79
  }
80
80
 
81
81
  // ============================================================
@@ -116,6 +116,7 @@ export function parseEChart(
116
116
  type: 'scatter',
117
117
  data: [],
118
118
  diagnostics: [],
119
+ error: null,
119
120
  };
120
121
 
121
122
  // Track current category for grouped scatter charts
@@ -144,7 +145,7 @@ export function parseEChart(
144
145
  }
145
146
 
146
147
  // Skip comments
147
- if (trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
148
+ if (trimmed.startsWith('//')) continue;
148
149
 
149
150
  // Check for category header: [Category Name]
150
151
  const categoryMatch = trimmed.match(/^\[(.+)\]$/);
@@ -964,7 +965,7 @@ function buildScatterOption(
964
965
  },
965
966
  }),
966
967
  grid: {
967
- left: parsed.ylabel ? '5%' : '3%',
968
+ left: parsed.ylabel ? '12%' : '3%',
968
969
  right: '4%',
969
970
  bottom: hasCategories ? '15%' : parsed.xlabel ? '10%' : '3%',
970
971
  top: parsed.title ? '15%' : '5%',
@@ -1289,18 +1290,29 @@ function makeGridAxis(
1289
1290
  splitLineColor: string,
1290
1291
  gridOpacity: number,
1291
1292
  label?: string,
1292
- data?: string[]
1293
+ data?: string[],
1294
+ nameGapOverride?: number
1293
1295
  ): Record<string, unknown> {
1296
+ const defaultGap = type === 'value' ? 75 : 40;
1294
1297
  return {
1295
1298
  type,
1296
1299
  ...(data && { data }),
1297
1300
  axisLine: { lineStyle: { color: axisLineColor } },
1298
- axisLabel: { color: textColor, fontSize: 16, fontFamily: FONT_FAMILY },
1301
+ axisLabel: {
1302
+ color: textColor,
1303
+ fontSize: type === 'category' && data ? (data.length > 10 ? 11 : data.length > 5 ? 12 : 16) : 16,
1304
+ fontFamily: FONT_FAMILY,
1305
+ ...(type === 'category' && {
1306
+ interval: 0,
1307
+ formatter: (value: string) =>
1308
+ value.replace(/([a-z])([A-Z])/g, '$1\n$2').replace(/ /g, '\n'),
1309
+ }),
1310
+ },
1299
1311
  splitLine: { lineStyle: { color: splitLineColor, opacity: gridOpacity } },
1300
1312
  ...(label && {
1301
1313
  name: label,
1302
1314
  nameLocation: 'middle',
1303
- nameGap: 40,
1315
+ nameGap: nameGapOverride ?? defaultGap,
1304
1316
  nameTextStyle: { color: textColor, fontSize: 18, fontFamily: FONT_FAMILY },
1305
1317
  }),
1306
1318
  };
@@ -1385,7 +1397,12 @@ function buildBarOption(
1385
1397
  itemStyle: { color: d.color ?? colors[i % colors.length] },
1386
1398
  }));
1387
1399
 
1388
- const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels);
1400
+ // When category labels are on the y-axis (horizontal bars), they can be wide
1401
+ // compute a nameGap that clears the longest label so the ylabel doesn't overlap.
1402
+ const hCatGap = isHorizontal && yLabel
1403
+ ? Math.max(40, Math.max(...labels.map((l) => l.length)) * 8 + 16)
1404
+ : undefined;
1405
+ const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap);
1389
1406
  const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel);
1390
1407
 
1391
1408
  // xAxis is always the bottom axis, yAxis is always the left axis in ECharts
@@ -1400,7 +1417,7 @@ function buildBarOption(
1400
1417
  axisPointer: { type: 'shadow' },
1401
1418
  },
1402
1419
  grid: {
1403
- left: yLabel ? '5%' : '3%',
1420
+ left: yLabel ? '12%' : '3%',
1404
1421
  right: '4%',
1405
1422
  bottom: xLabel ? '10%' : '3%',
1406
1423
  top: parsed.title ? '15%' : '5%',
@@ -1448,7 +1465,7 @@ function buildLineOption(
1448
1465
  axisPointer: { type: 'line' },
1449
1466
  },
1450
1467
  grid: {
1451
- left: yLabel ? '5%' : '3%',
1468
+ left: yLabel ? '12%' : '3%',
1452
1469
  right: '4%',
1453
1470
  bottom: xLabel ? '10%' : '3%',
1454
1471
  top: parsed.title ? '15%' : '5%',
@@ -1524,7 +1541,7 @@ function buildMultiLineOption(
1524
1541
  textStyle: { color: textColor },
1525
1542
  },
1526
1543
  grid: {
1527
- left: yLabel ? '5%' : '3%',
1544
+ left: yLabel ? '12%' : '3%',
1528
1545
  right: '4%',
1529
1546
  bottom: '15%',
1530
1547
  top: parsed.title ? '15%' : '5%',
@@ -1563,7 +1580,7 @@ function buildAreaOption(
1563
1580
  axisPointer: { type: 'line' },
1564
1581
  },
1565
1582
  grid: {
1566
- left: yLabel ? '5%' : '3%',
1583
+ left: yLabel ? '12%' : '3%',
1567
1584
  right: '4%',
1568
1585
  bottom: xLabel ? '10%' : '3%',
1569
1586
  top: parsed.title ? '15%' : '5%',
@@ -1807,7 +1824,10 @@ function buildBarStackedOption(
1807
1824
  };
1808
1825
  });
1809
1826
 
1810
- const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels);
1827
+ const hCatGap = isHorizontal && yLabel
1828
+ ? Math.max(40, Math.max(...labels.map((l) => l.length)) * 8 + 16)
1829
+ : undefined;
1830
+ const categoryAxis = makeGridAxis('category', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? yLabel : xLabel, labels, hCatGap);
1811
1831
  const valueAxis = makeGridAxis('value', textColor, axisLineColor, splitLineColor, gridOpacity, isHorizontal ? xLabel : yLabel);
1812
1832
 
1813
1833
  return {
@@ -1825,7 +1845,7 @@ function buildBarStackedOption(
1825
1845
  textStyle: { color: textColor },
1826
1846
  },
1827
1847
  grid: {
1828
- left: yLabel ? '5%' : '3%',
1848
+ left: yLabel ? '12%' : '3%',
1829
1849
  right: '4%',
1830
1850
  bottom: '15%',
1831
1851
  top: parsed.title ? '15%' : '5%',
package/src/er/parser.ts CHANGED
@@ -1,6 +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
5
  import type {
5
6
  ParsedERDiagram,
6
7
  ERTable,
@@ -13,16 +14,6 @@ import type {
13
14
  // Helpers
14
15
  // ============================================================
15
16
 
16
- function measureIndent(line: string): number {
17
- let indent = 0;
18
- for (const ch of line) {
19
- if (ch === ' ') indent++;
20
- else if (ch === '\t') indent += 4;
21
- else break;
22
- }
23
- return indent;
24
- }
25
-
26
17
  function tableId(name: string): string {
27
18
  return name.toLowerCase().trim();
28
19
  }
@@ -50,42 +41,39 @@ const CONSTRAINT_MAP: Record<string, ERConstraint> = {
50
41
  // Cardinality parsing
51
42
  // ============================================================
52
43
 
53
- // Cardinality keyword map
54
- const CARD_WORD: Record<string, ERCardinality> = {
55
- one: '1',
56
- many: '*',
57
- '1': '1',
58
- '*': '*',
59
- '?': '?',
60
- zero: '?',
61
- };
62
-
63
44
  /**
64
- * Parse a cardinality side token (e.g. "1", "*", "?", "one", "many", "zero").
45
+ * Parse a cardinality side token (symbolic only: "1", "*", "?").
65
46
  */
66
47
  function parseCardSide(token: string): ERCardinality | null {
67
- return CARD_WORD[token.toLowerCase()] ?? null;
48
+ if (token === '1' || token === '*' || token === '?') return token;
49
+ return null;
68
50
  }
69
51
 
70
52
  /**
71
- * Try to parse a relationship line with cardinality.
53
+ * Try to parse a relationship line with symbolic cardinality.
72
54
  *
73
- * Supported forms:
55
+ * Supported form:
74
56
  * tableName 1--* tableName : label
75
57
  * tableName 1-* tableName : label
76
- * tableName one-to-many tableName : label
77
- * tableName one to many tableName : label
78
- * tableName 1 to many tableName : label
79
58
  * tableName ?--1 tableName : label
80
59
  */
81
60
  const REL_SYMBOLIC_RE =
82
61
  /^([a-zA-Z_]\w*)\s+([1*?])\s*-{1,2}\s*([1*?])\s+([a-zA-Z_]\w*)(?:\s*:\s*(.+))?$/;
83
62
 
63
+ /** Detects keyword cardinality forms to emit helpful error */
84
64
  const REL_KEYWORD_RE =
85
- /^([a-zA-Z_]\w*)\s+(one|many|zero|1|\*|\?)[- ]to[- ](one|many|zero|1|\*|\?)\s+([a-zA-Z_]\w*)(?:\s*:\s*(.+))?$/i;
65
+ /^([a-zA-Z_]\w*)\s+(one|many|zero)[- ]to[- ](one|many|zero)\s+([a-zA-Z_]\w*)(?:\s*:\s*(.+))?$/i;
66
+
67
+ const KEYWORD_TO_SYMBOL: Record<string, string> = {
68
+ one: '1',
69
+ many: '*',
70
+ zero: '?',
71
+ };
86
72
 
87
73
  function parseRelationship(
88
- trimmed: string
74
+ trimmed: string,
75
+ lineNumber: number,
76
+ pushError: (line: number, message: string) => void,
89
77
  ): {
90
78
  source: string;
91
79
  target: string;
@@ -109,20 +97,16 @@ function parseRelationship(
109
97
  }
110
98
  }
111
99
 
112
- // Keyword / natural: one-to-many, one to many, 1 to many, etc.
100
+ // Keyword / natural: produce helpful error with symbolic suggestion
113
101
  const kw = trimmed.match(REL_KEYWORD_RE);
114
102
  if (kw) {
115
- const fromCard = parseCardSide(kw[2]);
116
- const toCard = parseCardSide(kw[3]);
117
- if (fromCard && toCard) {
118
- return {
119
- source: kw[1],
120
- target: kw[4],
121
- from: fromCard,
122
- to: toCard,
123
- label: kw[5]?.trim(),
124
- };
125
- }
103
+ const fromSym = KEYWORD_TO_SYMBOL[kw[2].toLowerCase()] ?? kw[2];
104
+ const toSym = KEYWORD_TO_SYMBOL[kw[3].toLowerCase()] ?? kw[3];
105
+ pushError(
106
+ lineNumber,
107
+ `Use symbolic cardinality (1--*, ?--1, *--*) instead of "${kw[2]}-to-${kw[3]}". Example: ${kw[1]} ${fromSym}--${toSym} ${kw[4]}`,
108
+ );
109
+ return null;
126
110
  }
127
111
 
128
112
  return null;
@@ -157,6 +141,7 @@ export function parseERDiagram(
157
141
  tables: [],
158
142
  relationships: [],
159
143
  diagnostics: [],
144
+ error: null,
160
145
  };
161
146
 
162
147
  const fail = (line: number, message: string): ParsedERDiagram => {
@@ -166,6 +151,12 @@ export function parseERDiagram(
166
151
  return result;
167
152
  };
168
153
 
154
+ const pushError = (line: number, message: string): void => {
155
+ const diag = makeDgmoError(line, message);
156
+ result.diagnostics.push(diag);
157
+ if (!result.error) result.error = formatDgmoError(diag);
158
+ };
159
+
169
160
  const tableMap = new Map<string, ERTable>();
170
161
  let currentTable: ERTable | null = null;
171
162
  let contentStarted = false;
@@ -257,7 +248,7 @@ export function parseERDiagram(
257
248
  contentStarted = true;
258
249
 
259
250
  // Try relationship
260
- const rel = parseRelationship(trimmed);
251
+ const rel = parseRelationship(trimmed, lineNumber, pushError);
261
252
  if (rel) {
262
253
  getOrCreateTable(rel.source, lineNumber);
263
254
  getOrCreateTable(rel.target, lineNumber);
@@ -347,7 +338,7 @@ export function looksLikeERDiagram(content: string): boolean {
347
338
  hasTableDecl = true;
348
339
  }
349
340
  // Check for relationship patterns
350
- if (REL_SYMBOLIC_RE.test(trimmed) || REL_KEYWORD_RE.test(trimmed)) {
341
+ if (REL_SYMBOLIC_RE.test(trimmed)) {
351
342
  hasRelationship = true;
352
343
  }
353
344
  }
package/src/er/types.ts CHANGED
@@ -39,5 +39,5 @@ export interface ParsedERDiagram {
39
39
  tables: ERTable[];
40
40
  relationships: ERRelationship[];
41
41
  diagnostics: DgmoError[];
42
- error?: string;
42
+ error: string | null;
43
43
  }
@@ -1,6 +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 } from '../utils/parsing';
4
5
  import type {
5
6
  ParsedGraph,
6
7
  GraphNode,
@@ -14,16 +15,6 @@ import type {
14
15
  // Helpers
15
16
  // ============================================================
16
17
 
17
- function measureIndent(line: string): number {
18
- let indent = 0;
19
- for (const ch of line) {
20
- if (ch === ' ') indent++;
21
- else if (ch === '\t') indent += 4;
22
- else break;
23
- }
24
- return indent;
25
- }
26
-
27
18
  function nodeId(shape: GraphShape, label: string): string {
28
19
  return `${shape}:${label.toLowerCase().trim()}`;
29
20
  }
@@ -35,21 +26,6 @@ interface NodeRef {
35
26
  color?: string;
36
27
  }
37
28
 
38
- const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
39
-
40
- function extractColor(
41
- label: string,
42
- palette?: PaletteColors
43
- ): { label: string; color?: string } {
44
- const m = label.match(COLOR_SUFFIX_RE);
45
- if (!m) return { label };
46
- const colorName = m[1].trim();
47
- return {
48
- label: label.substring(0, m.index!).trim(),
49
- color: resolveColor(colorName, palette),
50
- };
51
- }
52
-
53
29
  /**
54
30
  * Try to parse a node reference from a text fragment.
55
31
  * Order matters: subroutine & document before process.
@@ -239,6 +215,7 @@ export function parseFlowchart(
239
215
  edges: [],
240
216
  options: {},
241
217
  diagnostics: [],
218
+ error: null,
242
219
  };
243
220
 
244
221
  const fail = (line: number, message: string): ParsedGraph => {
@@ -45,5 +45,5 @@ export interface ParsedGraph {
45
45
  groups?: GraphGroup[];
46
46
  options: Record<string, string>;
47
47
  diagnostics: DgmoError[];
48
- error?: string;
48
+ error: string | null;
49
49
  }
package/src/index.ts CHANGED
@@ -126,6 +126,11 @@ export type {
126
126
  GraphDirection,
127
127
  } from './graph/types';
128
128
 
129
+ export type { TagGroup, TagEntry } from './utils/tag-groups';
130
+
131
+ export { parseInlineMarkdown, truncateBareUrl } from './utils/inline-markdown';
132
+ export type { InlineSpan } from './utils/inline-markdown';
133
+
129
134
  export { parseOrg } from './org/parser';
130
135
  export type {
131
136
  ParsedOrg,