@seed-ship/mcp-ui-solid 6.8.1 → 6.8.2

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.
@@ -8,7 +8,7 @@
8
8
  * - Security constraints (domain whitelist, XSS prevention)
9
9
  */
10
10
 
11
- import type { ZodIssue, ZodSchema } from 'zod'
11
+ import type { ZodIssue, ZodSchema } from 'zod';
12
12
  import {
13
13
  MetricComponentParamsSchema,
14
14
  TextComponentParamsSchema,
@@ -27,7 +27,7 @@ import {
27
27
  FormComponentParamsSchema,
28
28
  // v6.0.0 — graph primitive (peer @antv/g6 ^5)
29
29
  GraphComponentParamsSchema,
30
- } from '@seed-ship/mcp-ui-spec'
30
+ } from '@seed-ship/mcp-ui-spec';
31
31
  import type {
32
32
  UIComponent,
33
33
  UILayout,
@@ -39,19 +39,35 @@ import type {
39
39
  IframePolicy,
40
40
  ValidationOptions,
41
41
  ComponentType,
42
- } from '../types'
42
+ } from '../types';
43
43
 
44
44
  /**
45
45
  * All known ComponentType values — used to distinguish known-but-unvalidated
46
46
  * types (pass through) from truly unknown strings (reject).
47
47
  */
48
48
  const KNOWN_COMPONENT_TYPES: Set<string> = new Set<ComponentType>([
49
- 'chart', 'table', 'metric', 'text', 'grid', 'iframe', 'image', 'link',
50
- 'action', 'footer', 'carousel', 'artifact', 'form', 'modal',
51
- 'action-group', 'image-gallery', 'video', 'code', 'map',
49
+ 'chart',
50
+ 'table',
51
+ 'metric',
52
+ 'text',
53
+ 'grid',
54
+ 'iframe',
55
+ 'image',
56
+ 'link',
57
+ 'action',
58
+ 'footer',
59
+ 'carousel',
60
+ 'artifact',
61
+ 'form',
62
+ 'modal',
63
+ 'action-group',
64
+ 'image-gallery',
65
+ 'video',
66
+ 'code',
67
+ 'map',
52
68
  // v6.0.0
53
69
  'graph',
54
- ])
70
+ ]);
55
71
 
56
72
  /**
57
73
  * Spec-driven validation dispatch table (B.1 — v5.5.0, expanded in v5.6.0).
@@ -95,7 +111,7 @@ const SPEC_VALIDATORS: Partial<Record<ComponentType, { schema: ZodSchema; legacy
95
111
  // because LLM payloads sometimes ship edges to nodes added later.
96
112
  // Unresolved refs are gracefully ignored by G6 v5.)
97
113
  graph: { schema: GraphComponentParamsSchema, legacyCode: 'INVALID_GRAPH' },
98
- }
114
+ };
99
115
 
100
116
  /**
101
117
  * Map a Zod issue list to the legacy `ValidationError[]` shape.
@@ -113,7 +129,7 @@ function mapZodIssuesToErrors(
113
129
  path: issue.path.length > 0 ? `params.${issue.path.join('.')}` : 'params',
114
130
  message: issue.message,
115
131
  code: legacyCode,
116
- }))
132
+ }));
117
133
  }
118
134
 
119
135
  /**
@@ -133,7 +149,7 @@ export const DEFAULT_RESOURCE_LIMITS: ResourceLimits = {
133
149
  // default ceiling moved.
134
150
  maxPayloadSize: 512 * 1024, // 512KB
135
151
  renderTimeout: 5000, // 5 seconds
136
- }
152
+ };
137
153
 
138
154
  /**
139
155
  * Default allowed iframe domains (whitelist)
@@ -264,7 +280,7 @@ export const DEFAULT_IFRAME_DOMAINS = [
264
280
  'buy.stripe.com',
265
281
  'connect.stripe.com',
266
282
  'invoice.stripe.com',
267
- ]
283
+ ];
268
284
 
269
285
  /**
270
286
  * Trusted iframe domains that require allow-same-origin to function.
@@ -315,13 +331,13 @@ export const TRUSTED_IFRAME_DOMAINS = [
315
331
  'typeform.com',
316
332
  'cal.com',
317
333
  'canva.com',
318
- ]
334
+ ];
319
335
 
320
336
  /**
321
337
  * Validate grid position bounds (1-12 columns)
322
338
  */
