@diagrammo/dgmo 0.6.2 → 0.7.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 (61) hide show
  1. package/.claude/commands/dgmo.md +231 -13
  2. package/AGENTS.md +148 -0
  3. package/dist/cli.cjs +341 -165
  4. package/dist/index.cjs +4900 -1685
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +259 -18
  7. package/dist/index.d.ts +259 -18
  8. package/dist/index.js +4642 -1436
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -3
  11. package/src/c4/layout.ts +0 -5
  12. package/src/c4/parser.ts +0 -16
  13. package/src/c4/renderer.ts +7 -11
  14. package/src/class/layout.ts +0 -1
  15. package/src/class/parser.ts +28 -0
  16. package/src/class/renderer.ts +189 -34
  17. package/src/cli.ts +566 -25
  18. package/src/colors.ts +3 -3
  19. package/src/completion.ts +58 -0
  20. package/src/d3.ts +179 -122
  21. package/src/dgmo-router.ts +3 -58
  22. package/src/echarts.ts +96 -55
  23. package/src/er/parser.ts +30 -1
  24. package/src/er/renderer.ts +12 -7
  25. package/src/gantt/calculator.ts +677 -0
  26. package/src/gantt/parser.ts +761 -0
  27. package/src/gantt/renderer.ts +2125 -0
  28. package/src/gantt/resolver.ts +144 -0
  29. package/src/gantt/types.ts +168 -0
  30. package/src/graph/flowchart-parser.ts +27 -4
  31. package/src/graph/flowchart-renderer.ts +1 -2
  32. package/src/graph/state-parser.ts +0 -1
  33. package/src/graph/state-renderer.ts +1 -3
  34. package/src/index.ts +37 -0
  35. package/src/infra/compute.ts +0 -7
  36. package/src/infra/layout.ts +0 -2
  37. package/src/infra/parser.ts +46 -4
  38. package/src/infra/renderer.ts +49 -27
  39. package/src/initiative-status/filter.ts +63 -0
  40. package/src/initiative-status/layout.ts +319 -67
  41. package/src/initiative-status/parser.ts +200 -25
  42. package/src/initiative-status/renderer.ts +298 -35
  43. package/src/initiative-status/types.ts +6 -0
  44. package/src/kanban/parser.ts +0 -2
  45. package/src/org/layout.ts +22 -59
  46. package/src/org/renderer.ts +11 -36
  47. package/src/palettes/dracula.ts +60 -0
  48. package/src/palettes/index.ts +8 -6
  49. package/src/palettes/monokai.ts +60 -0
  50. package/src/palettes/registry.ts +4 -2
  51. package/src/sequence/parser.ts +14 -11
  52. package/src/sequence/renderer.ts +5 -6
  53. package/src/sequence/tag-resolution.ts +0 -1
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/layout.ts +1 -14
  56. package/src/sitemap/parser.ts +1 -2
  57. package/src/sitemap/renderer.ts +4 -7
  58. package/src/utils/arrows.ts +7 -7
  59. package/src/utils/duration.ts +212 -0
  60. package/src/utils/export-container.ts +40 -0
  61. package/src/utils/legend-constants.ts +1 -0
@@ -13,6 +13,9 @@ import type {
13
13
  } from './types';
14
14
  import { VALID_STATUSES } from './types';
15
15
  import { inferParticipantType } from '../sequence/participant-inference';
16
+ import { matchTagBlockHeading, injectDefaultTagMetadata, validateTagValues } from '../utils/tag-groups';
17
+ import type { TagGroup } from '../utils/tag-groups';
18
+ import { extractColor } from '../utils/parsing';
16
19
 
17
20
  // ============================================================
18
21
  // Heuristic — does this content look like an initiative-status diagram?
@@ -42,6 +45,62 @@ export function looksLikeInitiativeStatus(content: string): boolean {
42
45
  return hasIndentedArrow;
43
46
  }
44
47
 
