@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,1608 +0,0 @@
1
- /**
2
- * HTML Renderer for wireweave
3
- *
4
- * Converts AST nodes to HTML output
5
- */
6
-
7
- import type {
8
- AnyNode,
9
- PageNode,
10
- HeaderNode,
11
- MainNode,
12
- FooterNode,
13
- SidebarNode,
14
- SectionNode,
15
- RowNode,
16
- ColNode,
17
- CardNode,
18
- ModalNode,
19
- DrawerNode,
20
- AccordionNode,
21
- TextNode,
22
- TitleNode,
23
- LinkNode,
24
- InputNode,
25
- TextareaNode,
26
- SelectNode,
27
- CheckboxNode,
28
- RadioNode,
29
- SwitchNode,
30
- SliderNode,
31
- ButtonNode,
32
- ImageNode,
33
- PlaceholderNode,
34
- AvatarNode,
35
- BadgeNode,
36
- IconNode,
37
- TableNode,
38
- ListNode,
39
- AlertNode,
40
- ToastNode,
41
- ProgressNode,
42
- SpinnerNode,
43
- TooltipNode,
44
- PopoverNode,
45
- DropdownNode,
46
- NavNode,
47
- TabsNode,
48
- BreadcrumbNode,
49
- DividerComponentNode,
50
- CommonProps,
51
- ValueWithUnit,
52
- SpacingValue,
53
- } from '../../ast/types';
54
- // hasChildren guard available from ast/guards if needed
55
- import { BaseRenderer } from './base';
56
- import type { RenderOptions } from '../types';
57
- import { resolveViewport } from '../../viewport';
58
- import { getIconData, renderIconSvg } from '../../icons/lucide-icons';
59
-
60
- // Re-export layout and component utilities
61
- export * from './layout';
62
- export * from './components';
63
-
64
- // Import size resolution helper for use in HTMLRenderer
65
- import { resolveSizeValue, buildClassString as _buildClassString } from './components';
66
-
67
- // Spacing token table: number -> CSS value
68
- // Token values (0-20) map to px values
69
- const SPACING_TOKENS: Record<number, string> = {
70
- 0: '0px',
71
- 1: '4px',
72
- 2: '8px',
73
- 3: '12px',
74
- 4: '16px',
75
- 5: '20px',
76
- 6: '24px',
77
- 8: '32px',
78
- 10: '40px',
79
- 12: '48px',
80
- 16: '64px',
81
- 20: '80px',
82
- };
83
-
84
- /**
85
- * Resolve a spacing value to CSS string
86
- * - number: spacing token (e.g., 4 → '16px')
87
- * - ValueWithUnit: direct CSS value (e.g., { value: 16, unit: 'px' } → '16px')
88
- */
89
- function resolveSpacingValue(value: SpacingValue | undefined): string | undefined {
90
- if (value === undefined) return undefined;
91
-
92
- // ValueWithUnit object: direct CSS value
93
- if (typeof value === 'object' && 'value' in value && 'unit' in value) {
94
- return `${value.value}${value.unit}`;
95
- }
96
-
97
- // Number: spacing token
98
- if (typeof value === 'number') {
99
- // Look up token value, fallback to direct px if not in token table
100
- return SPACING_TOKENS[value] ?? `${value}px`;
101
- }
102
-
103
- return undefined;
104
- }
105
-
106
- /**
107
- * Type guard to check if a value is a ValueWithUnit object
108
- */
109
- function isValueWithUnit(value: unknown): value is ValueWithUnit {
110
- return typeof value === 'object' && value !== null && 'value' in value && 'unit' in value;
111
- }
112
-
113
- /**
114
- * Resolve a size value (width/height) to CSS string
115
- * - number: direct px value (dimensions are not tokenized)
116
- * - ValueWithUnit: direct CSS value with unit
117
- */
118
- function resolveSizeValueToCss(value: number | ValueWithUnit | undefined): string | undefined {
119
- if (value === undefined) return undefined;
120
-
121
- // ValueWithUnit object: direct CSS value
122
- if (isValueWithUnit(value)) {
123
- return `${value.value}${value.unit}`;
124
- }
125
-
126
- // Number: direct px value (for width/height)
127
- if (typeof value === 'number') {
128
- return `${value}px`;
129
- }
130
-
131
- return undefined;
132
- }
133
-
134
- /**
135
- * HTML Renderer class
136
- *
137
- * Renders wireframe AST to semantic HTML with utility classes
138
- */
139
- export class HtmlRenderer extends BaseRenderer {
140
- constructor(options: RenderOptions = {}) {
141
- super(options);
142
- }
143
-
144
- /**
145
- * Render a page node
146
- */
147
- protected renderPage(node: PageNode): string {
148
- // Resolve viewport size - use explicit w/h if provided, otherwise use viewport/device
149
- let viewport = resolveViewport(node.viewport, node.device);
150
-
151
- // Override with explicit width/height if provided (for playground-style sizing)
152
- // Support both short form (w/h) and long form (width/height)
153
- const nodeAny = node as PageNode & { width?: number; height?: number };
154
- const explicitW = node.w ?? nodeAny.width;
155
- const explicitH = node.h ?? nodeAny.height;
156
-
157
- if (explicitW !== undefined || explicitH !== undefined) {
158
- const explicitWidth = typeof explicitW === 'number' ? explicitW :
159
- (typeof explicitW === 'string' && /^\d+$/.test(explicitW) ? parseInt(explicitW) : viewport.width);
160
- const explicitHeight = typeof explicitH === 'number' ? explicitH :
161
- (typeof explicitH === 'string' && /^\d+$/.test(explicitH) ? parseInt(explicitH) : viewport.height);
162
- viewport = {
163
- width: explicitWidth,
164
- height: explicitHeight,
165
- label: `${explicitWidth}x${explicitHeight}`,
166
- category: explicitWidth <= 430 ? 'mobile' : explicitWidth <= 1024 ? 'tablet' : 'desktop',
167
- };
168
- }
169
-
170
- const classes = this.buildClassString([
171
- `${this.prefix}-page`,
172
- node.centered ? `${this.prefix}-page-centered` : undefined,
173
- ...this.getCommonClasses(node),
174
- ]);
175
-
176
- const children = this.renderChildren(node.children);
177
- const title = node.title ? `<title>${this.escapeHtml(node.title)}</title>\n` : '';
178
-
179
- // Build common styles (padding, margin, etc.) and combine with viewport dimensions
180
- const commonStyles = this.buildCommonStyles(node);
181
- const viewportStyle = `width: ${viewport.width}px; height: ${viewport.height}px`;
182
- const combinedStyle = commonStyles ? `${viewportStyle}; ${commonStyles}` : viewportStyle;
183
-
184
- // Add data attributes for viewport info
185
- const dataAttrs = `data-viewport-width="${viewport.width}" data-viewport-height="${viewport.height}" data-viewport-label="${viewport.label}"`;
186
-
187
- return `<div class="${classes}" style="${combinedStyle}" ${dataAttrs}>\n${title}${children}\n</div>`;
188
- }
189
-
190
- /**
191
- * Render any AST node
192
- */
193
- protected renderNode(node: AnyNode): string {
194
- switch (node.type) {
195
- // Layout nodes
196
- case 'Page':
197
- return this.renderPage(node as PageNode);
198
- case 'Header':
199
- return this.renderHeader(node as HeaderNode);
200
- case 'Main':
201
- return this.renderMain(node as MainNode);
202
- case 'Footer':
203
- return this.renderFooter(node as FooterNode);
204
- case 'Sidebar':
205
- return this.renderSidebar(node as SidebarNode);
206
- case 'Section':
207
- return this.renderSection(node as SectionNode);
208
-
209
- // Grid nodes
210
- case 'Row':
211
- return this.renderRow(node as RowNode);
212
- case 'Col':
213
- return this.renderCol(node as ColNode);
214
-
215
- // Container nodes
216
- case 'Card':
217
- return this.renderCard(node as CardNode);
218
- case 'Modal':
219
- return this.renderModal(node as ModalNode);
220
- case 'Drawer':
221
- return this.renderDrawer(node as DrawerNode);
222
- case 'Accordion':
223
- return this.renderAccordion(node as AccordionNode);
224
-
225
- // Text nodes
226
- case 'Text':
227
- return this.renderText(node as TextNode);
228
- case 'Title':
229
- return this.renderTitle(node as TitleNode);
230
- case 'Link':
231
- return this.renderLink(node as LinkNode);
232
-
233
- // Input nodes
234
- case 'Input':
235
- return this.renderInput(node as InputNode);
236
- case 'Textarea':
237
- return this.renderTextarea(node as TextareaNode);
238
- case 'Select':
239
- return this.renderSelect(node as SelectNode);
240
- case 'Checkbox':
241
- return this.renderCheckbox(node as CheckboxNode);
242
- case 'Radio':
243
- return this.renderRadio(node as RadioNode);
244
- case 'Switch':
245
- return this.renderSwitch(node as SwitchNode);
246
- case 'Slider':
247
- return this.renderSlider(node as SliderNode);
248
-
249
- // Button
250
- case 'Button':
251
- return this.renderButton(node as ButtonNode);
252
-
253
- // Display nodes
254
- case 'Image':
255
- return this.renderImage(node as ImageNode);
256
- case 'Placeholder':
257
- return this.renderPlaceholder(node as PlaceholderNode);
258
- case 'Avatar':
259
- return this.renderAvatar(node as AvatarNode);
260
- case 'Badge':
261
- return this.renderBadge(node as BadgeNode);
262
- case 'Icon':
263
- return this.renderIcon(node as IconNode);
264
-
265
- // Data nodes
266
- case 'Table':
267
- return this.renderTable(node as TableNode);
268
- case 'List':
269
- return this.renderList(node as ListNode);
270
-
271
- // Feedback nodes
272
- case 'Alert':
273
- return this.renderAlert(node as AlertNode);
274
- case 'Toast':
275
- return this.renderToast(node as ToastNode);
276
- case 'Progress':
277
- return this.renderProgress(node as ProgressNode);
278
- case 'Spinner':
279
- return this.renderSpinner(node as SpinnerNode);
280
-
281
- // Overlay nodes
282
- case 'Tooltip':
283
- return this.renderTooltip(node as TooltipNode);
284
- case 'Popover':
285
- return this.renderPopover(node as PopoverNode);
286
- case 'Dropdown':
287
- return this.renderDropdown(node as DropdownNode);
288
-
289
- // Navigation nodes
290
- case 'Nav':
291
- return this.renderNav(node as NavNode);
292
- case 'Tabs':
293
- return this.renderTabs(node as TabsNode);
294
- case 'Breadcrumb':
295
- return this.renderBreadcrumb(node as BreadcrumbNode);
296
-
297
- // Other
298
- case 'Divider':
299
- return this.renderDivider(node as DividerComponentNode);
300
-
301
- default:
302
- return `<!-- Unknown node type: ${(node as AnyNode).type} -->`;
303
- }
304
- }
305
-
306
- /**
307
- * Render children nodes
308
- */
309
- protected renderChildren(children: AnyNode[]): string {
310
- return this.withDepth(() => {
311
- return children
312
- .map((child) => this.indent(this.renderNode(child)))
313
- .join('\n');
314
- });
315
- }
316
-
317
- /**
318
- * Get common CSS classes from props
319
- * Uses Omit to exclude 'align' since TextNode/TitleNode have incompatible align types
320
- *
321
- * All numeric values are handled by buildCommonStyles as inline px values.
322
- * CSS classes are only used for keyword values (full, auto, screen, fit, etc.)
323
- */
324
- private getCommonClasses(props: Omit<Partial<CommonProps>, 'align'> & { align?: string }): string[] {
325
- const classes: string[] = [];
326
- const p = this.prefix;
327
-
328
- // Spacing - only 'auto' uses class, all numbers use inline px styles
329
- if (props.mx === 'auto') classes.push(`${p}-mx-auto`);
330
-
331
- // Size - only keyword values use classes
332
- if (props.w === 'full') classes.push(`${p}-w-full`);
333
- if (props.w === 'auto') classes.push(`${p}-w-auto`);
334
- if (props.w === 'fit') classes.push(`${p}-w-fit`);
335
- if (props.h === 'full') classes.push(`${p}-h-full`);
336
- if (props.h === 'auto') classes.push(`${p}-h-auto`);
337
- if (props.h === 'screen') classes.push(`${p}-h-screen`);
338
-
339
- // Flex
340
- if (props.flex === true) classes.push(`${p}-flex`);
341
- if (typeof props.flex === 'number') classes.push(`${p}-flex-${props.flex}`);
342
- if (props.direction === 'row') classes.push(`${p}-flex-row`);
343
- if (props.direction === 'column') classes.push(`${p}-flex-col`);
344
- if (props.direction === 'row-reverse') classes.push(`${p}-flex-row-reverse`);
345
- if (props.direction === 'column-reverse') classes.push(`${p}-flex-col-reverse`);
346
- if (props.justify) classes.push(`${p}-justify-${props.justify}`);
347
- if (props.align) classes.push(`${p}-align-${props.align}`);
348
- if (props.wrap === true) classes.push(`${p}-flex-wrap`);
349
- if (props.wrap === 'nowrap') classes.push(`${p}-flex-nowrap`);
350
- // gap is handled by inline styles for numeric values
351
-
352
- return classes;
353
- }
354
-
355
- // ===========================================
356
- // Layout Node Renderers
357
- // ===========================================
358
-
359
- private renderHeader(node: HeaderNode): string {
360
- const classes = this.buildClassString([
361
- `${this.prefix}-header`,
362
- node.border === false ? `${this.prefix}-no-border` : undefined,
363
- ...this.getCommonClasses(node),
364
- ]);
365
-
366
- const styles = this.buildCommonStyles(node);
367
- const styleAttr = styles ? ` style="${styles}"` : '';
368
-
369
- const children = this.renderChildren(node.children);
370
- return `<header class="${classes}"${styleAttr}>\n${children}\n</header>`;
371
- }
372
-
373
- private renderMain(node: MainNode): string {
374
- const classes = this.buildClassString([
375
- `${this.prefix}-main`,
376
- ...this.getCommonClasses(node),
377
- ]);
378
-
379
- const styles = this.buildCommonStyles(node);
380
- const styleAttr = styles ? ` style="${styles}"` : '';
381
-
382
- const children = this.renderChildren(node.children);
383
- return `<main class="${classes}"${styleAttr}>\n${children}\n</main>`;
384
- }
385
-
386
- private renderFooter(node: FooterNode): string {
387
- const classes = this.buildClassString([
388
- `${this.prefix}-footer`,
389
- node.border === false ? `${this.prefix}-no-border` : undefined,
390
- ...this.getCommonClasses(node),
391
- ]);
392
-
393
- const styles = this.buildCommonStyles(node);
394
- const styleAttr = styles ? ` style="${styles}"` : '';
395
-
396
- const children = this.renderChildren(node.children);
397
- return `<footer class="${classes}"${styleAttr}>\n${children}\n</footer>`;
398
- }
399
-
400
- private renderSidebar(node: SidebarNode): string {
401
- const classes = this.buildClassString([
402
- `${this.prefix}-sidebar`,
403
- node.position === 'right' ? `${this.prefix}-sidebar-right` : undefined,
404
- ...this.getCommonClasses(node),
405
- ]);
406
-
407
- const styles = this.buildCommonStyles(node);
408
- const styleAttr = styles ? ` style="${styles}"` : '';
409
-
410
- const children = this.renderChildren(node.children);
411
- return `<aside class="${classes}"${styleAttr}>\n${children}\n</aside>`;
412
- }
413
-
414
- private renderSection(node: SectionNode): string {
415
- const classes = this.buildClassString([
416
- `${this.prefix}-section`,
417
- ...this.getCommonClasses(node),
418
- ]);
419
-
420
- const styles = this.buildCommonStyles(node);
421
- const styleAttr = styles ? ` style="${styles}"` : '';
422
-
423
- const title = node.title
424
- ? `<h2 class="${this.prefix}-title">${this.escapeHtml(node.title)}</h2>\n`
425
- : '';
426
- const children = this.renderChildren(node.children);
427
- return `<section class="${classes}"${styleAttr}>\n${title}${children}\n</section>`;
428
- }
429
-
430
- // ===========================================
431
- // Grid Node Renderers
432
- // ===========================================
433
-
434
- private renderRow(node: RowNode): string {
435
- const classes = this.buildClassString([
436
- `${this.prefix}-row`,
437
- ...this.getCommonClasses(node),
438
- ]);
439
-
440
- const styles = this.buildCommonStyles(node);
441
- const styleAttr = styles ? ` style="${styles}"` : '';
442
-
443
- const children = this.renderChildren(node.children);
444
- return `<div class="${classes}"${styleAttr}>\n${children}\n</div>`;
445
- }
446
-
447
- private renderCol(node: ColNode): string {
448
- const classes = this.buildClassString([
449
- `${this.prefix}-col`,
450
- node.span ? `${this.prefix}-col-${node.span}` : undefined,
451
- // Responsive breakpoint classes
452
- node.sm ? `${this.prefix}-col-sm-${node.sm}` : undefined,
453
- node.md ? `${this.prefix}-col-md-${node.md}` : undefined,
454
- node.lg ? `${this.prefix}-col-lg-${node.lg}` : undefined,
455
- node.xl ? `${this.prefix}-col-xl-${node.xl}` : undefined,
456
- ...this.getCommonClasses(node),
457
- ]);
458
-
459
- // Build inline styles for numeric width/height and order
460
- const styles = this.buildColStyles(node);
461
- const styleAttr = styles ? ` style="${styles}"` : '';
462
-
463
- const children = this.renderChildren(node.children);
464
- return `<div class="${classes}"${styleAttr}>\n${children}\n</div>`;
465
- }
466
-
467
- /**
468
- * Build common inline styles for all values
469
- *
470
- * Spacing values (p, m, gap) use token system:
471
- * - number: spacing token (e.g., p=4 → padding: 16px from token table)
472
- * - ValueWithUnit: direct CSS value (e.g., p=16px → padding: 16px)
473
- *
474
- * Size values (w, h, minW, maxW, minH, maxH) use direct px:
475
- * - number: direct px value (e.g., w=400 → width: 400px)
476
- * - ValueWithUnit: direct CSS value (e.g., w=50% → width: 50%)
477
- *
478
- * Token table: 0=0px, 1=4px, 2=8px, 3=12px, 4=16px, 5=20px, 6=24px, 8=32px, etc.
479
- *
480
- * Uses Omit to exclude 'align' since TextNode/TitleNode have incompatible align types
481
- */
482
- private buildCommonStyles(props: Omit<Partial<CommonProps>, 'align'> & { align?: string }): string {
483
- const styles: string[] = [];
484
-
485
- // Width (direct px or ValueWithUnit)
486
- const wValue = resolveSizeValueToCss(props.w as number | ValueWithUnit | undefined);
487
- if (wValue) {
488
- styles.push(`width: ${wValue}`);
489
- }
490
-
491
- // Height (direct px or ValueWithUnit)
492
- // Also set min-height to override CSS defaults (e.g., placeholder min-height: 100px)
493
- const hValue = resolveSizeValueToCss(props.h as number | ValueWithUnit | undefined);
494
- if (hValue) {
495
- styles.push(`height: ${hValue}`);
496
- styles.push(`min-height: ${hValue}`);
497
- }
498
-
499
- // Min/Max Width
500
- const minWValue = resolveSizeValueToCss(props.minW);
501
- if (minWValue) {
502
- styles.push(`min-width: ${minWValue}`);
503
- }
504
- const maxWValue = resolveSizeValueToCss(props.maxW);
505
- if (maxWValue) {
506
- styles.push(`max-width: ${maxWValue}`);
507
- }
508
-
509
- // Min/Max Height
510
- const minHValue = resolveSizeValueToCss(props.minH);
511
- if (minHValue) {
512
- styles.push(`min-height: ${minHValue}`);
513
- }
514
- const maxHValue = resolveSizeValueToCss(props.maxH);
515
- if (maxHValue) {
516
- styles.push(`max-height: ${maxHValue}`);
517
- }
518
-
519
- // Padding - uses spacing tokens
520
- const pValue = resolveSpacingValue(props.p);
521
- if (pValue) {
522
- styles.push(`padding: ${pValue}`);
523
- }
524
- const pxValue = resolveSpacingValue(props.px);
525
- if (pxValue) {
526
- styles.push(`padding-left: ${pxValue}`);
527
- styles.push(`padding-right: ${pxValue}`);
528
- }
529
- const pyValue = resolveSpacingValue(props.py);
530
- if (pyValue) {
531
- styles.push(`padding-top: ${pyValue}`);
532
- styles.push(`padding-bottom: ${pyValue}`);
533
- }
534
- const ptValue = resolveSpacingValue(props.pt);
535
- if (ptValue) {
536
- styles.push(`padding-top: ${ptValue}`);
537
- }
538
- const prValue = resolveSpacingValue(props.pr);
539
- if (prValue) {
540
- styles.push(`padding-right: ${prValue}`);
541
- }
542
- const pbValue = resolveSpacingValue(props.pb);
543
- if (pbValue) {
544
- styles.push(`padding-bottom: ${pbValue}`);
545
- }
546
- const plValue = resolveSpacingValue(props.pl);
547
- if (plValue) {
548
- styles.push(`padding-left: ${plValue}`);
549
- }
550
-
551
- // Margin - uses spacing tokens
552
- const mValue = resolveSpacingValue(props.m);
553
- if (mValue) {
554
- styles.push(`margin: ${mValue}`);
555
- }
556
- const mxValue = props.mx !== 'auto' ? resolveSpacingValue(props.mx as SpacingValue | undefined) : undefined;
557
- if (mxValue) {
558
- styles.push(`margin-left: ${mxValue}`);
559
- styles.push(`margin-right: ${mxValue}`);
560
- }
561
- const myValue = resolveSpacingValue(props.my);
562
- if (myValue) {
563
- styles.push(`margin-top: ${myValue}`);
564
- styles.push(`margin-bottom: ${myValue}`);
565
- }
566
- const mtValue = resolveSpacingValue(props.mt);
567
- if (mtValue) {
568
- styles.push(`margin-top: ${mtValue}`);
569
- }
570
- const mrValue = resolveSpacingValue(props.mr);
571
- if (mrValue) {
572
- styles.push(`margin-right: ${mrValue}`);
573
- }
574
- const mbValue = resolveSpacingValue(props.mb);
575
- if (mbValue) {
576
- styles.push(`margin-bottom: ${mbValue}`);
577
- }
578
- const mlValue = resolveSpacingValue(props.ml);
579
- if (mlValue) {
580
- styles.push(`margin-left: ${mlValue}`);
581
- }
582
-
583
- // Gap - uses spacing tokens
584
- const gapValue = resolveSpacingValue(props.gap);
585
- if (gapValue) {
586
- styles.push(`gap: ${gapValue}`);
587
- }
588
-
589
- return styles.join('; ');
590
- }
591
-
592
- /**
593
- * Build inline styles for Col node (extends common styles with order)
594
- */
595
- private buildColStyles(node: ColNode): string {
596
- const styles: string[] = [];
597
-
598
- // Get common styles first
599
- const commonStyles = this.buildCommonStyles(node);
600
- if (commonStyles) {
601
- styles.push(commonStyles);
602
- }
603
-
604
- // Order (Col-specific)
605
- if (node.order !== undefined) {
606
- styles.push(`order: ${node.order}`);
607
- }
608
-
609
- return styles.join('; ');
610
- }
611
-
612
- // ===========================================
613
- // Container Node Renderers
614
- // ===========================================
615
-
616
- private renderCard(node: CardNode): string {
617
- const classes = this.buildClassString([
618
- `${this.prefix}-card`,
619
- node.shadow ? `${this.prefix}-card-shadow-${node.shadow}` : undefined,
620
- ...this.getCommonClasses(node),
621
- ]);
622
-
623
- const styles = this.buildCommonStyles(node);
624
- const styleAttr = styles ? ` style="${styles}"` : '';
625
-
626
- const title = node.title
627
- ? `<h3 class="${this.prefix}-title">${this.escapeHtml(node.title)}</h3>\n`
628
- : '';
629
- const children = this.renderChildren(node.children);
630
- return `<div class="${classes}"${styleAttr}>\n${title}${children}\n</div>`;
631
- }
632
-
633
- private renderModal(node: ModalNode): string {
634
- const classes = this.buildClassString([
635
- `${this.prefix}-modal`,
636
- ...this.getCommonClasses(node),
637
- ]);
638
-
639
- const styles = this.buildCommonStyles(node);
640
- const styleAttr = styles ? ` style="${styles}"` : '';
641
-
642
- const title = node.title
643
- ? `<h2 class="${this.prefix}-title">${this.escapeHtml(node.title)}</h2>\n`
644
- : '';
645
- const children = this.renderChildren(node.children);
646
- return `<div class="${this.prefix}-modal-backdrop">
647
- <div class="${classes}"${styleAttr} role="dialog" aria-modal="true">
648
- ${title}${children}
649
- </div>
650
- </div>`;
651
- }
652
-
653
- private renderDrawer(node: DrawerNode): string {
654
- const position = node.position || 'left';
655
- const classes = this.buildClassString([
656
- `${this.prefix}-drawer`,
657
- `${this.prefix}-drawer-${position}`,
658
- ...this.getCommonClasses(node),
659
- ]);
660
-
661
- const styles = this.buildCommonStyles(node);
662
- const styleAttr = styles ? ` style="${styles}"` : '';
663
-
664
- const title = node.title
665
- ? `<h2 class="${this.prefix}-title">${this.escapeHtml(node.title)}</h2>\n`
666
- : '';
667
- const children = this.renderChildren(node.children);
668
- return `<aside class="${classes}"${styleAttr}>\n${title}${children}\n</aside>`;
669
- }
670
-
671
- private renderAccordion(node: AccordionNode): string {
672
- const classes = this.buildClassString([
673
- `${this.prefix}-accordion`,
674
- ...this.getCommonClasses(node),
675
- ]);
676
-
677
- const styles = this.buildCommonStyles(node);
678
- const styleAttr = styles ? ` style="${styles}"` : '';
679
-
680
- const title = node.title
681
- ? `<button class="${this.prefix}-accordion-header">${this.escapeHtml(node.title)}</button>\n`
682
- : '';
683
- const children = this.renderChildren(node.children);
684
- return `<div class="${classes}"${styleAttr}>\n${title}<div class="${this.prefix}-accordion-content">\n${children}\n</div>\n</div>`;
685
- }
686
-
687
- // ===========================================
688
- // Text Node Renderers
689
- // ===========================================
690
-
691
- private renderText(node: TextNode): string {
692
- const classes = this.buildClassString([
693
- `${this.prefix}-text`,
694
- node.size ? `${this.prefix}-text-${node.size}` : undefined,
695
- node.weight ? `${this.prefix}-text-${node.weight}` : undefined,
696
- node.align ? `${this.prefix}-text-${node.align}` : undefined,
697
- node.muted ? `${this.prefix}-text-muted` : undefined,
698
- ...this.getCommonClasses(node),
699
- ]);
700
-
701
- const styles = this.buildCommonStyles(node);
702
- const styleAttr = styles ? ` style="${styles}"` : '';
703
-
704
- return `<p class="${classes}"${styleAttr}>${this.escapeHtml(node.content)}</p>`;
705
- }
706
-
707
- private renderTitle(node: TitleNode): string {
708
- const level = node.level || 1;
709
- const tag = `h${level}`;
710
- const classes = this.buildClassString([
711
- `${this.prefix}-title`,
712
- node.size ? `${this.prefix}-text-${node.size}` : undefined,
713
- node.align ? `${this.prefix}-text-${node.align}` : undefined,
714
- ...this.getCommonClasses(node),
715
- ]);
716
-
717
- const styles = this.buildCommonStyles(node);
718
- const styleAttr = styles ? ` style="${styles}"` : '';
719
-
720
- return `<${tag} class="${classes}"${styleAttr}>${this.escapeHtml(node.content)}</${tag}>`;
721
- }
722
-
723
- private renderLink(node: LinkNode): string {
724
- const classes = this.buildClassString([
725
- `${this.prefix}-link`,
726
- ...this.getCommonClasses(node),
727
- ]);
728
-
729
- const styles = this.buildCommonStyles(node);
730
- const styleAttr = styles ? ` style="${styles}"` : '';
731
-
732
- const attrs: Record<string, string | boolean | undefined> = {
733
- class: classes,
734
- href: node.href || '#',
735
- };
736
-
737
- if (node.external) {
738
- attrs.target = '_blank';
739
- attrs.rel = 'noopener noreferrer';
740
- }
741
-
742
- return `<a${this.buildAttrsString(attrs)}${styleAttr}>${this.escapeHtml(node.content)}</a>`;
743
- }
744
-
745
- // ===========================================
746
- // Input Node Renderers
747
- // ===========================================
748
-
749
- private renderInput(node: InputNode): string {
750
- const inputClasses = this.buildClassString([
751
- `${this.prefix}-input`,
752
- node.icon ? `${this.prefix}-input-with-icon` : undefined,
753
- ...this.getCommonClasses(node),
754
- ]);
755
-
756
- const styles = this.buildCommonStyles(node);
757
- const styleAttr = styles ? ` style="${styles}"` : '';
758
-
759
- const attrs: Record<string, string | boolean | undefined> = {
760
- class: inputClasses,
761
- type: node.inputType || 'text',
762
- placeholder: node.placeholder,
763
- value: node.value,
764
- disabled: node.disabled,
765
- required: node.required,
766
- readonly: node.readonly,
767
- };
768
-
769
- const inputElement = `<input${this.buildAttrsString(attrs)} />`;
770
-
771
- // Wrap with icon if specified
772
- if (node.icon) {
773
- const iconData = getIconData(node.icon);
774
- let iconHtml: string;
775
- if (iconData) {
776
- iconHtml = renderIconSvg(iconData, 16, 2, `${this.prefix}-input-icon`);
777
- } else {
778
- iconHtml = `<span class="${this.prefix}-input-icon">[${this.escapeHtml(node.icon)}]</span>`;
779
- }
780
-
781
- const wrapperClasses = this.buildClassString([`${this.prefix}-input-wrapper`]);
782
- const wrapper = `<div class="${wrapperClasses}"${styleAttr}>${iconHtml}${inputElement}</div>`;
783
-
784
- if (node.label) {
785
- return `<label class="${this.prefix}-input-label">${this.escapeHtml(node.label)}</label>\n${wrapper}`;
786
- }
787
- return wrapper;
788
- }
789
-
790
- const input = `<input${this.buildAttrsString(attrs)}${styleAttr} />`;
791
-
792
- if (node.label) {
793
- return `<label class="${this.prefix}-input-label">${this.escapeHtml(node.label)}</label>\n${input}`;
794
- }
795
-
796
- return input;
797
- }
798
-
799
- private renderTextarea(node: TextareaNode): string {
800
- const classes = this.buildClassString([
801
- `${this.prefix}-input`,
802
- ...this.getCommonClasses(node),
803
- ]);
804
-
805
- const styles = this.buildCommonStyles(node);
806
- const styleAttr = styles ? ` style="${styles}"` : '';
807
-
808
- const attrs: Record<string, string | boolean | undefined> = {
809
- class: classes,
810
- placeholder: node.placeholder,
811
- disabled: node.disabled,
812
- required: node.required,
813
- rows: node.rows?.toString(),
814
- };
815
-
816
- const textarea = `<textarea${this.buildAttrsString(attrs)}${styleAttr}>${this.escapeHtml(node.value || '')}</textarea>`;
817
-
818
- if (node.label) {
819
- return `<label class="${this.prefix}-input-label">${this.escapeHtml(node.label)}</label>\n${textarea}`;
820
- }
821
-
822
- return textarea;
823
- }
824
-
825
- private renderSelect(node: SelectNode): string {
826
- const classes = this.buildClassString([
827
- `${this.prefix}-input`,
828
- ...this.getCommonClasses(node),
829
- ]);
830
-
831
- const styles = this.buildCommonStyles(node);
832
- const styleAttr = styles ? ` style="${styles}"` : '';
833
-
834
- const attrs: Record<string, string | boolean | undefined> = {
835
- class: classes,
836
- disabled: node.disabled,
837
- required: node.required,
838
- };
839
-
840
- const options = node.options
841
- .map((opt) => {
842
- if (typeof opt === 'string') {
843
- const selected = opt === node.value ? ' selected' : '';
844
- return `<option value="${this.escapeHtml(opt)}"${selected}>${this.escapeHtml(opt)}</option>`;
845
- }
846
- const selected = opt.value === node.value ? ' selected' : '';
847
- return `<option value="${this.escapeHtml(opt.value)}"${selected}>${this.escapeHtml(opt.label)}</option>`;
848
- })
849
- .join('\n');
850
-
851
- const placeholder = node.placeholder
852
- ? `<option value="" disabled selected>${this.escapeHtml(node.placeholder)}</option>\n`
853
- : '';
854
-
855
- const select = `<select${this.buildAttrsString(attrs)}${styleAttr}>\n${placeholder}${options}\n</select>`;
856
-
857
- if (node.label) {
858
- return `<label class="${this.prefix}-input-label">${this.escapeHtml(node.label)}</label>\n${select}`;
859
- }
860
-
861
- return select;
862
- }
863
-
864
- private renderCheckbox(node: CheckboxNode): string {
865
- const styles = this.buildCommonStyles(node);
866
- const styleAttr = styles ? ` style="${styles}"` : '';
867
-
868
- const attrs: Record<string, string | boolean | undefined> = {
869
- type: 'checkbox',
870
- checked: node.checked,
871
- disabled: node.disabled,
872
- };
873
-
874
- const checkbox = `<input${this.buildAttrsString(attrs)} />`;
875
-
876
- if (node.label) {
877
- return `<label class="${this.prefix}-checkbox"${styleAttr}>${checkbox}<span>${this.escapeHtml(node.label)}</span></label>`;
878
- }
879
-
880
- return checkbox;
881
- }
882
-
883
- private renderRadio(node: RadioNode): string {
884
- const styles = this.buildCommonStyles(node);
885
- const styleAttr = styles ? ` style="${styles}"` : '';
886
-
887
- const attrs: Record<string, string | boolean | undefined> = {
888
- type: 'radio',
889
- name: node.name,
890
- checked: node.checked,
891
- disabled: node.disabled,
892
- };
893
-
894
- const radio = `<input${this.buildAttrsString(attrs)} />`;
895
-
896
- if (node.label) {
897
- return `<label class="${this.prefix}-radio"${styleAttr}>${radio}<span>${this.escapeHtml(node.label)}</span></label>`;
898
- }
899
-
900
- return radio;
901
- }
902
-
903
- private renderSwitch(node: SwitchNode): string {
904
- const classes = this.buildClassString([
905
- `${this.prefix}-switch`,
906
- ...this.getCommonClasses(node),
907
- ]);
908
-
909
- const styles = this.buildCommonStyles(node);
910
- const styleAttr = styles ? ` style="${styles}"` : '';
911
-
912
- const attrs: Record<string, string | boolean | undefined> = {
913
- type: 'checkbox',
914
- role: 'switch',
915
- checked: node.checked,
916
- disabled: node.disabled,
917
- };
918
-
919
- const switchEl = `<input${this.buildAttrsString(attrs)} />`;
920
-
921
- if (node.label) {
922
- return `<label class="${classes}"${styleAttr}>${switchEl} ${this.escapeHtml(node.label)}</label>`;
923
- }
924
-
925
- // Always wrap in label with .wf-switch class for proper styling
926
- return `<label class="${classes}"${styleAttr}>${switchEl}</label>`;
927
- }
928
-
929
- private renderSlider(node: SliderNode): string {
930
- const classes = this.buildClassString([
931
- `${this.prefix}-slider`,
932
- ...this.getCommonClasses(node),
933
- ]);
934
-
935
- const styles = this.buildCommonStyles(node);
936
- const styleAttr = styles ? ` style="${styles}"` : '';
937
-
938
- const attrs: Record<string, string | boolean | undefined> = {
939
- class: classes,
940
- type: 'range',
941
- min: node.min?.toString(),
942
- max: node.max?.toString(),
943
- step: node.step?.toString(),
944
- value: node.value?.toString(),
945
- disabled: node.disabled,
946
- };
947
-
948
- const slider = `<input${this.buildAttrsString(attrs)}${styleAttr} />`;
949
-
950
- if (node.label) {
951
- return `<label class="${this.prefix}-input-label">${this.escapeHtml(node.label)}</label>\n${slider}`;
952
- }
953
-
954
- return slider;
955
- }
956
-
957
- // ===========================================
958
- // Button Renderer
959
- // ===========================================
960
-
961
- private renderButton(node: ButtonNode): string {
962
- // Icon-only button: has icon but no text content
963
- const isIconOnly = node.icon && !node.content.trim();
964
- const classes = this.buildClassString([
965
- `${this.prefix}-button`,
966
- node.primary ? `${this.prefix}-button-primary` : undefined,
967
- node.secondary ? `${this.prefix}-button-secondary` : undefined,
968
- node.outline ? `${this.prefix}-button-outline` : undefined,
969
- node.ghost ? `${this.prefix}-button-ghost` : undefined,
970
- node.danger ? `${this.prefix}-button-danger` : undefined,
971
- node.size ? `${this.prefix}-button-${node.size}` : undefined,
972
- node.disabled ? `${this.prefix}-button-disabled` : undefined,
973
- node.loading ? `${this.prefix}-button-loading` : undefined,
974
- isIconOnly ? `${this.prefix}-button-icon-only` : undefined,
975
- ...this.getCommonClasses(node),
976
- ]);
977
-
978
- const styles = this.buildCommonStyles(node);
979
- const styleAttr = styles ? ` style="${styles}"` : '';
980
-
981
- const attrs: Record<string, string | boolean | undefined> = {
982
- class: classes,
983
- disabled: node.disabled,
984
- };
985
-
986
- let icon = '';
987
- if (node.icon) {
988
- const iconData = getIconData(node.icon);
989
- if (iconData) {
990
- icon = renderIconSvg(iconData, 16, 2, `${this.prefix}-icon`);
991
- } else {
992
- icon = `<span class="${this.prefix}-icon">[${this.escapeHtml(node.icon)}]</span>`;
993
- }
994
- }
995
- const loading = node.loading ? `<span class="${this.prefix}-spinner ${this.prefix}-spinner-sm"></span>` : '';
996
- const content = this.escapeHtml(node.content);
997
-
998
- return `<button${this.buildAttrsString(attrs)}${styleAttr}>${loading}${icon}${content}</button>`;
999
- }
1000
-
1001
- // ===========================================
1002
- // Display Node Renderers
1003
- // ===========================================
1004
-
1005
- private renderImage(node: ImageNode): string {
1006
- const classes = this.buildClassString([
1007
- `${this.prefix}-image`,
1008
- ...this.getCommonClasses(node),
1009
- ]);
1010
-
1011
- const styles = this.buildCommonStyles(node);
1012
- const styleAttr = styles ? ` style="${styles}"` : '';
1013
-
1014
- // If src is provided, render as actual img tag
1015
- if (node.src) {
1016
- const attrs: Record<string, string | boolean | undefined> = {
1017
- class: classes,
1018
- src: node.src,
1019
- alt: node.alt || 'Image',
1020
- };
1021
- // Add style attribute for img tag
1022
- const imgStyleAttr = styles ? `; ${styles}` : '';
1023
- return `<img${this.buildAttrsString(attrs)}${imgStyleAttr ? ` style="${imgStyleAttr.slice(2)}"` : ''} />`;
1024
- }
1025
-
1026
- // Otherwise render as placeholder with image icon
1027
- const label = node.alt || 'Image';
1028
- const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
1029
- return `<div class="${classes}"${styleAttr} role="img" aria-label="${this.escapeHtml(label)}">${icon}<span>${this.escapeHtml(label)}</span></div>`;
1030
- }
1031
-
1032
- private renderPlaceholder(node: PlaceholderNode): string {
1033
- const classes = this.buildClassString([
1034
- `${this.prefix}-placeholder`,
1035
- ...this.getCommonClasses(node),
1036
- ]);
1037
-
1038
- const styles = this.buildCommonStyles(node);
1039
- const styleAttr = styles ? ` style="${styles}"` : '';
1040
-
1041
- const label = node.label ? this.escapeHtml(node.label) : 'Placeholder';
1042
- return `<div class="${classes}"${styleAttr}>${label}</div>`;
1043
- }
1044
-
1045
- private renderAvatar(node: AvatarNode): string {
1046
- // Resolve size: token string (xs, sm, md, lg, xl) or custom px number
1047
- const sizeResolved = resolveSizeValue(node.size, 'avatar', this.prefix);
1048
-
1049
- const classes = this.buildClassString([
1050
- `${this.prefix}-avatar`,
1051
- sizeResolved.className,
1052
- ...this.getCommonClasses(node),
1053
- ]);
1054
-
1055
- const baseStyles = this.buildCommonStyles(node);
1056
- const sizeStyle = sizeResolved.style || '';
1057
- const combinedStyles = baseStyles && sizeStyle
1058
- ? `${baseStyles}; ${sizeStyle}`
1059
- : baseStyles || sizeStyle;
1060
- const styleAttr = combinedStyles ? ` style="${combinedStyles}"` : '';
1061
-
1062
- const initials = node.name
1063
- ? node.name
1064
- .split(' ')
1065
- .map((n) => n[0])
1066
- .join('')
1067
- .toUpperCase()
1068
- .slice(0, 2)
1069
- : '?';
1070
-
1071
- return `<div class="${classes}"${styleAttr} role="img" aria-label="${this.escapeHtml(node.name || 'Avatar')}">${initials}</div>`;
1072
- }
1073
-
1074
- private renderBadge(node: BadgeNode): string {
1075
- // If icon is provided, render as icon badge (circular background with icon)
1076
- if (node.icon) {
1077
- const iconData = getIconData(node.icon);
1078
- const classes = this.buildClassString([
1079
- `${this.prefix}-badge-icon`,
1080
- node.size ? `${this.prefix}-badge-icon-${node.size}` : undefined,
1081
- node.variant ? `${this.prefix}-badge-icon-${node.variant}` : undefined,
1082
- ...this.getCommonClasses(node),
1083
- ]);
1084
-
1085
- const styles = this.buildCommonStyles(node);
1086
- const styleAttr = styles ? ` style="${styles}"` : '';
1087
-
1088
- if (iconData) {
1089
- const svg = renderIconSvg(iconData, 24, 2, `${this.prefix}-icon`);
1090
- return `<span class="${classes}"${styleAttr} aria-label="${this.escapeHtml(node.icon)}">${svg}</span>`;
1091
- }
1092
-
1093
- // Fallback for unknown icon
1094
- return `<span class="${classes}"${styleAttr} aria-label="unknown icon">?</span>`;
1095
- }
1096
-
1097
- // Default text badge (empty content = dot indicator)
1098
- const isDot = !node.content || node.content.trim() === '';
1099
- const classes = this.buildClassString([
1100
- `${this.prefix}-badge`,
1101
- isDot ? `${this.prefix}-badge-dot` : undefined,
1102
- node.variant ? `${this.prefix}-badge-${node.variant}` : undefined,
1103
- node.pill ? `${this.prefix}-badge-pill` : undefined,
1104
- ...this.getCommonClasses(node),
1105
- ]);
1106
-
1107
- const styles = this.buildCommonStyles(node);
1108
- const styleAttr = styles ? ` style="${styles}"` : '';
1109
-
1110
- return `<span class="${classes}"${styleAttr}>${this.escapeHtml(node.content)}</span>`;
1111
- }
1112
-
1113
- private renderIcon(node: IconNode): string {
1114
- const iconData = getIconData(node.name);
1115
-
1116
- // Resolve size: token string (xs, sm, md, lg, xl) or custom px number
1117
- const sizeResolved = resolveSizeValue(node.size, 'icon', this.prefix);
1118
-
1119
- const wrapperClasses = this.buildClassString([
1120
- `${this.prefix}-icon-wrapper`,
1121
- node.muted ? `${this.prefix}-text-muted` : undefined,
1122
- ...this.getCommonClasses(node),
1123
- ]);
1124
-
1125
- const baseStyles = this.buildCommonStyles(node);
1126
-
1127
- if (iconData) {
1128
- // Build icon class with optional size class
1129
- const iconClasses = _buildClassString([
1130
- `${this.prefix}-icon`,
1131
- sizeResolved.className,
1132
- ]);
1133
- const svgStyleAttr = sizeResolved.style ? ` style="${sizeResolved.style}"` : '';
1134
- const svg = renderIconSvg(iconData, 24, 2, iconClasses, svgStyleAttr);
1135
- const wrapperStyleAttr = baseStyles ? ` style="${baseStyles}"` : '';
1136
- return `<span class="${wrapperClasses}"${wrapperStyleAttr} aria-hidden="true">${svg}</span>`;
1137
- }
1138
-
1139
- // Fallback for unknown icons - render a placeholder circle
1140
- const size = sizeResolved.style?.match(/(\d+)px/)?.[1] || '24';
1141
- const sizeNum = parseInt(size, 10);
1142
- const placeholderSvg = `<svg class="${this.prefix}-icon ${sizeResolved.className || ''}" width="${sizeNum}" height="${sizeNum}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1143
- <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" opacity="0.5"/>
1144
- <text x="12" y="16" text-anchor="middle" font-size="10" fill="currentColor" opacity="0.7">?</text>
1145
- </svg>`;
1146
- const wrapperStyleAttr = baseStyles ? ` style="${baseStyles}"` : '';
1147
- return `<span class="${wrapperClasses}"${wrapperStyleAttr} aria-hidden="true" title="Unknown icon: ${this.escapeHtml(node.name)}">${placeholderSvg}</span>`;
1148
- }
1149
-
1150
- // ===========================================
1151
- // Data Node Renderers
1152
- // ===========================================
1153
-
1154
- private renderTable(node: TableNode): string {
1155
- const classes = this.buildClassString([
1156
- `${this.prefix}-table`,
1157
- node.striped ? `${this.prefix}-table-striped` : undefined,
1158
- node.bordered ? `${this.prefix}-table-bordered` : undefined,
1159
- node.hover ? `${this.prefix}-table-hover` : undefined,
1160
- ...this.getCommonClasses(node),
1161
- ]);
1162
-
1163
- const styles = this.buildCommonStyles(node);
1164
- const styleAttr = styles ? ` style="${styles}"` : '';
1165
-
1166
- const thead = `<thead><tr>${node.columns
1167
- .map((col) => `<th>${this.escapeHtml(col)}</th>`)
1168
- .join('')}</tr></thead>`;
1169
-
1170
- const tbody = `<tbody>${node.rows
1171
- .map(
1172
- (row) =>
1173
- `<tr>${row
1174
- .map((cell) => {
1175
- if (typeof cell === 'string') {
1176
- // Support semantic markers and newlines in table cells
1177
- return `<td>${this.renderTableCellContent(cell)}</td>`;
1178
- }
1179
- return `<td>${this.renderNode(cell)}</td>`;
1180
- })
1181
- .join('')}</tr>`
1182
- )
1183
- .join('')}</tbody>`;
1184
-
1185
- return `<table class="${classes}"${styleAttr}>\n${thead}\n${tbody}\n</table>`;
1186
- }
1187
-
1188
- private renderList(node: ListNode): string {
1189
- const tag = node.ordered ? 'ol' : 'ul';
1190
- const classes = this.buildClassString([
1191
- `${this.prefix}-list`,
1192
- node.ordered ? `${this.prefix}-list-ordered` : undefined,
1193
- node.none ? `${this.prefix}-list-none` : undefined,
1194
- ...this.getCommonClasses(node),
1195
- ]);
1196
-
1197
- const styles = this.buildCommonStyles(node);
1198
- const styleAttr = styles ? ` style="${styles}"` : '';
1199
-
1200
- const items = node.items
1201
- .map((item) => {
1202
- if (typeof item === 'string') {
1203
- return `<li class="${this.prefix}-list-item">${this.escapeHtml(item)}</li>`;
1204
- }
1205
- return `<li class="${this.prefix}-list-item">${this.escapeHtml(item.content)}</li>`;
1206
- })
1207
- .join('\n');
1208
-
1209
- return `<${tag} class="${classes}"${styleAttr}>\n${items}\n</${tag}>`;
1210
- }
1211
-
1212
- // ===========================================
1213
- // Feedback Node Renderers
1214
- // ===========================================
1215
-
1216
- private renderAlert(node: AlertNode): string {
1217
- const classes = this.buildClassString([
1218
- `${this.prefix}-alert`,
1219
- node.variant ? `${this.prefix}-alert-${node.variant}` : undefined,
1220
- ...this.getCommonClasses(node),
1221
- ]);
1222
-
1223
- const styles = this.buildCommonStyles(node);
1224
- const styleAttr = styles ? ` style="${styles}"` : '';
1225
-
1226
- const dismissBtn = node.dismissible
1227
- ? ` <button class="${this.prefix}-alert-close" aria-label="Close">&times;</button>`
1228
- : '';
1229
-
1230
- return `<div class="${classes}"${styleAttr} role="alert">${this.escapeHtml(node.content)}${dismissBtn}</div>`;
1231
- }
1232
-
1233
- private renderToast(node: ToastNode): string {
1234
- const classes = this.buildClassString([
1235
- `${this.prefix}-toast`,
1236
- node.position ? `${this.prefix}-toast-${node.position}` : undefined,
1237
- node.variant ? `${this.prefix}-toast-${node.variant}` : undefined,
1238
- ...this.getCommonClasses(node),
1239
- ]);
1240
-
1241
- const styles = this.buildCommonStyles(node);
1242
- const styleAttr = styles ? ` style="${styles}"` : '';
1243
-
1244
- return `<div class="${classes}"${styleAttr} role="status">${this.escapeHtml(node.content)}</div>`;
1245
- }
1246
-
1247
- private renderProgress(node: ProgressNode): string {
1248
- const classes = this.buildClassString([
1249
- `${this.prefix}-progress`,
1250
- ...this.getCommonClasses(node),
1251
- ]);
1252
-
1253
- const styles = this.buildCommonStyles(node);
1254
- const styleAttr = styles ? ` style="${styles}"` : '';
1255
-
1256
- const value = node.value || 0;
1257
- const max = node.max || 100;
1258
- const percentage = Math.round((value / max) * 100);
1259
-
1260
- const label = node.label ? `<span class="${this.prefix}-progress-label">${this.escapeHtml(node.label)}</span>` : '';
1261
-
1262
- if (node.indeterminate) {
1263
- return `<div class="${classes} ${this.prefix}-progress-indeterminate"${styleAttr} role="progressbar">${label}</div>`;
1264
- }
1265
-
1266
- return `<div class="${classes}"${styleAttr} role="progressbar" aria-valuenow="${value}" aria-valuemin="0" aria-valuemax="${max}">
1267
- ${label}
1268
- <div class="${this.prefix}-progress-bar" style="width: ${percentage}%"></div>
1269
- </div>`;
1270
- }
1271
-
1272
- private renderSpinner(node: SpinnerNode): string {
1273
- // Resolve size: token string (xs, sm, md, lg, xl) or custom px number
1274
- const sizeResolved = resolveSizeValue(node.size, 'spinner', this.prefix);
1275
-
1276
- const classes = this.buildClassString([
1277
- `${this.prefix}-spinner`,
1278
- sizeResolved.className,
1279
- ...this.getCommonClasses(node),
1280
- ]);
1281
-
1282
- const baseStyles = this.buildCommonStyles(node);
1283
- const sizeStyle = sizeResolved.style || '';
1284
- const combinedStyles = baseStyles && sizeStyle
1285
- ? `${baseStyles}; ${sizeStyle}`
1286
- : baseStyles || sizeStyle;
1287
- const styleAttr = combinedStyles ? ` style="${combinedStyles}"` : '';
1288
-
1289
- const label = node.label || 'Loading...';
1290
- return `<span class="${classes}"${styleAttr} role="status" aria-label="${this.escapeHtml(label)}"></span>`;
1291
- }
1292
-
1293
- // ===========================================
1294
- // Overlay Node Renderers
1295
- // ===========================================
1296
-
1297
- private renderTooltip(node: TooltipNode): string {
1298
- const classes = this.buildClassString([
1299
- `${this.prefix}-tooltip-wrapper`,
1300
- ...this.getCommonClasses(node),
1301
- ]);
1302
-
1303
- const styles = this.buildCommonStyles(node);
1304
- const styleAttr = styles ? ` style="${styles}"` : '';
1305
-
1306
- const position = node.position || 'top';
1307
- const children = this.renderChildren(node.children);
1308
-
1309
- return `<div class="${classes}"${styleAttr}>
1310
- ${children}
1311
- <div class="${this.prefix}-tooltip ${this.prefix}-tooltip-${position}" role="tooltip">${this.escapeHtml(node.content)}</div>
1312
- </div>`;
1313
- }
1314
-
1315
- private renderPopover(node: PopoverNode): string {
1316
- const classes = this.buildClassString([
1317
- `${this.prefix}-popover`,
1318
- ...this.getCommonClasses(node),
1319
- ]);
1320
-
1321
- const styles = this.buildCommonStyles(node);
1322
- const styleAttr = styles ? ` style="${styles}"` : '';
1323
-
1324
- const title = node.title
1325
- ? `<div class="${this.prefix}-popover-header">${this.escapeHtml(node.title)}</div>\n`
1326
- : '';
1327
- const children = this.renderChildren(node.children);
1328
-
1329
- return `<div class="${classes}"${styleAttr}>\n${title}<div class="${this.prefix}-popover-body">\n${children}\n</div>\n</div>`;
1330
- }
1331
-
1332
- private renderDropdown(node: DropdownNode): string {
1333
- const classes = this.buildClassString([
1334
- `${this.prefix}-dropdown`,
1335
- ...this.getCommonClasses(node),
1336
- ]);
1337
-
1338
- const styles = this.buildCommonStyles(node);
1339
- const styleAttr = styles ? ` style="${styles}"` : '';
1340
-
1341
- const items = node.items
1342
- .map((item) => {
1343
- if ('type' in item && item.type === 'divider') {
1344
- return `<hr class="${this.prefix}-divider" />`;
1345
- }
1346
- // TypeScript narrowing: item is DropdownItemNode after the divider check
1347
- const dropdownItem = item as { label: string; danger?: boolean; disabled?: boolean };
1348
- const itemClasses = this.buildClassString([
1349
- `${this.prefix}-dropdown-item`,
1350
- dropdownItem.danger ? `${this.prefix}-dropdown-item-danger` : undefined,
1351
- dropdownItem.disabled ? `${this.prefix}-dropdown-item-disabled` : undefined,
1352
- ]);
1353
- return `<button class="${itemClasses}"${dropdownItem.disabled ? ' disabled' : ''}>${this.escapeHtml(dropdownItem.label)}</button>`;
1354
- })
1355
- .join('\n');
1356
-
1357
- return `<div class="${classes}"${styleAttr}>\n${items}\n</div>`;
1358
- }
1359
-
1360
- // ===========================================
1361
- // Navigation Node Renderers
1362
- // ===========================================
1363
-
1364
- private renderNav(node: NavNode): string {
1365
- const classes = this.buildClassString([
1366
- `${this.prefix}-nav`,
1367
- node.vertical ? `${this.prefix}-nav-vertical` : undefined,
1368
- ...this.getCommonClasses(node),
1369
- ]);
1370
-
1371
- const styles = this.buildCommonStyles(node);
1372
- const styleAttr = styles ? ` style="${styles}"` : '';
1373
-
1374
- const items = node.items
1375
- .map((item) => {
1376
- if (typeof item === 'string') {
1377
- return `<a class="${this.prefix}-nav-link" href="#">${this.escapeHtml(item)}</a>`;
1378
- }
1379
- const linkClasses = this.buildClassString([
1380
- `${this.prefix}-nav-link`,
1381
- item.active ? `${this.prefix}-nav-link-active` : undefined,
1382
- item.disabled ? `${this.prefix}-nav-link-disabled` : undefined,
1383
- ]);
1384
- return `<a class="${linkClasses}" href="${item.href || '#'}">${this.escapeHtml(item.label)}</a>`;
1385
- })
1386
- .join('\n');
1387
-
1388
- return `<nav class="${classes}"${styleAttr}>\n${items}\n</nav>`;
1389
- }
1390
-
1391
- private renderTabs(node: TabsNode): string {
1392
- const classes = this.buildClassString([
1393
- `${this.prefix}-tabs`,
1394
- ...this.getCommonClasses(node),
1395
- ]);
1396
-
1397
- const styles = this.buildCommonStyles(node);
1398
- const styleAttr = styles ? ` style="${styles}"` : '';
1399
-
1400
- const tabList = node.items
1401
- .map((label, idx) => {
1402
- const isActive = idx === (node.active || 0);
1403
- const tabClasses = `${this.prefix}-tab${isActive ? ` ${this.prefix}-tab-active` : ''}`;
1404
- return `<button class="${tabClasses}" role="tab" aria-selected="${isActive}">${this.escapeHtml(label)}</button>`;
1405
- })
1406
- .join('\n');
1407
-
1408
- return `<div class="${classes}"${styleAttr}>
1409
- <div class="${this.prefix}-tab-list" role="tablist">
1410
- ${tabList}
1411
- </div>
1412
- </div>`;
1413
- }
1414
-
1415
- private renderBreadcrumb(node: BreadcrumbNode): string {
1416
- const classes = this.buildClassString([
1417
- `${this.prefix}-breadcrumb`,
1418
- ...this.getCommonClasses(node),
1419
- ]);
1420
-
1421
- const styles = this.buildCommonStyles(node);
1422
- const styleAttr = styles ? ` style="${styles}"` : '';
1423
-
1424
- const items = node.items
1425
- .map((item, idx) => {
1426
- const isLast = idx === node.items.length - 1;
1427
- if (typeof item === 'string') {
1428
- return isLast
1429
- ? `<span class="${this.prefix}-breadcrumb-item" aria-current="page">${this.escapeHtml(item)}</span>`
1430
- : `<a class="${this.prefix}-breadcrumb-item" href="#">${this.escapeHtml(item)}</a>`;
1431
- }
1432
- return isLast
1433
- ? `<span class="${this.prefix}-breadcrumb-item" aria-current="page">${this.escapeHtml(item.label)}</span>`
1434
- : `<a class="${this.prefix}-breadcrumb-item" href="${item.href || '#'}">${this.escapeHtml(item.label)}</a>`;
1435
- })
1436
- .join(' / ');
1437
-
1438
- return `<nav class="${classes}"${styleAttr} aria-label="Breadcrumb">${items}</nav>`;
1439
- }
1440
-
1441
- // ===========================================
1442
- // Divider Renderer
1443
- // ===========================================
1444
-
1445
- private renderDivider(node: DividerComponentNode): string {
1446
- const styles = this.buildCommonStyles(node);
1447
- const styleAttr = styles ? ` style="${styles}"` : '';
1448
-
1449
- return `<hr class="${this.prefix}-divider"${styleAttr} />`;
1450
- }
1451
-
1452
- // ===========================================
1453
- // Semantic Marker Rendering
1454
- // ===========================================
1455
-
1456
- /**
1457
- * Parse and render semantic markers in text content
1458
- *
1459
- * Semantic markers use the syntax [component:variant] to indicate
1460
- * what a visual element represents. This helps LLMs understand
1461
- * the meaning of placeholder content.
1462
- *
1463
- * Supported markers:
1464
- * - [avatar] or [avatar:size] - User avatar (renders as circle placeholder)
1465
- * - [badge:variant] TEXT - Status badge (TEXT is displayed inside the badge)
1466
- * - [dot:variant] - Status dot (renders as small circle before text)
1467
- * - [icon:name] - Icon placeholder
1468
- *
1469
- * Examples:
1470
- * - "[avatar] John Doe" → renders avatar circle + "John Doe"
1471
- * - "[badge:primary] PRO" → renders badge containing "PRO"
1472
- * - "[dot:success] Active" → renders green dot + "Active"
1473
- */
1474
- private renderSemanticMarkers(text: string): string {
1475
- // Pattern: [component] or [component:variant] with optional following text for badge
1476
- const markerPattern = /\[([a-z]+)(?::([a-z0-9-]+))?\](\s*)/gi;
1477
-
1478
- let result = '';
1479
- let lastIndex = 0;
1480
- let match: RegExpExecArray | null;
1481
-
1482
- while ((match = markerPattern.exec(text)) !== null) {
1483
- // Add text before the marker
1484
- if (match.index > lastIndex) {
1485
- result += this.escapeHtml(text.substring(lastIndex, match.index));
1486
- }
1487
-
1488
- const [fullMatch, component, variant] = match;
1489
- const comp = component.toLowerCase();
1490
- const varnt = variant?.toLowerCase();
1491
-
1492
- // For badge, consume the following word as the badge content
1493
- if (comp === 'badge') {
1494
- const afterMarker = text.substring(match.index + fullMatch.length);
1495
- // Match until newline, next marker, or end
1496
- const contentMatch = afterMarker.match(/^([^\n\[]+?)(?=\n|\[|$)/);
1497
- const badgeContent = contentMatch ? contentMatch[1].trim() : '';
1498
-
1499
- result += this.renderSemanticMarkerWithContent(comp, varnt, badgeContent);
1500
- lastIndex = match.index + fullMatch.length + (contentMatch ? contentMatch[0].length : 0);
1501
- markerPattern.lastIndex = lastIndex; // Update regex position
1502
- } else {
1503
- result += this.renderSemanticMarker(comp, varnt);
1504
- lastIndex = match.index + fullMatch.length;
1505
- }
1506
- }
1507
-
1508
- // Add remaining text after last marker
1509
- if (lastIndex < text.length) {
1510
- result += this.escapeHtml(text.substring(lastIndex));
1511
- }
1512
-
1513
- // If no markers found, just escape and return
1514
- if (lastIndex === 0) {
1515
- return this.escapeHtml(text);
1516
- }
1517
-
1518
- return result;
1519
- }
1520
-
1521
- /**
1522
- * Render a single semantic marker to HTML (without content)
1523
- */
1524
- private renderSemanticMarker(component: string, variant?: string): string {
1525
- const prefix = this.prefix;
1526
-
1527
- switch (component) {
1528
- case 'avatar':
1529
- // Render as small circle placeholder
1530
- const avatarSize = variant || 'sm';
1531
- return `<span class="${prefix}-semantic-avatar ${prefix}-semantic-avatar-${avatarSize}" data-semantic="avatar" data-variant="${avatarSize}" aria-hidden="true"></span>`;
1532
-
1533
- case 'dot':
1534
- // Render as status dot
1535
- const dotVariant = variant || 'default';
1536
- return `<span class="${prefix}-semantic-dot ${prefix}-semantic-dot-${dotVariant}" data-semantic="dot" data-variant="${dotVariant}" aria-hidden="true"></span>`;
1537
-
1538
- case 'icon':
1539
- // Render as icon placeholder
1540
- const iconName = variant || 'default';
1541
- return `<span class="${prefix}-semantic-icon" data-semantic="icon" data-variant="${iconName}" aria-hidden="true">[${iconName}]</span>`;
1542
-
1543
- default:
1544
- // Unknown marker - render as data attribute only
1545
- return `<span class="${prefix}-semantic-unknown" data-semantic="${component}" data-variant="${variant || ''}">[${component}${variant ? ':' + variant : ''}]</span>`;
1546
- }
1547
- }
1548
-
1549
- /**
1550
- * Render a semantic marker with text content (for badge)
1551
- */
1552
- private renderSemanticMarkerWithContent(component: string, variant: string | undefined, content: string): string {
1553
- const prefix = this.prefix;
1554
-
1555
- switch (component) {
1556
- case 'badge':
1557
- // Render as inline badge with content inside
1558
- const badgeVariant = variant || 'default';
1559
- const escapedContent = this.escapeHtml(content);
1560
- return `<span class="${prefix}-semantic-badge ${prefix}-semantic-badge-${badgeVariant}" data-semantic="badge" data-variant="${badgeVariant}">${escapedContent}</span>`;
1561
-
1562
- default:
1563
- // Fallback: render marker then content
1564
- return this.renderSemanticMarker(component, variant) + this.escapeHtml(content);
1565
- }
1566
- }
1567
-
1568
- /**
1569
- * Process table cell content with semantic markers and newlines
1570
- *
1571
- * Special handling for avatar + text layout:
1572
- * When content starts with [avatar], wraps in flex container
1573
- * so avatar and text align horizontally, with text stacking vertically
1574
- */
1575
- private renderTableCellContent(content: string): string {
1576
- // Check if content starts with [avatar] marker
1577
- const avatarMatch = content.match(/^\[avatar(?::([a-z0-9-]+))?\]\s*/i);
1578
-
1579
- if (avatarMatch) {
1580
- // Avatar + text layout: flex container with avatar and text block
1581
- const avatarVariant = avatarMatch[1]?.toLowerCase();
1582
- const avatarHtml = this.renderSemanticMarker('avatar', avatarVariant);
1583
- const restContent = content.slice(avatarMatch[0].length);
1584
-
1585
- // Process remaining content for other markers
1586
- const restHtml = this.renderSemanticMarkers(restContent);
1587
- // Convert newlines to flex items for vertical stacking
1588
- const lines = restHtml.split('\n');
1589
- const textHtml =
1590
- lines.length > 1
1591
- ? lines.map((line) => `<span>${line}</span>`).join('')
1592
- : restHtml;
1593
-
1594
- return `<div class="${this.prefix}-cell-avatar-layout">${avatarHtml}<div class="${this.prefix}-cell-avatar-text">${textHtml}</div></div>`;
1595
- }
1596
-
1597
- // Normal rendering: semantic markers then newlines to <br>
1598
- const withMarkers = this.renderSemanticMarkers(content);
1599
- return withMarkers.replace(/\n/g, '<br>');
1600
- }
1601
- }
1602
-
1603
- /**
1604
- * Create a new HTML renderer instance
1605
- */
1606
- export function createHtmlRenderer(options?: RenderOptions): HtmlRenderer {
1607
- return new HtmlRenderer(options);
1608
- }