323
339
  export function validateGridPosition(position: UIComponent['position']): ValidationResult {
324
- const errors: ValidationResult['errors'] = []
340
+ const errors: ValidationResult['errors'] = [];
325
341
 
326
342
  // ✅ PHASE 3 FIX: Defensive check for undefined position
327
343
  if (!position) {
@@ -334,7 +350,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
334
350
  code: 'MISSING_POSITION',
335
351
  },
336
352
  ],
337
- }
353
+ };
338
354
  }
339
355
 
340
356
  if (position.colStart < 1 || position.colStart > 12) {
@@ -342,7 +358,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
342
358
  path: 'position.colStart',
343
359
  message: 'Column start must be between 1 and 12',
344
360
  code: 'INVALID_GRID_COL_START',
345
- })
361
+ });
346
362
  }
347
363
 
348
364
  if (position.colSpan < 1 || position.colSpan > 12) {
@@ -350,7 +366,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
350
366
  path: 'position.colSpan',
351
367
  message: 'Column span must be between 1 and 12',
352
368
  code: 'INVALID_GRID_COL_SPAN',
353
- })
369
+ });
354
370
  }
355
371
 
356
372
  if (position.colStart + position.colSpan - 1 > 12) {
@@ -358,7 +374,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
358
374
  path: 'position',
359
375
  message: 'Column start + span exceeds grid width (12)',
360
376
  code: 'GRID_OVERFLOW',
361
- })
377
+ });
362
378
  }
363
379
 
364
380
  if (position.rowStart !== undefined && position.rowStart < 1) {
@@ -366,7 +382,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
366
382
  path: 'position.rowStart',
367
383
  message: 'Row start must be >= 1',
368
384
  code: 'INVALID_GRID_ROW_START',
369
- })
385
+ });
370
386
  }
371
387
 
372
388
  if (position.rowSpan !== undefined && position.rowSpan < 1) {
@@ -374,13 +390,13 @@ export function validateGridPosition(position: UIComponent['position']): Validat
374
390
  path: 'position.rowSpan',
375
391
  message: 'Row span must be >= 1',
376
392
  code: 'INVALID_GRID_ROW_SPAN',
377
- })
393
+ });
378
394
  }
379
395
 
380
396
  return {
381
397
  valid: errors.length === 0,
382
398
  errors: errors.length > 0 ? errors : undefined,
383
- }
399
+ };
384
400
  }
385
401
 
