@diagrammo/dgmo 0.8.19 → 0.8.21

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 (74) hide show
  1. package/dist/cli.cjs +92 -131
  2. package/dist/editor.cjs +13 -1
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.js +13 -1
  5. package/dist/editor.js.map +1 -1
  6. package/dist/highlight.cjs +13 -1
  7. package/dist/highlight.cjs.map +1 -1
  8. package/dist/highlight.js +13 -1
  9. package/dist/highlight.js.map +1 -1
  10. package/dist/index.cjs +4524 -1511
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +427 -186
  13. package/dist/index.d.ts +427 -186
  14. package/dist/index.js +4526 -1503
  15. package/dist/index.js.map +1 -1
  16. package/docs/guide/chart-mindmap.md +198 -0
  17. package/docs/guide/chart-sequence.md +23 -1
  18. package/docs/guide/chart-wireframe.md +100 -0
  19. package/docs/guide/index.md +8 -0
  20. package/docs/language-reference.md +210 -2
  21. package/package.json +22 -9
  22. package/src/boxes-and-lines/collapse.ts +21 -3
  23. package/src/boxes-and-lines/layout.ts +51 -9
  24. package/src/boxes-and-lines/parser.ts +16 -4
  25. package/src/boxes-and-lines/renderer.ts +121 -23
  26. package/src/boxes-and-lines/types.ts +1 -0
  27. package/src/c4/parser.ts +8 -7
  28. package/src/class/parser.ts +6 -0
  29. package/src/cli.ts +1 -9
  30. package/src/completion.ts +26 -0
  31. package/src/d3.ts +169 -266
  32. package/src/dgmo-router.ts +103 -5
  33. package/src/diagnostics.ts +16 -6
  34. package/src/echarts.ts +43 -10
  35. package/src/editor/keywords.ts +12 -0
  36. package/src/er/parser.ts +22 -2
  37. package/src/gantt/renderer.ts +2 -2
  38. package/src/graph/flowchart-parser.ts +89 -52
  39. package/src/graph/layout.ts +73 -9
  40. package/src/graph/state-collapse.ts +78 -0
  41. package/src/graph/state-parser.ts +60 -35
  42. package/src/graph/state-renderer.ts +139 -34
  43. package/src/index.ts +41 -16
  44. package/src/infra/parser.ts +9 -2
  45. package/src/kanban/renderer.ts +305 -59
  46. package/src/mindmap/collapse.ts +88 -0
  47. package/src/mindmap/layout.ts +605 -0
  48. package/src/mindmap/parser.ts +379 -0
  49. package/src/mindmap/renderer.ts +543 -0
  50. package/src/mindmap/text-wrap.ts +207 -0
  51. package/src/mindmap/types.ts +55 -0
  52. package/src/palettes/color-utils.ts +4 -12
  53. package/src/palettes/index.ts +0 -4
  54. package/src/render.ts +31 -20
  55. package/src/sequence/parser.ts +7 -2
  56. package/src/sequence/renderer.ts +141 -21
  57. package/src/sharing.ts +2 -0
  58. package/src/sitemap/layout.ts +35 -12
  59. package/src/sitemap/renderer.ts +1 -6
  60. package/src/utils/arrows.ts +180 -11
  61. package/src/utils/d3-types.ts +4 -0
  62. package/src/utils/export-container.ts +3 -2
  63. package/src/utils/legend-constants.ts +0 -4
  64. package/src/utils/legend-d3.ts +1 -0
  65. package/src/utils/legend-layout.ts +2 -2
  66. package/src/utils/parsing.ts +2 -0
  67. package/src/utils/time-ticks.ts +213 -0
  68. package/src/wireframe/layout.ts +460 -0
  69. package/src/wireframe/parser.ts +956 -0
  70. package/src/wireframe/renderer.ts +1293 -0
  71. package/src/wireframe/types.ts +110 -0
  72. package/src/branding.ts +0 -67
  73. package/src/dgmo-mermaid.ts +0 -262
  74. package/src/palettes/mermaid-bridge.ts +0 -220
