@seed-ship/mcp-ui-solid 2.1.2 → 2.2.3
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.
- package/dist/components/ChartJSRenderer.cjs +79 -36
- package/dist/components/ChartJSRenderer.cjs.map +1 -1
- package/dist/components/ChartJSRenderer.d.ts.map +1 -1
- package/dist/components/ChartJSRenderer.js +80 -37
- package/dist/components/ChartJSRenderer.js.map +1 -1
- package/dist/components/CodeBlockRenderer.cjs +79 -56
- package/dist/components/CodeBlockRenderer.cjs.map +1 -1
- package/dist/components/CodeBlockRenderer.d.ts.map +1 -1
- package/dist/components/CodeBlockRenderer.js +80 -57
- package/dist/components/CodeBlockRenderer.js.map +1 -1
- package/dist/components/ExpandableWrapper.cjs +136 -0
- package/dist/components/ExpandableWrapper.cjs.map +1 -0
- package/dist/components/ExpandableWrapper.d.ts +31 -0
- package/dist/components/ExpandableWrapper.d.ts.map +1 -0
- package/dist/components/ExpandableWrapper.js +136 -0
- package/dist/components/ExpandableWrapper.js.map +1 -0
- package/dist/components/UIResourceRenderer.cjs +369 -242
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +4 -0
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +370 -243
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/node_modules/.pnpm/{dompurify@3.3.0 → dompurify@3.3.3}/node_modules/dompurify/dist/purify.es.cjs +19 -4
- package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.cjs.map +1 -0
- package/dist/node_modules/.pnpm/{dompurify@3.3.0 → dompurify@3.3.3}/node_modules/dompurify/dist/purify.es.js +19 -4
- package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.js.map +1 -0
- package/dist/services/component-registry.cjs.map +1 -1
- package/dist/services/component-registry.d.ts +1 -0
- package/dist/services/component-registry.d.ts.map +1 -1
- package/dist/services/component-registry.js.map +1 -1
- package/dist/services/validation.cjs +29 -5
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +29 -5
- package/dist/services/validation.js.map +1 -1
- package/dist/types/index.d.ts +17 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types.d.cts +17 -0
- package/dist/types.d.ts +17 -0
- package/package.json +4 -4
- package/src/components/ChartJSRenderer.tsx +71 -42
- package/src/components/CodeBlockRenderer.tsx +33 -14
- package/src/components/ExpandableWrapper.test.tsx +229 -0
- package/src/components/ExpandableWrapper.tsx +201 -0
- package/src/components/UIResourceRenderer.tsx +165 -62
- package/src/components/renderCellValue.test.ts +122 -0
- package/src/index.ts +2 -0
- package/src/services/component-registry.test.ts +81 -0
- package/src/services/component-registry.ts +3 -2
- package/src/services/validation.test.ts +134 -0
- package/src/services/validation.ts +21 -5
- package/src/types/index.ts +17 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/node_modules/.pnpm/dompurify@3.3.0/node_modules/dompurify/dist/purify.es.cjs.map +0 -1
- 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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
<
|
|
444
|
-
<
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
<
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
+
})
|