386
402
  /**
@@ -390,25 +406,47 @@ export function validateChartComponent(
390
406
  params: ChartComponentParams,
391
407
  limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
392
408
  ): ValidationResult {
393
- const errors: ValidationResult['errors'] = []
409
+ const errors: ValidationResult['errors'] = [];
394
410
 
395
411
  // Guard: params.data must exist with labels + datasets
396
412
  if (!params?.data) {
397
- return { valid: false, errors: [{ path: 'params.data', message: 'Missing chart data object', code: 'MISSING_DATA' }] }
413
+ return {
414
+ valid: false,
415
+ errors: [{ path: 'params.data', message: 'Missing chart data object', code: 'MISSING_DATA' }],
416
+ };
398
417
  }
399
418
  if (!Array.isArray(params.data.datasets)) {
400
- return { valid: false, errors: [{ path: 'params.data.datasets', message: 'Missing or invalid datasets array', code: 'MISSING_DATASETS' }] }
419
+ return {
420
+ valid: false,
421
+ errors: [
422
+ {
423
+ path: 'params.data.datasets',
424
+ message: 'Missing or invalid datasets array',
425
+ code: 'MISSING_DATASETS',
426
+ },
427
+ ],
428
+ };
401
429
  }
402
430
  // Detect point-based charts (scatter/bubble) or object data (time-series line)
403
- const chartType = params.type || 'bar'
404
- const firstDataPoint = params.data.datasets[0]?.data?.[0]
405
- const hasObjectData = typeof firstDataPoint === 'object' && firstDataPoint !== null && 'x' in firstDataPoint
406
- const isPointChart = chartType === 'scatter' || chartType === 'bubble' || hasObjectData
431
+ const chartType = params.type || 'bar';
432
+ const firstDataPoint = params.data.datasets[0]?.data?.[0];
433
+ const hasObjectData =
434
+ typeof firstDataPoint === 'object' && firstDataPoint !== null && 'x' in firstDataPoint;
435
+ const isPointChart = chartType === 'scatter' || chartType === 'bubble' || hasObjectData;
407
436
 
408
437
  // Labels required only for categorical charts (not scatter/bubble/time-series)
409
438
  if (!isPointChart) {
410
439
  if (!Array.isArray(params.data.labels)) {
411
- return { valid: false, errors: [{ path: 'params.data.labels', message: 'Missing or invalid labels array', code: 'MISSING_LABELS' }] }
440
+ return {
441
+ valid: false,
442
+ errors: [
443
+ {
444
+ path: 'params.data.labels',
445
+ message: 'Missing or invalid labels array',
446
+ code: 'MISSING_LABELS',
447
+ },
448
+ ],
449
+ };
412
450
  }
413
451
  }
414
452
 
@@ -416,42 +454,51 @@ export function validateChartComponent(
416
454
  const totalDataPoints = params.data.datasets.reduce(
417
455
  (sum, dataset) => sum + (Array.isArray(dataset.data) ? dataset.data.length : 0),
418
456
  0
419
- )
457
+ );
420
458
 
421
459
  if (totalDataPoints > limits.maxDataPoints) {
422
460
  errors.push({
423
461
  path: 'params.data',
424
462
  message: `Chart exceeds max data points: ${totalDataPoints} > ${limits.maxDataPoints}`,
425
463
  code: 'RESOURCE_LIMIT_EXCEEDED',
426
- })
464
+ });
427
465
  }
428
466
 
429
467
  // Length mismatch check — only for categorical charts, skip empty datasets
430
468
  if (!isPointChart && Array.isArray(params.data.labels)) {
431
- const expectedLength = params.data.labels.length
469
+ const expectedLength = params.data.labels.length;
432
470
  for (const [index, dataset] of params.data.datasets.entries()) {
433
- if (Array.isArray(dataset.data) && dataset.data.length > 0 && dataset.data.length !== expectedLength) {
471
+ if (
472
+ Array.isArray(dataset.data) &&
473
+ dataset.data.length > 0 &&
474
+ dataset.data.length !== expectedLength
475
+ ) {
434
476
  errors.push({
435
477
  path: `params.data.datasets[${index}]`,
436
478
  message: `Dataset length mismatch: expected ${expectedLength}, got ${dataset.data.length}`,
437
479
  code: 'DATA_LENGTH_MISMATCH',
438
- })
480
+ });
439
481
  }
440
482
  }
441
483
  }
442
484
 
443
485
  // Data type validation — numbers for categorical, {x,y} objects for point charts
444
486
  for (const [index, dataset] of params.data.datasets.entries()) {
445
- if (!Array.isArray(dataset.data)) continue
487
+ if (!Array.isArray(dataset.data)) continue;
446
488
  for (const [dataIndex, value] of dataset.data.entries()) {
447
489
  if (isPointChart) {
448
- const vObj = value as any
449
- if (typeof value !== 'object' || value === null || vObj.x == null || typeof vObj.y !== 'number') {
490
+ const vObj = value as any;
491
+ if (
492
+ typeof value !== 'object' ||
493
+ value === null ||
494
+ vObj.x == null ||
495
+ typeof vObj.y !== 'number'
496
+ ) {
450
497
  errors.push({
451
498
  path: `params.data.datasets[${index}].data[${dataIndex}]`,
452
499
  message: `Invalid point data: expected {x, y} object`,
453
500
  code: 'INVALID_POINT_DATA',
454
- })
501
+ });
455
502
  }
456
503
  } else {
457
504
  if (typeof value !== 'number' || !Number.isFinite(value)) {
@@ -459,7 +506,7 @@ export function validateChartComponent(
459
506
  path: `params.data.datasets[${index}].data[${dataIndex}]`,
460
507
  message: `Invalid data value: ${value} (must be finite number)`,
461
508
  code: 'INVALID_DATA_TYPE',
462
- })
509
+ });
463
510
  }
464
511
  }
465
512
  }
@@ -468,7 +515,7 @@ export function validateChartComponent(
468
515
  return {
469
516
  valid: errors.length === 0,
470
517
  errors: errors.length > 0 ? errors : undefined,
471
- }
518
+ };
472
519
  }
473
520
 
474
521
  /**
@@ -478,7 +525,7 @@ export function validateTableComponent(
478
525
  params: TableComponentParams,
479
526
  limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
480
527
  ): ValidationResult {
481
- const errors: ValidationResult['errors'] = []
528
+ const errors: ValidationResult['errors'] = [];
482
529
 
483
530
  // Validate row count
484
531
  if (params.rows.length > limits.maxTableRows) {
@@ -486,7 +533,7 @@ export function validateTableComponent(
486
533
  path: 'params.rows',
487
534
  message: `Table exceeds max rows: ${params.rows.length} > ${limits.maxTableRows}`,
488
535
  code: 'RESOURCE_LIMIT_EXCEEDED',
489
- })
536
+ });
490
537
  }
491
538
 
492
539
  // Validate columns
@@ -495,20 +542,20 @@ export function validateTableComponent(
495
542
  path: 'params.columns',
496
543
  message: 'Table must have at least one column',
497
544
  code: 'EMPTY_COLUMNS',
498
- })
545
+ });
499
546
  }
500
547
 
501
548
  // Validate column keys are unique
502
- const columnKeys = new Set<string>()
549
+ const columnKeys = new Set<string>();
503
550
  for (const [index, column] of params.columns.entries()) {
504
551
  if (columnKeys.has(column.key)) {
505
552
  errors.push({
506
553
  path: `params.columns[${index}]`,
507
554
  message: `Duplicate column key: ${column.key}`,
508
555
  code: 'DUPLICATE_COLUMN_KEY',
509
- })
556
+ });
510
557
  }
511
- columnKeys.add(column.key)
558
+ columnKeys.add(column.key);
512
559
  }
513
560
 
514
561
  // Validate rows have valid data for defined columns
@@ -519,7 +566,7 @@ export function validateTableComponent(
519
566
  path: `params.rows[${rowIndex}]`,
520
567
  message: `Missing column key: ${column.key}`,
521
568
  code: 'MISSING_COLUMN_DATA',
522
- })
569
+ });
523
570
  }
524
571
  }
525
572
  }
@@ -527,7 +574,7 @@ export function validateTableComponent(
527
574
  return {
528
575
  valid: errors.length === 0,
529
576
  errors: errors.length > 0 ? errors : undefined,
530
- }
577
+ };
531
578
  }
532
579
 
533
580
  /**
@@ -537,7 +584,7 @@ export function validatePayloadSize(
537
584
  component: UIComponent,
538
585
  limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
539
586
  ): ValidationResult {
540
- const payloadSize = JSON.stringify(component).length
587
+ const payloadSize = JSON.stringify(component).length;
541
588
 
542
589
  if (payloadSize > limits.maxPayloadSize) {
543
590
  return {
@@ -549,10 +596,10 @@ export function validatePayloadSize(
549
596
  code: 'PAYLOAD_TOO_LARGE',
550
597
  },
551
598
  ],
552
- }
599
+ };
553
600
  }
554
601
 
555
- return { valid: true }
602
+ return { valid: true };
556
603
  }
557
604
 
558
605
  /**
@@ -563,7 +610,7 @@ export function sanitizeString(input: string): string {
563
610
  return input
564
611
  .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
565
612
  .replace(/on\w+="[^"]*"/gi, '')
566
- .replace(/javascript:/gi, '')
613
+ .replace(/javascript:/gi, '');
567
614
  }
568
615
 
569
616
  /**
@@ -580,17 +627,17 @@ export function validateIframeDomain(
580
627
  ): ValidationResult {
581
628
  // If allow-all, skip validation
582
629
  if (options?.policy === 'allow-all') {
583
- return { valid: true }
630
+ return { valid: true };
584
631
  }
585
632
 
586
633
  try {
587
- const parsedUrl = new URL(url)
588
- const domain = parsedUrl.hostname
634
+ const parsedUrl = new URL(url);
635
+ const domain = parsedUrl.hostname;
589
636
 
590
637
  // Build effective whitelist
591
- let effectiveWhitelist = DEFAULT_IFRAME_DOMAINS
638
+ let effectiveWhitelist = DEFAULT_IFRAME_DOMAINS;
592
639
  if (options?.policy === 'extend' && options.customDomains) {
593
- effectiveWhitelist = [...DEFAULT_IFRAME_DOMAINS, ...options.customDomains]
640
+ effectiveWhitelist = [...DEFAULT_IFRAME_DOMAINS, ...options.customDomains];
594
641
  }
595
642
 
596
643
  // SECURITY (v5.5.1) — pre-fix bug: predicate was `allowed === 'localhost'`
@@ -598,12 +645,13 @@ export function validateIframeDomain(
598
645
  // 'localhost' (an entry from DEFAULT_IFRAME_DOMAINS), making the entire
599
646
  // domain whitelist inoperative. Fixed: only the URL's actual hostname
600
647
  // being 'localhost' (or a 127.0.0.x loopback) bypasses the whitelist.
601
- const isLoopback = domain === 'localhost' || /^127(\.\d{1,3}){3}$/.test(domain)
648
+ const isLoopback = domain === 'localhost' || /^127(\.\d{1,3}){3}$/.test(domain);
602
649
  const isAllowed =
603
650
  isLoopback ||
604
651
  effectiveWhitelist.some(
605
- (allowed) => allowed !== 'localhost' && (domain === allowed || domain.endsWith(`.${allowed}`))
606
- )
652
+ (allowed) =>
653
+ allowed !== 'localhost' && (domain === allowed || domain.endsWith(`.${allowed}`))
654
+ );
607
655
 
608
656
  if (!isAllowed) {
609
657
  return {
@@ -615,10 +663,10 @@ export function validateIframeDomain(
615
663
  code: 'DOMAIN_NOT_WHITELISTED',
616
664
  },
617
665
  ],
618
- }
666
+ };
619
667
  }
620
668
 
621
- return { valid: true }
669
+ return { valid: true };
622
670
  } catch (error) {
623
671
  return {
624
672
  valid: false,
@@ -629,7 +677,7 @@ export function validateIframeDomain(
629
677
  code: 'INVALID_URL',
630
678
  },
631
679
  ],
632
- }
680
+ };
633
681
  }
634
682
  }
635
683
 
@@ -649,27 +697,27 @@ export function getIframeSandbox(
649
697
  url: string,
650
698
  options?: { customTrustedDomains?: string[] }
651
699
  ): string {
652
- const baseSandbox = 'allow-scripts allow-popups'
700
+ const baseSandbox = 'allow-scripts allow-popups';
653
701
 
654
702
  try {
655
- const domain = new URL(url).hostname
656
- let trustedList = TRUSTED_IFRAME_DOMAINS
703
+ const domain = new URL(url).hostname;
704
+ let trustedList = TRUSTED_IFRAME_DOMAINS;
657
705
  if (options?.customTrustedDomains) {
658
- trustedList = [...TRUSTED_IFRAME_DOMAINS, ...options.customTrustedDomains]
706
+ trustedList = [...TRUSTED_IFRAME_DOMAINS, ...options.customTrustedDomains];
659
707
  }
660
708
 
661
709
  const isTrusted = trustedList.some(
662
710
  (trusted) => domain === trusted || domain.endsWith(`.${trusted}`)
663
- )
711
+ );
664
712
 
665
713
  if (isTrusted) {
666
- return `${baseSandbox} allow-same-origin allow-forms`
714
+ return `${baseSandbox} allow-same-origin allow-forms`;
667
715
  }
668
716
  } catch {
669
717
  // Invalid URL — use restrictive sandbox
670
718
  }
671
719
 
672
- return baseSandbox
720
+ return baseSandbox;
673
721
  }
674
722
 
675
723
  /**
@@ -682,24 +730,27 @@ export function validateComponent(
682
730
  component: UIComponent,
683
731
  options?: ValidationOptions
684
732
  ): ValidationResult {
685
- const limits = options?.limits ?? DEFAULT_RESOURCE_LIMITS
686
- const errors: ValidationResult['errors'] = []
733
+ const limits = options?.limits ?? DEFAULT_RESOURCE_LIMITS;
734
+ const errors: ValidationResult['errors'] = [];
687
735
 
688
736
  // Guard: params must exist
689
737
  if (!component.params) {
690
- return { valid: false, errors: [{ path: 'params', message: 'Missing component params', code: 'MISSING_PARAMS' }] }
738
+ return {
739
+ valid: false,
740
+ errors: [{ path: 'params', message: 'Missing component params', code: 'MISSING_PARAMS' }],
741
+ };
691
742
  }
692
743
 
693
744
  // Validate grid position
694
- const gridResult = validateGridPosition(component.position)
745
+ const gridResult = validateGridPosition(component.position);
695
746
  if (!gridResult.valid) {
696
- errors.push(...(gridResult.errors || []))
747
+ errors.push(...(gridResult.errors || []));
697
748
  }
698
749
 
699
750
  // Validate payload size
700
- const sizeResult = validatePayloadSize(component, limits)
751
+ const sizeResult = validatePayloadSize(component, limits);
701
752
  if (!sizeResult.valid) {
702
- errors.push(...(sizeResult.errors || []))
753
+ errors.push(...(sizeResult.errors || []));
703
754
  }
704
755
 
705
756
  // Type-specific validation (B.1 — v5.5.0, expanded v5.6.0).
@@ -708,38 +759,55 @@ export function validateComponent(
708
759
  // SPEC_VALIDATORS. The 3 remaining types stay imperative because they
709
760
  // need cross-field consistency, resource limits, or have nothing to validate
710
761
  // (see SPEC_VALIDATORS docstring).
711
- const specValidator = SPEC_VALIDATORS[component.type]
762
+ const specValidator = SPEC_VALIDATORS[component.type];
712
763
  if (specValidator) {
713
- const result = specValidator.schema.safeParse(component.params)
764
+ const result = specValidator.schema.safeParse(component.params);
714
765
  if (!result.success) {
715
- errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode))
766
+ errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode));
716
767
  }
717
768
  // Post-spec chained checks. Skipped when the shape parse failed to avoid
718
769
  // cascading errors on already-broken payloads.
719
770
  if (result.success) {
720
771
  // Iframe + video: domain whitelist
721
772
  if (component.type === 'iframe' || component.type === 'video') {
722
- const url = (component.params as { url?: string })?.url
773
+ const url = (component.params as { url?: string })?.url;
723
774
  if (typeof url === 'string') {
724
775
  const domainResult = validateIframeDomain(url, {
725
776
  policy: options?.iframePolicy,
726
777
  customDomains: options?.customIframeDomains,
727
- })
778
+ });
728
779
  if (!domainResult.valid) {
729
- errors.push(...(domainResult.errors || []))
780
+ errors.push(...(domainResult.errors || []));
730
781
  }
731
782
  }
732
783
  }
733
- // Map (v5.6.0): center OR markers required. Spec has both .optional()
734
- // since auto-center from markers is supported, but we need ONE of them.
784
+ // Map (v5.6.0; widened v6.8.2): a map must carry at least one
785
+ // visualizable thing. Originally only center/markers counted, but since
786
+ // spec@5.2.0 a `type:'map'` may render purely from `geojson`, named
787
+ // `layers`, or a `pmtiles` source (e.g. a Cadastre choropleth with no
788
+ // markers and an auto-fit viewport). Rejecting those as INVALID_MAP
789
+ // blocked valid maps the renderer draws. Accept ANY of:
790
+ // center | markers | geojson | layers | pmtiles.
735
791
  if (component.type === 'map') {
736
- const mapParams = component.params as { center?: unknown; markers?: unknown[] }
737
- if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
792
+ const mapParams = component.params as {
793
+ center?: unknown;
794
+ markers?: unknown[];
795
+ geojson?: unknown;
796
+ layers?: unknown[];
797
+ pmtiles?: unknown;
798
+ };
799
+ const hasContent =
800
+ mapParams.center != null ||
801
+ (Array.isArray(mapParams.markers) && mapParams.markers.length > 0) ||
802
+ mapParams.geojson != null ||
803
+ (Array.isArray(mapParams.layers) && mapParams.layers.length > 0) ||
804
+ mapParams.pmtiles != null;
805
+ if (!hasContent) {
738
806
  errors.push({
739
807
  path: 'params',
740
- message: 'Map must have center or markers',
808
+ message: 'Map must have center, markers, geojson, layers, or pmtiles',
741
809
  code: 'INVALID_MAP',
742
- })
810
+ });
743
811
  }
744
812
  }
745
813
  }
@@ -747,24 +815,30 @@ export function validateComponent(
747
815
  // Imperative path for chart/table/modal/grid/footer/composite.
748
816
  switch (component.type) {
749
817
  case 'chart': {
750
- const chartResult = validateChartComponent(component.params as ChartComponentParams, limits)
818
+ const chartResult = validateChartComponent(
819
+ component.params as ChartComponentParams,
820
+ limits
821
+ );
751
822
  if (!chartResult.valid) {
752
- errors.push(...(chartResult.errors || []))
823
+ errors.push(...(chartResult.errors || []));
753
824
  }
754
- break
825
+ break;
755
826
  }
756
827
 
757
828
  case 'table': {
758
- const tableResult = validateTableComponent(component.params as TableComponentParams, limits)
829
+ const tableResult = validateTableComponent(
830
+ component.params as TableComponentParams,
831
+ limits
832
+ );
759
833
  if (!tableResult.valid) {
760
- errors.push(...(tableResult.errors || []))
834
+ errors.push(...(tableResult.errors || []));
761
835
  }
762
- break
836
+ break;
763
837
  }
764
838
 
765
839
  case 'modal':
766
840
  // Modal is valid with minimal params (title optional, content can be children).
767
- break
841
+ break;
768
842
 
769
843
  default:
770
844
  // Known types without specific validation pass through — renderer handles errors.
@@ -774,16 +848,16 @@ export function validateComponent(
774
848
  path: 'type',
775
849
  message: `Unknown component type: ${component.type}`,
776
850
  code: 'UNKNOWN_COMPONENT_TYPE',
777
- })
851
+ });
778
852
  }
779
- break
853
+ break;
780
854
  }
781
855
  }
782
856
 
783
857
  return {
784
858
  valid: errors.length === 0,
785
859
  errors: errors.length > 0 ? errors : undefined,
786
- }
860
+ };
787
861
  }
788
862
 
789
863
  /**
@@ -792,11 +866,8 @@ export function validateComponent(
792
866
  * @param layout - The layout to validate
793
867
  * @param options - Optional validation options (limits, iframePolicy, customIframeDomains)
794
868
  */