48
+ // ============================================================
49
+ // Metadata parser — splits comma-delimited segment into status + tags
50
+ // ============================================================
51
+
52
+ /**
53
+ * Parse the metadata segment after a `|` pipe into a status keyword
54
+ * and key:value tag pairs. Does NOT use parsePipeMetadata() from
55
+ * parsing.ts — that utility drops bare words (no colon), making it
56
+ * incompatible with status keyword extraction.
57
+ *
58
+ * @param segment The raw text after `|` — e.g. `"wip, p: Build, t: Backend"`
59
+ * @param aliasMap Maps lowercase aliases to lowercase group names
60
+ * @param lineNum Line number for diagnostic reporting
61
+ * @param diagnostics Array to push warnings into
62
+ */
63
+ export function parseNodeMetadata(
64
+ segment: string,
65
+ aliasMap: Map<string, string>,
66
+ lineNum?: number,
67
+ diagnostics?: DgmoError[]
68
+ ): { status: InitiativeStatus; metadata: Record<string, string>; hadStatusWord: boolean } {
69
+ const metadata: Record<string, string> = {};
70
+ let status: InitiativeStatus = null;
71
+ let hadStatusWord = false;
72
+
73
+ const items = segment.split(',');
74
+ for (const item of items) {
75
+ const trimmed = item.trim();
76
+ if (!trimmed) continue;
77
+
78
+ const colonIdx = trimmed.indexOf(':');
79
+ if (colonIdx >= 0) {
80
+ // key: value pair
81
+ const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
82
+ const value = trimmed.slice(colonIdx + 1).trim();
83
+ // Resolve alias to group name
84
+ const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
85
+ metadata[resolvedKey] = value;
86
+ } else {
87
+ // Bare word — check if it's a status keyword
88
+ hadStatusWord = true;
89
+ const lower = trimmed.toLowerCase();
90
+ if (VALID_STATUSES.includes(lower)) {
91
+ status = lower as InitiativeStatus;
92
+ } else if (lineNum !== undefined && diagnostics) {
93
+ // Unknown bare word — likely a status typo, emit warning
94
+ const hint = suggest(lower, VALID_STATUSES);
95
+ const msg = `Unknown status "${trimmed}"${hint ? `. ${hint}` : ''}`;
96
+ diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
97
+ }
98
+ }
99
+ }
100
+
101
+ return { status, metadata, hadStatusWord };
102
+ }
103
+
45
104
  // ============================================================
46
105
  // Parser
47
106
  // ============================================================
@@ -58,6 +117,17 @@ function parseStatus(raw: string, line: number, diagnostics: DgmoError[]): Initi
58
117
  return null;
59
118
  }
60
119
 
120
+ /** Measure leading whitespace (tabs = 4 spaces) */
121
+ function measureIndent(line: string): number {
122
+ let count = 0;
123
+ for (const ch of line) {
124
+ if (ch === ' ') count++;
125
+ else if (ch === '\t') count += 4;
126
+ else break;
127
+ }
128
+ return count;
129
+ }
130
+
61
131
  export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
62
132
  const result: ParsedInitiativeStatus = {
63
133
  type: 'initiative-status',
@@ -66,7 +136,9 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
66
136
  nodes: [],
67
137
  edges: [],
68
138
  groups: [],
139
+ tagGroups: [],
69
140
  options: {},
141
+ initialHiddenTagValues: new Map(),
70
142
  diagnostics: [],
71
143
  error: null,
72
144
  };
@@ -76,6 +148,15 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
76
148
  let currentGroup: ISGroup | null = null;
77
149
  let lastNodeLabel: string | null = null;
78
150
 
