@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/completion.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  /**
2
- * Diagram symbol extraction API.
2
+ * Diagram symbol extraction API + completion registry.
3
+ *
4
+ * Provides:
5
+ * - DiagramSymbols interface + extractDiagramSymbols() dispatch
6
+ * - COMPLETION_REGISTRY: chart-type → directives map (for editor autocomplete)
7
+ * - CHART_TYPES: array of { name, description } for chart type completion
8
+ * - METADATA_KEY_SET: derived set of all known directive keys
3
9
  *
4
- * Provides DiagramSymbols interface + extractDiagramSymbols() dispatch.
5
10
  * Each diagram type registers its own extractor via registerExtractor().
6
11
  * All built-in extractors are registered at module init below.
7
12
  */
@@ -10,6 +15,11 @@ import { extractSymbols as extractErSymbols } from './er/parser';
10
15
  import { extractSymbols as extractFlowchartSymbols } from './graph/flowchart-parser';
11
16
  import { extractSymbols as extractInfraSymbols } from './infra/parser';
12
17
  import { extractSymbols as extractClassSymbols } from './class/parser';
18
+ import { parseFirstLine, ALL_CHART_TYPES } from './utils/parsing';
19
+
20
+ // ============================================================
21
+ // Symbol extraction
22
+ // ============================================================
13
23
 
14
24
  // ChartType is just a string — alias here for documentation clarity.
15
25
  export type ChartType = string;