795
- export function validateLayout(
796
- layout: UILayout,
797
- options?: ValidationOptions
798
- ): ValidationResult {
799
- const errors: ValidationResult['errors'] = []
869
+ export function validateLayout(layout: UILayout, options?: ValidationOptions): ValidationResult {
870
+ const errors: ValidationResult['errors'] = [];
800
871
 
801
872
  // Validate component count
802
873
  if (layout.components.length === 0) {
@@ -804,7 +875,7 @@ export function validateLayout(
804
875
  path: 'components',
805
876
  message: 'Layout must have at least one component',
806
877
  code: 'EMPTY_LAYOUT',
807
- })
878
+ });
808
879
  }
809
880
 
810
881
  if (layout.components.length > 12) {
@@ -812,19 +883,19 @@ export function validateLayout(
812
883
  path: 'components',
813
884
  message: `Layout exceeds max components: ${layout.components.length} > 12`,
814
885
  code: 'TOO_MANY_COMPONENTS',
815
- })
886
+ });
816
887
  }
817
888
 
818
889
  // Validate each component
819
890
  for (const [index, component] of layout.components.entries()) {
820
- const result = validateComponent(component, options)
891
+ const result = validateComponent(component, options);
821
892
  if (!result.valid) {
822
893
  errors.push(
823
894
  ...(result.errors?.map((error) => ({
824
895
  ...error,
825
896
  path: `components[${index}].${error.path}`,
826
897
  })) || [])
827
- )
898
+ );
828
899
  }