151
+ // Tag block state
152
+ let contentStarted = false;
153
+ let currentTagGroup: TagGroup | null = null;
154
+ const aliasMap = new Map<string, string>(); // lowercase alias → lowercase group name
155
+
156
+ const pushWarning = (lineNumber: number, message: string) => {
157
+ result.diagnostics.push(makeDgmoError(lineNumber, message, 'warning'));
158
+ };
159
+
79
160
  for (let i = 0; i < lines.length; i++) {
80
161
  const lineNum = i + 1; // 1-based
81
162
  const raw = lines[i];
@@ -105,9 +186,79 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
105
186
  continue;
106
187
  }
107
188
 
189
+ // hide: directive — parse before tag blocks and content
190
+ const hideMatch = trimmed.match(/^hide\s*:\s*(.+)/i);
191
+ if (hideMatch) {
192
+ const pairs = hideMatch[1].split(',');
193
+ for (const pair of pairs) {
194
+ const colonIdx = pair.indexOf(':');
195
+ if (colonIdx >= 0) {
196
+ const groupKey = pair.slice(0, colonIdx).trim().toLowerCase();
197
+ const value = pair.slice(colonIdx + 1).trim().toLowerCase();
198
+ if (groupKey && value) {
199
+ if (!result.initialHiddenTagValues.has(groupKey)) {
200
+ result.initialHiddenTagValues.set(groupKey, new Set());
201
+ }
202
+ result.initialHiddenTagValues.get(groupKey)!.add(value);
203
+ }
204
+ }
205
+ }
206
+ continue;
207
+ }
208
+
209
+ // Tag group heading — must be checked BEFORE group/node/edge matching
210
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
211
+ if (tagBlockMatch) {
212
+ if (contentStarted) {
213
+ result.diagnostics.push(
214
+ makeDgmoError(lineNum, 'Tag groups must appear before diagram content', 'error')
215
+ );
216
+ continue;
217
+ }
218
+ if (tagBlockMatch.deprecated) {
219
+ pushWarning(lineNum, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`);
220
+ }
221
+ currentTagGroup = {
222
+ name: tagBlockMatch.name,
223
+ alias: tagBlockMatch.alias,
224
+ entries: [],
225
+ lineNumber: lineNum,
226
+ };
227
+ if (tagBlockMatch.alias) {
228
+ aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
229
+ }
230
+ result.tagGroups.push(currentTagGroup);
231
+ continue;
232
+ }
233
+
234
+ // Tag group entries (indented Value(color) [default] under tag heading)
235
+ if (currentTagGroup && !contentStarted) {
236
+ const indent = measureIndent(raw);
237
+ if (indent > 0) {
238
+ const isDefault = /\bdefault\s*$/i.test(trimmed);
239
+ const entryText = isDefault
240
+ ? trimmed.replace(/\s+default\s*$/i, '').trim()
241
+ : trimmed;
242
+ const { label, color } = extractColor(entryText);
243
+ if (isDefault) {
244
+ currentTagGroup.defaultValue = label;
245
+ }
246
+ currentTagGroup.entries.push({
247
+ value: label,
248
+ color: color ?? '',
249
+ lineNumber: lineNum,
250
+ });
251
+ continue;
252
+ }
253
+ // Non-indented line after tag group — close and fall through
254
+ currentTagGroup = null;
255
+ }
256
+
108
257
  // Group header: [Group Name]
109
258
  const groupMatch = trimmed.match(/^\[(.+)\]\s*$/);
110
259
  if (groupMatch) {
260
+ contentStarted = true;
261
+ currentTagGroup = null;
111
262
  // Close previous group
112
263
  if (currentGroup) {
113
264
  result.groups.push(currentGroup);
@@ -125,6 +276,8 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
125
276
 
126
277
  // Edge: contains `->` or labeled form `-label->`
127
278
  if (trimmed.includes('->')) {
279
+ contentStarted = true;
280
+ currentTagGroup = null;
128
281
  let edgeText = trimmed;
129
282
  // Indented `-> Target` or `-label-> Target` shorthand
130
283
  if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
@@ -136,13 +289,15 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
136
289
  }
137
290
  edgeText = `${lastNodeLabel} ${trimmed}`;
138
291
  }
139
- const edge = parseEdgeLine(edgeText, lineNum, result.diagnostics);
292
+ const edge = parseEdgeLine(edgeText, lineNum, aliasMap, result.diagnostics);
140
293
  if (edge) result.edges.push(edge);
141
294
  continue;
142
295
  }
143
296
 
144
297
  // Node: everything else
145
- const node = parseNodeLine(trimmed, lineNum, result.diagnostics);
298
+ contentStarted = true;
299
+ currentTagGroup = null;
300
+ const node = parseNodeLine(trimmed, lineNum, aliasMap, result.diagnostics);
146
301
  if (node) {
147
302
  lastNodeLabel = node.label;
148
303
  if (nodeLabels.has(node.label)) {
@@ -173,7 +328,7 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
173
328
  );
174
329
  // Auto-create an implicit node
175
330
  if (!result.nodes.some((n) => n.label === edge.source)) {
176
- result.nodes.push({ label: edge.source, status: 'na', shape: inferParticipantType(edge.source), lineNumber: edge.lineNumber });
331
+ result.nodes.push({ label: edge.source, status: 'na', shape: inferParticipantType(edge.source), lineNumber: edge.lineNumber, metadata: {} });
177
332
  nodeLabels.add(edge.source);
178
333
  }
179
334
  }
@@ -182,12 +337,18 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
182
337
  makeDgmoError(edge.lineNumber, `Edge target "${edge.target}" is not a declared node`, 'warning')
183
338
  );
184
339
  if (!result.nodes.some((n) => n.label === edge.target)) {
185
- result.nodes.push({ label: edge.target, status: 'na', shape: inferParticipantType(edge.target), lineNumber: edge.lineNumber });
340
+ result.nodes.push({ label: edge.target, status: 'na', shape: inferParticipantType(edge.target), lineNumber: edge.lineNumber, metadata: {} });
186
341
  nodeLabels.add(edge.target);
187
342
  }
188
343
  }
189
344
  }
190
345
 
346
+ // Post-parse: inject default tag metadata and validate tag values
347
+ if (result.tagGroups.length > 0) {
348
+ injectDefaultTagMetadata(result.nodes, result.tagGroups);
349
+ validateTagValues(result.nodes, result.tagGroups, pushWarning, suggest);
350
+ }
351
+
191
352
  return result;
192
353
  }
193
354
 
@@ -198,27 +359,36 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
198
359
  function parseNodeLine(
199
360
  trimmed: string,
200
361
  lineNum: number,
362
+ aliasMap: Map<string, string>,
201
363
  diagnostics: DgmoError[]
202
364
  ): ISNode | null {
203
- // Format: <label> | <status>
365
+ // Format: <label> | <status>, <key: value>, ...
204
366
  // or just: <label>
205
- const pipeIdx = trimmed.lastIndexOf('|');
367
+ const pipeIdx = trimmed.indexOf('|');
206
368
  if (pipeIdx >= 0) {
207
369
  const label = trimmed.slice(0, pipeIdx).trim();
208
- const statusRaw = trimmed.slice(pipeIdx + 1).trim();
370
+ const metaSegment = trimmed.slice(pipeIdx + 1).trim();
209
371
  if (!label) return null;
210
- const status = parseStatus(statusRaw, lineNum, diagnostics);
211
- return { label, status, shape: inferParticipantType(label), lineNumber: lineNum };
372
+ const { status, metadata, hadStatusWord } = parseNodeMetadata(metaSegment, aliasMap, lineNum, diagnostics);
373
+ return {
374
+ label,
375
+ // Unknown status bare word → keep null; no bare word at all → default 'na'
376
+ status: hadStatusWord ? status : (status ?? 'na'),
377
+ shape: inferParticipantType(label),
378
+ lineNumber: lineNum,
379
+ metadata,
380
+ };
212
381
  }
213
- return { label: trimmed, status: 'na', shape: inferParticipantType(trimmed), lineNumber: lineNum };
382
+ return { label: trimmed, status: 'na', shape: inferParticipantType(trimmed), lineNumber: lineNum, metadata: {} };
214
383
  }
215
384
 
216
385
  function parseEdgeLine(
217
386
  trimmed: string,
218
387
  lineNum: number,
388
+ aliasMap: Map<string, string>,
219
389
  diagnostics: DgmoError[]
220
390
  ): ISEdge | null {
221
- // Format: <source> -> <target>: <label> | <status>
391
+ // Format: <source> -> <target>: <label> | <status>, <key: value>, ...
222
392
  // or: <source> -> <target> | <status>
223
393
  // or: <source> -> <target>: <label>
224
394
  // or: <source> -> <target>
@@ -232,13 +402,15 @@ function parseEdgeLine(
232
402
  let targetRest = labeledMatch[3].trim();
233
403
 
234
404
  if (label) {
235
- // Extract status from end (after last |)
236
405
  let status: InitiativeStatus = 'na';
237
- const lastPipe = targetRest.lastIndexOf('|');
238
- if (lastPipe >= 0) {
239
- const statusRaw = targetRest.slice(lastPipe + 1).trim();
240
- status = parseStatus(statusRaw, lineNum, diagnostics);
241
- targetRest = targetRest.slice(0, lastPipe).trim();
406
+ let metadata: Record<string, string> = {};
407
+ const pipeIdx = targetRest.indexOf('|');
408
+ if (pipeIdx >= 0) {
409
+ const metaSegment = targetRest.slice(pipeIdx + 1).trim();
410
+ const parsed = parseNodeMetadata(metaSegment, aliasMap, lineNum, diagnostics);
411
+ status = parsed.hadStatusWord ? (parsed.status ?? null) : (parsed.status ?? 'na');
412
+ metadata = parsed.metadata;
413
+ targetRest = targetRest.slice(0, pipeIdx).trim();
242
414
  }
243
415
 
244
416
  const target = targetRest.trim();
@@ -247,7 +419,7 @@ function parseEdgeLine(
247
419
  return null;
248
420
  }
249
421
 
250
- return { source, target, label, status, lineNumber: lineNum };
422
+ return { source, target, label, status, lineNumber: lineNum, metadata };
251
423
  }
252
424
  // Empty label — fall through to plain arrow parsing
253
425
  }
@@ -263,13 +435,16 @@ function parseEdgeLine(
263
435
  return null;
264
436
  }
265
437
 
266
- // Extract status from end (after last |)
438
+ // Extract metadata from end (after |)
267
439
  let status: InitiativeStatus = 'na';
268
- const lastPipe = rest.lastIndexOf('|');
269
- if (lastPipe >= 0) {
270
- const statusRaw = rest.slice(lastPipe + 1).trim();
271
- status = parseStatus(statusRaw, lineNum, diagnostics);
272
- rest = rest.slice(0, lastPipe).trim();
440
+ let metadata: Record<string, string> = {};
441
+ const pipeIdx = rest.indexOf('|');
442
+ if (pipeIdx >= 0) {
443
+ const metaSegment = rest.slice(pipeIdx + 1).trim();
444
+ const parsed = parseNodeMetadata(metaSegment, aliasMap, lineNum, diagnostics);
445
+ status = parsed.hadStatusWord ? (parsed.status ?? null) : (parsed.status ?? 'na');
446
+ metadata = parsed.metadata;
447
+ rest = rest.slice(0, pipeIdx).trim();
273
448
  }
274
449
 
275
450
  // Extract target and optional label (target: label)
@@ -288,5 +463,5 @@ function parseEdgeLine(
288
463
  return null;
289
464
  }
290
465
 
291
- return { source, target, label, status, lineNumber: lineNum };
466
+ return { source, target, label, status, lineNumber: lineNum, metadata };
292
467
  }