@@ -0,0 +1,956 @@
1
+ // ============================================================
2
+ // Wireframe Diagram Parser
3
+ // ============================================================
4
+
5
+ import type { DgmoError } from '../diagnostics';
6
+ import { makeDgmoError, formatDgmoError } from '../diagnostics';
7
+ import type { TagGroup } from '../utils/tag-groups';
8
+ import {
9
+ isTagBlockHeading,
10
+ matchTagBlockHeading,
11
+ validateTagGroupNames,
12
+ stripDefaultModifier,
13
+ } from '../utils/tag-groups';
14
+ import {
15
+ measureIndent,
16
+ extractColor,
17
+ parseFirstLine,
18
+ OPTION_NOCOLON_RE,
19
+ } from '../utils/parsing';
20
+ import type {
21
+ WireframeElement,
22
+ WireframeElementType,
23
+ WireframeFormFactor,
24
+ ParsedWireframe,
25
+ } from './types';
26
+
27
+ // ============================================================
28
+ // Constants
29
+ // ============================================================
30
+
31
+ /** Known wireframe option keys (header-phase options before content) */
32
+ const KNOWN_OPTIONS = new Set(['palette', 'theme', 'active-tag']);
33
+
34
+ /** Keywords that only make sense on groups — force group interpretation (EC1) */
35
+ const GROUP_ONLY_METADATA = new Set(['horizontal', 'scrollable', 'collapsed']);
36
+
37
+ /** Recognized state keywords for pipe metadata */
38
+ const STATE_KEYWORDS = new Set([
39
+ 'disabled',
40
+ 'active',
41
+ 'selected',
42
+ 'empty',
43
+ 'ghost',
44
+ 'destructive',
45
+ 'success',
46
+ 'warning',
47
+ 'info',
48
+ 'scrollable',
49
+ 'collapsed',
50
+ 'toggle',
51
+ 'password',
52
+ 'textarea',
53
+ 'horizontal',
54
+ 'primary',
55
+ ]);
56
+
57
+ /** Element keywords — matched when sole content or followed by recognized params */
58
+ const ELEMENT_KEYWORDS: Record<
59
+ string,
60
+ { block: boolean; params?: Set<string> }
61
+ > = {
62
+ nav: { block: true },
63
+ tabs: { block: true },
64
+ table: { block: true },
65
+ image: { block: false, params: new Set(['round', 'wide']) },
66
+ modal: { block: true },
67
+ skeleton: { block: true },
68
+ alert: { block: true },
69
+ progress: { block: false },
70
+ chart: { block: false, params: new Set(['line', 'bar', 'pie']) },
71
+ };
72
+
73
+ // ============================================================
74
+ // Regexes
75
+ // ============================================================
76
+
77
+ /** Group/input: `[content]` with optional pipe metadata */
78
+ const BRACKET_RE = /^\[([^\]]*)\]\s*(?:\|\s*(.+))?$/;
79
+
80
+ /** Button: `(label)` with optional pipe metadata */
81
+ const BUTTON_RE = /^\(([^)]+)\)\s*(?:\|\s*(.+))?$/;
82
+
83
+ /** Dropdown: `{opt1 | opt2 | ...}` with optional trailing pipe metadata */
84
+ const DROPDOWN_RE = /^\{([^}]+)\}\s*(?:\|\s*(.+))?$/;
85
+
86
+ /** Checkbox checked: `<x>` or `< x >` (whitespace-tolerant, EC6) */
87
+ const CHECKBOX_CHECKED_RE = /^<\s*x\s*>$/i;
88
+
89
+ /** Checkbox unchecked: `< >` or `<>` (whitespace-tolerant, EC6) */
90
+ const CHECKBOX_UNCHECKED_RE = /^<\s*>$/;
91
+
92
+ /** Radio selected: `(*) Label` */
93
+ const RADIO_SELECTED_RE = /^\(\*\)\s+(.+)$/;
94
+
95
+ /** Radio unselected: `( ) Label` */
96
+ const RADIO_UNSELECTED_RE = /^\(\s*\)\s+(.+)$/;
97
+
98
+ /** Heading: `# text` or `## text` */
99
+ const HEADING_RE = /^(#{1,2})\s+(.+)$/;
100
+
101
+ /** Divider: `---` (3+ dashes) */
102
+ const DIVIDER_RE = /^-{3,}$/;
103
+
104
+ /** List item: `- text` (anchored to start of segment, F38 fix) */
105
+ const LIST_ITEM_RE = /^-\s+(.+)$/;
106
+
107
+ /** Table skeleton shorthand: `table RxC` */
108
+ const TABLE_SKELETON_RE = /^table\s+(\d+)x(\d+)$/i;
109
+
110
+ // ============================================================
111
+ // Helpers
112
+ // ============================================================
113
+
114
+ /** Generate a deterministic ID from line number and indent. */
115
+ function genId(lineNumber: number, indent: number, suffix = ''): string {
116
+ return `wf-${lineNumber}-${indent}${suffix ? '-' + suffix : ''}`;
117
+ }
118
+
119
+ /** @deprecated No-op — IDs are now deterministic from line/indent. Kept for test compat. */
120
+ export function resetWireframeIds(): void {
121
+ // no-op
122
+ }
123
+
124
+ function makeElement(
125
+ type: WireframeElementType,
126
+ label: string,
127
+ lineNumber: number,
128
+ indent: number
129
+ ): WireframeElement {
130
+ return {
131
+ id: genId(lineNumber, indent),
132
+ type,
133
+ label,
134
+ children: [],
135
+ metadata: {},
136
+ states: [],
137
+ annotations: [],
138
+ lineNumber,
139
+ indent,
140
+ isContainer: false,
141
+ orientation: 'vertical',
142
+ isSkeleton: false,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Parse pipe metadata flags/annotations from the text after `|`.
148
+ * Returns states array + annotations array + metadata record.
149
+ */
150
+ function parseWireframeMetadata(raw: string): {
151
+ states: string[];
152
+ annotations: string[];
153
+ metadata: Record<string, string>;
154
+ } {
155
+ const states: string[] = [];
156
+ const annotations: string[] = [];
157
+ const metadata: Record<string, string> = {};
158
+
159
+ const parts = raw
160
+ .split(',')
161
+ .map((s) => s.trim())
162
+ .filter(Boolean);
163
+ for (const part of parts) {
164
+ const lower = part.toLowerCase();
165
+ if (STATE_KEYWORDS.has(lower)) {
166
+ states.push(lower);
167
+ } else if (part.includes(':')) {
168
+ const [key, ...rest] = part.split(':');
169
+ metadata[key.trim()] = rest.join(':').trim();
170
+ } else {
171
+ annotations.push(part);
172
+ }
173
+ }
174
+ return { states, annotations, metadata };
175
+ }
176
+
177
+ /**
178
+ * Apply parsed metadata to an element.
179
+ */
180
+ function applyMetadata(
181
+ el: WireframeElement,
182
+ metaStr: string | undefined
183
+ ): void {
184
+ if (!metaStr) return;
185
+ const { states, annotations, metadata } = parseWireframeMetadata(metaStr);
186
+ el.states.push(...states);
187
+ el.annotations.push(...annotations);
188
+ Object.assign(el.metadata, metadata);
189
+
190
+ // Field variants
191
+ if (el.type === 'textInput') {
192
+ if (states.includes('password')) el.fieldVariant = 'password';
193
+ if (states.includes('textarea')) el.fieldVariant = 'textarea';
194
+ }
195
+
196
+ // Group-only metadata forces container (EC1)
197
+ for (const s of states) {
198
+ if (GROUP_ONLY_METADATA.has(s)) {
199
+ el.isContainer = true;
200
+ if (s === 'horizontal') el.orientation = 'horizontal';
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Detect if a segment is a keyword element.
207
+ * Returns { keyword, params } or null.
208
+ * F9: keyword matched only when sole content or followed by recognized param.
209
+ */
210
+ function matchKeyword(segment: string): {
211
+ keyword: string;
212
+ type: WireframeElementType;
213
+ param?: string;
214
+ } | null {
215
+ const words = segment.split(/\s+/);
216
+ const first = words[0].toLowerCase();
217
+ const kw = ELEMENT_KEYWORDS[first];
218
+ if (!kw) return null;
219
+
220
+ // Sole content — just the keyword
221
+ if (words.length === 1) {
222
+ return { keyword: first, type: first as WireframeElementType };
223
+ }
224
+
225
+ const rest = words.slice(1).join(' ');
226
+
227
+ // `modal Title` — modal always takes the rest as title
228
+ if (first === 'modal') {
229
+ return { keyword: first, type: 'modal', param: rest };
230
+ }
231
+
232
+ // `progress 60` — takes a number
233
+ if (first === 'progress') {
234
+ const num = parseInt(rest, 10);
235
+ if (!isNaN(num)) {
236
+ return { keyword: first, type: 'progress', param: rest };
237
+ }
238
+ return null; // `progress bar` → bare text
239
+ }
240
+
241
+ // `table 5x4` — skeleton shorthand
242
+ if (first === 'table') {
243
+ if (TABLE_SKELETON_RE.test(segment)) {
244
+ return { keyword: first, type: 'table', param: rest };
245
+ }
246
+ // `table` followed by content → still a table block
247
+ return { keyword: first, type: 'table' };
248
+ }
249
+
250
+ // Keywords with recognized param sets
251
+ if (kw.params) {
252
+ if (kw.params.has(rest.toLowerCase())) {
253
+ return {
254
+ keyword: first,
255
+ type: first as WireframeElementType,
256
+ param: rest.toLowerCase(),
257
+ };
258
+ }
259
+ // Unrecognized trailing words → bare text (F9)
260
+ return null;
261
+ }
262
+
263
+ // Block keywords with unrecognized trailing → bare text
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * Parse a single segment (after multi-element line splitting) into an element.
269
+ */
270
+ function parseSegment(
271
+ segment: string,
272
+ lineNumber: number,
273
+ indent: number,
274
+ diagnostics: DgmoError[]
275
+ ): WireframeElement | null {
276
+ const trimmed = segment.trim();
277
+ if (!trimmed) return null;
278
+
279
+ // Heading: `# text` or `## text`
280
+ const headingMatch = trimmed.match(HEADING_RE);
281
+ if (headingMatch) {
282
+ const level = headingMatch[1].length as 1 | 2;
283
+ const el = makeElement(
284
+ 'heading',
285
+ headingMatch[2].trim(),
286
+ lineNumber,
287
+ indent
288
+ );
289
+ el.headingLevel = level;
290
+ return el;
291
+ }
292
+
293
+ // Divider: `---`
294
+ if (DIVIDER_RE.test(trimmed)) {
295
+ return makeElement('divider', '', lineNumber, indent);
296
+ }
297
+
298
+ // Checkbox checked: `<x>` (EC6 — strict pattern)
299
+ if (CHECKBOX_CHECKED_RE.test(trimmed)) {
300
+ const el = makeElement('checkbox', '', lineNumber, indent);
301
+ el.checked = true;
302
+ return el;
303
+ }
304
+
305
+ // Checkbox unchecked: `< >`
306
+ if (CHECKBOX_UNCHECKED_RE.test(trimmed)) {
307
+ const el = makeElement('checkbox', '', lineNumber, indent);
308
+ el.checked = false;
309
+ return el;
310
+ }
311
+
312
+ // Radio selected: `(*) Label`
313
+ const radioSelMatch = trimmed.match(RADIO_SELECTED_RE);
314
+ if (radioSelMatch) {
315
+ const el = makeElement(
316
+ 'radio',
317
+ radioSelMatch[1].trim(),
318
+ lineNumber,
319
+ indent
320
+ );
321
+ el.selected = true;
322
+ return el;
323
+ }
324
+
325
+ // Radio unselected: `( ) Label`
326
+ const radioUnselMatch = trimmed.match(RADIO_UNSELECTED_RE);
327
+ if (radioUnselMatch) {
328
+ const el = makeElement(
329
+ 'radio',
330
+ radioUnselMatch[1].trim(),
331
+ lineNumber,
332
+ indent
333
+ );
334
+ el.selected = false;
335
+ return el;
336
+ }
337
+
338
+ // Checkbox with label: `<x> Label` or `< > Label`
339
+ const checkLabelMatch = trimmed.match(/^(<\s*x?\s*>)\s+(.+)$/i);
340
+ if (checkLabelMatch) {
341
+ const isChecked = /x/i.test(checkLabelMatch[1]);
342
+ // Check for `| toggle` or other metadata
343
+ const labelPart = checkLabelMatch[2];
344
+ const pipeSplit = labelPart.split(/\s*\|\s*/);
345
+ const el = makeElement('checkbox', pipeSplit[0].trim(), lineNumber, indent);
346
+ el.checked = isChecked;
347
+ if (pipeSplit.length > 1) {
348
+ applyMetadata(el, pipeSplit.slice(1).join(', '));
349
+ }
350
+ return el;
351
+ }
352
+
353
+ // Dropdown: `{opt1 | opt2 | opt3}` (optional trailing metadata after closing brace)
354
+ const dropdownMatch = trimmed.match(DROPDOWN_RE);
355
+ if (dropdownMatch) {
356
+ const options = dropdownMatch[1]
357
+ .split('|')
358
+ .map((s) => s.trim())
359
+ .filter(Boolean);
360
+ const el = makeElement('dropdown', options[0] || '', lineNumber, indent);
361
+ el.options = options;
362
+ applyMetadata(el, dropdownMatch[2]);
363
+ return el;
364
+ }
365
+
366
+ // Parens with pipes — likely meant dropdown (AC25) — check before button match
367
+ const parenPipeMatch = trimmed.match(/^\(([^)]*\|[^)]*)\)\s*$/);
368
+ if (parenPipeMatch) {
369
+ diagnostics.push(
370
+ makeDgmoError(
371
+ lineNumber,
372
+ `Did you mean a dropdown? Use braces: {${parenPipeMatch[1]}} instead of (${parenPipeMatch[1]})`,
373
+ 'warning'
374
+ )
375
+ );
376
+ const options = parenPipeMatch[1]
377
+ .split('|')
378
+ .map((s) => s.trim())
379
+ .filter(Boolean);
380
+ const el = makeElement('dropdown', options[0] || '', lineNumber, indent);
381
+ el.options = options;
382
+ return el;
383
+ }
384
+
385
+ // Button: `(label)` (must come after radio/checkbox checks)
386
+ const buttonMatch = trimmed.match(BUTTON_RE);
387
+ if (buttonMatch) {
388
+ const el = makeElement('button', buttonMatch[1].trim(), lineNumber, indent);
389
+ applyMetadata(el, buttonMatch[2]);
390
+ return el;
391
+ }
392
+
393
+ // Group/Input: `[content]`
394
+ const bracketMatch = trimmed.match(BRACKET_RE);
395
+ if (bracketMatch) {
396
+ // Will be disambiguated as group vs input later (by children or EC1 metadata)
397
+ const el = makeElement('group', bracketMatch[1].trim(), lineNumber, indent);
398
+ applyMetadata(el, bracketMatch[2]);
399
+ // If no group-forcing metadata applied, default to textInput — will be
400
+ // overridden to 'group' if children are added during indent-stack processing
401
+ if (!el.isContainer) {
402
+ el.type = 'textInput';
403
+ // Apply field variants now that type is set
404
+ if (el.states.includes('password')) el.fieldVariant = 'password';
405
+ if (el.states.includes('textarea')) el.fieldVariant = 'textarea';
406
+ }
407
+ return el;
408
+ }
409
+
410
+ // List item: `- text` (F38: anchored to segment start)
411
+ const listMatch = trimmed.match(LIST_ITEM_RE);
412
+ if (listMatch) {
413
+ return makeElement('listItem', listMatch[1].trim(), lineNumber, indent);
414
+ }
415
+
416
+ // Keyword elements
417
+ const kwMatch = matchKeyword(trimmed);
418
+ if (kwMatch) {
419
+ const el = makeElement(
420
+ kwMatch.type,
421
+ kwMatch.param || '',
422
+ lineNumber,
423
+ indent
424
+ );
425
+ if (kwMatch.type === 'image') {
426
+ el.imageHint = (kwMatch.param as 'round' | 'wide') || 'default';
427
+ } else if (kwMatch.type === 'progress') {
428
+ const val = parseInt(kwMatch.param || '0', 10);
429
+ el.progressValue = Math.max(0, Math.min(100, isNaN(val) ? 0 : val));
430
+ } else if (kwMatch.type === 'chart') {
431
+ el.chartHint = (kwMatch.param as 'line' | 'bar' | 'pie') || 'line';
432
+ } else if (kwMatch.type === 'table') {
433
+ // Check for skeleton shorthand
434
+ const skelMatch = `table ${kwMatch.param || ''}`.match(TABLE_SKELETON_RE);
435
+ if (skelMatch) {
436
+ el.tableRows = parseInt(skelMatch[1], 10);
437
+ el.tableCols = parseInt(skelMatch[2], 10);
438
+ }
439
+ }
440
+ // Block keywords become containers
441
+ if (ELEMENT_KEYWORDS[kwMatch.keyword]?.block) {
442
+ el.isContainer = true;
443
+ }
444
+ return el;
445
+ }
446
+
447
+ // Unmatched opening brackets (before pipe split, which would interfere)
448
+ if (/^\[[^\]]*$/.test(trimmed)) {
449
+ diagnostics.push(
450
+ makeDgmoError(
451
+ lineNumber,
452
+ `Unmatched '[' — auto-closing bracket`,
453
+ 'warning'
454
+ )
455
+ );
456
+ const label = trimmed.substring(1).trim();
457
+ return makeElement('textInput', label, lineNumber, indent);
458
+ }
459
+ if (/^\([^)]*$/.test(trimmed) && !trimmed.match(/^\(\s*\*?\s*\)/)) {
460
+ diagnostics.push(
461
+ makeDgmoError(
462
+ lineNumber,
463
+ `Unmatched '(' — auto-closing bracket`,
464
+ 'warning'
465
+ )
466
+ );
467
+ const label = trimmed.substring(1).trim();
468
+ return makeElement('button', label, lineNumber, indent);
469
+ }
470
+ if (/^\{[^}]*$/.test(trimmed)) {
471
+ diagnostics.push(
472
+ makeDgmoError(
473
+ lineNumber,
474
+ `Unmatched '{' — auto-closing bracket`,
475
+ 'warning'
476
+ )
477
+ );
478
+ const rawContent = trimmed.substring(1).trim();
479
+ const options = rawContent
480
+ .split('|')
481
+ .map((s) => s.trim())
482
+ .filter(Boolean);
483
+ const el = makeElement('dropdown', options[0] || '', lineNumber, indent);
484
+ el.options = options;
485
+ return el;
486
+ }
487
+
488
+ // Bare text (with optional pipe metadata for inline alerts)
489
+ const pipeParts = trimmed.split(/\s*\|\s*/);
490
+ if (pipeParts.length > 1) {
491
+ const textContent = pipeParts[0].trim();
492
+ const metaStr = pipeParts.slice(1).join(', ');
493
+ const { states } = parseWireframeMetadata(metaStr);
494
+
495
+ // Bare text + semantic state = inline alert (F22)
496
+ const semanticStates = ['warning', 'destructive', 'success', 'info'];
497
+ const hasSemantic = states.some((s) => semanticStates.includes(s));
498
+ if (hasSemantic) {
499
+ const el = makeElement('alert', textContent, lineNumber, indent);
500
+ el.states = states;
501
+ return el;
502
+ }
503
+
504
+ // Regular text with metadata
505
+ const el = makeElement('text', textContent, lineNumber, indent);
506
+ applyMetadata(el, metaStr);
507
+ return el;
508
+ }
509
+
510
+ return makeElement('text', trimmed, lineNumber, indent);
511
+ }
512
+
513
+ /**
514
+ * Split a line into segments on 2+ space boundaries (multi-element line rule).
515
+ * Single space is same element.
516
+ */
517
+ function splitLineSegments(line: string): string[] {
518
+ return line.split(/\s{2,}/).filter(Boolean);
519
+ }
520
+
521
+ // ============================================================
522
+ // Main Parser
523
+ // ============================================================
524
+
525
+ export function parseWireframe(content: string): ParsedWireframe {
526
+ resetWireframeIds();
527
+
528
+ const diagnostics: DgmoError[] = [];
529
+ const lines = content.split('\n');
530
+
531
+ let title: string | null = null;
532
+ let titleLineNumber: number | null = null;
533
+ let formFactor: WireframeFormFactor = 'desktop';
534
+ const roots: WireframeElement[] = [];
535
+ const modals: WireframeElement[] = [];
536
+ const tagGroups: TagGroup[] = [];
537
+ const options: Record<string, string> = {};
538
+
539
+ // Parsing state
540
+ let phase: 'header' | 'tags' | 'content' = 'header';
541
+ let currentTagGroup: TagGroup | null = null;
542
+
543
+ function pushWarning(line: number, msg: string): void {
544
+ diagnostics.push(makeDgmoError(line, msg, 'warning'));
545
+ }
546
+
547
+ function makeTagGroup(trimmed: string, lineNumber: number): TagGroup | null {
548
+ const match = matchTagBlockHeading(trimmed);
549
+ if (!match) return null;
550
+ return {
551
+ name: match.name,
552
+ alias: match.alias,
553
+ entries: [],
554
+ lineNumber,
555
+ };
556
+ }
557
+
558
+ // Indent stack for hierarchy
559
+ const indentStack: { node: WireframeElement; indent: number }[] = [];
560
+
561
+ function findParent(indent: number): WireframeElement | null {
562
+ // Pop nodes at same or deeper indent
563
+ while (
564
+ indentStack.length > 0 &&
565
+ indentStack[indentStack.length - 1].indent >= indent
566
+ ) {
567
+ const popped = indentStack.pop()!;
568
+ // When a node had children pushed to it, mark as container
569
+ if (popped.node.children.length > 0 && !popped.node.isContainer) {
570
+ popped.node.isContainer = true;
571
+ // If it was textInput (from bracket detection), upgrade to group
572
+ if (popped.node.type === 'textInput') {
573
+ popped.node.type = 'group';
574
+ }
575
+ }
576
+ }
577
+ return indentStack.length > 0
578
+ ? indentStack[indentStack.length - 1].node
579
+ : null;
580
+ }
581
+
582
+ function pushElement(el: WireframeElement): void {
583
+ // Modal elements go to separate array
584
+ if (el.type === 'modal') {
585
+ modals.push(el);
586
+ indentStack.push({ node: el, indent: el.indent });
587
+ return;
588
+ }
589
+
590
+ // Find parent based on indent
591
+ findParent(el.indent);
592
+
593
+ if (indentStack.length === 0) {
594
+ roots.push(el);
595
+ } else {
596
+ const parent = indentStack[indentStack.length - 1].node;
597
+ parent.children.push(el);
598
+ // Propagate skeleton flag
599
+ if (parent.type === 'skeleton' || parent.isSkeleton) {
600
+ el.isSkeleton = true;
601
+ }
602
+ }
603
+
604
+ // Push onto stack if it could be a container
605
+ if (
606
+ el.isContainer ||
607
+ el.type === 'group' ||
608
+ el.type === 'textInput' || // might become group if children follow
609
+ el.type === 'nav' ||
610
+ el.type === 'tabs' ||
611
+ el.type === 'table' ||
612
+ el.type === 'skeleton' ||
613
+ el.type === 'alert'
614
+ ) {
615
+ indentStack.push({ node: el, indent: el.indent });
616
+ }
617
+ }
618
+
619
+ function pushInlineRow(
620
+ segments: string[],
621
+ lineNumber: number,
622
+ indent: number,
623
+ diags: DgmoError[]
624
+ ): void {
625
+ const children: WireframeElement[] = [];
626
+ let lastEl: WireframeElement | null = null;
627
+ for (const seg of segments) {
628
+ // EC5: segment starting with `|` attaches to previous element
629
+ if (seg.startsWith('|') && lastEl) {
630
+ applyMetadata(lastEl, seg.substring(1).trim());
631
+ continue;
632
+ }
633
+ const el = parseSegment(seg, lineNumber, indent, diags);
634
+ if (el) {
635
+ children.push(el);
636
+ lastEl = el;
637
+ }
638
+ }
639
+ if (children.length === 0) return;
640
+ if (children.length === 1) {
641
+ pushElement(children[0]);
642
+ return;
643
+ }
644
+ // Wrap in a horizontal inline row
645
+ const wrapper = makeElement('group', '', lineNumber, indent);
646
+ wrapper.id = genId(lineNumber, indent, 'row');
647
+ wrapper.isContainer = true;
648
+ wrapper.orientation = 'horizontal';
649
+ wrapper.children = children;
650
+ wrapper.metadata._inlineRow = 'true';
651
+ pushElement(wrapper);
652
+ }
653
+
654
+ for (let i = 0; i < lines.length; i++) {
655
+ const line = lines[i];
656
+ const lineNumber = i + 1;
657
+ const trimmed = line.trim();
658
+
659
+ // Skip empty lines and comments
660
+ if (!trimmed || trimmed.startsWith('//')) continue;
661
+
662
+ const indent = measureIndent(line);
663
+
664
+ // ── Header phase ──────────────────────────────────────
665
+ if (phase === 'header') {
666
+ // First line: chart type declaration
667
+ const firstLineResult = parseFirstLine(trimmed);
668
+ if (firstLineResult && firstLineResult.chartType === 'wireframe') {
669
+ title = firstLineResult.title || null;
670
+ titleLineNumber = lineNumber;
671
+ continue;
672
+ }
673
+
674
+ // Options: `mobile`, `palette xxx`, `theme xxx`
675
+ if (trimmed === 'mobile') {
676
+ formFactor = 'mobile';
677
+ continue;
678
+ }
679
+
680
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
681
+ if (optMatch && KNOWN_OPTIONS.has(optMatch[1].toLowerCase())) {
682
+ options[optMatch[1]] = optMatch[2] || '';
683
+ continue;
684
+ }
685
+
686
+ // Tag group heading
687
+ if (isTagBlockHeading(trimmed)) {
688
+ const tg = makeTagGroup(trimmed, lineNumber);
689
+ if (tg) {
690
+ currentTagGroup = tg;
691
+ tagGroups.push(tg);
692
+ phase = 'tags';
693
+ continue;
694
+ }
695
+ }
696
+
697
+ // Not a header line — switch to content phase
698
+ phase = 'content';
699
+ // Fall through to content parsing
700
+ }
701
+
702
+ // ── Tag group entries ─────────────────────────────────
703
+ if (phase === 'tags') {
704
+ // Tag group heading
705
+ if (isTagBlockHeading(trimmed)) {
706
+ const tg = makeTagGroup(trimmed, lineNumber);
707
+ if (tg) {
708
+ currentTagGroup = tg;
709
+ tagGroups.push(tg);
710
+ continue;
711
+ }
712
+ }
713
+
714
+ // Indented tag entry: `Value(color)` or `Value(color) default`
715
+ if (indent > 0 && currentTagGroup) {
716
+ const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
717
+ const { label, color } = extractColor(cleanEntry);
718
+ if (color) {
719
+ currentTagGroup.entries.push({
720
+ value: label,
721
+ color,
722
+ lineNumber,
723
+ });
724
+ if (isDefault) {
725
+ currentTagGroup.defaultValue = label;
726
+ } else if (currentTagGroup.entries.length === 1) {
727
+ currentTagGroup.defaultValue = label;
728
+ }
729
+ } else {
730
+ pushWarning(
731
+ lineNumber,
732
+ `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
733
+ );
734
+ }
735
+ continue;
736
+ }
737
+
738
+ // Non-indented, non-tag-heading → switch to content
739
+ phase = 'content';
740
+ currentTagGroup = null;
741
+ // Fall through
742
+ }
743
+
744
+ // ── Content phase ─────────────────────────────────────
745
+ if (phase !== 'content') {
746
+ phase = 'content';
747
+ }
748
+
749
+ // Options can appear in content phase too (e.g., `mobile` anywhere)
750
+ if (indent === 0 && trimmed === 'mobile') {
751
+ formFactor = 'mobile';
752
+ continue;
753
+ }
754
+ if (indent === 0) {
755
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
756
+ if (
757
+ optMatch &&
758
+ !trimmed.startsWith('#') &&
759
+ !trimmed.startsWith('[') &&
760
+ !trimmed.startsWith('(')
761
+ ) {
762
+ // Only treat as option if it looks like one (palette, theme, active-tag, etc.)
763
+ const key = optMatch[1];
764
+ if (['palette', 'theme', 'active-tag'].includes(key)) {
765
+ options[key] = optMatch[2] || '';
766
+ continue;
767
+ }
768
+ }
769
+ }
770
+
771
+ // Tag group entries can appear in content phase too
772
+ if (isTagBlockHeading(trimmed)) {
773
+ const tg = makeTagGroup(trimmed, lineNumber);
774
+ if (tg) {
775
+ currentTagGroup = tg;
776
+ tagGroups.push(tg);
777
+ phase = 'tags';
778
+ continue;
779
+ }
780
+ }
781
+
782
+ // Table data rows (comma-separated, indented under a table element)
783
+ if (indentStack.length > 0) {
784
+ const topNode = indentStack[indentStack.length - 1].node;
785
+ if (topNode.type === 'table' && indent > topNode.indent) {
786
+ // Parse as table row
787
+ const cells = parseTableRow(trimmed);
788
+ if (!topNode.tableHeaders) {
789
+ // First indented row = header row if it's a comma-separated line
790
+ if (trimmed.includes(',')) {
791
+ topNode.tableHeaders = cells;
792
+ } else {
793
+ // Single value — treat as header
794
+ topNode.tableHeaders = cells;
795
+ }
796
+ } else {
797
+ if (!topNode.tableData) topNode.tableData = [];
798
+ topNode.tableData.push(cells);
799
+ }
800
+ continue;
801
+ }
802
+ }
803
+
804
+ // Multi-element line splitting
805
+ const segments = splitLineSegments(trimmed);
806
+
807
+ if (segments.length === 1) {
808
+ // Single element
809
+ const el = parseSegment(segments[0], lineNumber, indent, diagnostics);
810
+ if (el) pushElement(el);
811
+ } else if (segments.length === 2) {
812
+ // Check for orphaned pipe metadata (EC5): second segment starts with `|`
813
+ if (segments[1].startsWith('|')) {
814
+ const el = parseSegment(segments[0], lineNumber, indent, diagnostics);
815
+ if (el) {
816
+ applyMetadata(el, segments[1].substring(1).trim());
817
+ pushElement(el);
818
+ }
819
+ } else {
820
+ // Check for label-field pairing (ADR-9):
821
+ // First is bare text AND second contains bracket-mnemonic element
822
+ const firstIsBare = isBareText(segments[0]);
823
+ const secondIsElement = hasBracketMnemonic(segments[1]);
824
+
825
+ if (firstIsBare && secondIsElement) {
826
+ // Label-for-element pairing
827
+ const fieldEl = parseSegment(
828
+ segments[1],
829
+ lineNumber,
830
+ indent,
831
+ diagnostics
832
+ );
833
+ if (fieldEl) {
834
+ const labelEl = makeElement(
835
+ 'text',
836
+ segments[0].trim(),
837
+ lineNumber,
838
+ indent
839
+ );
840
+ labelEl.id = genId(lineNumber, indent, 'lbl');
841
+ labelEl.labelFor = fieldEl;
842
+ // Wrap in inline container
843
+ const wrapper = makeElement('group', '', lineNumber, indent);
844
+ wrapper.id = genId(lineNumber, indent, 'lf');
845
+ wrapper.isContainer = true;
846
+ wrapper.orientation = 'horizontal';
847
+ wrapper.children.push(labelEl, fieldEl);
848
+ wrapper.metadata._labelField = 'true';
849
+ pushElement(wrapper);
850
+ }
851
+ } else {
852
+ // Two inline items — wrap in horizontal row
853
+ pushInlineRow(segments, lineNumber, indent, diagnostics);
854
+ }
855
+ }
856
+ } else {
857
+ // 3+ segments = inline items, no label pairing — wrap in horizontal row
858
+ pushInlineRow(segments, lineNumber, indent, diagnostics);
859
+ }
860
+ }
861
+
862
+ // Final cleanup: pop remaining indent stack to finalize container detection
863
+ while (indentStack.length > 0) {
864
+ const popped = indentStack.pop()!;
865
+ if (popped.node.children.length > 0 && !popped.node.isContainer) {
866
+ popped.node.isContainer = true;
867
+ if (popped.node.type === 'textInput') {
868
+ popped.node.type = 'group';
869
+ }
870
+ }
871
+ }
872
+
873
+ // Validate tag groups
874
+ validateTagGroupNames(tagGroups, pushWarning);
875
+
876
+ const error = diagnostics.find((d) => d.severity === 'error')
877
+ ? formatDgmoError(diagnostics.find((d) => d.severity === 'error')!)
878
+ : null;
879
+
880
+ return {
881
+ title,
882
+ titleLineNumber,
883
+ formFactor,
884
+ roots,
885
+ modals,
886
+ tagGroups,
887
+ options,
888
+ diagnostics,
889
+ error,
890
+ };
891
+ }
892
+
893
+ // ============================================================
894
+ // Table row parsing (bracket-depth tracking, F7)
895
+ // ============================================================
896
+
897
+ function parseTableRow(line: string): string[] {
898
+ const cells: string[] = [];
899
+ let current = '';
900
+ let depth = 0;
901
+ let escaped = false;
902
+
903
+ for (let i = 0; i < line.length; i++) {
904
+ const ch = line[i];
905
+
906
+ if (escaped) {
907
+ current += ch;
908
+ escaped = false;
909
+ continue;
910
+ }
911
+
912
+ if (ch === '\\') {
913
+ escaped = true;
914
+ continue;
915
+ }
916
+
917
+ if (ch === '[' || ch === '{' || ch === '(' || ch === '<') depth++;
918
+ if (ch === ']' || ch === '}' || ch === ')' || ch === '>')
919
+ depth = Math.max(0, depth - 1);
920
+
921
+ if (ch === ',' && depth === 0) {
922
+ cells.push(current.trim());
923
+ current = '';
924
+ continue;
925
+ }
926
+
927
+ current += ch;
928
+ }
929
+
930
+ if (current.trim()) {
931
+ cells.push(current.trim());
932
+ }
933
+
934
+ return cells;
935
+ }
936
+
937
+ // ============================================================
938
+ // Helpers for multi-element line disambiguation
939
+ // ============================================================
940
+
941
+ function isBareText(segment: string): boolean {
942
+ const s = segment.trim();
943
+ if (!s) return false;
944
+ // Not bare text if it starts with a bracket mnemonic character
945
+ if (/^[[\]({<#-]/.test(s)) return false;
946
+ // Not bare text if it's a keyword
947
+ if (matchKeyword(s)) return false;
948
+ return true;
949
+ }
950
+
951
+ function hasBracketMnemonic(segment: string): boolean {
952
+ const s = segment.trim();
953
+ return (
954
+ /^\[/.test(s) || /^\(/.test(s) || /^\{/.test(s) || /^<\s*x?\s*>/i.test(s)
955
+ );
956
+ }