@productshiv/baapui
Version:
A truly cross-platform multi-design UI framework that works with React, Next.js, React Native, and any React-based framework.
340 lines (303 loc) • 9.74 kB
text/typescript
/**
* Theme validation utilities and schemas for compile-time and runtime validation
*/
import { Theme, ThemeColors, ThemeDesign, ThemeMode } from './types';
import { validateThemeAccessibility, generateAccessibilityReport } from '../utils/accessibility';
// Branded types for better type safety
export type ValidatedTheme = Theme & { __validated: true };
export type ColorHex = string & { __colorHex: true };
export type AccessibleColorPair = {
foreground: ColorHex;
background: ColorHex;
__accessible: true;
};
/**
* Theme validation errors
*/
export class ThemeValidationError extends Error {
constructor(
message: string,
public readonly field: string,
public readonly value: unknown
) {
super(`Theme validation failed for '${field}': ${message}`);
this.name = 'ThemeValidationError';
}
}
/**
* Validation rules for theme properties
*/
export const THEME_VALIDATION_RULES = {
colors: {
required: ['primary', 'secondary', 'background', 'surface', 'text', 'error'] as const,
optional: ['textSecondary', 'border', 'success', 'warning', 'info', 'lightShadow', 'darkShadow'] as const,
hexPattern: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
contrastRequirements: {
'text-background': 4.5,
'text-surface': 4.5,
'primary-background': 3.0,
'secondary-background': 3.0,
},
},
shadows: {
required: ['small', 'medium', 'large'] as const,
properties: ['shadowColor', 'shadowOffset', 'shadowOpacity', 'shadowRadius', 'elevation'] as const,
},
spacing: {
required: ['xs', 'sm', 'md', 'lg', 'xl'] as const,
min: 0,
},
typography: {
required: ['h1', 'h2', 'h3', 'body', 'button', 'caption'] as const,
properties: ['fontSize', 'fontWeight'] as const,
optional: ['lineHeight', 'letterSpacing', 'textTransform'] as const,
},
shape: {
required: ['borderRadius'] as const,
borderRadius: {
required: ['sm', 'md', 'lg', 'full'] as const,
min: 0,
},
},
} as const;
/**
* Validate hex color format
*/
export function isValidHexColor(color: string): color is ColorHex {
return THEME_VALIDATION_RULES.colors.hexPattern.test(color);
}
/**
* Validate theme colors structure and format
*/
export function validateThemeColors(colors: ThemeColors): void {
const { required, hexPattern } = THEME_VALIDATION_RULES.colors;
// Check required colors
for (const requiredColor of required) {
if (!(requiredColor in colors)) {
throw new ThemeValidationError(
`Missing required color '${requiredColor}'`,
'colors',
colors
);
}
}
// Validate hex format for all colors
Object.entries(colors).forEach(([colorName, colorValue]) => {
if (!hexPattern.test(colorValue)) {
throw new ThemeValidationError(
`Invalid hex color format '${colorValue}'`,
`colors.${colorName}`,
colorValue
);
}
});
}
/**
* Validate theme shadows structure
*/
export function validateThemeShadows(shadows: Record<string, string>): void {
const { required } = THEME_VALIDATION_RULES.shadows;
for (const requiredShadow of required) {
if (!(requiredShadow in shadows)) {
throw new ThemeValidationError(
`Missing required shadow '${requiredShadow}'`,
'shadows',
shadows
);
}
}
}
/**
* Validate theme spacing structure
*/
export function validateThemeSpacing(spacing: Record<string, number>): void {
const { required } = THEME_VALIDATION_RULES.spacing;
for (const requiredSpacing of required) {
if (!(requiredSpacing in spacing)) {
throw new ThemeValidationError(
`Missing required spacing '${requiredSpacing}'`,
'spacing',
spacing
);
}
if (typeof spacing[requiredSpacing] !== 'number' || spacing[requiredSpacing] < 0) {
throw new ThemeValidationError(
`Invalid spacing value '${spacing[requiredSpacing]}'. Must be a positive number.`,
`spacing.${requiredSpacing}`,
spacing[requiredSpacing]
);
}
}
}
/**
* Validate theme typography structure
*/
export function validateThemeTypography(typography: Record<string, unknown>): void {
const { required } = THEME_VALIDATION_RULES.typography;
for (const requiredTypo of required) {
if (!(requiredTypo in typography)) {
throw new ThemeValidationError(
`Missing required typography property '${requiredTypo}'`,
'typography',
typography
);
}
}
}
/**
* Validate theme shape structure
*/
export function validateThemeShape(shape: Record<string, unknown>): void {
const { required, borderRadius } = THEME_VALIDATION_RULES.shape;
for (const requiredProp of required) {
if (!(requiredProp in shape)) {
throw new ThemeValidationError(
`Missing required shape property '${requiredProp}'`,
'shape',
shape
);
}
}
// Validate borderRadius structure
if ('borderRadius' in shape) {
const borderRadiusObj = shape.borderRadius;
if (!borderRadiusObj || typeof borderRadiusObj !== 'object') {
throw new ThemeValidationError(
'borderRadius must be an object',
'shape.borderRadius',
borderRadiusObj
);
}
const borderRadiusRecord = borderRadiusObj as Record<string, unknown>;
for (const requiredSize of borderRadius.required) {
if (!(requiredSize in borderRadiusRecord)) {
throw new ThemeValidationError(
`Missing required borderRadius size '${requiredSize}'`,
'shape.borderRadius',
borderRadiusRecord
);
}
const value = borderRadiusRecord[requiredSize];
if (typeof value !== 'number' || value < borderRadius.min) {
throw new ThemeValidationError(
`Invalid borderRadius value '${value}'. Must be a non-negative number.`,
`shape.borderRadius.${requiredSize}`,
value
);
}
}
}
}
/**
* Validate accessibility requirements for theme colors
*/
export function validateThemeColorAccessibility(colors: ThemeColors): void {
const { contrastRequirements } = THEME_VALIDATION_RULES.colors;
const colorsRecord = colors as unknown as Record<string, string>;
Object.entries(contrastRequirements).forEach(([pair, minRatio]) => {
const [foreground, background] = pair.split('-');
if (colorsRecord[foreground] && colorsRecord[background]) {
const accessibilityResults = validateThemeAccessibility({
[foreground]: colorsRecord[foreground],
}, [colorsRecord[background]]);
const result = accessibilityResults[foreground]?.[0];
if (result && result.ratio < minRatio) {
throw new ThemeValidationError(
`Insufficient color contrast between ${foreground} and ${background}. ` +
`Got ${result.ratio}:1, required ${minRatio}:1`,
`colors.${pair}`,
{ foreground: colorsRecord[foreground], background: colorsRecord[background] }
);
}
}
});
}
/**
* Comprehensive theme validation
*/
export function validateTheme(theme: Theme): ValidatedTheme {
try {
// Validate structure
validateThemeColors(theme.colors);
validateThemeShadows(theme.shadows as unknown as Record<string, string>);
validateThemeSpacing(theme.spacing as unknown as Record<string, number>);
validateThemeTypography(theme.typography as unknown as Record<string, unknown>);
validateThemeShape(theme.shape as unknown as Record<string, unknown>);
// Validate accessibility (optional - can be disabled for development)
if (process.env.NODE_ENV !== 'development' || process.env.VALIDATE_ACCESSIBILITY === 'true') {
validateThemeColorAccessibility(theme.colors);
}
return theme as ValidatedTheme;
} catch (error) {
if (error instanceof ThemeValidationError) {
console.error('Theme Validation Error:', error.message);
throw error;
}
throw new ThemeValidationError(
'Unknown validation error',
'theme',
theme
);
}
}
/**
* Create a validated theme with runtime checks
*/
export function createValidatedTheme(
design: ThemeDesign,
mode: ThemeMode,
themeData: Omit<Theme, 'design' | 'mode'>
): ValidatedTheme {
const theme: Theme = {
design,
mode,
...themeData,
};
return validateTheme(theme);
}
/**
* Generate theme validation report
*/
export function generateThemeValidationReport(theme: Theme): string {
let report = `# Theme Validation Report\n\n`;
report += `**Design:** ${theme.design}\n`;
report += `**Mode:** ${theme.mode}\n\n`;
try {
validateTheme(theme);
report += '✅ **Theme validation passed!**\n\n';
} catch (error) {
if (error instanceof ThemeValidationError) {
report += `❌ **Validation failed:** ${error.message}\n\n`;
}
}
// Add accessibility report
report += generateAccessibilityReport(theme.colors as unknown as Record<string, string>);
return report;
}
/**
* Type guard for validated themes
*/
export function isValidatedTheme(theme: Theme): theme is ValidatedTheme {
try {
validateTheme(theme);
return true;
} catch {
return false;
}
}
/**
* Development helper to validate all themes in a theme collection
*/
export function validateThemeCollection(
themes: Record<string, Theme>
): Record<string, ValidatedTheme | ThemeValidationError> {
const results: Record<string, ValidatedTheme | ThemeValidationError> = {};
Object.entries(themes).forEach(([themeName, theme]) => {
try {
results[themeName] = validateTheme(theme);
} catch (error) {
results[themeName] = error as ThemeValidationError;
}
});
return results;
}