Skip to main content

How Validation Works

Every component validates its options using a Zod schema with .strict() mode:
// Inside button component
const validation = validateOptions(options, {
  schema: ButtonOptionsSchema,
  componentName: 'button',
});

if (!validation.success) {
  return validation.error; // Renders error box HTML
}

Invalid Option Handling

When you pass invalid options:
// Invalid variant value
const html = button('Click', { variant: 'invalid' as any });
Instead of throwing an error, the component returns an error box:
<div class="validation-error bg-red-50 border border-red-200 rounded-lg p-4"
     data-component="button"
     data-param="variant">
  <div class="flex items-center gap-2 text-red-700">
    <svg><!-- Warning icon --></svg>
    <span class="font-medium">Validation Error</span>
  </div>
  <p class="text-red-600 text-sm mt-2">
    Invalid value for "variant" in button component
  </p>
</div>

Strict Mode

All component schemas use .strict() which rejects unknown properties:
// Unknown property rejected
const html = button('Click', { unknownProp: true } as any);
// Returns error box: "Unrecognized key 'unknownProp'"
This catches typos and API misuse early.

Schema Examples

Button Schema

const ButtonOptionsSchema = z.object({
  variant: z.enum(['primary', 'secondary', 'outline', 'ghost', 'danger', 'success', 'link']).optional(),
  size: z.enum(['xs', 'sm', 'md', 'lg', 'xl']).optional(),
  type: z.enum(['button', 'submit', 'reset']).optional(),
  disabled: z.boolean().optional(),
  loading: z.boolean().optional(),
  fullWidth: z.boolean().optional(),
  iconBefore: z.string().optional(),
  iconAfter: z.string().optional(),
  iconOnly: z.boolean().optional(),
  className: z.string().optional(),
  id: z.string().optional(),
  name: z.string().optional(),
  value: z.string().optional(),
  href: z.string().optional(),
  target: z.string().optional(),
  htmx: HtmxSchema.optional(),
  data: z.record(z.string()).optional(),
  ariaLabel: z.string().optional(),
}).strict();

HTMX Schema

const HtmxSchema = z.object({
  get: z.string().optional(),
  post: z.string().optional(),
  put: z.string().optional(),
  delete: z.string().optional(),
  target: z.string().optional(),
  swap: z.string().optional(),
  trigger: z.string().optional(),
  confirm: z.string().optional(),
  indicator: z.string().optional(),
}).strict();

Using validateOptions

Create validated custom components:
import { validateOptions, escapeHtml } from '@frontmcp/ui';
import { z } from 'zod';

// Define schema
const MyComponentSchema = z.object({
  variant: z.enum(['light', 'dark']).optional(),
  size: z.enum(['sm', 'md', 'lg']).optional(),
}).strict();

type MyComponentOptions = z.infer<typeof MyComponentSchema>;

// Create component
export function myComponent(content: string, options: MyComponentOptions = {}): string {
  // Validate options
  const validation = validateOptions(options, {
    schema: MyComponentSchema,
    componentName: 'myComponent',
  });

  if (!validation.success) {
    return validation.error; // Return error box
  }

  const { variant = 'light', size = 'md' } = validation.data;

  // Build HTML with validated options
  return `<div class="my-component my-component--${variant} my-component--${size}">
    ${escapeHtml(content)}
  </div>`;
}

Validation Error Box

The error box is styled consistently:
function renderErrorBox(componentName: string, errors: string[]): string {
  const errorList = errors
    .map(err => `<li>${escapeHtml(err)}</li>`)
    .join('');

  return `
    <div class="validation-error bg-red-50 border border-red-200 rounded-lg p-4"
         data-component="${escapeHtml(componentName)}">
      <div class="flex items-center gap-2 text-red-700">
        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
        </svg>
        <span class="font-medium">Validation Error</span>
      </div>
      <ul class="text-red-600 text-sm mt-2 list-disc list-inside">
        ${errorList}
      </ul>
    </div>
  `;
}

Testing Validation

Always test invalid inputs in your component tests:
describe('button validation', () => {
  it('should return error box for invalid variant', () => {
    const html = button('Test', { variant: 'invalid' as any });
    expect(html).toContain('validation-error');
    expect(html).toContain('data-component="button"');
  });

  it('should return error box for unknown properties', () => {
    const html = button('Test', { unknownProp: true } as any);
    expect(html).toContain('validation-error');
  });

  it('should accept valid options', () => {
    const html = button('Test', { variant: 'primary', size: 'lg' });
    expect(html).not.toContain('validation-error');
    expect(html).toContain('button');
  });
});

Common Validation Patterns

Required Fields

const schema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email format'),
});

Constrained Values

const schema = z.object({
  count: z.number().min(0).max(100),
  percentage: z.number().min(0).max(1),
  items: z.array(z.string()).max(10),
});

Conditional Validation

const schema = z.object({
  type: z.enum(['text', 'number']),
  value: z.union([z.string(), z.number()]),
}).refine(
  data => (data.type === 'number' ? typeof data.value === 'number' : true),
  { message: 'Value must be a number when type is "number"' }
);

Transform Values

const schema = z.object({
  size: z.enum(['sm', 'md', 'lg']).transform(s => ({
    sm: 'text-sm',
    md: 'text-base',
    lg: 'text-lg',
  }[s])),
});

Security Considerations

Validation helps with security but isn’t a complete solution:
  1. Still escape output - Validation doesn’t sanitize HTML
  2. Validate user input - Schema validation is for component options, not user data
  3. Don’t expose internals - Error messages shouldn’t reveal sensitive details
// ✅ Good - validates options AND escapes content
function card(content: string, options: CardOptions = {}): string {
  const validation = validateOptions(options, { schema: CardOptionsSchema, componentName: 'card' });
  if (!validation.success) return validation.error;

  return `<div class="card">${escapeHtml(content)}</div>`;
}

// ❌ Bad - validates but doesn't escape
function card(content: string, options: CardOptions = {}): string {
  const validation = validateOptions(options, { schema: CardOptionsSchema, componentName: 'card' });
  if (!validation.success) return validation.error;

  return `<div class="card">${content}</div>`; // XSS vulnerability!
}

Best Practices

  1. Use .strict() on schemas - Catches typos and unknown properties
  2. Provide good error messages - Help developers fix issues quickly
  3. Test validation - Ensure error handling works correctly
  4. Keep schemas close to components - Colocate in .schema.ts files
  5. Export types - Let consumers use TypeScript inference