@seed-ship/mcp-ui-solid 2.1.3 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/components/ChartJSRenderer.cjs +79 -36
  2. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  3. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  4. package/dist/components/ChartJSRenderer.js +80 -37
  5. package/dist/components/ChartJSRenderer.js.map +1 -1
  6. package/dist/components/CodeBlockRenderer.cjs +79 -56
  7. package/dist/components/CodeBlockRenderer.cjs.map +1 -1
  8. package/dist/components/CodeBlockRenderer.d.ts.map +1 -1
  9. package/dist/components/CodeBlockRenderer.js +80 -57
  10. package/dist/components/CodeBlockRenderer.js.map +1 -1
  11. package/dist/components/ExpandableWrapper.cjs +136 -0
  12. package/dist/components/ExpandableWrapper.cjs.map +1 -0
  13. package/dist/components/ExpandableWrapper.d.ts +31 -0
  14. package/dist/components/ExpandableWrapper.d.ts.map +1 -0
  15. package/dist/components/ExpandableWrapper.js +136 -0
  16. package/dist/components/ExpandableWrapper.js.map +1 -0
  17. package/dist/components/UIResourceRenderer.cjs +369 -242
  18. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  19. package/dist/components/UIResourceRenderer.d.ts +4 -0
  20. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  21. package/dist/components/UIResourceRenderer.js +370 -243
  22. package/dist/components/UIResourceRenderer.js.map +1 -1
  23. package/dist/index.cjs +3 -0
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +2 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/node_modules/.pnpm/{dompurify@3.3.0 → dompurify@3.3.3}/node_modules/dompurify/dist/purify.es.cjs +19 -4
  31. package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.cjs.map +1 -0
  32. package/dist/node_modules/.pnpm/{dompurify@3.3.0 → dompurify@3.3.3}/node_modules/dompurify/dist/purify.es.js +19 -4
  33. package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.js.map +1 -0
  34. package/dist/services/component-registry.cjs.map +1 -1
  35. package/dist/services/component-registry.d.ts +1 -0
  36. package/dist/services/component-registry.d.ts.map +1 -1
  37. package/dist/services/component-registry.js.map +1 -1
  38. package/dist/services/validation.cjs +29 -5
  39. package/dist/services/validation.cjs.map +1 -1
  40. package/dist/services/validation.d.ts.map +1 -1
  41. package/dist/services/validation.js +29 -5
  42. package/dist/services/validation.js.map +1 -1
  43. package/dist/types/index.d.ts +17 -0
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/dist/types.d.cts +17 -0
  46. package/dist/types.d.ts +17 -0
  47. package/package.json +3 -3
  48. package/src/components/ChartJSRenderer.tsx +71 -42
  49. package/src/components/CodeBlockRenderer.tsx +33 -14
  50. package/src/components/ExpandableWrapper.test.tsx +229 -0
  51. package/src/components/ExpandableWrapper.tsx +201 -0
  52. package/src/components/UIResourceRenderer.tsx +165 -62
  53. package/src/components/renderCellValue.test.ts +122 -0
  54. package/src/index.ts +2 -0
  55. package/src/services/component-registry.test.ts +81 -0
  56. package/src/services/component-registry.ts +3 -2
  57. package/src/services/validation.test.ts +134 -0
  58. package/src/services/validation.ts +21 -5
  59. package/src/types/index.ts +17 -0
  60. package/tsconfig.tsbuildinfo +1 -1
  61. package/dist/node_modules/.pnpm/dompurify@3.3.0/node_modules/dompurify/dist/purify.es.cjs.map +0 -1
  62. package/dist/node_modules/.pnpm/dompurify@3.3.0/node_modules/dompurify/dist/purify.es.js.map +0 -1
@@ -21,6 +21,7 @@ import { ImageGalleryRenderer } from './ImageGalleryRenderer'
21
21
  import { VideoRenderer } from './VideoRenderer'
22
22
  import { CodeBlockRenderer } from './CodeBlockRenderer'
23
23
  import { MapRenderer } from './MapRenderer'
