@seed-ship/mcp-ui-solid 2.2.10 → 2.3.0
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/ArtifactRenderer.cjs +4 -3
- package/dist/components/ArtifactRenderer.cjs.map +1 -1
- package/dist/components/ArtifactRenderer.js +4 -3
- package/dist/components/ArtifactRenderer.js.map +1 -1
- package/dist/components/CodeBlockRenderer.cjs +6 -1
- package/dist/components/CodeBlockRenderer.cjs.map +1 -1
- package/dist/components/CodeBlockRenderer.d.ts.map +1 -1
- package/dist/components/CodeBlockRenderer.js +6 -1
- package/dist/components/CodeBlockRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +29 -27
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.js +30 -28
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/validation.cjs +176 -18
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts +21 -0
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +176 -18
- package/dist/services/validation.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types.d.cts +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/ArtifactRenderer.tsx +3 -3
- package/src/components/CodeBlockRenderer.tsx +7 -1
- package/src/components/UIResourceRenderer.tsx +2 -2
- package/src/index.ts +2 -0
- package/src/services/index.ts +2 -0
- package/src/services/validation.test.ts +158 -1
- package/src/services/validation.ts +221 -19
- package/src/types/index.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect, vi } from 'vitest'
|
|
9
|
-
import { validateComponent, validateChartComponent } from './validation'
|
|
9
|
+
import { validateComponent, validateChartComponent, getIframeSandbox } from './validation'
|
|
10
10
|
import type { UIComponent, ComponentType } from '../types'
|
|
11
11
|
|
|
12
12
|
/** Helper to create a minimal valid UIComponent for testing */
|
|
@@ -142,6 +142,108 @@ describe('validateComponent', () => {
|
|
|
142
142
|
})
|
|
143
143
|
})
|
|
144
144
|
|
|
145
|
+
describe('component-specific validation', () => {
|
|
146
|
+
it('rejects video without url', () => {
|
|
147
|
+
const result = validateComponent(makeComponent('video'))
|
|
148
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_VIDEO')).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('rejects carousel with empty items', () => {
|
|
152
|
+
const result = validateComponent(makeComponent('carousel', { items: [] }))
|
|
153
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_CAROUSEL')).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('rejects image-gallery with empty images', () => {
|
|
157
|
+
const result = validateComponent(makeComponent('image-gallery', { images: [] }))
|
|
158
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_GALLERY')).toBe(true)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('rejects form with empty fields', () => {
|
|
162
|
+
const result = validateComponent(makeComponent('form', { fields: [] }))
|
|
163
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_FORM')).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('rejects action-group with empty actions', () => {
|
|
167
|
+
const result = validateComponent(makeComponent('action-group', { actions: [] }))
|
|
168
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_ACTION_GROUP')).toBe(true)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('rejects code without code content', () => {
|
|
172
|
+
const result = validateComponent(makeComponent('code'))
|
|
173
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_CODE')).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('rejects map without center or markers', () => {
|
|
177
|
+
const result = validateComponent(makeComponent('map'))
|
|
178
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('accepts map with markers but no center', () => {
|
|
182
|
+
const result = validateComponent(makeComponent('map', { markers: [{ position: [48, 2] }] }))
|
|
183
|
+
const mapError = result.errors?.find((e) => e.code === 'INVALID_MAP')
|
|
184
|
+
expect(mapError).toBeUndefined()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('accepts modal with no params beyond type', () => {
|
|
188
|
+
const result = validateComponent(makeComponent('modal', { title: 'Test' }))
|
|
189
|
+
expect(result.valid).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('validateChartComponent — scatter/bubble/time-series', () => {
|
|
194
|
+
it('validates scatter chart without labels', () => {
|
|
195
|
+
const result = validateChartComponent({
|
|
196
|
+
type: 'scatter',
|
|
197
|
+
data: { datasets: [{ label: 'Test', data: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }] },
|
|
198
|
+
} as any)
|
|
199
|
+
expect(result.valid).toBe(true)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('validates bubble chart without labels', () => {
|
|
203
|
+
const result = validateChartComponent({
|
|
204
|
+
type: 'bubble',
|
|
205
|
+
data: { datasets: [{ label: 'Test', data: [{ x: 1, y: 2, r: 5 }] }] },
|
|
206
|
+
} as any)
|
|
207
|
+
expect(result.valid).toBe(true)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('validates line chart with time-series object data', () => {
|
|
211
|
+
const result = validateChartComponent({
|
|
212
|
+
type: 'line',
|
|
213
|
+
data: { datasets: [{ label: 'Prix', data: [{ x: '2024-01-01', y: 42 }, { x: '2024-02-01', y: 45 }] }] },
|
|
214
|
+
} as any)
|
|
215
|
+
expect(result.valid).toBe(true)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('rejects scatter with number data instead of {x,y}', () => {
|
|
219
|
+
const result = validateChartComponent({
|
|
220
|
+
type: 'scatter',
|
|
221
|
+
data: { datasets: [{ label: 'Test', data: [1, 2, 3] }] },
|
|
222
|
+
} as any)
|
|
223
|
+
expect(result.valid).toBe(false)
|
|
224
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_POINT_DATA')).toBe(true)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('rejects bar chart without labels', () => {
|
|
228
|
+
const result = validateChartComponent({
|
|
229
|
+
type: 'bar',
|
|
230
|
+
data: { datasets: [{ label: 'Test', data: [1, 2, 3] }] },
|
|
231
|
+
} as any)
|
|
232
|
+
expect(result.valid).toBe(false)
|
|
233
|
+
expect(result.errors?.some((e) => e.code === 'MISSING_LABELS')).toBe(true)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('accepts empty dataset without length mismatch', () => {
|
|
237
|
+
const result = validateChartComponent({
|
|
238
|
+
type: 'bar',
|
|
239
|
+
data: { labels: ['A', 'B'], datasets: [{ label: 'Test', data: [] }] },
|
|
240
|
+
} as any)
|
|
241
|
+
// Empty dataset should not trigger DATA_LENGTH_MISMATCH
|
|
242
|
+
const mismatch = result.errors?.find((e) => e.code === 'DATA_LENGTH_MISMATCH')
|
|
243
|
+
expect(mismatch).toBeUndefined()
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
145
247
|
describe('validateChartComponent — H1 null guards', () => {
|
|
146
248
|
|
|
147
249
|
it('rejects chart with undefined data', () => {
|
|
@@ -170,3 +272,58 @@ describe('validateChartComponent — H1 null guards', () => {
|
|
|
170
272
|
expect(result.valid).toBe(true)
|
|
171
273
|
})
|
|
172
274
|
})
|
|
275
|
+
|
|
276
|
+
describe('getIframeSandbox — tiered sandbox', () => {
|
|
277
|
+
it('gives full sandbox to trusted domains (Google)', () => {
|
|
278
|
+
const sandbox = getIframeSandbox('https://docs.google.com/spreadsheets/d/123')
|
|
279
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
280
|
+
expect(sandbox).toContain('allow-scripts')
|
|
281
|
+
expect(sandbox).toContain('allow-forms')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('gives full sandbox to Deposium domains', () => {
|
|
285
|
+
const sandbox = getIframeSandbox('https://deposium.com/embed/123')
|
|
286
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('gives full sandbox to payment domains (Stripe)', () => {
|
|
290
|
+
const sandbox = getIframeSandbox('https://checkout.stripe.com/c/pay_123')
|
|
291
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
292
|
+
expect(sandbox).toContain('allow-forms')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('gives full sandbox to Polar.sh', () => {
|
|
296
|
+
const sandbox = getIframeSandbox('https://polar.sh/checkout/123')
|
|
297
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('gives restrictive sandbox to untrusted whitelisted domains (quickchart)', () => {
|
|
301
|
+
const sandbox = getIframeSandbox('https://quickchart.io/chart?c={}')
|
|
302
|
+
expect(sandbox).toContain('allow-scripts')
|
|
303
|
+
expect(sandbox).toContain('allow-popups')
|
|
304
|
+
expect(sandbox).not.toContain('allow-same-origin')
|
|
305
|
+
expect(sandbox).not.toContain('allow-forms')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('gives restrictive sandbox to YouTube', () => {
|
|
309
|
+
const sandbox = getIframeSandbox('https://www.youtube.com/embed/abc123')
|
|
310
|
+
expect(sandbox).not.toContain('allow-same-origin')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('gives restrictive sandbox to unknown domains', () => {
|
|
314
|
+
const sandbox = getIframeSandbox('https://evil.example.com/page')
|
|
315
|
+
expect(sandbox).not.toContain('allow-same-origin')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('handles invalid URLs gracefully', () => {
|
|
319
|
+
const sandbox = getIframeSandbox('not-a-url')
|
|
320
|
+
expect(sandbox).toBe('allow-scripts allow-popups')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('supports custom trusted domains', () => {
|
|
324
|
+
const sandbox = getIframeSandbox('https://my-internal-tool.corp.com/dash', {
|
|
325
|
+
customTrustedDomains: ['my-internal-tool.corp.com'],
|
|
326
|
+
})
|
|
327
|
+
expect(sandbox).toContain('allow-same-origin')
|
|
328
|
+
})
|
|
329
|
+
})
|
|
@@ -160,6 +160,67 @@ export const DEFAULT_IFRAME_DOMAINS = [
|
|
|
160
160
|
'www.clinicaltrials.gov',
|
|
161
161
|
'linear.app',
|
|
162
162
|
'www.linear.app',
|
|
163
|
+
|
|
164
|
+
// Payment platforms (v2.2.12)
|
|
165
|
+
'polar.sh',
|
|
166
|
+
'www.polar.sh',
|
|
167
|
+
'checkout.stripe.com',
|
|
168
|
+
'js.stripe.com',
|
|
169
|
+
'billing.stripe.com',
|
|
170
|
+
'buy.stripe.com',
|
|
171
|
+
'connect.stripe.com',
|
|
172
|
+
'invoice.stripe.com',
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Trusted iframe domains that require allow-same-origin to function.
|
|
177
|
+
* These domains need access to their own cookies/storage for auth.
|
|
178
|
+
* All other whitelisted domains get a restrictive sandbox without allow-same-origin.
|
|
179
|
+
*/
|
|
180
|
+
export const TRUSTED_IFRAME_DOMAINS = [
|
|
181
|
+
// Deposium (own domains)
|
|
182
|
+
'deposium.com',
|
|
183
|
+
'deposium.vip',
|
|
184
|
+
'deposium.ai',
|
|
185
|
+
'localhost',
|
|
186
|
+
|
|
187
|
+
// Google services (need auth cookies)
|
|
188
|
+
'docs.google.com',
|
|
189
|
+
'drive.google.com',
|
|
190
|
+
'sheets.google.com',
|
|
191
|
+
'slides.google.com',
|
|
192
|
+
'maps.google.com',
|
|
193
|
+
'datastudio.google.com',
|
|
194
|
+
'lookerstudio.google.com',
|
|
195
|
+
|
|
196
|
+
// Productivity (need auth)
|
|
197
|
+
'notion.so',
|
|
198
|
+
'www.notion.so',
|
|
199
|
+
'airtable.com',
|
|
200
|
+
'figma.com',
|
|
201
|
+
'www.figma.com',
|
|
202
|
+
'miro.com',
|
|
203
|
+
|
|
204
|
+
// Payment (need auth + cookies for checkout)
|
|
205
|
+
'polar.sh',
|
|
206
|
+
'www.polar.sh',
|
|
207
|
+
'checkout.stripe.com',
|
|
208
|
+
'js.stripe.com',
|
|
209
|
+
'billing.stripe.com',
|
|
210
|
+
'buy.stripe.com',
|
|
211
|
+
'connect.stripe.com',
|
|
212
|
+
'invoice.stripe.com',
|
|
213
|
+
|
|
214
|
+
// Business tools (need auth)
|
|
215
|
+
'app.hubspot.com',
|
|
216
|
+
'share.hubspot.com',
|
|
217
|
+
'app.powerbi.com',
|
|
218
|
+
'linear.app',
|
|
219
|
+
'www.linear.app',
|
|
220
|
+
'calendly.com',
|
|
221
|
+
'typeform.com',
|
|
222
|
+
'cal.com',
|
|
223
|
+
'canva.com',
|
|
163
224
|
]
|
|
164
225
|
|
|
165
226
|
/**
|
|
@@ -244,13 +305,22 @@ export function validateChartComponent(
|
|
|
244
305
|
if (!Array.isArray(params.data.datasets)) {
|
|
245
306
|
return { valid: false, errors: [{ path: 'params.data.datasets', message: 'Missing or invalid datasets array', code: 'MISSING_DATASETS' }] }
|
|
246
307
|
}
|
|
247
|
-
|
|
248
|
-
|
|
308
|
+
// Detect point-based charts (scatter/bubble) or object data (time-series line)
|
|
309
|
+
const chartType = params.type || 'bar'
|
|
310
|
+
const firstDataPoint = params.data.datasets[0]?.data?.[0]
|
|
311
|
+
const hasObjectData = typeof firstDataPoint === 'object' && firstDataPoint !== null && 'x' in firstDataPoint
|
|
312
|
+
const isPointChart = chartType === 'scatter' || chartType === 'bubble' || hasObjectData
|
|
313
|
+
|
|
314
|
+
// Labels required only for categorical charts (not scatter/bubble/time-series)
|
|
315
|
+
if (!isPointChart) {
|
|
316
|
+
if (!Array.isArray(params.data.labels)) {
|
|
317
|
+
return { valid: false, errors: [{ path: 'params.data.labels', message: 'Missing or invalid labels array', code: 'MISSING_LABELS' }] }
|
|
318
|
+
}
|
|
249
319
|
}
|
|
250
320
|
|
|
251
321
|
// Validate data points count
|
|
252
322
|
const totalDataPoints = params.data.datasets.reduce(
|
|
253
|
-
(sum, dataset) => sum + dataset.data.length,
|
|
323
|
+
(sum, dataset) => sum + (Array.isArray(dataset.data) ? dataset.data.length : 0),
|
|
254
324
|
0
|
|
255
325
|
)
|
|
256
326
|
|
|
@@ -262,27 +332,41 @@ export function validateChartComponent(
|
|
|
262
332
|
})
|
|
263
333
|
}
|
|
264
334
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
335
|
+
// Length mismatch check — only for categorical charts, skip empty datasets
|
|
336
|
+
if (!isPointChart && Array.isArray(params.data.labels)) {
|
|
337
|
+
const expectedLength = params.data.labels.length
|
|
338
|
+
for (const [index, dataset] of params.data.datasets.entries()) {
|
|
339
|
+
if (Array.isArray(dataset.data) && dataset.data.length > 0 && dataset.data.length !== expectedLength) {
|
|
340
|
+
errors.push({
|
|
341
|
+
path: `params.data.datasets[${index}]`,
|
|
342
|
+
message: `Dataset length mismatch: expected ${expectedLength}, got ${dataset.data.length}`,
|
|
343
|
+
code: 'DATA_LENGTH_MISMATCH',
|
|
344
|
+
})
|
|
345
|
+
}
|
|
274
346
|
}
|
|
275
347
|
}
|
|
276
348
|
|
|
277
|
-
//
|
|
349
|
+
// Data type validation — numbers for categorical, {x,y} objects for point charts
|
|
278
350
|
for (const [index, dataset] of params.data.datasets.entries()) {
|
|
351
|
+
if (!Array.isArray(dataset.data)) continue
|
|
279
352
|
for (const [dataIndex, value] of dataset.data.entries()) {
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
353
|
+
if (isPointChart) {
|
|
354
|
+
const vObj = value as any
|
|
355
|
+
if (typeof value !== 'object' || value === null || vObj.x == null || typeof vObj.y !== 'number') {
|
|
356
|
+
errors.push({
|
|
357
|
+
path: `params.data.datasets[${index}].data[${dataIndex}]`,
|
|
358
|
+
message: `Invalid point data: expected {x, y} object`,
|
|
359
|
+
code: 'INVALID_POINT_DATA',
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
364
|
+
errors.push({
|
|
365
|
+
path: `params.data.datasets[${index}].data[${dataIndex}]`,
|
|
366
|
+
message: `Invalid data value: ${value} (must be finite number)`,
|
|
367
|
+
code: 'INVALID_DATA_TYPE',
|
|
368
|
+
})
|
|
369
|
+
}
|
|
286
370
|
}
|
|
287
371
|
}
|
|
288
372
|
}
|
|
@@ -447,6 +531,45 @@ export function validateIframeDomain(
|
|
|
447
531
|
}
|
|
448
532
|
}
|
|
449
533
|
|
|
534
|
+
/**
|
|
535
|
+
* Get the appropriate sandbox attribute for an iframe URL.
|
|
536
|
+
*
|
|
537
|
+
* Trusted domains (Google, Deposium, payment, auth-requiring services) get
|
|
538
|
+
* `allow-same-origin` so they can access their own cookies/storage.
|
|
539
|
+
* All other whitelisted domains get a restrictive sandbox without it,
|
|
540
|
+
* preventing access to the parent page's localStorage/cookies.
|
|
541
|
+
*
|
|
542
|
+
* @param url - The iframe URL
|
|
543
|
+
* @param options - Optional custom trusted domains
|
|
544
|
+
* @returns sandbox attribute string
|
|
545
|
+
*/
|
|
546
|
+
export function getIframeSandbox(
|
|
547
|
+
url: string,
|
|
548
|
+
options?: { customTrustedDomains?: string[] }
|
|
549
|
+
): string {
|
|
550
|
+
const baseSandbox = 'allow-scripts allow-popups'
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const domain = new URL(url).hostname
|
|
554
|
+
let trustedList = TRUSTED_IFRAME_DOMAINS
|
|
555
|
+
if (options?.customTrustedDomains) {
|
|
556
|
+
trustedList = [...TRUSTED_IFRAME_DOMAINS, ...options.customTrustedDomains]
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const isTrusted = trustedList.some(
|
|
560
|
+
(trusted) => domain === trusted || domain.endsWith(`.${trusted}`)
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if (isTrusted) {
|
|
564
|
+
return `${baseSandbox} allow-same-origin allow-forms`
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
// Invalid URL — use restrictive sandbox
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return baseSandbox
|
|
571
|
+
}
|
|
572
|
+
|
|
450
573
|
/**
|
|
451
574
|
* Validate entire component
|
|
452
575
|
*
|
|
@@ -582,6 +705,85 @@ export function validateComponent(
|
|
|
582
705
|
break
|
|
583
706
|
}
|
|
584
707
|
|
|
708
|
+
case 'video': {
|
|
709
|
+
const videoParams = component.params as any
|
|
710
|
+
if (!videoParams.url) {
|
|
711
|
+
errors.push({ path: 'params', message: 'Video component must have url', code: 'INVALID_VIDEO' })
|
|
712
|
+
} else {
|
|
713
|
+
// Reuse iframe domain validation for video URLs
|
|
714
|
+
const videoResult = validateIframeDomain(videoParams.url, {
|
|
715
|
+
policy: options?.iframePolicy,
|
|
716
|
+
customDomains: options?.customIframeDomains,
|
|
717
|
+
})
|
|
718
|
+
if (!videoResult.valid) {
|
|
719
|
+
errors.push(...(videoResult.errors || []))
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
break
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
case 'carousel': {
|
|
726
|
+
const carouselParams = component.params as any
|
|
727
|
+
if (!Array.isArray(carouselParams.items) || carouselParams.items.length === 0) {
|
|
728
|
+
errors.push({ path: 'params.items', message: 'Carousel must have non-empty items array', code: 'EMPTY_CAROUSEL' })
|
|
729
|
+
}
|
|
730
|
+
break
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
case 'image-gallery': {
|
|
734
|
+
const galleryParams = component.params as any
|
|
735
|
+
if (!Array.isArray(galleryParams.images) || galleryParams.images.length === 0) {
|
|
736
|
+
errors.push({ path: 'params.images', message: 'Gallery must have non-empty images array', code: 'EMPTY_GALLERY' })
|
|
737
|
+
}
|
|
738
|
+
break
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
case 'form': {
|
|
742
|
+
const formParams = component.params as any
|
|
743
|
+
if (!Array.isArray(formParams.fields) || formParams.fields.length === 0) {
|
|
744
|
+
errors.push({ path: 'params.fields', message: 'Form must have non-empty fields array', code: 'EMPTY_FORM' })
|
|
745
|
+
}
|
|
746
|
+
break
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
case 'action-group': {
|
|
750
|
+
const agParams = component.params as any
|
|
751
|
+
if (!Array.isArray(agParams.actions) || agParams.actions.length === 0) {
|
|
752
|
+
errors.push({ path: 'params.actions', message: 'Action group must have non-empty actions array', code: 'EMPTY_ACTION_GROUP' })
|
|
753
|
+
}
|
|
754
|
+
break
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
case 'code': {
|
|
758
|
+
const codeParams = component.params as any
|
|
759
|
+
if (!codeParams.code) {
|
|
760
|
+
errors.push({ path: 'params.code', message: 'Code component must have code content', code: 'INVALID_CODE' })
|
|
761
|
+
}
|
|
762
|
+
break
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
case 'map': {
|
|
766
|
+
// Map can auto-detect center from markers, so center is not strictly required
|
|
767
|
+
const mapParams = component.params as any
|
|
768
|
+
if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
|
|
769
|
+
errors.push({ path: 'params', message: 'Map must have center or markers', code: 'INVALID_MAP' })
|
|
770
|
+
}
|
|
771
|
+
break
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
case 'modal': {
|
|
775
|
+
// Modal is valid with minimal params (title optional, content can be children)
|
|
776
|
+
break
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
case 'artifact': {
|
|
780
|
+
const artifactParams = component.params as any
|
|
781
|
+
if (!artifactParams.content) {
|
|
782
|
+
errors.push({ path: 'params.content', message: 'Artifact must have content', code: 'INVALID_ARTIFACT' })
|
|
783
|
+
}
|
|
784
|
+
break
|
|
785
|
+
}
|
|
786
|
+
|
|
585
787
|
default:
|
|
586
788
|
// Known types without specific validation pass through — renderer handles errors
|
|
587
789
|
// Truly unknown types (e.g. typos in streamed JSON) are rejected
|
package/src/types/index.ts
CHANGED
|
@@ -32,7 +32,7 @@ export type ComponentType =
|
|
|
32
32
|
/**
|
|
33
33
|
* Chart types (powered by Quickchart)
|
|
34
34
|
*/
|
|
35
|
-
export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'scatter'
|
|
35
|
+
export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'scatter' | 'bubble' | 'polarArea'
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Grid layout specification (12-column system)
|