@@ -22,10 +32,10 @@ export interface DiagramSymbols {
22
32
 
23
33
  export type ExtractFn = (docText: string) => DiagramSymbols;
24
34
 
25
- const registry = new Map<ChartType, ExtractFn>();
35
+ const extractorRegistry = new Map<ChartType, ExtractFn>();
26
36
 
27
37
  export function registerExtractor(kind: ChartType, fn: ExtractFn): void {
28
- registry.set(kind, fn);
38
+ extractorRegistry.set(kind, fn);
29
39
  }
30
40
 
31
41
  /**
@@ -33,21 +43,752 @@ export function registerExtractor(kind: ChartType, fn: ExtractFn): void {
33
43
  * Returns null if the chart type is unknown or has no registered extractor.
34
44
  */
35
45
  export function extractDiagramSymbols(docText: string): DiagramSymbols | null {
36
- // Parse chartType from first `chart:` line — lightweight, no full parser.
46
+ // Parse chartType from first line — supports bare type name and old `chart:` syntax.
37
47
  let chartType: string | null = null;
38
48
  for (const line of docText.split('\n')) {
39
- const m = line.match(/^\s*chart\s*:\s*(.+)/i);
40
- if (m) {
41
- chartType = m[1]!.trim().toLowerCase();
42
- break;
49
+ const trimmed = line.trim();
50
+ if (!trimmed || trimmed.startsWith('//')) continue;
51
+ const result = parseFirstLine(trimmed);
52
+ if (result) {
53
+ chartType = result.chartType;
43
54
  }
55
+ break; // only check the first non-empty, non-comment line
44
56
  }
45
57
  if (!chartType) return null;
46
- const fn = registry.get(chartType);
58
+ const fn = extractorRegistry.get(chartType);
47
59
  if (!fn) return null;
48
60
  return fn(docText);
49
61
  }
50
62
 
63
+ // ============================================================
64
+ // Completion registry
65
+ // ============================================================
66
+
67
+ /** Specification for a single directive: description + optional enumerated values. */
68
+ export interface DirectiveValueSpec {
69
+ description: string;
70
+ values?: string[];
71
+ }
72
+
73
+ /** Specification for a chart type's directives. */
74
+ export interface DirectiveSpec {
75
+ directives: Record<string, DirectiveValueSpec>;
76
+ }
77
+
78
+ // Global directives applied to every chart type
79
+ const GLOBAL_DIRECTIVES: Record<string, DirectiveValueSpec> = {
80
+ palette: {
81
+ description: 'Color palette name',
82
+ values: ['nord', 'solarized', 'catppuccin', 'rose-pine', 'gruvbox', 'tokyo-night', 'one-dark', 'bold', 'dracula', 'monokai'],
83
+ },
84
+ theme: {
85
+ description: 'Color theme',
86
+ values: ['light', 'dark', 'transparent'],
87
+ },
88
+ };
89
+
90
+ function withGlobals(directives: Record<string, DirectiveValueSpec> = {}): DirectiveSpec {
91
+ return { directives: { ...GLOBAL_DIRECTIVES, ...directives } };
92
+ }
93
+
94
+ /** Chart-type → directive specifications. Every chart type has at least palette + theme. */
95
+ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
96
+ // ── Data charts ──────────────────────────────────────────
97
+ ['bar', withGlobals({
98
+ series: { description: 'Series name(s)' },
99
+ xlabel: { description: 'X-axis label' },
100
+ ylabel: { description: 'Y-axis label' },
101
+ orientation: { description: 'Layout direction', values: ['horizontal', 'vertical'] },
102
+ labels: { description: 'Label format', values: ['name', 'value', 'percent', 'full'] },
103
+ color: { description: 'Bar color override' },
104
+ })],
105
+ ['line', withGlobals({
106
+ series: { description: 'Series name(s)' },
107
+ xlabel: { description: 'X-axis label' },
108
+ ylabel: { description: 'Y-axis label' },
109
+ labels: { description: 'Label format', values: ['name', 'value', 'percent', 'full'] },
110
+ })],
111
+ ['pie', withGlobals({
112
+ labels: { description: 'Label format', values: ['name', 'value', 'percent', 'full'] },
113
+ })],
114
+ ['doughnut', withGlobals({
115
+ labels: { description: 'Label format', values: ['name', 'value', 'percent', 'full'] },
116
+ })],
117
+ ['area', withGlobals({
118
+ series: { description: 'Series name(s)' },
119
+ xlabel: { description: 'X-axis label' },
120
+ ylabel: { description: 'Y-axis label' },
121
+ labels: { description: 'Label format', values: ['name', 'value', 'percent', 'full'] },
122
+ })],
123
+ ['polar-area', withGlobals({
124
+ labels: { description: 'Label format', values: ['name', 'value', 'percent', 'full'] },
125
+ })],
126
+ ['radar', withGlobals()],
127
+ ['bar-stacked', withGlobals({
128
+ series: { description: 'Series name(s) (required)' },
129
+ xlabel: { description: 'X-axis label' },
130
+ ylabel: { description: 'Y-axis label' },
131
+ orientation: { description: 'Layout direction', values: ['horizontal', 'vertical'] },
132
+ })],
133
+
134
+ // ── Extended charts ──────────────────────────────────────
135
+ ['scatter', withGlobals({
136
+ labels: { description: 'Show labels', values: ['on', 'off'] },
137
+ xlabel: { description: 'X-axis label' },
138
+ ylabel: { description: 'Y-axis label' },
139
+ sizelabel: { description: 'Size axis label' },
140
+ })],
141
+ ['heatmap', withGlobals({
142
+ columns: { description: 'Column labels (required)' },
143
+ })],
144
+ ['sankey', withGlobals()],
145
+ ['chord', withGlobals()],
146
+ ['funnel', withGlobals()],
147
+ ['function', withGlobals({
148
+ x: { description: 'X-axis range (start to end)' },
149
+ xlabel: { description: 'X-axis label' },
150
+ ylabel: { description: 'Y-axis label' },
151
+ })],
152
+
153
+ // ── Visualizations ───────────────────────────────────────
154
+ ['slope', withGlobals({
155
+ orientation: { description: 'Layout direction', values: ['horizontal', 'vertical'] },
156
+ })],
157
+ ['wordcloud', withGlobals({
158
+ rotate: { description: 'Word rotation', values: ['none', 'mixed', 'angled'] },
159
+ max: { description: 'Maximum word count' },
160
+ size: { description: 'Font size range (min, max)' },
161
+ })],
162
+ ['arc', withGlobals({
163
+ order: { description: 'Node ordering', values: ['appearance', 'name', 'group', 'degree'] },
164
+ orientation: { description: 'Layout direction' },
165
+ })],
166
+ ['timeline', withGlobals({
167
+ scale: { description: 'Show time scale', values: ['on', 'off'] },
168
+ sort: { description: 'Sort order', values: ['time', 'group', 'tag'] },
169
+ swimlanes: { description: 'Show swimlanes', values: ['on', 'off'] },
170
+ })],
171
+ ['venn', withGlobals({
172
+ values: { description: 'Show values', values: ['on', 'off'] },
173
+ })],
174
+ ['quadrant', withGlobals({
175
+ 'x-axis': { description: 'X-axis labels (low, high)' },
176
+ 'y-axis': { description: 'Y-axis labels (low, high)' },
177
+ })],
178
+
179
+ // ── Diagrams ─────────────────────────────────────────────
180
+ ['sequence', withGlobals({
181
+ activations: { description: 'Show activation bars', values: ['on', 'off'] },
182
+ 'collapse-notes': { description: 'Collapse note blocks', values: ['yes', 'no'] },
183
+ 'active-tag': { description: 'Active tag group name' },
184
+ })],
185
+ ['flowchart', withGlobals()],
186
+ ['class', withGlobals()],
187
+ ['er', withGlobals()],
188
+ ['org', withGlobals({
189
+ 'sub-node-label': { description: 'Label for sub-nodes' },
190
+ 'show-sub-node-count': { description: 'Show sub-node counts' },
191
+ })],
192
+ ['kanban', withGlobals()],
193
+ ['c4', withGlobals()],
194
+ ['initiative-status', withGlobals()],
195
+ ['state', withGlobals({
196
+ direction: { description: 'Layout direction', values: ['TB', 'LR'] },
197
+ color: { description: 'Color mode', values: ['off'] },
198
+ })],
199
+ ['sitemap', withGlobals({
200
+ direction: { description: 'Layout direction', values: ['TB', 'LR'] },
201
+ })],
202
+ ['infra', withGlobals({
203
+ direction: { description: 'Layout direction', values: ['LR', 'TB'] },
204
+ 'default-latency-ms': { description: 'Default latency for all nodes' },
205
+ 'default-uptime': { description: 'Default uptime for all nodes' },
206
+ 'default-rps': { description: 'Default RPS capacity for all nodes' },
207
+ 'slo-availability': { description: 'SLO availability target (0-1)' },
208
+ 'slo-p90-latency-ms': { description: 'SLO p90 latency target in ms' },
209
+ 'slo-warning-margin': { description: 'SLO warning margin percentage' },
210
+ })],
211
+ ['gantt', withGlobals({
212
+ start: { description: 'Project start date (YYYY-MM-DD)' },
213
+ 'today-marker': { description: 'Today marker (bare = on, or YYYY-MM-DD date)' },
214
+ sort: { description: 'Sort order', values: ['time', 'group', 'tag'] },
215
+ 'critical-path': { description: 'Show critical path' },
216
+ dependencies: { description: 'Show dependencies' },
217
+ })],
218
+ ]);
219
+
220
+ // ============================================================
221
+ // Chart types array (for chart type completion popup)
222
+ // ============================================================
223
+
224
+ const CHART_TYPE_DESCRIPTIONS: Record<string, string> = {
225
+ // Data charts
226
+ bar: 'Bar chart',
227
+ line: 'Line chart',
228
+ pie: 'Pie chart',
229
+ doughnut: 'Doughnut chart',
230
+ area: 'Area chart',
231
+ 'polar-area': 'Polar area chart',
232
+ radar: 'Radar chart',
233
+ 'bar-stacked': 'Stacked bar chart',
234
+ // Extended charts
235
+ scatter: 'Scatter plot',
236
+ heatmap: 'Heatmap',
237
+ sankey: 'Sankey flow diagram',
238
+ chord: 'Chord diagram',
239
+ funnel: 'Funnel chart',
240
+ function: 'Mathematical function plot',
241
+ // Visualizations
242
+ slope: 'Slope chart',
243
+ wordcloud: 'Word cloud',
244
+ arc: 'Arc diagram',
245
+ timeline: 'Timeline',
246
+ venn: 'Venn diagram',
247
+ quadrant: 'Quadrant chart',
248
+ // Diagrams
249
+ sequence: 'Sequence diagram',
250
+ flowchart: 'Flowchart',
251
+ class: 'Class diagram',
252
+ er: 'Entity-relationship diagram',
253
+ org: 'Organization chart',
254
+ kanban: 'Kanban board',
255
+ c4: 'C4 architecture diagram',
256
+ 'initiative-status': 'Initiative status diagram',
257
+ state: 'State diagram',
258
+ sitemap: 'Sitemap diagram',
259
+ infra: 'Infrastructure diagram',
260
+ gantt: 'Gantt chart',
261
+ };
262
+
263
+ /** All chart types with descriptions, for chart type autocomplete. Excludes `multi-line` alias. */
264
+ export const CHART_TYPES: ReadonlyArray<{ name: string; description: string }> = [...ALL_CHART_TYPES]
265
+ .filter(t => t !== 'multi-line')
266
+ .map(name => ({
267
+ name,
268
+ description: CHART_TYPE_DESCRIPTIONS[name] ?? name,
269
+ }));
270
+
271
+ // ============================================================
272
+ // Entity types for `is a` declarations
273
+ // ============================================================
274
+
275
+ /**
276
+ * Entity types for `Name is a <type>` declarations, keyed by chart type.
277
+ * Values are sourced from parser constants (VALID_PARTICIPANT_TYPES,
278
+ * VALID_NODE_TYPES, C4_IS_A_RE).
279
+ */
280
+ export const ENTITY_TYPES = new Map<string, string[]>([
281
+ ['sequence', ['service', 'database', 'actor', 'queue', 'cache', 'gateway', 'external', 'networking', 'frontend']],
282
+ ['infra', ['database', 'cache', 'queue', 'service', 'gateway', 'storage', 'function', 'network']],
283
+ ['c4', ['person', 'system', 'container', 'component', 'external', 'database']],
284
+ ]);
285
+
286
+ // ============================================================
287
+ // Pipe metadata for inline `| key value` on data lines
288
+ // ============================================================
289
+
290
+ /** Specification for a single pipe metadata key. */
291
+ export interface PipeKeySpec {
292
+ description: string;
293
+ values?: string[];
294
+ }
295
+
296
+ /**
297
+ * Pipe metadata keys for inline `| key value` on data lines.
298
+ * Keyed by chart type → { node: ..., edge: ... }.
299
+ *
300
+ * IMPORTANT: NEVER add 'sequence' here. The `|` character in sequence
301
+ * diagrams separates display names from identifiers and tag metadata.
302
+ * Adding sequence would trigger false pipe-metadata completions on every `|`.
303
+ */
304
+ export const PIPE_METADATA = new Map<string, {
305
+ node: Record<string, PipeKeySpec>;
306
+ edge: Record<string, PipeKeySpec>;
307
+ }>([
308
+ ['infra', {
309
+ node: {
310
+ description: { description: 'Node description text' },
311
+ instances: { description: 'Instance count or auto-scaling range (N-M)' },
312
+ 'latency-ms': { description: 'Per-request latency in milliseconds' },
313
+ 'max-rps': { description: 'Max requests per second per instance' },
314
+ 'cache-hit': { description: 'Cache hit percentage (0-100)' },
315
+ 'firewall-block': { description: 'Traffic blocked percentage' },
316
+ 'ratelimit-rps': { description: 'Max RPS allowed through' },
317
+ 'cb-error-threshold': { description: 'Circuit breaker error threshold %' },
318
+ 'cb-latency-threshold-ms': { description: 'Circuit breaker latency threshold' },
319
+ uptime: { description: 'Component availability (0-1)' },
320
+ concurrency: { description: 'Concurrent request limit' },
321
+ 'duration-ms': { description: 'Processing duration' },
322
+ 'cold-start-ms': { description: 'Function cold-start time' },
323
+ buffer: { description: 'Queue/buffer capacity' },
324
+ 'drain-rate': { description: 'Queue drain rate' },
325
+ 'retention-hours': { description: 'Data retention period' },
326
+ partitions: { description: 'Queue/stream partition count' },
327
+ 'slo-availability': { description: 'Node availability target (0-1)' },
328
+ 'slo-p90-latency-ms': { description: 'Node p90 latency target' },
329
+ 'slo-warning-margin': { description: 'Node SLO warning margin' },
330
+ },
331
+ edge: {
332
+ split: { description: 'Traffic split percentage (e.g., 60%)' },
333
+ fanout: { description: 'Fanout multiplier (integer >= 1)' },
334
+ },
335
+ }],
336
+ ['c4', {
337
+ node: {
338
+ description: { description: 'Element description' },
339
+ tech: { description: 'Technology stack' },
340
+ technology: { description: 'Technology stack (alias for tech)' },
341
+ },
342
+ edge: {},
343
+ }],
344
+ ['gantt', {
345
+ node: {},
346
+ edge: {
347
+ // Gantt "edge" = dependency arrow (TaskA -> TaskB | offset 2bd)
348
+ offset: { description: 'Dependency offset (e.g., 2bd, -1w)' },
349
+ },
350
+ }],
351
+ ]);
352
+
353
+ // ============================================================
354
+ // Derived metadata key set
355
+ // ============================================================
356
+
357
+ /** All known directive keys, derived from COMPLETION_REGISTRY. Includes implicit keys. */
358
+ export const METADATA_KEY_SET: ReadonlySet<string> = new Set([
359
+ 'chart', 'title', // implicit directives recognized as metadata
360
+ ...[...COMPLETION_REGISTRY.values()].flatMap(spec => Object.keys(spec.directives)),
361
+ ]);
362
+
363
+ // ============================================================
364
+ // Sequence extractor
365
+ // ============================================================
366
+
367
+ const SEQ_ARROW_RE = /^(\S+)\s+(->|-[^>\s]*->|~>|~[^>\s]*~>)\s+(\S+)/;
368
+ const SEQ_IS_A_RE = /^(\S+)\s+is\s+an?\s+/i;
369
+ const SEQ_SECTION_RE = /^==/;
370
+ const SEQ_STRUCTURAL_RE = /^(if|else|loop|parallel|end)\b/i;
371
+
372
+ function extractSequenceSymbols(docText: string): DiagramSymbols {
373
+ const lines = docText.split('\n');
374
+ const entities: string[] = [];
375
+ let pastFirstLine = false;
376
+
377
+ for (const line of lines) {
378
+ const trimmed = line.trim();
379
+ if (!trimmed || trimmed.startsWith('//')) continue;
380
+
381
+ // Skip first line (chart type)
382
+ if (!pastFirstLine) {
383
+ pastFirstLine = true;
384
+ continue;
385
+ }
386
+
387
+ // Skip metadata lines
388
+ const firstToken = trimmed.split(/\s+/)[0].toLowerCase();
389
+ if (METADATA_KEY_SET.has(firstToken)) continue;
390
+
391
+ // Skip sections, structural keywords
392
+ if (SEQ_SECTION_RE.test(trimmed)) continue;
393
+ if (SEQ_STRUCTURAL_RE.test(trimmed)) continue;
394
+
395
+ // Arrow lines: A -> B, A -label-> B, A ~> B
396
+ const arrowMatch = trimmed.match(SEQ_ARROW_RE);
397
+ if (arrowMatch) {
398
+ const src = arrowMatch[1].split('|')[0].trim();
399
+ const dst = arrowMatch[3].split('|')[0].trim();
400
+ if (src && !entities.includes(src)) entities.push(src);
401
+ if (dst && !entities.includes(dst)) entities.push(dst);
402
+ continue;
403
+ }
404
+
405
+ // Type declarations: A is a person, A is an actor
406
+ const isAMatch = trimmed.match(SEQ_IS_A_RE);
407
+ if (isAMatch) {
408
+ const name = isAMatch[1].split('|')[0].trim();
409
+ if (name && !entities.includes(name)) entities.push(name);
410
+ continue;
411
+ }
412
+ }
413
+
414
+ return {
415
+ kind: 'sequence',
416
+ entities,
417
+ keywords: ['if', 'else', 'loop', 'parallel', 'note'],
418
+ };
419
+ }
420
+
421
+ // ============================================================
422
+ // State extractor
423
+ // ============================================================
424
+
425
+ const STATE_ARROW_RE = /^(\S+)\s+->\s+(\S+)/;
426
+
427
+ function extractStateSymbols(docText: string): DiagramSymbols {
428
+ const lines = docText.split('\n');
429
+ const entities: string[] = [];
430
+ let pastFirstLine = false;
431
+
432
+ for (const line of lines) {
433
+ const trimmed = line.trim();
434
+ if (!trimmed || trimmed.startsWith('//')) continue;
435
+
436
+ if (!pastFirstLine) {
437
+ pastFirstLine = true;
438
+ continue;
439
+ }
440
+
441
+ // Skip metadata lines
442
+ const firstToken = trimmed.split(/\s+/)[0].toLowerCase();
443
+ if (METADATA_KEY_SET.has(firstToken)) continue;
444
+
445
+ const arrowMatch = trimmed.match(STATE_ARROW_RE);
446
+ if (arrowMatch) {
447
+ const src = arrowMatch[1].split('|')[0].trim();
448
+ const dst = arrowMatch[2].split('|')[0].trim();
449
+ if (src && !entities.includes(src)) entities.push(src);
450
+ if (dst && !entities.includes(dst)) entities.push(dst);
451
+ }
452
+ }
453
+
454
+ return { kind: 'state', entities, keywords: [] };
455
+ }
456
+
457
+ // ============================================================
458
+ // Tag declaration extraction
459
+ // ============================================================
460
+
461
+ // Matches tag declarations in both forms:
462
+ // - `tag Name alias x` (explicit alias keyword)
463
+ // - `tag Name x` (shorthand: 1-4 lowercase chars = alias, matching parser's isAliasToken)
464
+ const TAG_DECL_EXPLICIT_RE = /^tag\s+(\S+)\s+alias\s+(\S+)/i;
465
+ const TAG_DECL_SHORT_RE = /^tag\s+(\S+)\s+([a-z]{1,4})(?:\s|$)/;
466
+
467
+ /**
468
+ * Extract tag declarations from document text.
469
+ * Returns a map of alias (or full name) → array of tag values.
470
+ * Keys preserve original case for display; use case-insensitive lookup.
471
+ */
472
+ export function extractTagDeclarations(docText: string): Map<string, string[]> {
473
+ const result = new Map<string, string[]>();
474
+ const lines = docText.split('\n');
475
+ let currentAlias: string | null = null;
476
+ let currentValues: string[] = [];
477
+
478
+ for (let i = 0; i < lines.length; i++) {
479
+ const raw = lines[i];
480
+ const trimmed = raw.trim();
481
+
482
+ // Check for tag declaration — try explicit `alias` keyword first, then shorthand
483
+ const tagMatch = trimmed.match(TAG_DECL_EXPLICIT_RE) ?? trimmed.match(TAG_DECL_SHORT_RE);
484
+ if (tagMatch) {
485
+ // Save previous tag group
486
+ if (currentAlias !== null) {
487
+ result.set(currentAlias, currentValues);
488
+ }
489
+ const name = tagMatch[1];
490
+ const alias = tagMatch[2] ?? name;
491
+ currentAlias = alias;
492
+ currentValues = [];
493
+ continue;
494
+ }
495
+ // Also match bare `tag Name` (no alias) — fall through with name as key
496
+ if (/^tag\s+(\S+)\s*$/i.test(trimmed)) {
497
+ if (currentAlias !== null) {
498
+ result.set(currentAlias, currentValues);
499
+ }
500
+ currentAlias = trimmed.match(/^tag\s+(\S+)/i)![1];
501
+ currentValues = [];
502
+ continue;
503
+ }
504
+
505
+ // Collect indented tag values
506
+ if (currentAlias !== null && raw.length > 0 && (raw[0] === ' ' || raw[0] === '\t')) {
507
+ if (trimmed && !trimmed.startsWith('//')) {
508
+ // Strip color annotation: Frontend(blue) → Frontend
509
+ const colorIdx = trimmed.indexOf('(');
510
+ const value = colorIdx > 0 ? trimmed.substring(0, colorIdx).trim() : trimmed;
511
+ if (value) currentValues.push(value);
512
+ }
513
+ continue;
514
+ }
515
+
516
+ // Non-indented non-tag line ends the current tag block
517
+ if (currentAlias !== null && trimmed) {
518
+ result.set(currentAlias, currentValues);
519
+ currentAlias = null;
520
+ currentValues = [];
521
+ }
522
+ }
523
+
524
+ // Save last tag group
525
+ if (currentAlias !== null) {
526
+ result.set(currentAlias, currentValues);
527
+ }
528
+
529
+ return result;
530
+ }
531
+
532
+ // ============================================================
533
+ // Sitemap extractor
534
+ // ============================================================
535
+
536
+ const SITEMAP_CONTAINER_RE = /^\[([^\]]+)\]/;
537
+ const SITEMAP_ARROW_RE = /^-.*->\s*(.+)$/;
538
+ const SITEMAP_BARE_ARROW_RE = /^->\s*(.+)$/;
539
+ const SITEMAP_METADATA_RE = /^([^:]+):\s*(.+)$/;
540
+
541
+ function extractSitemapSymbols(docText: string): DiagramSymbols {
542
+ const lines = docText.split('\n');
543
+ const entities: string[] = [];
544
+ let pastFirstLine = false;
545
+ let inTagBlock = false;
546
+ let lastNodeIndent = -1;
547
+
548
+ for (const line of lines) {
549
+ const trimmed = line.trim();
550
+ if (!trimmed || trimmed.startsWith('//')) continue;
551
+
552
+ if (!pastFirstLine) {
553
+ pastFirstLine = true;
554
+ continue;
555
+ }
556
+
557
+ // Skip metadata lines
558
+ const firstToken = trimmed.split(/\s+/)[0].toLowerCase();
559
+ if (METADATA_KEY_SET.has(firstToken)) continue;
560
+
561
+ // Track tag blocks
562
+ if (/^tag\s+/i.test(trimmed)) { inTagBlock = true; continue; }
563
+ const indent = line.search(/\S/);
564
+ if (inTagBlock) {
565
+ if (indent > 0) continue;
566
+ inTagBlock = false;
567
+ }
568
+
569
+ // Containers: [GroupName]
570
+ const containerMatch = trimmed.match(SITEMAP_CONTAINER_RE);
571
+ if (containerMatch) {
572
+ const name = containerMatch[1].split('|')[0].trim();
573
+ if (name && !entities.includes(name)) entities.push(name);
574
+ lastNodeIndent = indent;
575
+ continue;
576
+ }
577
+
578
+ // Arrows: -> Target or -label-> Target
579
+ const bareArrow = trimmed.match(SITEMAP_BARE_ARROW_RE);
580
+ const labeledArrow = !bareArrow ? trimmed.match(SITEMAP_ARROW_RE) : null;
581
+ if (bareArrow || labeledArrow) {
582
+ const target = (bareArrow?.[1] ?? labeledArrow?.[1] ?? '').split('|')[0].trim();
583
+ if (target && !entities.includes(target)) entities.push(target);
584
+ continue;
585
+ }
586
+
587
+ // Indented metadata under a node (key: value) — skip
588
+ if (indent > 0 && lastNodeIndent >= 0 && indent > lastNodeIndent && SITEMAP_METADATA_RE.test(trimmed)) {
589
+ continue;
590
+ }
591
+
592
+ // Page label (anything else that's not special)
593
+ const label = trimmed.split('|')[0].trim();
594
+ if (label) {
595
+ if (!entities.includes(label)) entities.push(label);
596
+ lastNodeIndent = indent;
597
+ }
598
+ }
599
+
600
+ return { kind: 'sitemap', entities, keywords: [] };
601
+ }
602
+
603
+ // ============================================================
604
+ // C4 extractor
605
+ // ============================================================
606
+
607
+ const C4_ELEMENT_RE = /^(person|system|container|component)\s+(.+)$/i;
608
+ const C4_IS_A_RE = /^(.+?)\s+is\s+an?\s+(person|system|container|component|external|database)\b/i;
609
+ const C4_ARROW_RE = /^(\S+)\s+(?:->|-[^>\s]*->|~>|~[^>\s]*~>|<->|<-[^>\s]*->|<~>|<~[^>\s]*~>)\s+(\S+)/;
610
+ const C4_SECTION_RE = /^(containers|components|deployment)\s*$/i;
611
+
612
+ function extractC4Symbols(docText: string): DiagramSymbols {
613
+ const lines = docText.split('\n');
614
+ const entities: string[] = [];
615
+ let pastFirstLine = false;
616
+ let inTagBlock = false;
617
+
618
+ for (const line of lines) {
619
+ const trimmed = line.trim();
620
+ if (!trimmed || trimmed.startsWith('//')) continue;
621
+
622
+ if (!pastFirstLine) {
623
+ pastFirstLine = true;
624
+ continue;
625
+ }
626
+
627
+ const firstToken = trimmed.split(/\s+/)[0].toLowerCase();
628
+ if (METADATA_KEY_SET.has(firstToken)) continue;
629
+
630
+ if (/^tag\s+/i.test(trimmed)) { inTagBlock = true; continue; }
631
+ const indent = line.search(/\S/);
632
+ if (inTagBlock) {
633
+ if (indent > 0) continue;
634
+ inTagBlock = false;
635
+ }
636
+
637
+ // Skip section headers
638
+ if (C4_SECTION_RE.test(trimmed)) continue;
639
+
640
+ // Element declaration: person Name, system Name, etc.
641
+ const elemMatch = trimmed.match(C4_ELEMENT_RE);
642
+ if (elemMatch) {
643
+ const name = elemMatch[2].split('|')[0].trim();
644
+ if (name && !entities.includes(name)) entities.push(name);
645
+ continue;
646
+ }
647
+
648
+ // Is-a declaration: Name is a person
649
+ const isAMatch = trimmed.match(C4_IS_A_RE);
650
+ if (isAMatch) {
651
+ const name = isAMatch[1].split('|')[0].trim();
652
+ if (name && !entities.includes(name)) entities.push(name);
653
+ continue;
654
+ }
655
+
656
+ // Arrow lines: Source -> Target, Source ~> Target, etc.
657
+ const arrowMatch = trimmed.match(C4_ARROW_RE);
658
+ if (arrowMatch) {
659
+ const src = arrowMatch[1].split('|')[0].trim();
660
+ const dst = arrowMatch[2].split('|')[0].trim();
661
+ if (src && !entities.includes(src)) entities.push(src);
662
+ if (dst && !entities.includes(dst)) entities.push(dst);
663
+ continue;
664
+ }
665
+ }
666
+
667
+ return { kind: 'c4', entities, keywords: ['containers', 'components', 'deployment'] };
668
+ }
669
+
670
+ // ============================================================
671
+ // Gantt extractor
672
+ // ============================================================
673
+
674
+ const GANTT_DURATION_RE = /^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)\??\s+(.+)$/;
675
+ const GANTT_DATE_RE = /^(\d{4}-\d{2}-\d{2}(?:\s\d{2}:\d{2})?)\s+(.+)$/;
676
+ const GANTT_GROUP_RE = /^\[(.+?)\]/;
677
+ const GANTT_STRUCTURAL_RE = /^(era|marker|holidays|workweek|parallel)\b/i;
678
+
679
+ function extractGanttSymbols(docText: string): DiagramSymbols {
680
+ const lines = docText.split('\n');
681
+ const entities: string[] = [];
682
+ let pastFirstLine = false;
683
+ let inTagBlock = false;
684
+
685
+ for (const line of lines) {
686
+ const trimmed = line.trim();
687
+ if (!trimmed || trimmed.startsWith('//')) continue;
688
+
689
+ if (!pastFirstLine) {
690
+ pastFirstLine = true;
691
+ continue;
692
+ }
693
+
694
+ const firstToken = trimmed.split(/\s+/)[0].toLowerCase();
695
+ if (METADATA_KEY_SET.has(firstToken)) continue;
696
+
697
+ if (/^tag\s+/i.test(trimmed)) { inTagBlock = true; continue; }
698
+ const indent = line.search(/\S/);
699
+ if (inTagBlock) {
700
+ if (indent > 0) continue;
701
+ inTagBlock = false;
702
+ }
703
+
704
+ // Skip structural keywords
705
+ if (GANTT_STRUCTURAL_RE.test(trimmed)) continue;
706
+
707
+ // Groups: [GroupName]
708
+ const groupMatch = trimmed.match(GANTT_GROUP_RE);
709
+ if (groupMatch) {
710
+ const name = groupMatch[1].trim();
711
+ if (name && !entities.includes(name)) entities.push(name);
712
+ continue;
713
+ }
714
+
715
+ // Tasks by duration: 30d Task Name | metadata
716
+ const durMatch = trimmed.match(GANTT_DURATION_RE);
717
+ if (durMatch) {
718
+ // Strip pipe metadata and dependency arrows from task name
719
+ let taskName = durMatch[3].split('|')[0].trim();
720
+ // Remove trailing dependency: "Task Name -> Other" → "Task Name"
721
+ const arrowIdx = taskName.indexOf('->');
722
+ if (arrowIdx > 0) taskName = taskName.substring(0, arrowIdx).replace(/-[^>]*$/, '').trim();
723
+ if (taskName && !entities.includes(taskName)) entities.push(taskName);
724
+ continue;
725
+ }
726
+
727
+ // Tasks by date: 2024-01-15 Task Name
728
+ const dateMatch = trimmed.match(GANTT_DATE_RE);
729
+ if (dateMatch) {
730
+ let taskName = dateMatch[2].split('|')[0].trim();
731
+ const arrowIdx = taskName.indexOf('->');
732
+ if (arrowIdx > 0) taskName = taskName.substring(0, arrowIdx).replace(/-[^>]*$/, '').trim();
733
+ if (taskName && !entities.includes(taskName)) entities.push(taskName);
734
+ continue;
735
+ }
736
+ }
737
+
738
+ return { kind: 'gantt', entities, keywords: [] };
739
+ }
740
+
741
+ // ============================================================
742
+ // Initiative-status extractor
743
+ // ============================================================
744
+
745
+ const IS_ARROW_RE = /^(\S+)\s+(?:-.*)?->\s+(\S+)/;
746
+
747
+ function extractInitiativeStatusSymbols(docText: string): DiagramSymbols {
748
+ const lines = docText.split('\n');
749
+ const entities: string[] = [];
750
+ let pastFirstLine = false;
751
+ let inTagBlock = false;
752
+
753
+ for (const line of lines) {
754
+ const trimmed = line.trim();
755
+ if (!trimmed || trimmed.startsWith('//')) continue;
756
+
757
+ if (!pastFirstLine) {
758
+ pastFirstLine = true;
759
+ continue;
760
+ }
761
+
762
+ const firstToken = trimmed.split(/\s+/)[0].toLowerCase();
763
+ if (METADATA_KEY_SET.has(firstToken)) continue;
764
+
765
+ if (/^tag\s+/i.test(trimmed)) { inTagBlock = true; continue; }
766
+ const indent = line.search(/\S/);
767
+ if (inTagBlock) {
768
+ if (indent > 0) continue;
769
+ inTagBlock = false;
770
+ }
771
+
772
+ // Edge lines: Source -> Target or Source -label-> Target
773
+ const arrowMatch = trimmed.match(IS_ARROW_RE);
774
+ if (arrowMatch) {
775
+ const src = arrowMatch[1].split('|')[0].trim();
776
+ const dst = arrowMatch[2].split('|')[0].trim();
777
+ if (src && !entities.includes(src)) entities.push(src);
778
+ if (dst && !entities.includes(dst)) entities.push(dst);
779
+ continue;
780
+ }
781
+
782
+ // Node lines: Label | status or just Label (at root indent)
783
+ if (indent === 0) {
784
+ const label = trimmed.split('|')[0].trim();
785
+ if (label && !entities.includes(label)) entities.push(label);
786
+ }
787
+ }
788
+
789
+ return { kind: 'initiative-status', entities, keywords: [] };
790
+ }
791
+
51
792
  // ============================================================
52
793
  // Register built-in extractors
53
794
  // ============================================================
@@ -56,3 +797,9 @@ registerExtractor('er', extractErSymbols);
56
797
  registerExtractor('flowchart', extractFlowchartSymbols);
57
798
  registerExtractor('infra', extractInfraSymbols);
58
799
  registerExtractor('class', extractClassSymbols);
800
+ registerExtractor('sequence', extractSequenceSymbols);
801
+ registerExtractor('state', extractStateSymbols);
802
+ registerExtractor('sitemap', extractSitemapSymbols);
803
+ registerExtractor('c4', extractC4Symbols);
804
+ registerExtractor('gantt', extractGanttSymbols);
805
+ registerExtractor('initiative-status', extractInitiativeStatusSymbols);