24
+ import { ExpandableWrapper } from './ExpandableWrapper'
24
25
  import { RenderProvider } from './RenderContext'
25
26
  import { useAction } from '../hooks/useAction'
26
27
  import { marked } from 'marked'
@@ -230,7 +231,7 @@ function ChartRenderer(props: {
230
231
  /**
231
232
  * Smart cell value renderer that handles markdown links and other formats
232
233
  */
233
- function renderCellValue(value: any): string {
234
+ export function renderCellValue(value: any): string {
234
235
  // Handle null/undefined
235
236
  if (value === null || value === undefined) {
236
237
  return '-'
@@ -285,6 +286,17 @@ function renderCellValue(value: any): string {
285
286
  return DOMPurify.sanitize(htmlValue, { ADD_ATTR: ['target', 'rel'] })
286
287
  }
287
288
 
289
+ // Detect raw HTML in cell values (e.g. <a href="..." data-citation-page="5">text</a>)
290
+ // This handles cases where cell data comes from innerHTML extraction
291
+ const hasHtml = /<[a-z][\s\S]*>/i.test(strValue)
292
+ if (hasHtml) {
293
+ return DOMPurify.sanitize(strValue, {
294
+ ALLOWED_TAGS: ['a', 'strong', 'em', 'b', 'i', 'code', 'span', 'br'],
295
+ ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'data-citation-page', 'data-citation-source', 'title'],
296
+ ADD_ATTR: ['target', 'rel'],
297
+ })
298
+ }
299
+
288
300
  // Check if value contains markdown formatting (bold, italic, code, etc.)
289
301
  const hasMarkdown = /[*_`[\]#]/.test(strValue)
290
302
  if (hasMarkdown) {
@@ -293,8 +305,8 @@ function renderCellValue(value: any): string {
293
305
  return DOMPurify.sanitize(parsed, { ADD_ATTR: ['target', 'rel'] })
294
306
  }
295
307
 
296
- // Plain text
297
- return strValue
308
+ // Plain text — sanitize to prevent XSS via innerHTML
309
+ return DOMPurify.sanitize(strValue)
298
310
  }
299
311
 
300
312
  /**
@@ -361,22 +373,85 @@ function TableRenderer(props: {
361
373
  }
362
374
  })
363
375
 
376
+ // Cell value extraction helper
377
+ const getCellValue = (row: any, key: string): string => {
378
+ const value = row[key]
379
+ if (value === null || value === undefined) return ''
380
+ if (typeof value === 'object') return value.name || value.label || JSON.stringify(value)
381
+ return String(value)
382
+ }
383
+
364
384
  // Generate copyable text from table data (TSV format for spreadsheet compatibility)
365
385
  const getTableText = () => {
366
386
  const columns = tableParams.columns || []
367
387
  const rows = tableParams.rows || []
368
388
  const header = columns.map((c: any) => c.label).join('\t')
369
389
  const dataRows = rows.map((row: any) =>
370
- columns.map((c: any) => {
371
- const value = row[c.key]
372
- if (value === null || value === undefined) return ''
373
- if (typeof value === 'object') return value.name || value.label || JSON.stringify(value)
374
- return String(value)
375
- }).join('\t')
390
+ columns.map((c: any) => getCellValue(row, c.key)).join('\t')
391
+ ).join('\n')
392
+ return `${header}\n${dataRows}`
393
+ }
394
+
395
+ // CSV generation (RFC 4180 compliant)
396
+ const getTableCSV = () => {
397
+ const columns = tableParams.columns || []
398
+ const rows = tableParams.rows || []
399
+ const escapeCSV = (val: string) => {
400
+ if (val.includes(',') || val.includes('"') || val.includes('\n')) {
401
+ return `"${val.replace(/"/g, '""')}"`
402
+ }
403
+ return val
404
+ }
405
+ const header = columns.map((c: any) => escapeCSV(c.label)).join(',')
406
+ const dataRows = rows.map((row: any) =>
407
+ columns.map((c: any) => escapeCSV(getCellValue(row, c.key))).join(',')
376
408
  ).join('\n')
377
409
  return `${header}\n${dataRows}`
378
410
  }
