@diagrammo/dgmo 0.8.21 → 0.8.23

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 (114) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +145 -93
  4. package/dist/editor.cjs +20 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +20 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +15 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +15 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +20843 -14937
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +426 -17
  15. package/dist/index.d.ts +426 -17
  16. package/dist/index.js +20795 -14912
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal.cjs +380 -0
  19. package/dist/internal.cjs.map +1 -0
  20. package/dist/internal.d.cts +179 -0
  21. package/dist/internal.d.ts +179 -0
  22. package/dist/internal.js +337 -0
  23. package/dist/internal.js.map +1 -0
  24. package/docs/guide/chart-cycle.md +156 -0
  25. package/docs/guide/chart-journey-map.md +179 -0
  26. package/docs/guide/chart-pyramid.md +111 -0
  27. package/docs/guide/chart-sitemap.md +18 -1
  28. package/docs/guide/chart-tech-radar.md +219 -0
  29. package/docs/guide/registry.json +6 -0
  30. package/docs/language-reference.md +177 -6
  31. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  32. package/gallery/fixtures/c4-full.dgmo +2 -2
  33. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  34. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  35. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  36. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  37. package/gallery/fixtures/gantt-full.dgmo +2 -2
  38. package/gallery/fixtures/gantt.dgmo +2 -2
  39. package/gallery/fixtures/infra-full.dgmo +2 -2
  40. package/gallery/fixtures/infra.dgmo +1 -1
  41. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  42. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  43. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  44. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  45. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  46. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  47. package/gallery/fixtures/tech-radar.dgmo +36 -0
  48. package/gallery/fixtures/timeline.dgmo +1 -1
  49. package/package.json +11 -1
  50. package/src/boxes-and-lines/layout.ts +309 -33
  51. package/src/boxes-and-lines/parser.ts +86 -10
  52. package/src/boxes-and-lines/renderer.ts +250 -91
  53. package/src/boxes-and-lines/types.ts +1 -1
  54. package/src/c4/layout.ts +8 -8
  55. package/src/c4/parser.ts +35 -2
  56. package/src/c4/renderer.ts +19 -3
  57. package/src/c4/types.ts +1 -0
  58. package/src/chart.ts +14 -7
  59. package/src/cli.ts +5 -35
  60. package/src/completion.ts +233 -41
  61. package/src/cycle/layout.ts +723 -0
  62. package/src/cycle/parser.ts +352 -0
  63. package/src/cycle/renderer.ts +566 -0
  64. package/src/cycle/types.ts +98 -0
  65. package/src/d3.ts +107 -8
  66. package/src/dgmo-router.ts +82 -3
  67. package/src/echarts.ts +8 -5
  68. package/src/editor/dgmo.grammar +5 -1
  69. package/src/editor/dgmo.grammar.js +1 -1
  70. package/src/editor/keywords.ts +17 -0
  71. package/src/gantt/parser.ts +2 -8
  72. package/src/graph/flowchart-parser.ts +15 -21
  73. package/src/graph/state-parser.ts +5 -10
  74. package/src/index.ts +63 -2
  75. package/src/infra/layout.ts +218 -74
  76. package/src/infra/parser.ts +32 -8
  77. package/src/infra/renderer.ts +14 -8
  78. package/src/infra/types.ts +10 -3
  79. package/src/internal.ts +16 -0
  80. package/src/journey-map/layout.ts +386 -0
  81. package/src/journey-map/parser.ts +540 -0
  82. package/src/journey-map/renderer.ts +1521 -0
  83. package/src/journey-map/types.ts +47 -0
  84. package/src/kanban/parser.ts +3 -10
  85. package/src/kanban/renderer.ts +31 -15
  86. package/src/mindmap/parser.ts +12 -18
  87. package/src/mindmap/renderer.ts +14 -13
  88. package/src/mindmap/text-wrap.ts +22 -12
  89. package/src/mindmap/types.ts +2 -2
  90. package/src/org/collapse.ts +81 -0
  91. package/src/org/parser.ts +2 -6
  92. package/src/org/renderer.ts +212 -4
  93. package/src/pyramid/parser.ts +172 -0
  94. package/src/pyramid/renderer.ts +684 -0
  95. package/src/pyramid/types.ts +28 -0
  96. package/src/render.ts +2 -8
  97. package/src/sequence/parser.ts +62 -20
  98. package/src/sequence/renderer.ts +146 -40
  99. package/src/sharing.ts +1 -0
  100. package/src/sitemap/layout.ts +21 -6
  101. package/src/sitemap/parser.ts +26 -17
  102. package/src/sitemap/renderer.ts +34 -0
  103. package/src/sitemap/types.ts +1 -0
  104. package/src/tech-radar/index.ts +14 -0
  105. package/src/tech-radar/interactive.ts +1112 -0
  106. package/src/tech-radar/layout.ts +190 -0
  107. package/src/tech-radar/parser.ts +385 -0
  108. package/src/tech-radar/renderer.ts +1159 -0
  109. package/src/tech-radar/shared.ts +187 -0
  110. package/src/tech-radar/types.ts +81 -0
  111. package/src/utils/description-helpers.ts +33 -0
  112. package/src/utils/legend-layout.ts +3 -1
  113. package/src/utils/parsing.ts +47 -7
  114. package/src/utils/tag-groups.ts +46 -60
