@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,1092 +0,0 @@
1
- /**
2
- * Component Renderers for wireweave
3
- *
4
- * Dedicated renderers for all UI components with:
5
- * - XSS prevention via HTML escaping
6
- * - Accessibility attributes (role, aria-*)
7
- * - Consistent class naming
8
- */
9
-
10
- import type {
11
- AnyNode,
12
- CardNode,
13
- ModalNode,
14
- DrawerNode,
15
- AccordionNode,
16
- TextNode,
17
- TitleNode,
18
- LinkNode,
19
- InputNode,
20
- TextareaNode,
21
- SelectNode,
22
- CheckboxNode,
23
- RadioNode,
24
- SwitchNode,
25
- SliderNode,
26
- ButtonNode,
27
- ImageNode,
28
- PlaceholderNode,
29
- AvatarNode,
30
- BadgeNode,
31
- IconNode,
32
- TableNode,
33
- ListNode,
34
- AlertNode,
35
- ToastNode,
36
- ProgressNode,
37
- SpinnerNode,
38
- TooltipNode,
39
- PopoverNode,
40
- DropdownNode,
41
- NavNode,
42
- TabsNode,
43
- BreadcrumbNode,
44
- DividerComponentNode,
45
- CommonProps,
46
- } from '../../ast/types';
47
- import type { RenderContext } from '../types';
48
- import { getIconData, renderIconSvg } from '../../icons/lucide-icons';
49
-
50
- /**
51
- * Type for children renderer callback (re-exported from layout)
52
- */
53
- import type { ChildrenRenderer } from './layout';
54
- export type { ChildrenRenderer };
55
-
56
- /**
57
- * Component node types
58
- */
59
- export type ComponentNodeType =
60
- | 'Card' | 'Modal' | 'Drawer' | 'Accordion'
61
- | 'Text' | 'Title' | 'Link'
62
- | 'Input' | 'Textarea' | 'Select' | 'Checkbox' | 'Radio' | 'Switch' | 'Slider'
63
- | 'Button'
64
- | 'Image' | 'Placeholder' | 'Avatar' | 'Badge' | 'Icon'
65
- | 'Table' | 'List'
66
- | 'Alert' | 'Toast' | 'Progress' | 'Spinner'
67
- | 'Tooltip' | 'Popover' | 'Dropdown'
68
- | 'Nav' | 'Tabs' | 'Breadcrumb'
69
- | 'Divider';
70
-
71
- /**
72
- * Check if a node type is a component node
73
- */
74
- export function isComponentNodeType(type: string): type is ComponentNodeType {
75
- const componentTypes: string[] = [
76
- 'Card', 'Modal', 'Drawer', 'Accordion',
77
- 'Text', 'Title', 'Link',
78
- 'Input', 'Textarea', 'Select', 'Checkbox', 'Radio', 'Switch', 'Slider',
79
- 'Button',
80
- 'Image', 'Placeholder', 'Avatar', 'Badge', 'Icon',
81
- 'Table', 'List',
82
- 'Alert', 'Toast', 'Progress', 'Spinner',
83
- 'Tooltip', 'Popover', 'Dropdown',
84
- 'Nav', 'Tabs', 'Breadcrumb',
85
- 'Divider',
86
- ];
87
- return componentTypes.includes(type);
88
- }
89
-
90
- // ===========================================
91
- // Utility Functions
92
- // ===========================================
93
-
94
- /**
95
- * Escape HTML special characters to prevent XSS
96
- */
97
- export function escapeHtml(text: string): string {
98
- const escapeMap: Record<string, string> = {
99
- '&': '&amp;',
100
- '<': '&lt;',
101
- '>': '&gt;',
102
- '"': '&quot;',
103
- "'": '&#39;',
104
- };
105
- return text.replace(/[&<>"']/g, (char) => escapeMap[char] || char);
106
- }
107
-
108
- /**
109
- * Build CSS class string from an array
110
- */
111
- export function buildClassString(classes: (string | undefined | false)[]): string {
112
- return classes.filter(Boolean).join(' ');
113
- }
114
-
115
- /**
116
- * Build HTML attributes string
117
- */
118
- export function buildAttrsString(attrs: Record<string, string | undefined | boolean>): string {
119
- const parts: string[] = [];
120
-
121
- for (const [key, value] of Object.entries(attrs)) {
122
- if (value === undefined || value === false) {
123
- continue;
124
- }
125
-
126
- if (value === true) {
127
- parts.push(key);
128
- } else {
129
- parts.push(`${key}="${escapeHtml(value)}"`);
130
- }
131
- }
132
-
133
- return parts.length > 0 ? ' ' + parts.join(' ') : '';
134
- }
135
-
136
- /**
137
- * Get common CSS classes from props
138
- * Uses Omit to exclude 'align' since TextNode/TitleNode have incompatible align types
139
- */
140
- export function getCommonClasses(props: Omit<Partial<CommonProps>, 'align'> & { align?: string }, prefix: string): string[] {
141
- const classes: string[] = [];
142
-
143
- // Spacing
144
- if (props.p !== undefined) classes.push(`${prefix}-p-${props.p}`);
145
- if (props.px !== undefined) classes.push(`${prefix}-px-${props.px}`);
146
- if (props.py !== undefined) classes.push(`${prefix}-py-${props.py}`);
147
- if (props.pt !== undefined) classes.push(`${prefix}-pt-${props.pt}`);
148
- if (props.pr !== undefined) classes.push(`${prefix}-pr-${props.pr}`);
149
- if (props.pb !== undefined) classes.push(`${prefix}-pb-${props.pb}`);
150
- if (props.pl !== undefined) classes.push(`${prefix}-pl-${props.pl}`);
151
- if (props.m !== undefined) classes.push(`${prefix}-m-${props.m}`);
152
- if (props.mx !== undefined) classes.push(`${prefix}-mx-${props.mx}`);
153
- if (props.my !== undefined) classes.push(`${prefix}-my-${props.my}`);
154
- if (props.mt !== undefined) classes.push(`${prefix}-mt-${props.mt}`);
155
- if (props.mr !== undefined) classes.push(`${prefix}-mr-${props.mr}`);
156
- if (props.mb !== undefined) classes.push(`${prefix}-mb-${props.mb}`);
157
- if (props.ml !== undefined) classes.push(`${prefix}-ml-${props.ml}`);
158
-
159
- // Size
160
- if (props.w === 'full') classes.push(`${prefix}-w-full`);
161
- if (props.w === 'auto') classes.push(`${prefix}-w-auto`);
162
- if (props.w === 'fit') classes.push(`${prefix}-w-fit`);
163
- if (props.h === 'full') classes.push(`${prefix}-h-full`);
164
- if (props.h === 'auto') classes.push(`${prefix}-h-auto`);
165
- if (props.h === 'screen') classes.push(`${prefix}-h-screen`);
166
-
167
- // Flex
168
- if (props.flex === true) classes.push(`${prefix}-flex`);
169
- if (typeof props.flex === 'number') classes.push(`${prefix}-flex-${props.flex}`);
170
- if (props.direction === 'row') classes.push(`${prefix}-flex-row`);
171
- if (props.direction === 'column') classes.push(`${prefix}-flex-col`);
172
- if (props.direction === 'row-reverse') classes.push(`${prefix}-flex-row-reverse`);
173
- if (props.direction === 'column-reverse') classes.push(`${prefix}-flex-col-reverse`);
174
- if (props.justify) classes.push(`${prefix}-justify-${props.justify}`);
175
- if (props.align) classes.push(`${prefix}-align-${props.align}`);
176
- if (props.wrap === true) classes.push(`${prefix}-flex-wrap`);
177
- if (props.wrap === 'nowrap') classes.push(`${prefix}-flex-nowrap`);
178
- if (props.gap !== undefined) classes.push(`${prefix}-gap-${props.gap}`);
179
-
180
- return classes;
181
- }
182
-
183
- /**
184
- * Size token definitions for each component type
185
- * Maps token strings (xs, sm, md, lg, xl) to pixel values
186
- */
187
- const SIZE_TOKENS = {
188
- icon: { xs: 12, sm: 14, md: 16, lg: 20, xl: 24 },
189
- avatar: { xs: 24, sm: 32, md: 40, lg: 48, xl: 64 },
190
- spinner: { xs: 12, sm: 16, md: 24, lg: 32, xl: 48 },
191
- } as const;
192
-
193
- type SizeTokenType = keyof typeof SIZE_TOKENS;
194
-
195
- /**
196
- * Resolve size value to either a CSS class name or inline style
197
- * Supports both token strings (xs, sm, md, lg, xl) and custom px numbers
198
- *
199
- * @param size - Size value (token string or number in px)
200
- * @param componentType - Component type for token lookup
201
- * @param prefix - CSS class prefix
202
- * @returns Object with className and style for the component
203
- */
204
- export function resolveSizeValue(
205
- size: string | number | undefined,
206
- componentType: SizeTokenType,
207
- prefix: string
208
- ): { className?: string; style?: string } {
209
- if (size === undefined) {
210
- return {};
211
- }
212
-
213
- // If it's a known token, use CSS class
214
- if (typeof size === 'string') {
215
- const tokens = SIZE_TOKENS[componentType];
216
- if (size in tokens) {
217
- return { className: `${prefix}-${componentType}-${size}` };
218
- }
219
- // Unknown string, try to parse as number
220
- const parsed = parseInt(size, 10);
221
- if (!isNaN(parsed)) {
222
- return { style: `width: ${parsed}px; height: ${parsed}px;` };
223
- }
224
- return {};
225
- }
226
-
227
- // If it's a number, use inline style
228
- if (typeof size === 'number') {
229
- return { style: `width: ${size}px; height: ${size}px;` };
230
- }
231
-
232
- return {};
233
- }
234
-
235
- /**
236
- * Render an icon using Lucide icons
237
- * Falls back to text placeholder if icon not found
238
- */
239
- export function renderIconPlaceholder(name: string, prefix: string, size: number = 16): string {
240
- const iconData = getIconData(name);
241
-
242
- if (iconData) {
243
- return renderIconSvg(iconData, size, 2, `${prefix}-icon`);
244
- }
245
-
246
- // Fallback to text placeholder for unknown icons
247
- return `<span class="${prefix}-icon" aria-hidden="true">[${escapeHtml(name)}]</span>`;
248
- }
249
-
250
- // ===========================================
251
- // Container Component Renderers
252
- // ===========================================
253
-
254
- export function renderCard(
255
- node: CardNode,
256
- context: RenderContext,
257
- renderChildren: ChildrenRenderer
258
- ): string {
259
- const prefix = context.options.classPrefix;
260
- const classes = buildClassString([
261
- `${prefix}-card`,
262
- node.shadow ? `${prefix}-card-shadow-${node.shadow}` : undefined,
263
- ...getCommonClasses(node, prefix),
264
- ]);
265
-
266
- const title = node.title
267
- ? `<h3 class="${prefix}-card-title">${escapeHtml(node.title)}</h3>\n`
268
- : '';
269
- const children = renderChildren(node.children);
270
-
271
- return `<div class="${classes}">\n${title}${children}\n</div>`;
272
- }
273
-
274
- export function renderModal(
275
- node: ModalNode,
276
- context: RenderContext,
277
- renderChildren: ChildrenRenderer
278
- ): string {
279
- const prefix = context.options.classPrefix;
280
- const classes = buildClassString([
281
- `${prefix}-modal`,
282
- ...getCommonClasses(node, prefix),
283
- ]);
284
-
285
- // Build inline styles for numeric width/height
286
- const styles: string[] = [];
287
- if (typeof node.w === 'number') {
288
- styles.push(`width: ${node.w}px`);
289
- }
290
- if (typeof node.h === 'number') {
291
- styles.push(`height: ${node.h}px`);
292
- }
293
- const styleAttr = styles.length > 0 ? ` style="${styles.join('; ')}"` : '';
294
-
295
- const title = node.title
296
- ? `<h2 class="${prefix}-modal-title">${escapeHtml(node.title)}</h2>\n`
297
- : '';
298
- const children = renderChildren(node.children);
299
-
300
- return `<div class="${prefix}-modal-backdrop">
301
- <div class="${classes}" role="dialog" aria-modal="true"${styleAttr}${node.title ? ` aria-labelledby="modal-title"` : ''}>
302
- ${title}${children}
303
- </div>
304
- </div>`;
305
- }
306
-
307
- export function renderDrawer(
308
- node: DrawerNode,
309
- context: RenderContext,
310
- renderChildren: ChildrenRenderer
311
- ): string {
312
- const prefix = context.options.classPrefix;
313
- const position = node.position || 'left';
314
- const classes = buildClassString([
315
- `${prefix}-drawer`,
316
- `${prefix}-drawer-${position}`,
317
- ...getCommonClasses(node, prefix),
318
- ]);
319
-
320
- const title = node.title
321
- ? `<h2 class="${prefix}-drawer-title">${escapeHtml(node.title)}</h2>\n`
322
- : '';
323
- const children = renderChildren(node.children);
324
-
325
- return `<aside class="${classes}" role="complementary">\n${title}${children}\n</aside>`;
326
- }
327
-
328
- export function renderAccordion(
329
- node: AccordionNode,
330
- context: RenderContext,
331
- renderChildren: ChildrenRenderer
332
- ): string {
333
- const prefix = context.options.classPrefix;
334
- const classes = buildClassString([
335
- `${prefix}-accordion`,
336
- ...getCommonClasses(node, prefix),
337
- ]);
338
-
339
- const title = node.title
340
- ? `<button class="${prefix}-accordion-header" aria-expanded="false">${escapeHtml(node.title)}</button>\n`
341
- : '';
342
- const children = renderChildren(node.children);
343
-
344
- return `<div class="${classes}">\n${title}<div class="${prefix}-accordion-content" role="region">\n${children}\n</div>\n</div>`;
345
- }
346
-
347
- // ===========================================
348
- // Text Component Renderers
349
- // ===========================================
350
-
351
- export function renderText(node: TextNode, context: RenderContext): string {
352
- const prefix = context.options.classPrefix;
353
-
354
- // Handle size: token (string) vs direct value (ValueWithUnit)
355
- const isTokenSize = typeof node.size === 'string';
356
- const sizeClass = isTokenSize && node.size ? `${prefix}-text-${node.size}` : undefined;
357
- const sizeStyle = !isTokenSize && node.size && typeof node.size === 'object' && 'value' in node.size
358
- ? `font-size: ${node.size.value}${node.size.unit}`
359
- : undefined;
360
-
361
- const classes = buildClassString([
362
- `${prefix}-text`,
363
- sizeClass,
364
- node.weight ? `${prefix}-text-${node.weight}` : undefined,
365
- node.align ? `${prefix}-text-${node.align}` : undefined,
366
- node.muted ? `${prefix}-text-muted` : undefined,
367
- ...getCommonClasses(node, prefix),
368
- ]);
369
-
370
- const styleAttr = sizeStyle ? ` style="${sizeStyle}"` : '';
371
-
372
- return `<p class="${classes}"${styleAttr}>${escapeHtml(node.content)}</p>`;
373
- }
374
-
375
- export function renderTitle(node: TitleNode, context: RenderContext): string {
376
- const prefix = context.options.classPrefix;
377
- const level = Math.min(Math.max(node.level || 1, 1), 6);
378
- const tag = `h${level}`;
379
-
380
- // Handle size: token (string) vs direct value (ValueWithUnit)
381
- const isTokenSize = typeof node.size === 'string';
382
- const sizeClass = isTokenSize && node.size ? `${prefix}-text-${node.size}` : undefined;
383
- const sizeStyle = !isTokenSize && node.size && typeof node.size === 'object' && 'value' in node.size
384
- ? `font-size: ${node.size.value}${node.size.unit}`
385
- : undefined;
386
-
387
- const classes = buildClassString([
388
- `${prefix}-title`,
389
- sizeClass,
390
- node.align ? `${prefix}-text-${node.align}` : undefined,
391
- ...getCommonClasses(node, prefix),
392
- ]);
393
-
394
- const styleAttr = sizeStyle ? ` style="${sizeStyle}"` : '';
395
-
396
- return `<${tag} class="${classes}"${styleAttr}>${escapeHtml(node.content)}</${tag}>`;
397
- }
398
-
399
- export function renderLink(node: LinkNode, context: RenderContext): string {
400
- const prefix = context.options.classPrefix;
401
- const classes = buildClassString([
402
- `${prefix}-link`,
403
- ...getCommonClasses(node, prefix),
404
- ]);
405
-
406
- const attrs: Record<string, string | boolean | undefined> = {
407
- class: classes,
408
- href: node.href || '#',
409
- };
410
-
411
- if (node.external) {
412
- attrs.target = '_blank';
413
- attrs.rel = 'noopener noreferrer';
414
- }
415
-
416
- return `<a${buildAttrsString(attrs)}>${escapeHtml(node.content)}</a>`;
417
- }
418
-
419
- // ===========================================
420
- // Input Component Renderers
421
- // ===========================================
422
-
423
- export function renderInput(node: InputNode, context: RenderContext): string {
424
- const prefix = context.options.classPrefix;
425
- const classes = buildClassString([
426
- `${prefix}-input`,
427
- ...getCommonClasses(node, prefix),
428
- ]);
429
-
430
- const attrs: Record<string, string | boolean | undefined> = {
431
- class: classes,
432
- type: node.inputType || 'text',
433
- placeholder: node.placeholder,
434
- value: node.value,
435
- disabled: node.disabled,
436
- required: node.required,
437
- readonly: node.readonly,
438
- 'aria-required': node.required ? 'true' : undefined,
439
- };
440
-
441
- const input = `<input${buildAttrsString(attrs)} />`;
442
-
443
- if (node.label) {
444
- const labelId = `input-${Math.random().toString(36).substr(2, 9)}`;
445
- return `<div class="${prefix}-form-field">
446
- <label class="${prefix}-input-label" for="${labelId}">${escapeHtml(node.label)}</label>
447
- <input${buildAttrsString({ ...attrs, id: labelId })} />
448
- </div>`;
449
- }
450
-
451
- return input;
452
- }
453
-
454
- export function renderTextarea(node: TextareaNode, context: RenderContext): string {
455
- const prefix = context.options.classPrefix;
456
- const classes = buildClassString([
457
- `${prefix}-input`,
458
- `${prefix}-textarea`,
459
- ...getCommonClasses(node, prefix),
460
- ]);
461
-
462
- const attrs: Record<string, string | boolean | undefined> = {
463
- class: classes,
464
- placeholder: node.placeholder,
465
- disabled: node.disabled,
466
- required: node.required,
467
- rows: node.rows?.toString(),
468
- 'aria-required': node.required ? 'true' : undefined,
469
- };
470
-
471
- const textarea = `<textarea${buildAttrsString(attrs)}>${escapeHtml(node.value || '')}</textarea>`;
472
-
473
- if (node.label) {
474
- return `<div class="${prefix}-form-field">
475
- <label class="${prefix}-input-label">${escapeHtml(node.label)}</label>
476
- ${textarea}
477
- </div>`;
478
- }
479
-
480
- return textarea;
481
- }
482
-
483
- export function renderSelect(node: SelectNode, context: RenderContext): string {
484
- const prefix = context.options.classPrefix;
485
- const classes = buildClassString([
486
- `${prefix}-input`,
487
- `${prefix}-select`,
488
- ...getCommonClasses(node, prefix),
489
- ]);
490
-
491
- const attrs: Record<string, string | boolean | undefined> = {
492
- class: classes,
493
- disabled: node.disabled,
494
- required: node.required,
495
- 'aria-required': node.required ? 'true' : undefined,
496
- };
497
-
498
- const options = node.options
499
- .map((opt) => {
500
- if (typeof opt === 'string') {
501
- const selected = opt === node.value ? ' selected' : '';
502
- return `<option value="${escapeHtml(opt)}"${selected}>${escapeHtml(opt)}</option>`;
503
- }
504
- const selected = opt.value === node.value ? ' selected' : '';
505
- return `<option value="${escapeHtml(opt.value)}"${selected}>${escapeHtml(opt.label)}</option>`;
506
- })
507
- .join('\n');
508
-
509
- const placeholder = node.placeholder
510
- ? `<option value="" disabled selected>${escapeHtml(node.placeholder)}</option>\n`
511
- : '';
512
-
513
- const select = `<select${buildAttrsString(attrs)}>\n${placeholder}${options}\n</select>`;
514
-
515
- if (node.label) {
516
- return `<div class="${prefix}-form-field">
517
- <label class="${prefix}-input-label">${escapeHtml(node.label)}</label>
518
- ${select}
519
- </div>`;
520
- }
521
-
522
- return select;
523
- }
524
-
525
- export function renderCheckbox(node: CheckboxNode, context: RenderContext): string {
526
- const prefix = context.options.classPrefix;
527
-
528
- const attrs: Record<string, string | boolean | undefined> = {
529
- type: 'checkbox',
530
- checked: node.checked,
531
- disabled: node.disabled,
532
- 'aria-checked': node.checked ? 'true' : 'false',
533
- };
534
-
535
- const checkbox = `<input${buildAttrsString(attrs)} />`;
536
-
537
- if (node.label) {
538
- return `<label class="${prefix}-checkbox">
539
- ${checkbox}
540
- <span class="${prefix}-checkbox-label">${escapeHtml(node.label)}</span>
541
- </label>`;
542
- }
543
-
544
- return checkbox;
545
- }
546
-
547
- export function renderRadio(node: RadioNode, context: RenderContext): string {
548
- const prefix = context.options.classPrefix;
549
-
550
- const attrs: Record<string, string | boolean | undefined> = {
551
- type: 'radio',
552
- name: node.name,
553
- checked: node.checked,
554
- disabled: node.disabled,
555
- 'aria-checked': node.checked ? 'true' : 'false',
556
- };
557
-
558
- const radio = `<input${buildAttrsString(attrs)} />`;
559
-
560
- if (node.label) {
561
- return `<label class="${prefix}-radio">
562
- ${radio}
563
- <span class="${prefix}-radio-label">${escapeHtml(node.label)}</span>
564
- </label>`;
565
- }
566
-
567
- return radio;
568
- }
569
-
570
- export function renderSwitch(node: SwitchNode, context: RenderContext): string {
571
- const prefix = context.options.classPrefix;
572
- const classes = buildClassString([
573
- `${prefix}-switch`,
574
- ...getCommonClasses(node, prefix),
575
- ]);
576
-
577
- const attrs: Record<string, string | boolean | undefined> = {
578
- type: 'checkbox',
579
- role: 'switch',
580
- checked: node.checked,
581
- disabled: node.disabled,
582
- 'aria-checked': node.checked ? 'true' : 'false',
583
- };
584
-
585
- const switchEl = `<input${buildAttrsString(attrs)} />`;
586
-
587
- if (node.label) {
588
- return `<label class="${classes}">
589
- ${switchEl}
590
- <span class="${prefix}-switch-label">${escapeHtml(node.label)}</span>
591
- </label>`;
592
- }
593
-
594
- // Always wrap in label with .wf-switch class for proper styling
595
- return `<label class="${classes}">${switchEl}</label>`;
596
- }
597
-
598
- export function renderSlider(node: SliderNode, context: RenderContext): string {
599
- const prefix = context.options.classPrefix;
600
- const classes = buildClassString([
601
- `${prefix}-slider`,
602
- ...getCommonClasses(node, prefix),
603
- ]);
604
-
605
- const attrs: Record<string, string | boolean | undefined> = {
606
- class: classes,
607
- type: 'range',
608
- min: node.min?.toString(),
609
- max: node.max?.toString(),
610
- step: node.step?.toString(),
611
- value: node.value?.toString(),
612
- disabled: node.disabled,
613
- 'aria-valuemin': node.min?.toString(),
614
- 'aria-valuemax': node.max?.toString(),
615
- 'aria-valuenow': node.value?.toString(),
616
- };
617
-
618
- const slider = `<input${buildAttrsString(attrs)} />`;
619
-
620
- if (node.label) {
621
- return `<div class="${prefix}-form-field">
622
- <label class="${prefix}-input-label">${escapeHtml(node.label)}</label>
623
- ${slider}
624
- </div>`;
625
- }
626
-
627
- return slider;
628
- }
629
-
630
- // ===========================================
631
- // Button Component Renderer
632
- // ===========================================
633
-
634
- export function renderButton(node: ButtonNode, context: RenderContext): string {
635
- const prefix = context.options.classPrefix;
636
- // Icon-only button: has icon but no text content
637
- const isIconOnly = node.icon && !node.content.trim();
638
- const classes = buildClassString([
639
- `${prefix}-button`,
640
- node.primary ? `${prefix}-button-primary` : undefined,
641
- node.secondary ? `${prefix}-button-secondary` : undefined,
642
- node.outline ? `${prefix}-button-outline` : undefined,
643
- node.ghost ? `${prefix}-button-ghost` : undefined,
644
- node.danger ? `${prefix}-button-danger` : undefined,
645
- node.size ? `${prefix}-button-${node.size}` : undefined,
646
- node.disabled ? `${prefix}-button-disabled` : undefined,
647
- node.loading ? `${prefix}-button-loading` : undefined,
648
- isIconOnly ? `${prefix}-button-icon-only` : undefined,
649
- ...getCommonClasses(node, prefix),
650
- ]);
651
-
652
- const attrs: Record<string, string | boolean | undefined> = {
653
- class: classes,
654
- type: 'button',
655
- disabled: node.disabled,
656
- 'aria-disabled': node.disabled ? 'true' : undefined,
657
- 'aria-busy': node.loading ? 'true' : undefined,
658
- };
659
-
660
- const icon = node.icon ? `${renderIconPlaceholder(node.icon, prefix)} ` : '';
661
- const loading = node.loading ? `<span class="${prefix}-spinner ${prefix}-spinner-sm" aria-hidden="true"></span> ` : '';
662
- const content = escapeHtml(node.content);
663
-
664
- return `<button${buildAttrsString(attrs)}>${loading}${icon}${content}</button>`;
665
- }
666
-
667
- // ===========================================
668
- // Display Component Renderers
669
- // ===========================================
670
-
671
- export function renderImage(node: ImageNode, context: RenderContext): string {
672
- const prefix = context.options.classPrefix;
673
- const classes = buildClassString([
674
- `${prefix}-image`,
675
- ...getCommonClasses(node, prefix),
676
- ]);
677
-
678
- const attrs: Record<string, string | boolean | undefined> = {
679
- class: classes,
680
- src: node.src || '',
681
- alt: node.alt || 'Image',
682
- loading: 'lazy',
683
- };
684
-
685
- return `<img${buildAttrsString(attrs)} />`;
686
- }
687
-
688
- export function renderPlaceholder(node: PlaceholderNode, context: RenderContext): string {
689
- const prefix = context.options.classPrefix;
690
- const classes = buildClassString([
691
- `${prefix}-placeholder`,
692
- ...getCommonClasses(node, prefix),
693
- ]);
694
-
695
- const label = node.label ? escapeHtml(node.label) : 'Placeholder';
696
- return `<div class="${classes}" role="img" aria-label="${label}">${label}</div>`;
697
- }
698
-
699
- export function renderAvatar(node: AvatarNode, context: RenderContext): string {
700
- const prefix = context.options.classPrefix;
701
-
702
- // Resolve size: token string (xs, sm, md, lg, xl) or custom px number
703
- const sizeResolved = resolveSizeValue(node.size, 'avatar', prefix);
704
-
705
- const classes = buildClassString([
706
- `${prefix}-avatar`,
707
- sizeResolved.className,
708
- ...getCommonClasses(node, prefix),
709
- ]);
710
-
711
- const styleAttr = sizeResolved.style ? ` style="${sizeResolved.style}"` : '';
712
-
713
- const initials = node.name
714
- ? node.name
715
- .split(' ')
716
- .map((n) => n[0])
717
- .join('')
718
- .toUpperCase()
719
- .slice(0, 2)
720
- : '?';
721
-
722
- const ariaLabel = escapeHtml(node.name || 'Avatar');
723
-
724
- if (node.src) {
725
- return `<img class="${classes}"${styleAttr} src="${node.src}" alt="${ariaLabel}" />`;
726
- }
727
-
728
- return `<div class="${classes}"${styleAttr} role="img" aria-label="${ariaLabel}">${initials}</div>`;
729
- }
730
-
731
- export function renderBadge(node: BadgeNode, context: RenderContext): string {
732
- const prefix = context.options.classPrefix;
733
-
734
- // If icon is provided, render as icon badge (circular background with icon)
735
- if (node.icon) {
736
- const iconData = getIconData(node.icon);
737
- const classes = buildClassString([
738
- `${prefix}-badge-icon`,
739
- node.size ? `${prefix}-badge-icon-${node.size}` : undefined,
740
- node.variant ? `${prefix}-badge-icon-${node.variant}` : undefined,
741
- ...getCommonClasses(node, prefix),
742
- ]);
743
-
744
- if (iconData) {
745
- const svg = renderIconSvg(iconData, 24, 2, `${prefix}-icon`);
746
- return `<span class="${classes}" aria-label="${escapeHtml(node.icon)}">${svg}</span>`;
747
- }
748
-
749
- // Fallback for unknown icon
750
- return `<span class="${classes}" aria-label="unknown icon">?</span>`;
751
- }
752
-
753
- // Default text badge (empty content = dot indicator)
754
- const isDot = !node.content || node.content.trim() === '';
755
- const classes = buildClassString([
756
- `${prefix}-badge`,
757
- isDot ? `${prefix}-badge-dot` : undefined,
758
- node.variant ? `${prefix}-badge-${node.variant}` : undefined,
759
- node.pill ? `${prefix}-badge-pill` : undefined,
760
- ...getCommonClasses(node, prefix),
761
- ]);
762
-
763
- return `<span class="${classes}">${escapeHtml(node.content)}</span>`;
764
- }
765
-
766
- export function renderIcon(node: IconNode, context: RenderContext): string {
767
- const prefix = context.options.classPrefix;
768
- const iconData = getIconData(node.name);
769
-
770
- // Resolve size: token string (xs, sm, md, lg, xl) or custom px number
771
- const sizeResolved = resolveSizeValue(node.size, 'icon', prefix);
772
-
773
- const wrapperClasses = buildClassString([
774
- `${prefix}-icon-wrapper`,
775
- node.muted ? `${prefix}-text-muted` : undefined,
776
- ...getCommonClasses(node, prefix),
777
- ]);
778
-
779
- if (iconData) {
780
- // Build icon class with optional size class
781
- const iconClasses = buildClassString([
782
- `${prefix}-icon`,
783
- sizeResolved.className,
784
- ]);
785
- const styleAttr = sizeResolved.style ? ` style="${sizeResolved.style}"` : '';
786
- const svg = renderIconSvg(iconData, 24, 2, iconClasses, styleAttr);
787
- return `<span class="${wrapperClasses}" aria-hidden="true">${svg}</span>`;
788
- }
789
-
790
- // Fallback for unknown icons - render a placeholder circle
791
- const size = sizeResolved.style?.match(/(\d+)px/)?.[1] || '24';
792
- const sizeNum = parseInt(size, 10);
793
- const placeholderSvg = `<svg class="${prefix}-icon ${sizeResolved.className || ''}" width="${sizeNum}" height="${sizeNum}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
794
- <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" opacity="0.5"/>
795
- <text x="12" y="16" text-anchor="middle" font-size="10" fill="currentColor" opacity="0.7">?</text>
796
- </svg>`;
797
- return `<span class="${wrapperClasses}" aria-hidden="true" title="Unknown icon: ${escapeHtml(node.name)}">${placeholderSvg}</span>`;
798
- }
799
-
800
- // ===========================================
801
- // Data Component Renderers
802
- // ===========================================
803
-
804
- export function renderTable(
805
- node: TableNode,
806
- context: RenderContext,
807
- renderNode: (node: AnyNode) => string
808
- ): string {
809
- const prefix = context.options.classPrefix;
810
- const classes = buildClassString([
811
- `${prefix}-table`,
812
- node.striped ? `${prefix}-table-striped` : undefined,
813
- node.bordered ? `${prefix}-table-bordered` : undefined,
814
- node.hover ? `${prefix}-table-hover` : undefined,
815
- ...getCommonClasses(node, prefix),
816
- ]);
817
-
818
- const thead = `<thead><tr>${node.columns
819
- .map((col) => `<th scope="col">${escapeHtml(col)}</th>`)
820
- .join('')}</tr></thead>`;
821
-
822
- const tbody = `<tbody>${node.rows
823
- .map(
824
- (row) =>
825
- `<tr>${row
826
- .map((cell) => {
827
- if (typeof cell === 'string') {
828
- return `<td>${escapeHtml(cell)}</td>`;
829
- }
830
- return `<td>${renderNode(cell)}</td>`;
831
- })
832
- .join('')}</tr>`
833
- )
834
- .join('')}</tbody>`;
835
-
836
- return `<table class="${classes}" role="table">\n${thead}\n${tbody}\n</table>`;
837
- }
838
-
839
- export function renderList(node: ListNode, context: RenderContext): string {
840
- const prefix = context.options.classPrefix;
841
- const tag = node.ordered ? 'ol' : 'ul';
842
- const classes = buildClassString([
843
- `${prefix}-list`,
844
- node.ordered ? `${prefix}-list-ordered` : undefined,
845
- node.none ? `${prefix}-list-none` : undefined,
846
- ...getCommonClasses(node, prefix),
847
- ]);
848
-
849
- const items = node.items
850
- .map((item) => {
851
- if (typeof item === 'string') {
852
- return `<li class="${prefix}-list-item">${escapeHtml(item)}</li>`;
853
- }
854
- const icon = item.icon ? `${renderIconPlaceholder(item.icon, prefix)} ` : '';
855
- return `<li class="${prefix}-list-item">${icon}${escapeHtml(item.content)}</li>`;
856
- })
857
- .join('\n');
858
-
859
- return `<${tag} class="${classes}" role="list">\n${items}\n</${tag}>`;
860
- }
861
-
862
- // ===========================================
863
- // Feedback Component Renderers
864
- // ===========================================
865
-
866
- export function renderAlert(node: AlertNode, context: RenderContext): string {
867
- const prefix = context.options.classPrefix;
868
- const classes = buildClassString([
869
- `${prefix}-alert`,
870
- node.variant ? `${prefix}-alert-${node.variant}` : undefined,
871
- ...getCommonClasses(node, prefix),
872
- ]);
873
-
874
- const icon = node.icon ? `${renderIconPlaceholder(node.icon, prefix)} ` : '';
875
- const dismissBtn = node.dismissible
876
- ? ` <button class="${prefix}-alert-close" aria-label="Close alert">&times;</button>`
877
- : '';
878
-
879
- return `<div class="${classes}" role="alert">${icon}${escapeHtml(node.content)}${dismissBtn}</div>`;
880
- }
881
-
882
- export function renderToast(node: ToastNode, context: RenderContext): string {
883
- const prefix = context.options.classPrefix;
884
- const classes = buildClassString([
885
- `${prefix}-toast`,
886
- node.position ? `${prefix}-toast-${node.position}` : undefined,
887
- node.variant ? `${prefix}-toast-${node.variant}` : undefined,
888
- ...getCommonClasses(node, prefix),
889
- ]);
890
-
891
- return `<div class="${classes}" role="status" aria-live="polite">${escapeHtml(node.content)}</div>`;
892
- }
893
-
894
- export function renderProgress(node: ProgressNode, context: RenderContext): string {
895
- const prefix = context.options.classPrefix;
896
- const classes = buildClassString([
897
- `${prefix}-progress`,
898
- node.indeterminate ? `${prefix}-progress-indeterminate` : undefined,
899
- ...getCommonClasses(node, prefix),
900
- ]);
901
-
902
- const value = node.value || 0;
903
- const max = node.max || 100;
904
- const percentage = Math.round((value / max) * 100);
905
-
906
- const label = node.label
907
- ? `<span class="${prefix}-progress-label">${escapeHtml(node.label)}</span>`
908
- : '';
909
-
910
- if (node.indeterminate) {
911
- return `<div class="${classes}" role="progressbar" aria-label="Loading">${label}</div>`;
912
- }
913
-
914
- return `<div class="${classes}" role="progressbar" aria-valuenow="${value}" aria-valuemin="0" aria-valuemax="${max}" aria-label="Progress: ${percentage}%">
915
- ${label}
916
- <div class="${prefix}-progress-bar" style="width: ${percentage}%"></div>
917
- </div>`;
918
- }
919
-
920
- export function renderSpinner(node: SpinnerNode, context: RenderContext): string {
921
- const prefix = context.options.classPrefix;
922
-
923
- // Resolve size: token string (xs, sm, md, lg, xl) or custom px number
924
- const sizeResolved = resolveSizeValue(node.size, 'spinner', prefix);
925
-
926
- const classes = buildClassString([
927
- `${prefix}-spinner`,
928
- sizeResolved.className,
929
- ...getCommonClasses(node, prefix),
930
- ]);
931
-
932
- const styleAttr = sizeResolved.style ? ` style="${sizeResolved.style}"` : '';
933
- const label = escapeHtml(node.label || 'Loading...');
934
- return `<span class="${classes}"${styleAttr} role="status" aria-label="${label}"><span class="sr-only">${label}</span></span>`;
935
- }
936
-
937
- // ===========================================
938
- // Overlay Component Renderers
939
- // ===========================================
940
-
941
- export function renderTooltip(
942
- node: TooltipNode,
943
- context: RenderContext,
944
- renderChildren: ChildrenRenderer
945
- ): string {
946
- const prefix = context.options.classPrefix;
947
- const classes = buildClassString([
948
- `${prefix}-tooltip-wrapper`,
949
- ...getCommonClasses(node, prefix),
950
- ]);
951
-
952
- const position = node.position || 'top';
953
- const children = renderChildren(node.children);
954
- const tooltipId = `tooltip-${Math.random().toString(36).substr(2, 9)}`;
955
-
956
- return `<div class="${classes}">
957
- ${children}
958
- <div class="${prefix}-tooltip ${prefix}-tooltip-${position}" role="tooltip" id="${tooltipId}">${escapeHtml(node.content)}</div>
959
- </div>`;
960
- }
961
-
962
- export function renderPopover(
963
- node: PopoverNode,
964
- context: RenderContext,
965
- renderChildren: ChildrenRenderer
966
- ): string {
967
- const prefix = context.options.classPrefix;
968
- const classes = buildClassString([
969
- `${prefix}-popover`,
970
- ...getCommonClasses(node, prefix),
971
- ]);
972
-
973
- const title = node.title
974
- ? `<div class="${prefix}-popover-header">${escapeHtml(node.title)}</div>\n`
975
- : '';
976
- const children = renderChildren(node.children);
977
-
978
- return `<div class="${classes}" role="dialog">\n${title}<div class="${prefix}-popover-body">\n${children}\n</div>\n</div>`;
979
- }
980
-
981
- export function renderDropdown(node: DropdownNode, context: RenderContext): string {
982
- const prefix = context.options.classPrefix;
983
- const classes = buildClassString([
984
- `${prefix}-dropdown`,
985
- ...getCommonClasses(node, prefix),
986
- ]);
987
-
988
- const items = node.items
989
- .map((item) => {
990
- if ('type' in item && item.type === 'divider') {
991
- return `<hr class="${prefix}-divider" role="separator" />`;
992
- }
993
- // TypeScript narrowing: item is DropdownItemNode after the divider check
994
- const dropdownItem = item as { label: string; icon?: string; danger?: boolean; disabled?: boolean };
995
- const itemClasses = buildClassString([
996
- `${prefix}-dropdown-item`,
997
- dropdownItem.danger ? `${prefix}-dropdown-item-danger` : undefined,
998
- dropdownItem.disabled ? `${prefix}-dropdown-item-disabled` : undefined,
999
- ]);
1000
- const icon = dropdownItem.icon ? `${renderIconPlaceholder(dropdownItem.icon, prefix)} ` : '';
1001
- return `<button class="${itemClasses}" role="menuitem"${dropdownItem.disabled ? ' disabled aria-disabled="true"' : ''}>${icon}${escapeHtml(dropdownItem.label)}</button>`;
1002
- })
1003
- .join('\n');
1004
-
1005
- return `<div class="${classes}" role="menu">\n${items}\n</div>`;
1006
- }
1007
-
1008
- // ===========================================
1009
- // Navigation Component Renderers
1010
- // ===========================================
1011
-
1012
- export function renderNav(node: NavNode, context: RenderContext): string {
1013
- const prefix = context.options.classPrefix;
1014
- const classes = buildClassString([
1015
- `${prefix}-nav`,
1016
- node.vertical ? `${prefix}-nav-vertical` : undefined,
1017
- ...getCommonClasses(node, prefix),
1018
- ]);
1019
-
1020
- const items = node.items
1021
- .map((item) => {
1022
- if (typeof item === 'string') {
1023
- return `<a class="${prefix}-nav-link" href="#" role="menuitem">${escapeHtml(item)}</a>`;
1024
- }
1025
- const linkClasses = buildClassString([
1026
- `${prefix}-nav-link`,
1027
- item.active ? `${prefix}-nav-link-active` : undefined,
1028
- item.disabled ? `${prefix}-nav-link-disabled` : undefined,
1029
- ]);
1030
- const icon = item.icon ? `${renderIconPlaceholder(item.icon, prefix)} ` : '';
1031
- const ariaCurrent = item.active ? ' aria-current="page"' : '';
1032
- return `<a class="${linkClasses}" href="${item.href || '#'}" role="menuitem"${ariaCurrent}${item.disabled ? ' aria-disabled="true"' : ''}>${icon}${escapeHtml(item.label)}</a>`;
1033
- })
1034
- .join('\n');
1035
-
1036
- return `<nav class="${classes}" role="navigation">\n${items}\n</nav>`;
1037
- }
1038
-
1039
- export function renderTabs(node: TabsNode, context: RenderContext): string {
1040
- const prefix = context.options.classPrefix;
1041
- const classes = buildClassString([
1042
- `${prefix}-tabs`,
1043
- ...getCommonClasses(node, prefix),
1044
- ]);
1045
-
1046
- const tabList = node.items
1047
- .map((label, idx) => {
1048
- const isActive = idx === (node.active || 0);
1049
- const tabClasses = `${prefix}-tab${isActive ? ` ${prefix}-tab-active` : ''}`;
1050
- return `<button class="${tabClasses}" role="tab" aria-selected="${isActive}">${escapeHtml(label)}</button>`;
1051
- })
1052
- .join('\n');
1053
-
1054
- return `<div class="${classes}">
1055
- <div class="${prefix}-tab-list" role="tablist">
1056
- ${tabList}
1057
- </div>
1058
- </div>`;
1059
- }
1060
-
1061
- export function renderBreadcrumb(node: BreadcrumbNode, context: RenderContext): string {
1062
- const prefix = context.options.classPrefix;
1063
- const classes = buildClassString([
1064
- `${prefix}-breadcrumb`,
1065
- ...getCommonClasses(node, prefix),
1066
- ]);
1067
-
1068
- const items = node.items
1069
- .map((item, idx) => {
1070
- const isLast = idx === node.items.length - 1;
1071
- if (typeof item === 'string') {
1072
- return isLast
1073
- ? `<span class="${prefix}-breadcrumb-item" aria-current="page">${escapeHtml(item)}</span>`
1074
- : `<a class="${prefix}-breadcrumb-item" href="#">${escapeHtml(item)}</a>`;
1075
- }
1076
- return isLast
1077
- ? `<span class="${prefix}-breadcrumb-item" aria-current="page">${escapeHtml(item.label)}</span>`
1078
- : `<a class="${prefix}-breadcrumb-item" href="${item.href || '#'}">${escapeHtml(item.label)}</a>`;
1079
- })
1080
- .join(` <span class="${prefix}-breadcrumb-separator" aria-hidden="true">/</span> `);
1081
-
1082
- return `<nav class="${classes}" aria-label="Breadcrumb">${items}</nav>`;
1083
- }
1084
-
1085
- // ===========================================
1086
- // Divider Component Renderer
1087
- // ===========================================
1088
-
1089
- export function renderDivider(_node: DividerComponentNode, context: RenderContext): string {
1090
- const prefix = context.options.classPrefix;
1091
- return `<hr class="${prefix}-divider" role="separator" />`;
1092
- }