379
411
 
412
+ // JSON generation
413
+ const getTableJSON = () => {
414
+ const columns = tableParams.columns || []
415
+ const rows = tableParams.rows || []
416
+ return JSON.stringify({ columns: columns.map((c: any) => ({ key: c.key, label: c.label })), rows }, null, 2)
417
+ }
418
+
419
+ // Download helper
420
+ const downloadFile = (content: string, filename: string, mimeType: string) => {
421
+ const blob = new Blob([content], { type: mimeType })
422
+ const url = URL.createObjectURL(blob)
423
+ const a = document.createElement('a')
424
+ a.href = url
425
+ a.download = filename
426
+ a.click()
427
+ URL.revokeObjectURL(url)
428
+ }
429
+
430
+ // Export config
431
+ const exportable = tableParams.exportable
432
+ const exportFormats = typeof exportable === 'object' && exportable?.formats
433
+ ? exportable.formats
434
+ : ['csv', 'tsv', 'json']
435
+ const exportFilename = (typeof exportable === 'object' && exportable?.filename) || `table-${Math.random().toString(36).slice(2, 9)}`
436
+
437
+ // Export dropdown state
438
+ const [showExportMenu, setShowExportMenu] = createSignal(false)
439
+
440
+ const handleExport = (format: string) => {
441
+ setShowExportMenu(false)
442
+ switch (format) {
443
+ case 'tsv':
444
+ navigator.clipboard.writeText(getTableText())
445
+ break
446
+ case 'csv':
447
+ downloadFile(getTableCSV(), `${exportFilename}.csv`, 'text/csv')
448
+ break
449
+ case 'json':
450
+ downloadFile(getTableJSON(), `${exportFilename}.json`, 'application/json')
451
+ break
452
+ }
453
+ }
454
+
380
455
  const tableId = `table-${Math.random().toString(36).slice(2, 9)}`
381
456
 
382
457
  // Standard table body (non-virtualized)
