@diagrammo/dgmo 0.7.0 → 0.7.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/c4/parser.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  measureIndent,
12
12
  extractColor,
13
13
  parsePipeMetadata,
14
+ MULTIPLE_PIPE_WARNING,
14
15
  CHART_TYPE_RE,
15
16
  TITLE_RE,
16
17
  OPTION_RE,
@@ -411,7 +412,7 @@ export function parseC4(
411
412
  // Otherwise it's a deployment node (possibly with pipe metadata)
412
413
  const segments = trimmed.split('|').map((s) => s.trim());
413
414
  const nodeName = segments[0];
414
- const metadata = parsePipeMetadata(segments, aliasMap);
415
+ const metadata = parsePipeMetadata(segments, aliasMap, () => pushError(lineNumber, MULTIPLE_PIPE_WARNING, 'warning'));
415
416
  const shape = inferC4Shape(nodeName, metadata.tech ?? metadata.technology);
416
417
 
417
418
  const dNode: C4DeploymentNode = {
@@ -598,7 +599,7 @@ export function parseC4(
598
599
  namePart = namePart.substring(0, isAMatch.index!).trim();
599
600
  }
600
601
 
601
- const metadata = parsePipeMetadata(segments, aliasMap);
602
+ const metadata = parsePipeMetadata(segments, aliasMap, () => pushError(lineNumber, MULTIPLE_PIPE_WARNING, 'warning'));
602
603
 
603
604
  // Determine shape: explicit > inference
604
605
  const shape =
package/src/d3.ts CHANGED
@@ -181,7 +181,7 @@ import { getSeriesColors } from './palettes';
181
181
  import { mix } from './palettes/color-utils';
182
182
  import type { DgmoError } from './diagnostics';
183
183
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
184
- import { collectIndentedValues, extractColor, parsePipeMetadata } from './utils/parsing';
184
+ import { collectIndentedValues, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from './utils/parsing';
185
185
  import { matchTagBlockHeading, validateTagValues, resolveTagColor } from './utils/tag-groups';
186
186
  import type { TagGroup } from './utils/tag-groups';
187
187
  import {
@@ -545,9 +545,9 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
545
545
  continue;
546
546
  }
547
547
 
548
- // Timeline marker lines: marker YYYY: Label (color)
548
+ // Timeline marker lines: marker: YYYY Label (color)
549
549
  const markerMatch = line.match(
550
- /^marker\s+(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/
550
+ /^marker:\s+(\d{4}(?:-\d{2})?(?:-\d{2})?)\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
551
551
  );
552
552
  if (markerMatch) {
553
553
  const colorAnnotation = markerMatch[3]?.trim() || null;
@@ -579,7 +579,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
579
579
  const endDate = addDurationToDate(startDate, amount, unit);
580
580
  const segments = durationMatch[5].split('|');
581
581
  const metadata = segments.length > 1
582
- ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
582
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING))
583
583
  : {};
584
584
  result.timelineEvents.push({
585
585
  date: startDate,
@@ -600,7 +600,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
600
600
  if (rangeMatch) {
601
601
  const segments = rangeMatch[4].split('|');
602
602
  const metadata = segments.length > 1
603
- ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
603
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING))
604
604
  : {};
605
605
  result.timelineEvents.push({
606
606
  date: rangeMatch[1],
@@ -621,7 +621,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
621
621
  if (pointMatch) {
622
622
  const segments = pointMatch[2].split('|');
623
623
  const metadata = segments.length > 1
624
- ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
624
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING))
625
625
  : {};
626
626
  result.timelineEvents.push({
627
627
  date: pointMatch[1],
@@ -6095,10 +6095,8 @@ export async function renderForExport(
6095
6095
 
6096
6096
  const effectivePalette = await resolveExportPalette(theme, palette);
6097
6097
  const ganttParsed = parseGantt(content, effectivePalette);
6098
- if (ganttParsed.error) return '';
6099
-
6100
6098
  const resolved = calculateSchedule(ganttParsed);
6101
- if (resolved.error || resolved.tasks.length === 0) return '';
6099
+ if (resolved.tasks.length === 0) return '';
6102
6100
 
6103
6101
  const EXPORT_W = 1200;
6104
6102
  const EXPORT_H = 800;
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 } from '../utils/parsing';
4
+ import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } 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 {
@@ -350,8 +350,10 @@ export function parseERDiagram(
350
350
  // Parse pipe metadata: TableName(color) | key: value, key2: value2
351
351
  const pipeStr = tableDecl[3]?.trim();
352
352
  if (pipeStr) {
353
- // parsePipeMetadata skips index 0 (name segment), so prepend empty
354
- const meta = parsePipeMetadata(['', pipeStr], aliasMap);
353
+ // Split on additional pipes (treated as commas) and warn if found
354
+ const pipeSegments = pipeStr.split('|');
355
+ const meta = parsePipeMetadata(['', ...pipeSegments], aliasMap,
356
+ () => result.diagnostics.push(makeDgmoError(lineNumber, MULTIPLE_PIPE_WARNING, 'warning')));
355
357
  Object.assign(table.metadata, meta);
356
358
  }
357
359
 
@@ -119,11 +119,8 @@ export function calculateSchedule(parsed: ParsedGantt): ResolvedSchedule {
119
119
  for (const dep of task.dependencies) {
120
120
  const resolved = resolveTaskName(dep.targetName, allTasks);
121
121
  if (isResolverError(resolved)) {
122
- if (resolved.kind === 'ambiguous') {
123
- return fail(dep.lineNumber, `\`-> ${dep.targetName}\` — ${resolved.message}`);
124
- } else {
125
- return fail(dep.lineNumber, `\`-> ${dep.targetName}\` — ${resolved.message}`);
126
- }
122
+ warn(dep.lineNumber, `\`-> ${dep.targetName}\` — ${resolved.message}`);
123
+ continue;
127
124
  }
128
125
 
129
126
  // The dependency means: target starts after source
@@ -146,15 +143,34 @@ export function calculateSchedule(parsed: ParsedGantt): ResolvedSchedule {
146
143
 
147
144
  // ── Topological sort with cycle detection ───────────────
148
145
 
149
- const sortedIds = topologicalSort(taskMap);
146
+ let sortedIds = topologicalSort(taskMap);
150
147
  if (!sortedIds) {
151
- // Find cycle for error message
148
+ // Find cycle, warn, and break it by removing one explicit dep edge
152
149
  const cycle = findCycle(taskMap);
153
150
  const cycleStr = cycle.map(id => taskMap.get(id)!.task.label).join(' → ');
154
- return fail(
151
+ warn(
155
152
  taskMap.get(cycle[0])!.task.lineNumber,
156
- `Circular dependency detected: ${cycleStr}`,
153
+ `Circular dependency detected: ${cycleStr}. The cycle-creating dependency was dropped.`,
157
154
  );
155
+
156
+ // Remove the last edge in the cycle to break it
157
+ // (prefer removing explicit -> deps over implicit sequential ones)
158
+ breakCycle(cycle, taskMap, depOffsetMap);
159
+
160
+ // Retry — if still cyclic after breaking, keep breaking until resolved
161
+ sortedIds = topologicalSort(taskMap);
162
+ let safety = 10;
163
+ while (!sortedIds && safety-- > 0) {
164
+ const nextCycle = findCycle(taskMap);
165
+ if (nextCycle.length === 0) break;
166
+ breakCycle(nextCycle, taskMap, depOffsetMap);
167
+ sortedIds = topologicalSort(taskMap);
168
+ }
169
+
170
+ if (!sortedIds) {
171
+ // Truly unresolvable — fall back to task insertion order
172
+ sortedIds = [...taskMap.keys()];
173
+ }
158
174
  }
159
175
 
160
176
  // ── Forward pass: resolve dates ─────────────────────────
@@ -521,6 +537,30 @@ function findCycle(taskMap: Map<string, TaskNode>): string[] {
521
537
  }
522
538
  }
523
539
 
540
+ /**
541
+ * Break a cycle by removing the last edge. The cycle array is [A, B, ..., A]
542
+ * (starts and ends with the same node). We remove A's predecessor edge to
543
+ * the penultimate node, which breaks the cycle.
544
+ */
545
+ function breakCycle(
546
+ cycle: string[],
547
+ taskMap: Map<string, TaskNode>,
548
+ depOffsetMap: Map<string, Offset>,
549
+ ): void {
550
+ if (cycle.length < 3) return; // need at least [A, B, A]
551
+ // Remove the edge from second-to-last → first (i.e. the edge that closes the cycle)
552
+ const fromId = cycle[cycle.length - 2];
553
+ const toId = cycle[0];
554
+ const toNode = taskMap.get(toId);
555
+ if (toNode) {
556
+ const idx = toNode.predecessors.indexOf(fromId);
557
+ if (idx !== -1) {
558
+ toNode.predecessors.splice(idx, 1);
559
+ depOffsetMap.delete(`${fromId}->${toId}`);
560
+ }
561
+ }
562
+ }
563
+
524
564
  // ── Critical path ───────────────────────────────────────────
525
565
 
526
566
  function computeCriticalPath(
@@ -641,10 +681,10 @@ function buildResolvedGroups(
641
681
  if (!resolved?.startDate || !resolved?.endDate) continue;
642
682
  if (resolved.startDate.getTime() < minStart) minStart = resolved.startDate.getTime();
643
683
  if (resolved.endDate.getTime() > maxEnd) maxEnd = resolved.endDate.getTime();
684
+ const dur = resolved.endDate.getTime() - resolved.startDate.getTime();
685
+ totalDuration += dur;
644
686
  if (task.progress !== null) {
645
- const dur = resolved.endDate.getTime() - resolved.startDate.getTime();
646
687
  totalProgress += task.progress * dur;
647
- totalDuration += dur;
648
688
  hasProgress = true;
649
689
  }
650
690
  }
@@ -6,7 +6,7 @@ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
  import type { TagGroup, TagEntry } from '../utils/tag-groups';
8
8
  import { matchTagBlockHeading } from '../utils/tag-groups';
9
- import { measureIndent, extractColor, parsePipeMetadata } from '../utils/parsing';
9
+ import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from '../utils/parsing';
10
10
  import { parseOffset } from '../utils/duration';
11
11
  import type { PaletteColors } from '../palettes';
12
12
  import { resolveColor } from '../colors';
@@ -51,8 +51,8 @@ const COMMENT_RE = /^\/\//;
51
51
  /** Era: `era YYYY[-MM[-DD]] -> YYYY[-MM[-DD]]: Label (color?)` */
52
52
  const ERA_RE = /^era\s+(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*->\s*(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*:\s*(.+)$/i;
53
53
 
54
- /** Marker: `marker YYYY[-MM[-DD]]: Label (color?)` */
55
- const MARKER_RE = /^marker\s+(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*:\s*(.+)$/i;
54
+ /** Marker: `marker: YYYY[-MM[-DD]] Label (color?)` */
55
+ const MARKER_RE = /^marker:\s+(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s+(.+)$/i;
56
56
 
57
57
  /** Holiday date: `2024-01-15: Label` */
58
58
  const HOLIDAY_DATE_RE = /^(\d{4}-\d{2}-\d{2}):\s*(.+)$/;
@@ -107,6 +107,8 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
107
107
  dependencies: false,
108
108
  sort: 'default',
109
109
  defaultSwimlaneGroup: null,
110
+ optionLineNumbers: {},
111
+ holidaysLineNumber: null,
110
112
  },
111
113
  diagnostics,
112
114
  error: null,
@@ -123,6 +125,11 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
123
125
  diagnostics.push(makeDgmoError(line, message, 'warning'));
124
126
  };
125
127
 
128
+ /** Red squiggly but parsing continues — line is wrong, rest of chart is fine */
129
+ const softError = (line: number, message: string): void => {
130
+ diagnostics.push(makeDgmoError(line, message, 'error'));
131
+ };
132
+
126
133
  // ── Alias map for pipe metadata ─────────────────────────
127
134
 
128
135
  const aliasMap = new Map<string, string>();
@@ -300,10 +307,10 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
300
307
  let offset: Offset | undefined;
301
308
 
302
309
  if (depParts.length > 1) {
303
- const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap);
310
+ const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
304
311
  if (meta.lag || meta.lead) {
305
312
  const key = meta.lag ? 'lag' : 'lead';
306
- return fail(lineNumber, `Unknown keyword "${key}". Use "offset: ${meta[key]}" instead.`);
313
+ softError(lineNumber, `Unknown keyword "${key}". Use "offset: ${meta[key]}" instead.`);
307
314
  }
308
315
  if (meta.offset) {
309
316
  const raw = meta.offset;
@@ -346,6 +353,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
346
353
  inHolidaysBlock = true;
347
354
  holidaysBlockIndent = indent;
348
355
  inHeaderBlock = false;
356
+ result.options.holidaysLineNumber = lineNumber;
349
357
  continue;
350
358
  }
351
359
 
@@ -377,6 +385,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
377
385
  endDate: eraMatch[2],
378
386
  label: eraExtracted.label,
379
387
  color: eraExtracted.color || null,
388
+ lineNumber,
380
389
  });
381
390
  inHeaderBlock = false;
382
391
  continue;
@@ -402,6 +411,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
402
411
  if (optMatch && isKnownOption(optMatch[1].toLowerCase())) {
403
412
  const key = optMatch[1].toLowerCase();
404
413
  const value = optMatch[2].trim();
414
+ result.options.optionLineNumbers[key] = lineNumber;
405
415
 
406
416
  switch (key) {
407
417
  case 'start':
@@ -470,7 +480,8 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
470
480
  if (groupMatch) {
471
481
  // Validate nesting: group under a task is invalid
472
482
  if (blockStack.length > 0 && blockStack[blockStack.length - 1].containerType === 'task') {
473
- return fail(lineNumber, `Cannot nest a group inside a task. Groups must be inside other groups or parallel blocks.`);
483
+ softError(lineNumber, `Cannot nest a group inside a task. Groups must be inside other groups or parallel blocks.`);
484
+ continue;
474
485
  }
475
486
 
476
487
  const afterBrackets = groupMatch[2].trim();
@@ -480,11 +491,12 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
480
491
  let metadata: Record<string, string> = {};
481
492
  let color: string | null = null;
482
493
 
494
+ const pipeWarn = () => warn(lineNumber, MULTIPLE_PIPE_WARNING);
483
495
  if (segments.length > 0 && segments[0].trim()) {
484
496
  // Check if first segment after brackets is pipe metadata
485
- metadata = parsePipeMetadata(['', ...segments], aliasMap);
497
+ metadata = parsePipeMetadata(['', ...segments], aliasMap, pipeWarn);
486
498
  } else if (segments.length > 1) {
487
- metadata = parsePipeMetadata(['', ...segments.slice(1)], aliasMap);
499
+ metadata = parsePipeMetadata(['', ...segments.slice(1)], aliasMap, pipeWarn);
488
500
  }
489
501
 
490
502
  // Extract color from group name if present
@@ -522,7 +534,6 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
522
534
  const labelRaw = timelineDurMatch[5];
523
535
 
524
536
  const task = makeTask(labelRaw, { amount, unit }, uncertain, lineNumber, startDate);
525
- if (result.error) return result;
526
537
  const taskNode: GanttNode = { kind: 'task', ...task };
527
538
  currentContainer().push(taskNode);
528
539
  lastTaskNode = taskNode as GanttNode & { kind: 'task' };
@@ -540,7 +551,6 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
540
551
  const labelRaw = durMatch[4];
541
552
 
542
553
  const task = makeTask(labelRaw, { amount, unit }, uncertain, lineNumber);
543
- if (result.error) return result;
544
554
  const taskNode: GanttNode = { kind: 'task', ...task };
545
555
  currentContainer().push(taskNode);
546
556
  lastTaskNode = taskNode as GanttNode & { kind: 'task' };
@@ -559,7 +569,6 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
559
569
  lineNumber,
560
570
  explicitDateMatch[1],
561
571
  );
562
- if (result.error) return result;
563
572
  // Explicit date tasks with no duration are milestones
564
573
  const taskNode: GanttNode = { kind: 'task', ...task };
565
574
  currentContainer().push(taskNode);
@@ -574,7 +583,8 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
574
583
  if (depMatch) {
575
584
  // Dependency without a task context is an error
576
585
  if (!lastTaskNode) {
577
- return fail(lineNumber, `Dependency "-> ${depMatch[1]}" must be indented under a task.`);
586
+ softError(lineNumber, `Dependency "-> ${depMatch[1]}" must be indented under a task.`);
587
+ continue;
578
588
  }
579
589
  // This happens when the dep is at the same indent as the task
580
590
  const depParts = depMatch[1].split('|');
@@ -582,7 +592,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
582
592
  let offset: Offset | undefined;
583
593
 
584
594
  if (depParts.length > 1) {
585
- const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap);
595
+ const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
586
596
  if (meta.lag || meta.lead) {
587
597
  const key = meta.lag ? 'lag' : 'lead';
588
598
  warn(lineNumber, `"${key}" is deprecated — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
@@ -606,7 +616,8 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
606
616
 
607
617
  // ── Bare label = parse error ──────────────────────────
608
618
 
609
- return fail(lineNumber, `Expected duration (e.g., "10d: Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`);
619
+ softError(lineNumber, `Expected duration (e.g., "10d: Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`);
620
+ continue;
610
621
  }
611
622
 
612
623
  // ── Finalize ────────────────────────────────────────────
@@ -640,12 +651,12 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
640
651
 
641
652
  // Check for reserved keyword
642
653
  if (label.toLowerCase() === 'parallel') {
643
- fail(ln, `"parallel" is a reserved keyword and cannot be used as a task name.`);
654
+ softError(ln, `"parallel" is a reserved keyword and cannot be used as a task name.`);
644
655
  }
645
656
 
646
657
  // Parse pipe metadata
647
658
  const metadata = segments.length > 1
648
- ? parsePipeMetadata(segments, aliasMap)
659
+ ? parsePipeMetadata(segments, aliasMap, () => warn(ln, MULTIPLE_PIPE_WARNING))
649
660
  : {};
650
661
 
651
662
  // Extract progress from metadata or shorthand
@@ -654,9 +665,9 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
654
665
  progress = parseFloat(metadata.progress);
655
666
  delete metadata.progress;
656
667
  }
657
- // Check for progress shorthand: `| 80%`
658
- for (let j = 1; j < segments.length; j++) {
659
- const seg = segments[j].trim();
668
+ // Check for progress shorthand: `| 80%` or `| t:X, 80%`
669
+ for (const part of segments.slice(1).join(',').split(',')) {
670
+ const seg = part.trim();
660
671
  const progressMatch = seg.match(/^(\d+)%$/);
661
672
  if (progressMatch) {
662
673
  progress = parseInt(progressMatch[1], 10);
@@ -666,7 +677,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
666
677
  // Reject lag/lead — use offset instead
667
678
  if (metadata.lag || metadata.lead) {
668
679
  const key = metadata.lag ? 'lag' : 'lead';
669
- fail(ln, `Unknown keyword "${key}". Use "offset: ${metadata[key]}" instead.`);
680
+ softError(ln, `Unknown keyword "${key}". Use "offset: ${metadata[key]}" instead.`);
670
681
  }
671
682
 
672
683
  // Extract task-level offset from metadata