829
900
  }
830
901
 
@@ -834,13 +905,13 @@ export function validateLayout(
834
905
  path: 'grid.columns',
835
906
  message: 'Grid must have 12 columns (Bootstrap-like)',
836
907
  code: 'INVALID_GRID_COLUMNS',
837
- })
908
+ });
838
909
  }
839
910
 
840
911
  return {
841
912
  valid: errors.length === 0,
842
913
  errors: errors.length > 0 ? errors : undefined,
843
- }
914
+ };
844
915
  }
845
916
 
846
917
  /**
@@ -853,16 +924,16 @@ export function validateFieldValue(
853
924
  // Required check
854
925
  if (field.required) {
855
926
  if (value === undefined || value === null || value === '') {
856
- return { valid: false, error: `${field.label || field.name} is required` }
927
+ return { valid: false, error: `${field.label || field.name} is required` };
857
928
  }
858
929
  if (field.type === 'checkbox' && value !== true) {
859
- return { valid: false, error: `${field.label || field.name} must be checked` }
930
+ return { valid: false, error: `${field.label || field.name} must be checked` };
860
931
  }
861
932
  }
862
933
 
863
934
  // Skip further validation if value is empty and not required
864
935
  if (value === undefined || value === null || value === '') {
865
- return { valid: true }
936
+ return { valid: true };
866
937
  }
867
938
 
868
939
  // Type-specific validation
@@ -871,68 +942,71 @@ export function validateFieldValue(
871
942
  case 'textarea':
872
943
  case 'password':
873
944
  if (field.minLength && String(value).length < field.minLength) {
874
- return { valid: false, error: `Minimum ${field.minLength} characters required` }
945
+ return { valid: false, error: `Minimum ${field.minLength} characters required` };
875
946
  }
876
947
  if (field.maxLength && String(value).length > field.maxLength) {
877
- return { valid: false, error: `Maximum ${field.maxLength} characters allowed` }
948
+ return { valid: false, error: `Maximum ${field.maxLength} characters allowed` };
878
949
  }
879
950
  if (field.pattern && !new RegExp(field.pattern).test(String(value))) {
880
- return { valid: false, error: 'Invalid format' }
951
+ return { valid: false, error: 'Invalid format' };
881
952
  }
882
- break
953
+ break;
883
954
 
884
955
  case 'email':
885
956
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))) {
886
- return { valid: false, error: 'Invalid email address' }
957
+ return { valid: false, error: 'Invalid email address' };
887
958
  }
888
- break
959
+ break;
889
960
 
890
961
  case 'number': {
891
- const numValue = Number(value)
962
+ const numValue = Number(value);
892
963
  if (isNaN(numValue)) {
893
- return { valid: false, error: 'Must be a valid number' }
964
+ return { valid: false, error: 'Must be a valid number' };
894
965
  }
895
966
  if (field.min !== undefined && numValue < field.min) {
896
- return { valid: false, error: `Minimum value is ${field.min}` }
967
+ return { valid: false, error: `Minimum value is ${field.min}` };
897
968
  }
898
969
  if (field.max !== undefined && numValue > field.max) {
899
- return { valid: false, error: `Maximum value is ${field.max}` }
970
+ return { valid: false, error: `Maximum value is ${field.max}` };
900
971
  }
901
- break
972
+ break;
902
973
  }
903
974
 
904
975
  case 'date':
905
976
  if (field.minDate && value < field.minDate) {
906
- return { valid: false, error: `Date must be after ${field.minDate}` }
977
+ return { valid: false, error: `Date must be after ${field.minDate}` };
907
978
  }
908
979
  if (field.maxDate && value > field.maxDate) {
909
- return { valid: false, error: `Date must be before ${field.maxDate}` }
980
+ return { valid: false, error: `Date must be before ${field.maxDate}` };
910
981
  }
911
- break
982
+ break;
912
983
 
913
984
  case 'select':
914
985
  case 'radio':
915
986
  // Validate that value is one of the options
916
987
  if (field.options && field.options.length > 0) {
917
- const validValues = field.options.map((opt) => opt.value)
988
+ const validValues = field.options.map((opt) => opt.value);
918
989
  if (!validValues.includes(String(value))) {
919
- return { valid: false, error: 'Please select a valid option' }
990
+ return { valid: false, error: 'Please select a valid option' };
920
991
  }
921
992
  }
922
- break
993
+ break;
923
994
  }
924
995
 
925
996
  // valueFormat validation (v4.3.0) — runs after type-specific checks
926
997
  if (field.valueFormat && value !== undefined && value !== null && value !== '') {
927
- const vals = Array.isArray(value) ? value : [String(value)]
998
+ const vals = Array.isArray(value) ? value : [String(value)];
928
999
  for (const v of vals) {
929
1000
  if (!new RegExp(field.valueFormat).test(v)) {
930
- return { valid: false, error: field.valueFormatHint || `Invalid format (expected: ${field.valueFormat})` }
1001
+ return {
1002
+ valid: false,
1003
+ error: field.valueFormatHint || `Invalid format (expected: ${field.valueFormat})`,
1004
+ };
931
1005
  }
932
1006
  }
933
1007
  }
934
1008
 
935
- return { valid: true }
1009
+ return { valid: true };
936
1010
  }
937
1011
 
938
1012
  /**
@@ -942,17 +1016,17 @@ export function validateFormData(
942
1016
  data: Record<string, any>,
943
1017
  fields: FormFieldParams[]
944
1018
  ): { valid: boolean; errors: Record<string, string> } {
945
- const errors: Record<string, string> = {}
1019
+ const errors: Record<string, string> = {};
946
1020
 
947
1021
  for (const field of fields) {
948
- const result = validateFieldValue(data[field.name], field)
1022
+ const result = validateFieldValue(data[field.name], field);
949
1023
  if (!result.valid && result.error) {
950
- errors[field.name] = result.error
1024
+ errors[field.name] = result.error;
951
1025
  }
952
1026
  }
953
1027
 
954
1028
  return {
955
1029
  valid: Object.keys(errors).length === 0,
956
1030
  errors,
957
- }
1031
+ };
958
1032
  }