@@ -440,65 +515,93 @@ function TableRenderer(props: {
440
515
  }
441
516
 
442
517
  return (
443
- <div class="relative w-full h-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden group">
444
- <CopyButton getText={getTableText} title="Copy table data" position="top-right" />
445
- <div class="p-4">
446
- <Show when={tableParams.title}>
447
- <h3 id={`${tableId}-title`} class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
448
- {tableParams.title}
449
- <Show when={isVirtualizing()}>
450
- <span class="ml-2 text-xs font-normal text-gray-400">(virtualized: {tableParams.rows?.length} rows)</span>
518
+ <ExpandableWrapper title={tableParams.title || 'Table'} copyData={getTableText()} copyLabel="Copy table (TSV)">
519
+ <div class="relative w-full h-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden group">
520
+ <Show when={exportable} fallback={<CopyButton getText={getTableText} title="Copy table data" position="top-right" />}>
521
+ <div class="absolute right-10 top-2 z-10">
522
+ <button
523
+ onClick={() => setShowExportMenu(!showExportMenu())}
524
+ class="opacity-60 hover:opacity-100 px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
525
+ title="Export table"
526
+ aria-label="Export table"
527
+ >
528
+ <svg class="w-3 h-3 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
529
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
530
+ </svg>
531
+ </button>
532
+ <Show when={showExportMenu()}>
533
+ <div class="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 text-sm">
534
+ <Show when={(exportFormats as string[]).includes('tsv')}>
535
+ <button onClick={() => handleExport('tsv')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Copy TSV</button>
536
+ </Show>
537
+ <Show when={(exportFormats as string[]).includes('csv')}>
538
+ <button onClick={() => handleExport('csv')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Download CSV</button>
539
+ </Show>
540
+ <Show when={(exportFormats as string[]).includes('json')}>
541
+ <button onClick={() => handleExport('json')} class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">Download JSON</button>
542
+ </Show>
543
+ </div>
451
544
  </Show>
452
- </h3>
545
+ </div>
453
546
  </Show>
547
+ <div class="p-4">
548
+ <Show when={tableParams.title}>
549
+ <h3 id={`${tableId}-title`} class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
550
+ {tableParams.title}
551
+ <Show when={isVirtualizing()}>
552
+ <span class="ml-2 text-xs font-normal text-gray-400">(virtualized: {tableParams.rows?.length} rows)</span>
553
+ </Show>
554
+ </h3>
555
+ </Show>
454
556
 
455
- <div
456
- ref={scrollContainerRef}
457
- class="overflow-x-auto"
458
- style={isVirtualizing() ? { 'max-height': '500px', 'overflow-y': 'auto' } : {}}
459
- role="region"
460
- aria-label={tableParams.title || 'Data table'}
461
- tabindex="0"
462
- >
463
- <table
464
- class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 border-separate border-spacing-0"
465
- aria-labelledby={tableParams.title ? `${tableId}-title` : undefined}
557
+ <div
558
+ ref={scrollContainerRef}
559
+ class="overflow-x-auto"
560
+ style={isVirtualizing() ? { 'max-height': '500px', 'overflow-y': 'auto' } : {}}
561
+ role="region"
562
+ aria-label={tableParams.title || 'Data table'}
563
+ tabindex="0"
466
564
  >
467
- <thead class="bg-gray-50 dark:bg-gray-900/50 sticky top-0 z-10">
468
- <tr>
469
- <For each={tableParams.columns}>
470
- {(column: any) => (
471
- <th
472
- scope="col"
473
- class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700 first:pl-6 last:pr-6 bg-gray-50 dark:bg-gray-900/50"
474
- style={column.width ? { width: column.width } : {}}
475
- >
476
- {column.label}
477
- </th>
478
- )}
479
- </For>
480
- </tr>
481
- </thead>
482
- <Show when={isVirtualizing()} fallback={<StandardTableBody />}>
483
- <VirtualizedTableBody />
484
- </Show>
485
- </table>
486
- </div>
487
-
488
- <Show when={tableParams.pagination}>
489
- <div class="mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
490
- <span>
491
- Showing {tableParams.pagination.currentPage * tableParams.pagination.pageSize + 1} -{' '}
492
- {Math.min(
493
- (tableParams.pagination.currentPage + 1) * tableParams.pagination.pageSize,
494
- tableParams.pagination.totalRows
495
- )}{' '}
496
- of {tableParams.pagination.totalRows}
497
- </span>
565
+ <table
566
+ class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 border-separate border-spacing-0"
567
+ aria-labelledby={tableParams.title ? `${tableId}-title` : undefined}
568
+ >
569
+ <thead class="bg-gray-50 dark:bg-gray-900/50 sticky top-0 z-10">
570
+ <tr>
571
+ <For each={tableParams.columns}>
572
+ {(column: any) => (
573
+ <th
574
+ scope="col"
575
+ class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700 first:pl-6 last:pr-6 bg-gray-50 dark:bg-gray-900/50"
576
+ style={column.width ? { width: column.width } : {}}
577
+ >
578
+ {column.label}
579
+ </th>
580
+ )}
581
+ </For>
582
+ </tr>
583
+ </thead>
584
+ <Show when={isVirtualizing()} fallback={<StandardTableBody />}>
585
+ <VirtualizedTableBody />
586
+ </Show>
587
+ </table>
498
588
  </div>
499
- </Show>
589
+
590
+ <Show when={tableParams.pagination}>
591
+ <div class="mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
592
+ <span>
593
+ Showing {tableParams.pagination.currentPage * tableParams.pagination.pageSize + 1} -{' '}
594
+ {Math.min(
595
+ (tableParams.pagination.currentPage + 1) * tableParams.pagination.pageSize,
596
+ tableParams.pagination.totalRows
597
+ )}{' '}
598
+ of {tableParams.pagination.totalRows}
599
+ </span>
600
+ </div>
601
+ </Show>
602
+ </div>
500
603
  </div>
501
- </div>
604
+ </ExpandableWrapper>
502
605
  )
503
606
  }
504
607
 
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Tests for renderCellValue — P1.1: HTML/link preservation in table cells
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest'
6
+ import { renderCellValue } from './UIResourceRenderer'
7
+
8
+ describe('renderCellValue', () => {
9
+ // Basic values
10
+ it('returns "-" for null', () => {
11
+ expect(renderCellValue(null)).toBe('-')
12
+ })
13
+
14
+ it('returns "-" for undefined', () => {
15
+ expect(renderCellValue(undefined)).toBe('-')
16
+ })
17
+
18
+ it('returns plain text as-is', () => {
19
+ expect(renderCellValue('Hello world')).toBe('Hello world')
20
+ })
21
+
22
+ it('returns number as string', () => {
23
+ expect(renderCellValue(42)).toBe('42')
24
+ })
25
+
26
+ // Object with URL (existing behavior)
27
+ it('renders object with url as link', () => {
28
+ const result = renderCellValue({ url: 'https://example.com', name: 'Example' })
29
+ expect(result).toContain('href="https://example.com"')
30
+ expect(result).toContain('Example')
31
+ expect(result).toContain('<a ')
32
+ })
33
+
34
+ // Markdown links
35
+ it('converts markdown links to HTML', () => {
36
+ const result = renderCellValue('[Google](https://google.com)')
37
+ expect(result).toContain('href="https://google.com"')
38
+ expect(result).toContain('Google')
39
+ expect(result).toContain('<a ')
40
+ })
41
+
42
+ it('handles multiple markdown links in same value', () => {
43
+ const result = renderCellValue('See [Google](https://google.com) and [GitHub](https://github.com)')
44
+ expect(result).toContain('Google')
45
+ expect(result).toContain('GitHub')
46
+ expect((result.match(/<a /g) || []).length).toBe(2)
47
+ })
48
+
49
+ // Raw HTML links (P1.1 fix)
50
+ it('preserves raw HTML <a> tags', () => {
51
+ const result = renderCellValue('<a href="https://example.com">Click here</a>')
52
+ expect(result).toContain('href="https://example.com"')
53
+ expect(result).toContain('Click here')
54
+ expect(result).toContain('<a ')
55
+ })
56
+
57
+ it('preserves citation links with data attributes', () => {
58
+ const result = renderCellValue('<a href="#p5" data-citation-page="5">Source [5]</a>')
59
+ expect(result).toContain('data-citation-page="5"')
60
+ expect(result).toContain('Source [5]')
61
+ expect(result).toContain('<a ')
62
+ })
63
+
64
+ it('preserves mixed text and HTML links', () => {
65
+ const result = renderCellValue('Revenue: $1.2M <a href="/report">details</a>')
66
+ expect(result).toContain('Revenue: $1.2M')
67
+ expect(result).toContain('href="/report"')
68
+ expect(result).toContain('details')
69
+ })
70
+
71
+ it('preserves multiple HTML links', () => {
72
+ const result = renderCellValue('<a href="/a">Link A</a> and <a href="/b">Link B</a>')
73
+ expect(result).toContain('Link A')
74
+ expect(result).toContain('Link B')
75
+ expect((result.match(/<a /g) || []).length).toBe(2)
76
+ })
77
+
78
+ // HTML sanitization (security)
79
+ it('strips dangerous tags from HTML', () => {
80
+ const result = renderCellValue('<script>alert("xss")</script>Safe text')
81
+ expect(result).not.toContain('<script>')
82
+ expect(result).toContain('Safe text')
83
+ })
84
+
85
+ it('strips onclick handlers from links', () => {
86
+ const result = renderCellValue('<a href="#" onclick="alert(1)">Link</a>')
87
+ expect(result).not.toContain('onclick')
88
+ expect(result).toContain('Link')
89
+ })
90
+
91
+ it('strips iframe tags', () => {
92
+ const result = renderCellValue('<iframe src="https://evil.com"></iframe>')
93
+ expect(result).not.toContain('<iframe')
94
+ })
95
+
96
+ // Allowed inline HTML
97
+ it('preserves <strong> and <em> tags', () => {
98
+ const result = renderCellValue('<strong>Bold</strong> and <em>italic</em>')
99
+ expect(result).toContain('<strong>Bold</strong>')
100
+ expect(result).toContain('<em>italic</em>')
101
+ })
102
+
103
+ it('preserves <code> tags', () => {
104
+ const result = renderCellValue('Use <code>npm install</code>')
105
+ expect(result).toContain('<code>npm install</code>')
106
+ })
107
+
108
+ // Plain text XSS prevention
109
+ it('sanitizes plain text that looks like HTML injection', () => {
110
+ const result = renderCellValue('<img src=x onerror=alert(1)>')
111
+ expect(result).not.toContain('onerror')
112
+ })
113
+
114
+ // Undefined cleanup
115
+ it('cleans up "Text – undefined" patterns', () => {
116
+ expect(renderCellValue('Paris – undefined')).toBe('Paris')
117
+ })
118
+
119
+ it('returns "-" for standalone "undefined"', () => {
120
+ expect(renderCellValue('undefined')).toBe('-')
121
+ })
122
+ })
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export { UIResourceRenderer, StreamingUIRenderer, GenerativeUIErrorBoundary } fr
35
35
  export { DraggableGridItem } from './components/DraggableGridItem'
36
36
  export { ResizeHandle } from './components/ResizeHandle'
37
37
  export { EditableUIResourceRenderer } from './components/EditableUIResourceRenderer'
38
+ export { ExpandableWrapper } from './components/ExpandableWrapper'
38
39
 
39
40
  // Autocomplete Components
40
41
  export { GhostText, GhostTextInput } from './components/GhostText'
@@ -50,6 +51,7 @@ export type {
50
51
  export type { DraggableGridItemProps } from './components/DraggableGridItem'
51
52
  export type { ResizeHandleProps as ResizeHandleComponentProps } from './components/ResizeHandle'
52
53
  export type { EditableUIResourceRendererProps } from './components/EditableUIResourceRenderer'
54
+ export type { ExpandableWrapperProps } from './components/ExpandableWrapper'
53
55
  export type { GhostTextProps, GhostTextInputProps } from './components/GhostText'
54
56
  export type { AutocompleteDropdownProps } from './components/AutocompleteDropdown'
55
57
  export type { AutocompleteFormFieldProps, AutocompleteFormFieldParams } from './components/AutocompleteFormField'
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Tests for P0 fix: ComponentRegistry validation leniency
3
+ *
4
+ * Ensures unregistered component types (code, map, form, modal, etc.)
5
+ * pass validation with warnings instead of failing with errors.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest'
9
+ import { validateAgainstRegistry, getComponentEntry, ComponentRegistry } from './component-registry'
10
+ import type { ComponentType } from '../types'
11
+
12
+ /** The 9 types currently registered in ComponentRegistry */
13
+ const REGISTERED_TYPES: ComponentType[] = [
14
+ 'chart', 'table', 'metric', 'text', 'grid',
15
+ 'action', 'footer', 'carousel', 'artifact',
16
+ ]
17
+
18
+ /** The 9 types with renderers but NO registry entry */
19
+ const UNREGISTERED_TYPES: ComponentType[] = [
20
+ 'code', 'map', 'form', 'modal', 'action-group',
21
+ 'image-gallery', 'video', 'iframe', 'image', 'link',
22
+ ]
23
+
24
+ describe('validateAgainstRegistry', () => {
25
+ describe('registered types', () => {
26
+ it.each(REGISTERED_TYPES)('validates "%s" with valid: true when params are correct', (type) => {
27
+ const entry = getComponentEntry(type)
28
+ expect(entry).toBeDefined()
29
+
30
+ // Build minimal valid params from required fields
31
+ const params: Record<string, any> = {}
32
+ const required = entry!.schema.required || []
33
+ for (const key of required) {
34
+ params[key] = 'test-value'
35
+ }
36
+
37
+ const result = validateAgainstRegistry(type, params)
38
+ expect(result.valid).toBe(true)
39
+ expect(result.errors).toBeUndefined()
40
+ expect(result.warnings).toBeUndefined()
41
+ })
42
+
43
+ it.each(REGISTERED_TYPES)('rejects "%s" when required fields are missing', (type) => {
44
+ const entry = getComponentEntry(type)
45
+ const required = entry!.schema.required || []
46
+ if (required.length === 0) return // skip types with no required fields
47
+
48
+ const result = validateAgainstRegistry(type, {})
49
+ expect(result.valid).toBe(false)
50
+ expect(result.errors).toBeDefined()
51
+ expect(result.errors!.length).toBeGreaterThan(0)
52
+ expect(result.errors![0]).toContain('Missing required field')
53
+ })
54
+ })
55
+
56
+ describe('unregistered types (have renderers, no registry entry)', () => {
57
+ it.each(UNREGISTERED_TYPES)('passes "%s" with valid: true and a warning', (type) => {
58
+ const result = validateAgainstRegistry(type, { anything: true })
59
+ expect(result.valid).toBe(true)
60
+ expect(result.errors).toBeUndefined()
61
+ expect(result.warnings).toBeDefined()
62
+ expect(result.warnings![0]).toContain(`No registry entry for type: ${type}`)
63
+ })
64
+
65
+ it.each(UNREGISTERED_TYPES)('getComponentEntry returns undefined for "%s"', (type) => {
66
+ expect(getComponentEntry(type)).toBeUndefined()
67
+ })
68
+ })
69
+
70
+ describe('registry consistency', () => {
71
+ it('has exactly 9 registered types', () => {
72
+ expect(ComponentRegistry.size).toBe(9)
73
+ })
74
+
75
+ it('all registered types are in REGISTERED_TYPES', () => {
76
+ for (const [type] of ComponentRegistry) {
77
+ expect(REGISTERED_TYPES).toContain(type)
78
+ }
79
+ })
80
+ })
81
+ })
@@ -596,10 +596,11 @@ export function getRegistryForLLM(): string {
596
596
  export function validateAgainstRegistry(
597
597
  componentType: ComponentType,
598
598
  params: any
599
- ): { valid: boolean; errors?: string[] } {
599
+ ): { valid: boolean; errors?: string[]; warnings?: string[] } {
600
600
  const entry = getComponentEntry(componentType)
601
601
  if (!entry) {
602
- return { valid: false, errors: [`Unknown component type: ${componentType}`] }
602
+ // Warn but don't block renderer may exist even without registry entry
603
+ return { valid: true, warnings: [`No registry entry for type: ${componentType}`] }
603
604
  }
604
605
 
605
606
  // Basic validation (Phase 1 will add Zod schema validation)
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tests for P0 fix: validateComponent() default case leniency
3
+ *
4
+ * Ensures unregistered component types pass through validateComponent()
5
+ * without UNKNOWN_COMPONENT_TYPE errors.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest'
9
+ import { validateComponent } from './validation'
10
+ import type { UIComponent, ComponentType } from '../types'
11
+
12
+ /** Helper to create a minimal valid UIComponent for testing */
13
+ function makeComponent(type: ComponentType, params: Record<string, any> = {}): UIComponent {
14
+ return {
15
+ id: `test-${type}`,
16
+ type,
17
+ position: { colStart: 1, colSpan: 12 },
18
+ params: params as any,
19
+ }
20
+ }
21
+
22
+ /** Types that have explicit validation cases in validateComponent */
23
+ const VALIDATED_TYPES: ComponentType[] = [
24
+ 'chart', 'table', 'metric', 'text', 'iframe', 'image', 'link', 'action',
25
+ ]
26
+
27
+ /** Types that hit the default case (no specific validation) */
28
+ const PASSTHROUGH_TYPES: ComponentType[] = [
29
+ 'code', 'map', 'form', 'modal', 'action-group',
30
+ 'image-gallery', 'video', 'grid', 'carousel',
31
+ 'artifact', 'footer',
32
+ ]
33
+
34
+ describe('validateComponent', () => {
35
+ describe('passthrough types (no specific validation case)', () => {
36
+ it.each(PASSTHROUGH_TYPES)('"%s" does NOT produce UNKNOWN_COMPONENT_TYPE error', (type) => {
37
+ const component = makeComponent(type)
38
+ const result = validateComponent(component)
39
+
40
+ const unknownTypeError = result.errors?.find(
41
+ (e) => e.code === 'UNKNOWN_COMPONENT_TYPE'
42
+ )
43
+ expect(unknownTypeError).toBeUndefined()
44
+ })
45
+ })
46
+
47
+ describe('validated types still work', () => {
48
+ it('validates a valid chart component', () => {
49
+ const component = makeComponent('chart', {
50
+ chartType: 'bar',
51
+ data: { labels: ['A'], datasets: [{ data: [1] }] },
52
+ })
53
+ const result = validateComponent(component)
54
+ expect(result.valid).toBe(true)
55
+ })
56
+
57
+ it('validates a valid table component', () => {
58
+ const component = makeComponent('table', {
59
+ columns: [{ key: 'name', label: 'Name' }],
60
+ rows: [{ name: 'test' }],
61
+ })
62
+ const result = validateComponent(component)
63
+ expect(result.valid).toBe(true)
64
+ })
65
+
66
+ it('validates a valid text component', () => {
67
+ const component = makeComponent('text', {
68
+ content: 'Hello world',
69
+ })
70
+ const result = validateComponent(component)
71
+ expect(result.valid).toBe(true)
72
+ })
73
+
74
+ it('validates a valid metric component', () => {
75
+ const component = makeComponent('metric', {
76
+ value: 42,
77
+ title: 'Count',
78
+ })
79
+ const result = validateComponent(component)
80
+ expect(result.valid).toBe(true)
81
+ })
82
+ })
83
+
84
+ describe('regression: code type renders without validation error', () => {
85
+ it('type "code" passes validation', () => {
86
+ const component = makeComponent('code', {
87
+ code: 'console.log("hello")',
88
+ language: 'typescript',
89
+ })
90
+ const result = validateComponent(component)
91
+ const unknownTypeError = result.errors?.find(
92
+ (e) => e.code === 'UNKNOWN_COMPONENT_TYPE'
93
+ )
94
+ expect(unknownTypeError).toBeUndefined()
95
+ })
96
+
97
+ it('type "map" passes validation', () => {
98
+ const component = makeComponent('map', {
99
+ center: { lat: 48.8566, lng: 2.3522 },
100
+ zoom: 13,
101
+ })
102
+ const result = validateComponent(component)
103
+ const unknownTypeError = result.errors?.find(
104
+ (e) => e.code === 'UNKNOWN_COMPONENT_TYPE'
105
+ )
106
+ expect(unknownTypeError).toBeUndefined()
107
+ })
108
+ })
109
+
110
+ describe('truly unknown types are rejected', () => {
111
+ it('rejects a typo like "chrt" with UNKNOWN_COMPONENT_TYPE', () => {
112
+ const component = makeComponent('chrt' as any)
113
+ const result = validateComponent(component)
114
+ const unknownTypeError = result.errors?.find(
115
+ (e) => e.code === 'UNKNOWN_COMPONENT_TYPE'
116
+ )
117
+ expect(unknownTypeError).toBeDefined()
118
+ expect(result.valid).toBe(false)
119
+ })
120
+
121
+ it('rejects garbage type "foobar"', () => {
122
+ const component = makeComponent('foobar' as any)
123
+ const result = validateComponent(component)
124
+ expect(result.valid).toBe(false)
125
+ expect(result.errors?.some((e) => e.code === 'UNKNOWN_COMPONENT_TYPE')).toBe(true)
126
+ })
127
+
128
+ it('rejects empty string type', () => {
129
+ const component = makeComponent('' as any)
130
+ const result = validateComponent(component)
131
+ expect(result.valid).toBe(false)
132
+ })
133
+ })
134
+ })