@@ -0,0 +1,187 @@
1
+ import type * as d3Selection from 'd3-selection';
2
+ import { FONT_FAMILY } from '../fonts';
3
+ import type { PaletteColors } from '../palettes';
4
+ import type { QuadrantPosition, BlipTrend } from './types';
5
+
6
+ /** Default quadrant colors by position when not overridden. */
7
+ export const DEFAULT_QUADRANT_COLORS: Record<QuadrantPosition, string> = {
8
+ 'top-left': 'blue',
9
+ 'top-right': 'green',
10
+ 'bottom-left': 'red',
11
+ 'bottom-right': 'orange',
12
+ };
13
+
14
+ /** Resolve a quadrant's color from palette, falling back to palette.border. */
15
+ export function resolveQuadrantColor(
16
+ position: QuadrantPosition,
17
+ color: string | null,
18
+ palette: PaletteColors
19
+ ): string {
20
+ const name = color ?? DEFAULT_QUADRANT_COLORS[position];
21
+ return (palette.colors as Record<string, string>)[name] ?? palette.border;
22
+ }
23
+
24
+ /**
25
+ * Render a trend indicator (blip circle + optional crescent/ring).
26
+ *
27
+ * @param angleToCenter — angle in radians from the blip toward the radar center.
28
+ * Used to orient the crescent for up/down trends (toward center = up, away = down).
29
+ */
30
+ export function renderTrendIndicator(
31
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
32
+ trend: BlipTrend | null,
33
+ color: string,
34
+ cx: number,
35
+ cy: number,
36
+ r: number,
37
+ angleToCenter: number
38
+ ): void {
39
+ // Base filled circle (always present)
40
+ g.append('circle')
41
+ .attr('cx', cx)
42
+ .attr('cy', cy)
43
+ .attr('r', r * 0.65)
44
+ .attr('fill', color);
45
+
46
+ if (trend === 'new') {
47
+ // Double circle — outer ring
48
+ g.append('circle')
49
+ .attr('cx', cx)
50
+ .attr('cy', cy)
51
+ .attr('r', r)
52
+ .attr('fill', 'none')
53
+ .attr('stroke', color)
54
+ .attr('stroke-width', 1.5);
55
+ } else if (trend === 'up' || trend === 'down') {
56
+ // Semi-circle crescent (ThoughtWorks style)
57
+ // "up" = crescent on the center-facing side (moving inward)
58
+ // "down" = crescent on the outward-facing side (moving outward)
59
+ const crescentAngle =
60
+ trend === 'up' ? angleToCenter : angleToCenter + Math.PI;
61
+ renderCrescent(g, cx, cy, r, crescentAngle, color);
62
+ }
63
+ // 'stable' or null: plain filled circle only
64
+ }
65
+
66
+ /**
67
+ * Draw a crescent (semi-circle arc) on one side of the blip, oriented
68
+ * at `angle` radians (0 = right, π/2 = up in math coords / down in SVG).
69
+ *
70
+ * The crescent is a thick arc segment hugging the outer edge of the blip circle,
71
+ * spanning ~160° centered on `angle`.
72
+ */
73
+ function renderCrescent(
74
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
75
+ cx: number,
76
+ cy: number,
77
+ r: number,
78
+ angle: number,
79
+ color: string
80
+ ): void {
81
+ const outerR = r;
82
+ const halfSpan = (Math.PI * 4) / 9; // ~80° each side = 160° total arc
83
+
84
+ const startAngle = angle - halfSpan;
85
+ const endAngle = angle + halfSpan;
86
+
87
+ // SVG arc: start and end points on the outer circle (SVG Y-down convention)
88
+ const x1 = cx + outerR * Math.cos(startAngle);
89
+ const y1 = cy + outerR * Math.sin(startAngle);
90
+ const x2 = cx + outerR * Math.cos(endAngle);
91
+ const y2 = cy + outerR * Math.sin(endAngle);
92
+
93
+ // Large arc flag: 0 since we span < 180°; sweep=1 for clockwise in SVG
94
+ const largeArc = halfSpan * 2 > Math.PI ? 1 : 0;
95
+
96
+ g.append('path')
97
+ .attr('d', `M${x1},${y1} A${outerR},${outerR} 0 ${largeArc},1 ${x2},${y2}`)
98
+ .attr('fill', 'none')
99
+ .attr('stroke', color)
100
+ .attr('stroke-width', 2.5)
101
+ .attr('stroke-linecap', 'round');
102
+ }
103
+
104
+ /** Trend indicator character for text listings. */
105
+ export function getTrendChar(trend: BlipTrend | null): string {
106
+ switch (trend) {
107
+ case 'new':
108
+ return ' ★';
109
+ case 'up':
110
+ return ' ▲';
111
+ case 'down':
112
+ return ' ▼';
113
+ default:
114
+ return '';
115
+ }
116
+ }
117
+
118
+ // ============================================================
119
+ // Shared Constants
120
+ // ============================================================
121
+
122
+ export const DIM_OPACITY = 0.25;
123
+
124
+ export const TREND_ITEMS: { trend: BlipTrend | null; label: string }[] = [
125
+ { trend: 'new', label: 'New' },
126
+ { trend: 'up', label: 'Moved in' },
127
+ { trend: 'down', label: 'Moved out' },
128
+ { trend: null, label: 'No change' },
129
+ ];
130
+
131
+ // ============================================================
132
+ // Tooltip Helpers
133
+ // ============================================================
134
+
135
+ export function createTooltip(
136
+ container: HTMLElement,
137
+ palette: PaletteColors,
138
+ isDark: boolean
139
+ ): HTMLDivElement {
140
+ container.style.position = 'relative';
141
+ const existing = container.querySelector<HTMLDivElement>('[data-d3-tooltip]');
142
+ if (existing) {
143
+ existing.style.display = 'none';
144
+ return existing;
145
+ }
146
+ const tip = document.createElement('div');
147
+ tip.setAttribute('data-d3-tooltip', '');
148
+ tip.style.position = 'absolute';
149
+ tip.style.display = 'none';
150
+ tip.style.pointerEvents = 'none';
151
+ tip.style.padding = '6px 10px';
152
+ tip.style.borderRadius = '4px';
153
+ tip.style.fontSize = '12px';
154
+ tip.style.fontFamily = FONT_FAMILY;
155
+ tip.style.lineHeight = '1.4';
156
+ tip.style.zIndex = '10';
157
+ tip.style.whiteSpace = 'nowrap';
158
+ tip.style.background = palette.surface;
159
+ tip.style.color = palette.text;
160
+ tip.style.boxShadow = isDark
161
+ ? '0 2px 6px rgba(0,0,0,0.3)'
162
+ : '0 2px 6px rgba(0,0,0,0.12)';
163
+ container.appendChild(tip);
164
+ return tip;
165
+ }
166
+
167
+ export function showTooltip(
168
+ tooltip: HTMLDivElement,
169
+ text: string,
170
+ event: MouseEvent
171
+ ): void {
172
+ tooltip.textContent = text;
173
+ tooltip.style.display = 'block';
174
+ const container = tooltip.parentElement!;
175
+ const rect = container.getBoundingClientRect();
176
+ let left = event.clientX - rect.left + 12;
177
+ let top = event.clientY - rect.top - 28;
178
+ const tipW = tooltip.offsetWidth;
179
+ if (left + tipW > rect.width) left = rect.width - tipW - 4;
180
+ if (top < 0) top = event.clientY - rect.top + 16;
181
+ tooltip.style.left = `${left}px`;
182
+ tooltip.style.top = `${top}px`;
183
+ }
184
+
185
+ export function hideTooltip(tooltip: HTMLDivElement): void {
186
+ tooltip.style.display = 'none';
187
+ }
@@ -0,0 +1,81 @@
1
+ import type { DgmoError } from '../diagnostics';
2
+
3
+ // ============================================================
4
+ // Tech Radar — Parsed Types
5
+ // ============================================================
6
+
7
+ export type QuadrantPosition =
8
+ | 'top-left'
9
+ | 'top-right'
10
+ | 'bottom-left'
11
+ | 'bottom-right';
12
+
13
+ export type BlipTrend = 'new' | 'up' | 'down' | 'stable';
14
+
15
+ export interface TechRadarRing {
16
+ name: string;
17
+ alias: string | null;
18
+ lineNumber: number;
19
+ }
20
+
21
+ export interface TechRadarBlip {
22
+ name: string;
23
+ ring: string;
24
+ trend: BlipTrend | null;
25
+ description: string[];
26
+ lineNumber: number;
27
+ /** Assigned after parsing — global numbering across all quadrants. */
28
+ globalNumber: number;
29
+ }
30
+
31
+ export interface TechRadarQuadrant {
32
+ name: string;
33
+ position: QuadrantPosition;
34
+ color: string | null;
35
+ lineNumber: number;
36
+ blips: TechRadarBlip[];
37
+ }
38
+
39
+ export interface ParsedTechRadar {
40
+ type: 'tech-radar';
41
+ title: string;
42
+ titleLineNumber: number;
43
+ rings: TechRadarRing[];
44
+ quadrants: TechRadarQuadrant[];
45
+ options: Record<string, string>;
46
+ diagnostics: DgmoError[];
47
+ error: string | null;
48
+ }
49
+
50
+ // ============================================================
51
+ // Tech Radar — Layout Types
52
+ // ============================================================
53
+
54
+ export interface TechRadarLayoutPoint {
55
+ blip: TechRadarBlip;
56
+ x: number;
57
+ y: number;
58
+ quadrantIndex: number;
59
+ ringIndex: number;
60
+ }
61
+
62
+ // ============================================================
63
+ // Tech Radar — Render Options
64
+ // ============================================================
65
+
66
+ export interface TechRadarRenderOptions {
67
+ /** Whether the blip listing is visible. Default: true for export, false for interactive. */
68
+ showListing?: boolean;
69
+ /** Callback when the listing toggle is clicked. */
70
+ onToggleListing?: (show: boolean) => void;
71
+ /** Whether the controls legend capsule is expanded. */
72
+ controlsExpanded?: boolean;
73
+ /** Callback when the controls gear pill is clicked (expand/collapse). */
74
+ onToggleControlsExpand?: () => void;
75
+ /** Active legend group name (e.g. 'Trends'). */
76
+ activeLegendGroup?: string | null;
77
+ /** Callback when a legend group pill is toggled. */
78
+ onLegendGroupToggle?: (groupName: string) => void;
79
+ /** Active line from the editor cursor — triggers popover/expansion for that blip. */
80
+ activeLine?: number | null;
81
+ }
@@ -0,0 +1,33 @@
1
+ // ============================================================
2
+ // Description Helpers — shared utilities for node descriptions
3
+ // ============================================================
4
+
5
+ /**
6
+ * Try to strip a leading `description` keyword from a line.
7
+ * Matches: `description text`, `description: text` (colon optional).
8
+ * Does NOT match bare `description` with no trailing text.
9
+ */
10
+ export function tryStripDescriptionKeyword(line: string): {
11
+ isKeyword: boolean;
12
+ text: string;
13
+ } {
14
+ const match = line.match(/^description\s*:?\s+(.+)$/i);
15
+ if (match) return { isKeyword: true, text: match[1] };
16
+ return { isKeyword: false, text: line };
17
+ }
18
+
19
+ /**
20
+ * Pre-process a single description line:
21
+ * - `- text` → `• text` (bullet)
22
+ * - `http example.com` → `https://example.com` (bare URL normalization)
23
+ */
24
+ export function preprocessDescriptionLine(line: string): string {
25
+ // Bullet transform
26
+ if (line.startsWith('- ')) line = '\u2022 ' + line.slice(2);
27
+ // Bare URL normalization
28
+ line = line.replace(
29
+ /\bhttps?\s+([\w][\w.-]+\.[a-z]{2,}(?:\/\S*)?)/gi,
30
+ (_, domain) => `https://${domain}`
31
+ );
32
+ return line;
33
+ }
@@ -556,7 +556,9 @@ function layoutRows(
556
556
 
557
557
  // Commit last row
558
558
  if (currentRowItems.length > 0) {
559
- centerRowItems(currentRowItems, containerWidth, totalControlsW, gearW);
559
+ if (!alignLeft) {
560
+ centerRowItems(currentRowItems, containerWidth, totalControlsW, gearW);
561
+ }
560
562
  rows.push({ y: rowY, items: currentRowItems });
561
563
  }
562
564
 
@@ -49,6 +49,10 @@ export const ALL_CHART_TYPES = new Set([
49
49
  'boxes-and-lines',
50
50
  'mindmap',
51
51
  'wireframe',
52
+ 'tech-radar',
53
+ 'cycle',
54
+ 'journey-map',
55
+ 'pyramid',
52
56
  ]);
53
57
 
54
58
  /** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */
@@ -205,15 +209,51 @@ export function prescanOptions(
205
209
  }
206
210
 
207
211
  /**
208
- * Normalize a comma-grouped number string to a plain integer string.
209
- * Validates the strict pattern: leftmost group 1-3 digits, then groups of exactly 3.
212
+ * Normalize a numeric token with visual separators (commas or underscores) to a plain number string.
210
213
  *
211
- * Examples: `1,087` → `'1087'`, `1,250,000` → `'1250000'`
212
- * Returns `null` if the string is not a valid comma-grouped number.
214
+ * Supported formats:
215
+ * - Comma-grouped integers: `1,000` `'1000'`, `1,234,567` `'1234567'` (strict 3-digit grouping)
216
+ * - Comma-grouped decimals: `1,234.56` → `'1234.56'`
217
+ * - Underscore-separated integers: `1_000` → `'1000'`, `10_00_000` → `'1000000'` (any grouping)
218
+ * - Underscore-separated decimals: `1_234.56` → `'1234.56'` (no underscores in decimal part)
219
+ * - Negatives: `-1,000` → `'-1000'`, `-1_000` → `'-1000'`
220
+ *
221
+ * Returns `null` if:
222
+ * - Token has no commas or underscores (caller should use raw token as-is)
223
+ * - Token has BOTH commas and underscores (mixed separators rejected)
224
+ * - Token has separators but doesn't match any valid pattern
213
225
  */
214
- export function normalizeGroupedNumber(token: string): string | null {
215
- if (!/^\d{1,3}(,\d{3})+$/.test(token)) return null;
216
- return token.replace(/,/g, '');
226
+ export function normalizeNumericToken(token: string): string | null {
227
+ // No separators → null (caller uses raw token)
228
+ if (!token.includes(',') && !token.includes('_')) return null;
229
+ // Mixed separators → rejected
230
+ if (token.includes(',') && token.includes('_')) return null;
231
+
232
+ // Strip optional leading minus sign
233
+ let sign = '';
234
+ let unsigned = token;
235
+ if (unsigned.startsWith('-')) {
236
+ sign = '-';
237
+ unsigned = unsigned.substring(1);
238
+ }
239
+ if (!unsigned) return null;
240
+
241
+ if (unsigned.includes(',')) {
242
+ // Comma-grouped integers: 1,000 or 1,234,567
243
+ if (/^\d{1,3}(,\d{3})+$/.test(unsigned))
244
+ return sign + unsigned.replace(/,/g, '');
245
+ // Comma-grouped decimals: 1,234.56
246
+ if (/^\d{1,3}(,\d{3})+\.\d+$/.test(unsigned))
247
+ return sign + unsigned.replace(/,/g, '');
248
+ return null;
249
+ }
250
+
251
+ // Underscore-separated integers: 1_000, 10_00_000
252
+ if (/^\d+(_\d+)+$/.test(unsigned)) return sign + unsigned.replace(/_/g, '');
253
+ // Underscore-separated decimals: 1_234.56 (no underscores in decimal part)
254
+ if (/^\d+(_\d+)*\.\d+$/.test(unsigned) && unsigned.includes('_'))
255
+ return sign + unsigned.replace(/_/g, '');
256
+ return null;
217
257
  }
218
258
 
219
259
  /**
@@ -108,70 +108,56 @@ export function parseTagDeclaration(line: string): TagBlockMatch | null {
108
108
  // Unquoted — collect multi-word name. The alias is the last token that's 1-4 lowercase
109
109
  // BEFORE any value tokens (values have `(color)` suffixes or appear after we see a comma).
110
110
 
111
- // First check for explicit `alias` keyword: `tag Name alias X`
112
- const aliasKeywordIdx = tokens.findIndex(
113
- (t, i) => i > 0 && t.toLowerCase() === 'alias'
114
- );
115
- if (aliasKeywordIdx > 0 && aliasKeywordIdx + 1 < tokens.length) {
116
- // Everything before `alias` is the name, the token after `alias` is the alias
117
- name = tokens
118
- .slice(0, aliasKeywordIdx)
119
- .map((t) => stripQuotes(t))
120
- .join(' ');
121
- alias = tokens[aliasKeywordIdx + 1];
122
- restStartIdx = aliasKeywordIdx + 2;
123
- } else {
124
- // Find where inline values start — look for a token with `(` in it (color suffix)
125
- // or the presence of a comma in the remaining text
126
- const remainingText = tokens.slice(1).join(' ');
127
- const commaInRemaining = remainingText.includes(',');
128
-
129
- if (tokens.length === 1) {
130
- // Just `tag Name` — no alias, no values
131
- } else if (
132
- tokens.length === 2 &&
133
- isAliasToken(tokens[1]) &&
134
- !commaInRemaining
135
- ) {
136
- // `tag Priority p` — alias only, no values
111
+ // Find where inline values start — look for a token with `(` in it (color suffix)
112
+ // or the presence of a comma in the remaining text
113
+ const remainingText = tokens.slice(1).join(' ');
114
+ const commaInRemaining = remainingText.includes(',');
115
+
116
+ if (tokens.length === 1) {
117
+ // Just `tag Name` — no alias, no values
118
+ } else if (
119
+ tokens.length === 2 &&
120
+ isAliasToken(tokens[1]) &&
121
+ !commaInRemaining
122
+ ) {
123
+ // `tag Priority p` — alias only, no values
124
+ alias = tokens[1];
125
+ restStartIdx = 2;
126
+ } else if (tokens.length >= 2) {
127
+ // Check if token[1] is an alias
128
+ if (isAliasToken(tokens[1])) {
137
129
  alias = tokens[1];
138
130
  restStartIdx = 2;
139
- } else if (tokens.length >= 2) {
140
- // Check if token[1] is an alias
141
- if (isAliasToken(tokens[1])) {
142
- alias = tokens[1];
143
- restStartIdx = 2;
144
- // Multi-word name not applicable when alias is right after first token
145
- } else {
146
- // Could be multi-word name: `tag Risk Level lo`
147
- // Walk tokens to find the alias at the end (before inline values)
148
- // Find where inline values begin — first token containing `(` or after comma
149
- let valueStart = tokens.length; // default: no values
150
- for (let i = 1; i < tokens.length; i++) {
151
- // A token containing `(` suggests a value with color: `High(red)`
152
- if (tokens[i].includes('(')) {
153
- valueStart = i;
154
- break;
155
- }
131
+ // Multi-word name not applicable when alias is right after first token
132
+ } else {
133
+ // Could be multi-word name: `tag Risk Level lo`
134
+ // Walk tokens to find the alias at the end (before inline values)
135
+ // Find where inline values begin — first token containing `(` or after comma
136
+ let valueStart = tokens.length; // default: no values
137
+ for (let i = 1; i < tokens.length; i++) {
138
+ // A token containing `(` suggests a value with color: `High(red)`
139
+ if (tokens[i].includes('(')) {
140
+ valueStart = i;
141
+ break;
156
142
  }
143
+ }
157
144
 
158
- // Check if the token just before valueStart is an alias
159
- if (valueStart > 1 && isAliasToken(tokens[valueStart - 1])) {
160
- alias = tokens[valueStart - 1];
161
- // Name is everything from token[0] to token[valueStart-2]
162
- name = tokens
163
- .slice(0, valueStart - 1)
164
- .map((t) => stripQuotes(t))
165
- .join(' ');
166
- restStartIdx = valueStart;
167
- } else {
168
- // No alias — name is everything before values
169
- name = tokens
170
- .slice(0, valueStart)
171
- .map((t) => stripQuotes(t))
172
- .join(' ');
173
- restStartIdx = valueStart;
174
- }
145
+ // Check if the token just before valueStart is an alias
146
+ if (valueStart > 1 && isAliasToken(tokens[valueStart - 1])) {
147
+ alias = tokens[valueStart - 1];
148
+ // Name is everything from token[0] to token[valueStart-2]
149
+ name = tokens
150
+ .slice(0, valueStart - 1)
151
+ .map((t) => stripQuotes(t))
152
+ .join(' ');
153
+ restStartIdx = valueStart;
154
+ } else {
155
+ // No alias — name is everything before values
156
+ name = tokens
157
+ .slice(0, valueStart)
158
+ .map((t) => stripQuotes(t))
159
+ .join(' ');
160
+ restStartIdx = valueStart;
175
161
  }
176
162
  }
177
163
  }