@diagrammo/dgmo 0.7.0 → 0.7.1

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.1",
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*(.+)$/;
@@ -123,6 +123,11 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
123
123
  diagnostics.push(makeDgmoError(line, message, 'warning'));
124
124
  };
125
125
 
126
+ /** Red squiggly but parsing continues — line is wrong, rest of chart is fine */
127
+ const softError = (line: number, message: string): void => {
128
+ diagnostics.push(makeDgmoError(line, message, 'error'));
129
+ };
130
+
126
131
  // ── Alias map for pipe metadata ─────────────────────────
127
132
 
128
133
  const aliasMap = new Map<string, string>();
@@ -300,10 +305,10 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
300
305
  let offset: Offset | undefined;
301
306
 
302
307
  if (depParts.length > 1) {
303
- const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap);
308
+ const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
304
309
  if (meta.lag || meta.lead) {
305
310
  const key = meta.lag ? 'lag' : 'lead';
306
- return fail(lineNumber, `Unknown keyword "${key}". Use "offset: ${meta[key]}" instead.`);
311
+ softError(lineNumber, `Unknown keyword "${key}". Use "offset: ${meta[key]}" instead.`);
307
312
  }
308
313
  if (meta.offset) {
309
314
  const raw = meta.offset;
@@ -470,7 +475,8 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
470
475
  if (groupMatch) {
471
476
  // Validate nesting: group under a task is invalid
472
477
  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.`);
478
+ softError(lineNumber, `Cannot nest a group inside a task. Groups must be inside other groups or parallel blocks.`);
479
+ continue;
474
480
  }
475
481
 
476
482
  const afterBrackets = groupMatch[2].trim();
@@ -480,11 +486,12 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
480
486
  let metadata: Record<string, string> = {};
481
487
  let color: string | null = null;
482
488
 
489
+ const pipeWarn = () => warn(lineNumber, MULTIPLE_PIPE_WARNING);
483
490
  if (segments.length > 0 && segments[0].trim()) {
484
491
  // Check if first segment after brackets is pipe metadata
485
- metadata = parsePipeMetadata(['', ...segments], aliasMap);
492
+ metadata = parsePipeMetadata(['', ...segments], aliasMap, pipeWarn);
486
493
  } else if (segments.length > 1) {
487
- metadata = parsePipeMetadata(['', ...segments.slice(1)], aliasMap);
494
+ metadata = parsePipeMetadata(['', ...segments.slice(1)], aliasMap, pipeWarn);
488
495
  }
489
496
 
490
497
  // Extract color from group name if present
@@ -522,7 +529,6 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
522
529
  const labelRaw = timelineDurMatch[5];
523
530
 
524
531
  const task = makeTask(labelRaw, { amount, unit }, uncertain, lineNumber, startDate);
525
- if (result.error) return result;
526
532
  const taskNode: GanttNode = { kind: 'task', ...task };
527
533
  currentContainer().push(taskNode);
528
534
  lastTaskNode = taskNode as GanttNode & { kind: 'task' };
@@ -540,7 +546,6 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
540
546
  const labelRaw = durMatch[4];
541
547
 
542
548
  const task = makeTask(labelRaw, { amount, unit }, uncertain, lineNumber);
543
- if (result.error) return result;
544
549
  const taskNode: GanttNode = { kind: 'task', ...task };
545
550
  currentContainer().push(taskNode);
546
551
  lastTaskNode = taskNode as GanttNode & { kind: 'task' };
@@ -559,7 +564,6 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
559
564
  lineNumber,
560
565
  explicitDateMatch[1],
561
566
  );
562
- if (result.error) return result;
563
567
  // Explicit date tasks with no duration are milestones
564
568
  const taskNode: GanttNode = { kind: 'task', ...task };
565
569
  currentContainer().push(taskNode);
@@ -574,7 +578,8 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
574
578
  if (depMatch) {
575
579
  // Dependency without a task context is an error
576
580
  if (!lastTaskNode) {
577
- return fail(lineNumber, `Dependency "-> ${depMatch[1]}" must be indented under a task.`);
581
+ softError(lineNumber, `Dependency "-> ${depMatch[1]}" must be indented under a task.`);
582
+ continue;
578
583
  }
579
584
  // This happens when the dep is at the same indent as the task
580
585
  const depParts = depMatch[1].split('|');
@@ -582,7 +587,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
582
587
  let offset: Offset | undefined;
583
588
 
584
589
  if (depParts.length > 1) {
585
- const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap);
590
+ const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
586
591
  if (meta.lag || meta.lead) {
587
592
  const key = meta.lag ? 'lag' : 'lead';
588
593
  warn(lineNumber, `"${key}" is deprecated — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
@@ -606,7 +611,8 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
606
611
 
607
612
  // ── Bare label = parse error ──────────────────────────
608
613
 
609
- return fail(lineNumber, `Expected duration (e.g., "10d: Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`);
614
+ softError(lineNumber, `Expected duration (e.g., "10d: Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`);
615
+ continue;
610
616
  }
611
617
 
612
618
  // ── Finalize ────────────────────────────────────────────
@@ -640,12 +646,12 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
640
646
 
641
647
  // Check for reserved keyword
642
648
  if (label.toLowerCase() === 'parallel') {
643
- fail(ln, `"parallel" is a reserved keyword and cannot be used as a task name.`);
649
+ softError(ln, `"parallel" is a reserved keyword and cannot be used as a task name.`);
644
650
  }
645
651
 
646
652
  // Parse pipe metadata
647
653
  const metadata = segments.length > 1
648
- ? parsePipeMetadata(segments, aliasMap)
654
+ ? parsePipeMetadata(segments, aliasMap, () => warn(ln, MULTIPLE_PIPE_WARNING))
649
655
  : {};
650
656
 
651
657
  // Extract progress from metadata or shorthand
@@ -654,9 +660,9 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
654
660
  progress = parseFloat(metadata.progress);
655
661
  delete metadata.progress;
656
662
  }
657
- // Check for progress shorthand: `| 80%`
658
- for (let j = 1; j < segments.length; j++) {
659
- const seg = segments[j].trim();
663
+ // Check for progress shorthand: `| 80%` or `| t:X, 80%`
664
+ for (const part of segments.slice(1).join(',').split(',')) {
665
+ const seg = part.trim();
660
666
  const progressMatch = seg.match(/^(\d+)%$/);
661
667
  if (progressMatch) {
662
668
  progress = parseInt(progressMatch[1], 10);
@@ -666,7 +672,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
666
672
  // Reject lag/lead — use offset instead
667
673
  if (metadata.lag || metadata.lead) {
668
674
  const key = metadata.lag ? 'lag' : 'lead';
669
- fail(ln, `Unknown keyword "${key}". Use "offset: ${metadata[key]}" instead.`);
675
+ softError(ln, `Unknown keyword "${key}". Use "offset: ${metadata[key]}" instead.`);
670
676
  }
671
677
 
672
678
  // Extract task-level offset from metadata