@wireweave/core 1.0.0-beta.20260107130839 → 1.0.0-beta.20260107132939

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.
@@ -1,1050 +0,0 @@
1
- /**
2
- * SVG Renderer for wireweave
3
- *
4
- * Renders wireframe AST to SVG image format
5
- */
6
-
7
- import type {
8
- WireframeDocument,
9
- PageNode,
10
- AnyNode,
11
- RowNode,
12
- ColNode,
13
- HeaderNode,
14
- MainNode,
15
- FooterNode,
16
- SidebarNode,
17
- CardNode,
18
- TextNode,
19
- TitleNode,
20
- ButtonNode,
21
- InputNode,
22
- TextareaNode,
23
- TableNode,
24
- ListNode,
25
- AlertNode,
26
- BadgeNode,
27
- ProgressNode,
28
- SpinnerNode,
29
- CheckboxNode,
30
- RadioNode,
31
- SwitchNode,
32
- ImageNode,
33
- PlaceholderNode,
34
- AvatarNode,
35
- ModalNode,
36
- NavNode,
37
- TabsNode,
38
- BreadcrumbNode,
39
- SelectNode,
40
- LinkNode,
41
- } from '../../ast/types';
42
- import type { SvgRenderOptions, SvgRenderResult, ThemeConfig } from '../types';
43
- import { defaultTheme } from '../types';
44
- import { resolveViewport } from '../../viewport';
45
- import { getIconData, type IconData } from '../../icons/lucide-icons';
46
-
47
- /**
48
- * Bounding box for layout calculations
49
- */
50
- export interface BoundingBox {
51
- x: number;
52
- y: number;
53
- width: number;
54
- height: number;
55
- }
56
-
57
- /**
58
- * SVG Renderer class
59
- *
60
- * Renders wireframe AST nodes to SVG elements
61
- */
62
- export class SvgRenderer {
63
- private options: Required<SvgRenderOptions>;
64
- private theme: ThemeConfig;
65
- private currentX: number = 0;
66
- private currentY: number = 0;
67
- private contentWidth: number = 0;
68
-
69
- constructor(options: SvgRenderOptions = {}) {
70
- this.options = {
71
- width: options.width ?? 800,
72
- height: options.height ?? 600,
73
- scale: options.scale ?? 1,
74
- background: options.background ?? '#ffffff',
75
- padding: options.padding ?? 20,
76
- fontFamily: options.fontFamily ?? 'system-ui, -apple-system, sans-serif',
77
- };
78
- this.theme = defaultTheme;
79
- this.contentWidth = this.options.width - this.options.padding * 2;
80
- }
81
-
82
- /**
83
- * Render a wireframe document to SVG
84
- */
85
- render(doc: WireframeDocument): SvgRenderResult {
86
- this.currentX = this.options.padding;
87
- this.currentY = this.options.padding;
88
-
89
- // Get viewport from first page (only if explicitly set), or use options
90
- const firstPage = doc.children[0];
91
- let width = this.options.width;
92
- let height = this.options.height;
93
-
94
- if (firstPage && (firstPage.viewport !== undefined || firstPage.device !== undefined)) {
95
- const viewport = resolveViewport(firstPage.viewport, firstPage.device);
96
- width = viewport.width;
97
- height = viewport.height;
98
- this.contentWidth = width - this.options.padding * 2;
99
- }
100
-
101
- const content = doc.children.map((page) => this.renderPage(page)).join('\n');
102
-
103
- const svg = `<?xml version="1.0" encoding="UTF-8"?>
104
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
105
- <defs>
106
- ${this.generateDefs()}
107
- </defs>
108
- <rect width="100%" height="100%" fill="${this.options.background}"/>
109
- <g transform="scale(${this.options.scale})">
110
- ${content}
111
- </g>
112
- </svg>`;
113
-
114
- return { svg, width, height };
115
- }
116
-
117
- /**
118
- * Generate SVG defs (styles, patterns, etc.)
119
- */
120
- private generateDefs(): string {
121
- return `
122
- <style>
123
- text { font-family: ${this.options.fontFamily}; }
124
- .wf-title { font-weight: 600; }
125
- .wf-muted { fill: ${this.theme.colors.muted}; }
126
- </style>
127
- `;
128
- }
129
-
130
- /**
131
- * Render a page node
132
- */
133
- private renderPage(node: PageNode): string {
134
- const elements: string[] = [];
135
-
136
- // Render page title if present
137
- if (node.title) {
138
- elements.push(this.renderPageTitle(node.title));
139
- }
140
-
141
- for (const child of node.children) {
142
- elements.push(this.renderNode(child));
143
- }
144
-
145
- return elements.join('\n');
146
- }
147
-
148
- /**
149
- * Render page title
150
- */
151
- private renderPageTitle(title: string): string {
152
- const fontSize = 24;
153
- const y = this.currentY + fontSize;
154
- this.currentY += fontSize + 16;
155
-
156
- return `<text x="${this.currentX}" y="${y}" font-size="${fontSize}" font-weight="600" fill="${this.theme.colors.foreground}">${this.escapeXml(title)}</text>`;
157
- }
158
-
159
- /**
160
- * Render any AST node
161
- */
162
- private renderNode(node: AnyNode): string {
163
- switch (node.type) {
164
- // Layout nodes
165
- case 'Row':
166
- return this.renderRow(node as RowNode);
167
- case 'Col':
168
- return this.renderCol(node as ColNode);
169
- case 'Header':
170
- return this.renderHeader(node as HeaderNode);
171
- case 'Main':
172
- return this.renderMain(node as MainNode);
173
- case 'Footer':
174
- return this.renderFooter(node as FooterNode);
175
- case 'Sidebar':
176
- return this.renderSidebar(node as SidebarNode);
177
-
178
- // Container nodes
179
- case 'Card':
180
- return this.renderCard(node as CardNode);
181
- case 'Modal':
182
- return this.renderModal(node as ModalNode);
183
-
184
- // Text nodes
185
- case 'Text':
186
- return this.renderText(node as TextNode);
187
- case 'Title':
188
- return this.renderTitle(node as TitleNode);
189
- case 'Link':
190
- return this.renderLink(node as LinkNode);
191
-
192
- // Input nodes
193
- case 'Input':
194
- return this.renderInput(node as InputNode);
195
- case 'Textarea':
196
- return this.renderTextarea(node as TextareaNode);
197
- case 'Select':
198
- return this.renderSelect(node as SelectNode);
199
- case 'Checkbox':
200
- return this.renderCheckbox(node as CheckboxNode);
201
- case 'Radio':
202
- return this.renderRadio(node as RadioNode);
203
- case 'Switch':
204
- return this.renderSwitch(node as SwitchNode);
205
-
206
- // Button
207
- case 'Button':
208
- return this.renderButton(node as ButtonNode);
209
-
210
- // Display nodes
211
- case 'Image':
212
- return this.renderImage(node as ImageNode);
213
- case 'Placeholder':
214
- return this.renderPlaceholder(node as PlaceholderNode);
215
- case 'Avatar':
216
- return this.renderAvatar(node as AvatarNode);
217
- case 'Badge':
218
- return this.renderBadge(node as BadgeNode);
219
-
220
- // Data nodes
221
- case 'Table':
222
- return this.renderTable(node as TableNode);
223
- case 'List':
224
- return this.renderList(node as ListNode);
225
-
226
- // Feedback nodes
227
- case 'Alert':
228
- return this.renderAlert(node as AlertNode);
229
- case 'Progress':
230
- return this.renderProgress(node as ProgressNode);
231
- case 'Spinner':
232
- return this.renderSpinner(node as SpinnerNode);
233
-
234
- // Navigation nodes
235
- case 'Nav':
236
- return this.renderNav(node as NavNode);
237
- case 'Tabs':
238
- return this.renderTabs(node as TabsNode);
239
- case 'Breadcrumb':
240
- return this.renderBreadcrumb(node as BreadcrumbNode);
241
-
242
- default:
243
- return `<!-- Unsupported: ${node.type} -->`;
244
- }
245
- }
246
-
247
- // ===========================================
248
- // Layout Renderers
249
- // ===========================================
250
-
251
- private renderRow(node: RowNode): string {
252
- const savedX = this.currentX;
253
- const savedY = this.currentY;
254
- const elements: string[] = [];
255
-
256
- // Calculate column widths
257
- const totalSpan = node.children.reduce((sum, child) => {
258
- if ('span' in child && typeof child.span === 'number') {
259
- return sum + child.span;
260
- }
261
- return sum + 1;
262
- }, 0);
263
-
264
- const colWidth = this.contentWidth / Math.max(totalSpan, 1);
265
- let maxHeight = 0;
266
-
267
- for (const child of node.children) {
268
- const span = 'span' in child && typeof child.span === 'number' ? child.span : 1;
269
- const childWidth = colWidth * span;
270
- const startY = this.currentY;
271
-
272
- elements.push(this.renderNode(child));
273
-
274
- const childHeight = this.currentY - startY;
275
- maxHeight = Math.max(maxHeight, childHeight);
276
-
277
- this.currentX += childWidth;
278
- this.currentY = savedY;
279
- }
280
-
281
- this.currentX = savedX;
282
- this.currentY = savedY + maxHeight;
283
-
284
- return elements.join('\n');
285
- }
286
-
287
- private renderCol(node: ColNode): string {
288
- return node.children.map((child) => this.renderNode(child)).join('\n');
289
- }
290
-
291
- private renderHeader(node: HeaderNode): string {
292
- const height = 60;
293
- const x = this.currentX;
294
- const y = this.currentY;
295
-
296
- const savedY = this.currentY;
297
- this.currentY += 16;
298
- const children = node.children.map((c) => this.renderNode(c)).join('\n');
299
- this.currentY = savedY + height + 8;
300
-
301
- return `
302
- <g transform="translate(${x}, ${y})">
303
- <rect width="${this.contentWidth}" height="${height}" fill="${this.theme.colors.background}" stroke="${this.theme.colors.border}" stroke-width="1"/>
304
- ${children}
305
- </g>`;
306
- }
307
-
308
- private renderMain(node: MainNode): string {
309
- return node.children.map((c) => this.renderNode(c)).join('\n');
310
- }
311
-
312
- private renderFooter(node: FooterNode): string {
313
- const height = 60;
314
- const x = this.currentX;
315
- const y = this.currentY;
316
-
317
- const savedY = this.currentY;
318
- this.currentY += 16;
319
- const children = node.children.map((c) => this.renderNode(c)).join('\n');
320
- this.currentY = savedY + height + 8;
321
-
322
- return `
323
- <g transform="translate(${x}, ${y})">
324
- <rect width="${this.contentWidth}" height="${height}" fill="${this.theme.colors.background}" stroke="${this.theme.colors.border}" stroke-width="1"/>
325
- ${children}
326
- </g>`;
327
- }
328
-
329
- private renderSidebar(node: SidebarNode): string {
330
- const width = 200;
331
- const height = 300;
332
- const x = this.currentX;
333
- const y = this.currentY;
334
-
335
- const savedY = this.currentY;
336
- this.currentY += 16;
337
- const children = node.children.map((c) => this.renderNode(c)).join('\n');
338
- this.currentY = savedY + height + 8;
339
-
340
- return `
341
- <g transform="translate(${x}, ${y})">
342
- <rect width="${width}" height="${height}" fill="${this.theme.colors.background}" stroke="${this.theme.colors.border}" stroke-width="1"/>
343
- ${children}
344
- </g>`;
345
- }
346
-
347
- // ===========================================
348
- // Container Renderers
349
- // ===========================================
350
-
351
- private renderCard(node: CardNode): string {
352
- const width = Math.min(300, this.contentWidth);
353
- const x = this.currentX;
354
- const y = this.currentY;
355
-
356
- const savedY = this.currentY;
357
- this.currentY += 16;
358
-
359
- // Render title if present
360
- let titleSvg = '';
361
- if (node.title) {
362
- const titleFontSize = 16;
363
- titleSvg = `<text x="16" y="${titleFontSize + 12}" font-size="${titleFontSize}" font-weight="600" fill="${this.theme.colors.foreground}">${this.escapeXml(node.title)}</text>`;
364
- this.currentY += titleFontSize + 8;
365
- }
366
-
367
- const childStartY = this.currentY - savedY;
368
- const children = node.children.map((c) => this.renderNode(c)).join('\n');
369
- const contentHeight = Math.max(this.currentY - savedY, 100);
370
-
371
- this.currentY = savedY + contentHeight + 16;
372
-
373
- return `
374
- <g transform="translate(${x}, ${y})">
375
- <rect width="${width}" height="${contentHeight}" rx="8" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>
376
- ${titleSvg}
377
- <g transform="translate(16, ${childStartY})">
378
- ${children}
379
- </g>
380
- </g>`;
381
- }
382
-
383
- private renderModal(node: ModalNode): string {
384
- const width = 400;
385
- const height = 300;
386
- const x = (this.options.width - width) / 2;
387
- const y = (this.options.height - height) / 2;
388
-
389
- let titleSvg = '';
390
- if (node.title) {
391
- titleSvg = `<text x="20" y="30" font-size="18" font-weight="600" fill="${this.theme.colors.foreground}">${this.escapeXml(node.title)}</text>`;
392
- }
393
-
394
- const savedX = this.currentX;
395
- const savedY = this.currentY;
396
- this.currentX = 20;
397
- this.currentY = 50;
398
-
399
- const children = node.children.map((c) => this.renderNode(c)).join('\n');
400
-
401
- this.currentX = savedX;
402
- this.currentY = savedY;
403
-
404
- return `
405
- <g>
406
- <rect width="100%" height="100%" fill="rgba(0,0,0,0.5)" opacity="0.5"/>
407
- <g transform="translate(${x}, ${y})">
408
- <rect width="${width}" height="${height}" rx="8" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>
409
- ${titleSvg}
410
- ${children}
411
- </g>
412
- </g>`;
413
- }
414
-
415
- // ===========================================
416
- // Text Renderers
417
- // ===========================================
418
-
419
- private renderText(node: TextNode): string {
420
- const fontSize = this.resolveFontSize(node.size);
421
- const fill = node.muted ? this.theme.colors.muted : this.theme.colors.foreground;
422
- const fontWeight = node.weight || 'normal';
423
-
424
- const y = this.currentY + fontSize;
425
- this.currentY += fontSize + 8;
426
-
427
- return `<text x="${this.currentX}" y="${y}" font-size="${fontSize}" font-weight="${fontWeight}" fill="${fill}">${this.escapeXml(node.content)}</text>`;
428
- }
429
-
430
- private renderTitle(node: TitleNode): string {
431
- const level = node.level || 1;
432
- const fontSize = this.getTitleFontSize(level);
433
-
434
- const y = this.currentY + fontSize;
435
- this.currentY += fontSize + 12;
436
-
437
- return `<text x="${this.currentX}" y="${y}" font-size="${fontSize}" font-weight="600" fill="${this.theme.colors.foreground}">${this.escapeXml(node.content)}</text>`;
438
- }
439
-
440
- private renderLink(node: LinkNode): string {
441
- const fontSize = 14;
442
- const y = this.currentY + fontSize;
443
- this.currentY += fontSize + 8;
444
-
445
- return `<text x="${this.currentX}" y="${y}" font-size="${fontSize}" fill="${this.theme.colors.primary}" text-decoration="underline">${this.escapeXml(node.content)}</text>`;
446
- }
447
-
448
- // ===========================================
449
- // Input Renderers
450
- // ===========================================
451
-
452
- private renderInput(node: InputNode): string {
453
- const width = 280;
454
- const height = 40;
455
- const x = this.currentX;
456
- let y = this.currentY;
457
-
458
- let result = '';
459
-
460
- if (node.label) {
461
- result += `<text x="${x}" y="${y + 14}" font-size="14" fill="${this.theme.colors.foreground}">${this.escapeXml(node.label)}</text>`;
462
- y += 24;
463
- }
464
-
465
- const placeholder = node.placeholder || '';
466
-
467
- result += `
468
- <g transform="translate(${x}, ${y})">
469
- <rect width="${width}" height="${height}" rx="4" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>
470
- <text x="12" y="${height / 2 + 5}" font-size="14" fill="${this.theme.colors.muted}">${this.escapeXml(placeholder)}</text>
471
- </g>`;
472
-
473
- this.currentY = y + height + 12;
474
-
475
- return result;
476
- }
477
-
478
- private renderTextarea(node: TextareaNode): string {
479
- const width = 280;
480
- const height = 100;
481
- const x = this.currentX;
482
- let y = this.currentY;
483
-
484
- let result = '';
485
-
486
- if (node.label) {
487
- result += `<text x="${x}" y="${y + 14}" font-size="14" fill="${this.theme.colors.foreground}">${this.escapeXml(node.label)}</text>`;
488
- y += 24;
489
- }
490
-
491
- const placeholder = node.placeholder || '';
492
-
493
- result += `
494
- <g transform="translate(${x}, ${y})">
495
- <rect width="${width}" height="${height}" rx="4" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>
496
- <text x="12" y="24" font-size="14" fill="${this.theme.colors.muted}">${this.escapeXml(placeholder)}</text>
497
- </g>`;
498
-
499
- this.currentY = y + height + 12;
500
-
501
- return result;
502
- }
503
-
504
- private renderSelect(node: SelectNode): string {
505
- const width = 280;
506
- const height = 40;
507
- const x = this.currentX;
508
- let y = this.currentY;
509
-
510
- let result = '';
511
-
512
- if (node.label) {
513
- result += `<text x="${x}" y="${y + 14}" font-size="14" fill="${this.theme.colors.foreground}">${this.escapeXml(node.label)}</text>`;
514
- y += 24;
515
- }
516
-
517
- const placeholder = node.placeholder || 'Select...';
518
-
519
- result += `
520
- <g transform="translate(${x}, ${y})">
521
- <rect width="${width}" height="${height}" rx="4" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>
522
- <text x="12" y="${height / 2 + 5}" font-size="14" fill="${this.theme.colors.muted}">${this.escapeXml(placeholder)}</text>
523
- <path d="M${width - 24} ${height / 2 - 3} l6 6 l6 -6" fill="none" stroke="${this.theme.colors.muted}" stroke-width="1.5"/>
524
- </g>`;
525
-
526
- this.currentY = y + height + 12;
527
-
528
- return result;
529
- }
530
-
531
- private renderCheckbox(node: CheckboxNode): string {
532
- const x = this.currentX;
533
- const y = this.currentY;
534
- const size = 18;
535
-
536
- let result = `
537
- <g transform="translate(${x}, ${y})">
538
- <rect width="${size}" height="${size}" rx="3" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>`;
539
-
540
- if (node.checked) {
541
- result += `<path d="M4 9 L7 12 L14 5" fill="none" stroke="${this.theme.colors.foreground}" stroke-width="2"/>`;
542
- }
543
-
544
- if (node.label) {
545
- result += `<text x="${size + 8}" y="${size - 3}" font-size="14" fill="${this.theme.colors.foreground}">${this.escapeXml(node.label)}</text>`;
546
- }
547
-
548
- result += '</g>';
549
-
550
- this.currentY += size + 12;
551
-
552
- return result;
553
- }
554
-
555
- private renderRadio(node: RadioNode): string {
556
- const x = this.currentX;
557
- const y = this.currentY;
558
- const size = 18;
559
- const radius = size / 2;
560
-
561
- let result = `
562
- <g transform="translate(${x}, ${y})">
563
- <circle cx="${radius}" cy="${radius}" r="${radius - 1}" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>`;
564
-
565
- if (node.checked) {
566
- result += `<circle cx="${radius}" cy="${radius}" r="${radius - 5}" fill="${this.theme.colors.foreground}"/>`;
567
- }
568
-
569
- if (node.label) {
570
- result += `<text x="${size + 8}" y="${size - 3}" font-size="14" fill="${this.theme.colors.foreground}">${this.escapeXml(node.label)}</text>`;
571
- }
572
-
573
- result += '</g>';
574
-
575
- this.currentY += size + 12;
576
-
577
- return result;
578
- }
579
-
580
- private renderSwitch(node: SwitchNode): string {
581
- const x = this.currentX;
582
- const y = this.currentY;
583
- const width = 44;
584
- const height = 24;
585
- const radius = height / 2;
586
-
587
- const isOn = node.checked;
588
- const bgColor = isOn ? this.theme.colors.primary : this.theme.colors.border;
589
- const knobX = isOn ? width - radius : radius;
590
-
591
- let result = `
592
- <g transform="translate(${x}, ${y})">
593
- <rect width="${width}" height="${height}" rx="${radius}" fill="${bgColor}"/>
594
- <circle cx="${knobX}" cy="${radius}" r="${radius - 3}" fill="white"/>`;
595
-
596
- if (node.label) {
597
- result += `<text x="${width + 8}" y="${height - 6}" font-size="14" fill="${this.theme.colors.foreground}">${this.escapeXml(node.label)}</text>`;
598
- }
599
-
600
- result += '</g>';
601
-
602
- this.currentY += height + 12;
603
-
604
- return result;
605
- }
606
-
607
- // ===========================================
608
- // Button Renderer
609
- // ===========================================
610
-
611
- private renderButton(node: ButtonNode): string {
612
- const content = node.content;
613
- const hasIcon = !!node.icon;
614
- const isIconOnly = hasIcon && !content.trim();
615
-
616
- // Calculate button dimensions
617
- const iconSize = 16;
618
- const padding = isIconOnly ? 8 : 16;
619
- let width: number;
620
-
621
- if (isIconOnly) {
622
- width = iconSize + padding * 2;
623
- } else if (hasIcon) {
624
- width = Math.max(80, content.length * 10 + iconSize + 40);
625
- } else {
626
- width = Math.max(80, content.length * 10 + 32);
627
- }
628
-
629
- const height = 36;
630
- const x = this.currentX;
631
- const y = this.currentY;
632
-
633
- // Determine button variant from boolean flags
634
- let fill = this.theme.colors.primary;
635
- let textFill = '#ffffff';
636
- let isOutline = false;
637
-
638
- if (node.secondary) {
639
- fill = this.theme.colors.secondary;
640
- } else if (node.outline) {
641
- fill = 'white';
642
- textFill = this.theme.colors.foreground;
643
- isOutline = true;
644
- } else if (node.ghost) {
645
- fill = 'transparent';
646
- textFill = this.theme.colors.foreground;
647
- }
648
-
649
- this.currentY += height + 8;
650
-
651
- const strokeAttr = isOutline ? `stroke="${this.theme.colors.border}" stroke-width="1"` : '';
652
-
653
- // Render icon if present
654
- let iconSvg = '';
655
- if (hasIcon) {
656
- const iconData = getIconData(node.icon!);
657
- if (iconData) {
658
- const iconX = isIconOnly ? (width - iconSize) / 2 : padding;
659
- const iconY = (height - iconSize) / 2;
660
- iconSvg = this.renderIconPaths(iconData, iconX, iconY, iconSize, textFill);
661
- }
662
- }
663
-
664
- // Calculate text position
665
- const textX = hasIcon && !isIconOnly ? padding + iconSize + 8 + (width - padding - iconSize - 8 - padding) / 2 : width / 2;
666
- const textContent = isIconOnly ? '' : `<text x="${textX}" y="${height / 2 + 5}" font-size="14" fill="${textFill}" text-anchor="middle">${this.escapeXml(content)}</text>`;
667
-
668
- return `
669
- <g transform="translate(${x}, ${y})">
670
- <rect width="${width}" height="${height}" rx="4" fill="${fill}" ${strokeAttr}/>
671
- ${iconSvg}
672
- ${textContent}
673
- </g>`;
674
- }
675
-
676
- /**
677
- * Render icon paths for SVG
678
- */
679
- private renderIconPaths(data: IconData, x: number, y: number, size: number, color: string): string {
680
- const scale = size / 24;
681
- const paths = data.map(([tag, attrs]) => {
682
- const attrStr = Object.entries(attrs)
683
- .map(([key, value]) => `${key}="${value}"`)
684
- .join(' ');
685
- return `<${tag} ${attrStr} />`;
686
- }).join('');
687
-
688
- return `<g transform="translate(${x}, ${y}) scale(${scale})" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</g>`;
689
- }
690
-
691
- // ===========================================
692
- // Display Renderers
693
- // ===========================================
694
-
695
- private renderImage(node: ImageNode): string {
696
- const width = node.w && typeof node.w === 'number' ? node.w : 200;
697
- const height = node.h && typeof node.h === 'number' ? node.h : 150;
698
- const x = this.currentX;
699
- const y = this.currentY;
700
-
701
- this.currentY += height + 12;
702
-
703
- return `
704
- <g transform="translate(${x}, ${y})">
705
- <rect width="${width}" height="${height}" fill="${this.theme.colors.muted}" stroke="${this.theme.colors.border}" stroke-width="1"/>
706
- <line x1="0" y1="0" x2="${width}" y2="${height}" stroke="${this.theme.colors.border}" stroke-width="1"/>
707
- <line x1="${width}" y1="0" x2="0" y2="${height}" stroke="${this.theme.colors.border}" stroke-width="1"/>
708
- <text x="${width / 2}" y="${height / 2 + 5}" font-size="14" fill="${this.theme.colors.foreground}" text-anchor="middle">${this.escapeXml(node.alt || 'Image')}</text>
709
- </g>`;
710
- }
711
-
712
- private renderPlaceholder(node: PlaceholderNode): string {
713
- const width = node.w && typeof node.w === 'number' ? node.w : 200;
714
- const height = node.h && typeof node.h === 'number' ? node.h : 100;
715
- const x = this.currentX;
716
- const y = this.currentY;
717
-
718
- this.currentY += height + 12;
719
-
720
- return `
721
- <g transform="translate(${x}, ${y})">
722
- <rect width="${width}" height="${height}" fill="${this.theme.colors.muted}" stroke="${this.theme.colors.border}" stroke-width="1" stroke-dasharray="4,4"/>
723
- <text x="${width / 2}" y="${height / 2 + 5}" font-size="14" fill="${this.theme.colors.foreground}" text-anchor="middle">${this.escapeXml(node.label || 'Placeholder')}</text>
724
- </g>`;
725
- }
726
-
727
- private renderAvatar(node: AvatarNode): string {
728
- const sizes: Record<string, number> = { xs: 24, sm: 32, md: 40, lg: 48, xl: 64 };
729
- const size = sizes[node.size || 'md'] || 40;
730
- const radius = size / 2;
731
- const x = this.currentX;
732
- const y = this.currentY;
733
-
734
- const initial = node.name ? node.name.charAt(0).toUpperCase() : '?';
735
-
736
- this.currentY += size + 12;
737
-
738
- return `
739
- <g transform="translate(${x}, ${y})">
740
- <circle cx="${radius}" cy="${radius}" r="${radius}" fill="${this.theme.colors.muted}" stroke="${this.theme.colors.border}" stroke-width="1"/>
741
- <text x="${radius}" y="${radius + 5}" font-size="${size / 2.5}" fill="${this.theme.colors.foreground}" text-anchor="middle">${initial}</text>
742
- </g>`;
743
- }
744
-
745
- private renderBadge(node: BadgeNode): string {
746
- const content = node.content;
747
- const width = Math.max(24, content.length * 8 + 16);
748
- const height = 22;
749
- const x = this.currentX;
750
- const y = this.currentY;
751
-
752
- const fill = this.theme.colors.muted;
753
- const textFill = this.theme.colors.foreground;
754
-
755
- this.currentY += height + 8;
756
-
757
- return `
758
- <g transform="translate(${x}, ${y})">
759
- <rect width="${width}" height="${height}" rx="11" fill="${fill}" stroke="${this.theme.colors.border}" stroke-width="1"/>
760
- <text x="${width / 2}" y="${height / 2 + 4}" font-size="12" fill="${textFill}" text-anchor="middle">${this.escapeXml(content)}</text>
761
- </g>`;
762
- }
763
-
764
- // ===========================================
765
- // Data Renderers
766
- // ===========================================
767
-
768
- private renderTable(node: TableNode): string {
769
- const columns = node.columns || [];
770
- const rows = node.rows || [];
771
- const rowCount = rows.length || 3;
772
- const colWidth = 120;
773
- const rowHeight = 40;
774
- const x = this.currentX;
775
- const y = this.currentY;
776
-
777
- let svg = `<g transform="translate(${x}, ${y})">`;
778
-
779
- // Header
780
- svg += `<rect width="${columns.length * colWidth}" height="${rowHeight}" fill="${this.theme.colors.muted}"/>`;
781
- columns.forEach((col, i) => {
782
- svg += `<text x="${i * colWidth + 12}" y="${rowHeight / 2 + 5}" font-size="14" font-weight="600">${this.escapeXml(col)}</text>`;
783
- });
784
-
785
- // Rows - if we have actual row data, use it; otherwise show placeholders
786
- const displayRowCount = rows.length > 0 ? rows.length : Math.max(rowCount, 3);
787
- for (let rowIdx = 0; rowIdx < displayRowCount; rowIdx++) {
788
- const rowY = (rowIdx + 1) * rowHeight;
789
- svg += `<rect y="${rowY}" width="${columns.length * colWidth}" height="${rowHeight}" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>`;
790
- columns.forEach((_, colIdx) => {
791
- // If we have row data, try to render it
792
- const cellContent = rows[rowIdx] && rows[rowIdx][colIdx]
793
- ? String(typeof rows[rowIdx][colIdx] === 'object' ? '...' : rows[rowIdx][colIdx])
794
- : '—';
795
- svg += `<text x="${colIdx * colWidth + 12}" y="${rowY + rowHeight / 2 + 5}" font-size="14" fill="${this.theme.colors.muted}">${this.escapeXml(cellContent)}</text>`;
796
- });
797
- }
798
-
799
- svg += '</g>';
800
-
801
- this.currentY += (displayRowCount + 1) * rowHeight + 16;
802
-
803
- return svg;
804
- }
805
-
806
- private renderList(node: ListNode): string {
807
- const x = this.currentX;
808
- let y = this.currentY;
809
- const items = node.items || [];
810
- const ordered = node.ordered || false;
811
-
812
- let svg = `<g transform="translate(${x}, ${y})">`;
813
-
814
- items.forEach((item, idx) => {
815
- const marker = ordered ? `${idx + 1}.` : '•';
816
- const content = typeof item === 'string' ? item : item.content;
817
- svg += `<text x="0" y="${idx * 24 + 16}" font-size="14" fill="${this.theme.colors.foreground}">${marker} ${this.escapeXml(content)}</text>`;
818
- });
819
-
820
- svg += '</g>';
821
-
822
- this.currentY += items.length * 24 + 12;
823
-
824
- return svg;
825
- }
826
-
827
- // ===========================================
828
- // Feedback Renderers
829
- // ===========================================
830
-
831
- private renderAlert(node: AlertNode): string {
832
- const width = Math.min(400, this.contentWidth);
833
- const height = 48;
834
- const x = this.currentX;
835
- const y = this.currentY;
836
-
837
- this.currentY += height + 12;
838
-
839
- return `
840
- <g transform="translate(${x}, ${y})">
841
- <rect width="${width}" height="${height}" rx="4" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>
842
- <text x="16" y="${height / 2 + 5}" font-size="14" fill="${this.theme.colors.foreground}">${this.escapeXml(node.content)}</text>
843
- </g>`;
844
- }
845
-
846
- private renderProgress(node: ProgressNode): string {
847
- const width = 200;
848
- const height = 8;
849
- const x = this.currentX;
850
- let y = this.currentY;
851
-
852
- let result = '';
853
-
854
- if (node.label) {
855
- result += `<text x="${x}" y="${y + 14}" font-size="14" fill="${this.theme.colors.foreground}">${this.escapeXml(node.label)}</text>`;
856
- y += 24;
857
- }
858
-
859
- const value = node.value || 0;
860
- const max = node.max || 100;
861
- const percent = Math.min(100, Math.max(0, (value / max) * 100));
862
-
863
- result += `
864
- <g transform="translate(${x}, ${y})">
865
- <rect width="${width}" height="${height}" rx="${height / 2}" fill="${this.theme.colors.muted}"/>
866
- <rect width="${(width * percent) / 100}" height="${height}" rx="${height / 2}" fill="${this.theme.colors.primary}"/>
867
- </g>`;
868
-
869
- this.currentY = y + height + 12;
870
-
871
- return result;
872
- }
873
-
874
- private renderSpinner(node: SpinnerNode): string {
875
- const sizes: Record<string, number> = { xs: 16, sm: 20, md: 24, lg: 32, xl: 40 };
876
- const size = sizes[node.size || 'md'] || 24;
877
- const x = this.currentX + size / 2;
878
- const y = this.currentY + size / 2;
879
- const radius = size / 2 - 2;
880
-
881
- this.currentY += size + 12;
882
-
883
- return `
884
- <g transform="translate(${x}, ${y})">
885
- <circle r="${radius}" fill="none" stroke="${this.theme.colors.muted}" stroke-width="2"/>
886
- <path d="M0,-${radius} A${radius},${radius} 0 0,1 ${radius},0" fill="none" stroke="${this.theme.colors.primary}" stroke-width="2" stroke-linecap="round"/>
887
- </g>`;
888
- }
889
-
890
- // ===========================================
891
- // Navigation Renderers
892
- // ===========================================
893
-
894
- private renderNav(node: NavNode): string {
895
- const items = node.items || [];
896
- const x = this.currentX;
897
- const y = this.currentY;
898
- const vertical = node.vertical || false;
899
-
900
- let svg = `<g transform="translate(${x}, ${y})">`;
901
-
902
- if (vertical) {
903
- items.forEach((item, idx) => {
904
- const label = typeof item === 'string' ? item : item.label;
905
- const isActive = typeof item === 'object' && item.active;
906
- const fill = isActive ? this.theme.colors.foreground : this.theme.colors.muted;
907
- svg += `<text x="0" y="${idx * 32 + 16}" font-size="14" fill="${fill}">${this.escapeXml(label)}</text>`;
908
- });
909
- this.currentY += items.length * 32 + 12;
910
- } else {
911
- let offsetX = 0;
912
- items.forEach((item) => {
913
- const label = typeof item === 'string' ? item : item.label;
914
- const isActive = typeof item === 'object' && item.active;
915
- const fill = isActive ? this.theme.colors.foreground : this.theme.colors.muted;
916
- svg += `<text x="${offsetX}" y="16" font-size="14" fill="${fill}">${this.escapeXml(label)}</text>`;
917
- offsetX += label.length * 8 + 24;
918
- });
919
- this.currentY += 32;
920
- }
921
-
922
- svg += '</g>';
923
-
924
- return svg;
925
- }
926
-
927
- private renderTabs(node: TabsNode): string {
928
- const items = node.items || [];
929
- const x = this.currentX;
930
- const y = this.currentY;
931
- const tabHeight = 40;
932
-
933
- let svg = `<g transform="translate(${x}, ${y})">`;
934
-
935
- let offsetX = 0;
936
- items.forEach((item) => {
937
- const label = typeof item === 'string' ? item : item;
938
- const tabWidth = label.length * 10 + 24;
939
- svg += `<rect x="${offsetX}" width="${tabWidth}" height="${tabHeight}" fill="white" stroke="${this.theme.colors.border}" stroke-width="1"/>`;
940
- svg += `<text x="${offsetX + tabWidth / 2}" y="${tabHeight / 2 + 5}" font-size="14" text-anchor="middle">${this.escapeXml(label)}</text>`;
941
- offsetX += tabWidth;
942
- });
943
-
944
- svg += '</g>';
945
-
946
- this.currentY += tabHeight + 12;
947
-
948
- return svg;
949
- }
950
-
951
- private renderBreadcrumb(node: BreadcrumbNode): string {
952
- const items = node.items || [];
953
- const separator = '/';
954
- const x = this.currentX;
955
- const y = this.currentY;
956
-
957
- let svg = `<g transform="translate(${x}, ${y})">`;
958
-
959
- let offsetX = 0;
960
- items.forEach((item, idx) => {
961
- const label = typeof item === 'string' ? item : item.label;
962
- const isLast = idx === items.length - 1;
963
- const fill = isLast ? this.theme.colors.foreground : this.theme.colors.muted;
964
-
965
- svg += `<text x="${offsetX}" y="16" font-size="14" fill="${fill}">${this.escapeXml(label)}</text>`;
966
- offsetX += label.length * 8 + 8;
967
-
968
- if (!isLast) {
969
- svg += `<text x="${offsetX}" y="16" font-size="14" fill="${this.theme.colors.muted}">${separator}</text>`;
970
- offsetX += 16;
971
- }
972
- });
973
-
974
- svg += '</g>';
975
-
976
- this.currentY += 28;
977
-
978
- return svg;
979
- }
980
-
981
- // ===========================================
982
- // Utility Methods
983
- // ===========================================
984
-
985
- private getFontSize(size: string): number {
986
- const sizes: Record<string, number> = {
987
- xs: 12,
988
- sm: 14,
989
- base: 16,
990
- md: 16,
991
- lg: 18,
992
- xl: 20,
993
- '2xl': 24,
994
- '3xl': 30,
995
- };
996
- return sizes[size] || 16;
997
- }
998
-
999
- private resolveFontSize(size: TextNode['size']): number {
1000
- if (!size) return 16; // default 'base'
1001
- if (typeof size === 'string') {
1002
- return this.getFontSize(size);
1003
- }
1004
- // ValueWithUnit object
1005
- if (typeof size === 'object' && 'value' in size) {
1006
- // Convert to px if needed
1007
- if (size.unit === 'px') return size.value;
1008
- if (size.unit === 'rem') return size.value * 16;
1009
- if (size.unit === 'em') return size.value * 16;
1010
- return size.value;
1011
- }
1012
- return 16;
1013
- }
1014
-
1015
- private getTitleFontSize(level: number): number {
1016
- const sizes: Record<number, number> = {
1017
- 1: 32,
1018
- 2: 28,
1019
- 3: 24,
1020
- 4: 20,
1021
- 5: 18,
1022
- 6: 16,
1023
- };
1024
- return sizes[level] || 24;
1025
- }
1026
-
1027
- private escapeXml(str: string): string {
1028
- return str
1029
- .replace(/&/g, '&amp;')
1030
- .replace(/</g, '&lt;')
1031
- .replace(/>/g, '&gt;')
1032
- .replace(/"/g, '&quot;')
1033
- .replace(/'/g, '&apos;');
1034
- }
1035
- }
1036
-
1037
- /**
1038
- * Create a new SVG renderer instance
1039
- */
1040
- export function createSvgRenderer(options?: SvgRenderOptions): SvgRenderer {
1041
- return new SvgRenderer(options);
1042
- }
1043
-
1044
- /**
1045
- * Render a wireframe document to SVG
1046
- */
1047
- export function renderToSvg(doc: WireframeDocument, options?: SvgRenderOptions): SvgRenderResult {
1048
- const renderer = new SvgRenderer(options);
1049
- return renderer.render(doc);
1050
- }