498 lines
12 KiB
TypeScript
498 lines
12 KiB
TypeScript
import {
|
|
ThemeAnimationDefinition,
|
|
ThemeDocument,
|
|
ThemeElementStyleProperty,
|
|
ThemeElementStyles,
|
|
ThemeValidationResult
|
|
} from '../models/theme.model';
|
|
import { createDefaultThemeDocument } from './theme-defaults.logic';
|
|
import {
|
|
THEME_LAYOUT_CONTAINERS,
|
|
THEME_REGISTRY,
|
|
getLayoutEditableThemeKeys
|
|
} from './theme-registry.logic';
|
|
|
|
const TOP_LEVEL_KEYS = [
|
|
'meta',
|
|
'tokens',
|
|
'layout',
|
|
'elements',
|
|
'animations'
|
|
] as const;
|
|
const META_KEYS = [
|
|
'name',
|
|
'version',
|
|
'description'
|
|
] as const;
|
|
const TOKEN_GROUP_KEYS = [
|
|
'colors',
|
|
'spacing',
|
|
'radii',
|
|
'effects'
|
|
] as const;
|
|
const LAYOUT_ENTRY_KEYS = ['container', 'grid'] as const;
|
|
const GRID_KEYS = [
|
|
'x',
|
|
'y',
|
|
'w',
|
|
'h'
|
|
] as const;
|
|
const ANIMATION_KEYS = [
|
|
'duration',
|
|
'easing',
|
|
'delay',
|
|
'iterationCount',
|
|
'fillMode',
|
|
'direction',
|
|
'keyframes'
|
|
] as const;
|
|
const POSITION_VALUES = [
|
|
'static',
|
|
'relative',
|
|
'absolute',
|
|
'sticky'
|
|
] as const;
|
|
const FILL_MODE_VALUES = [
|
|
'none',
|
|
'forwards',
|
|
'backwards',
|
|
'both'
|
|
] as const;
|
|
const DIRECTION_VALUES = [
|
|
'normal',
|
|
'reverse',
|
|
'alternate',
|
|
'alternate-reverse'
|
|
] as const;
|
|
const SAFE_LINK_PROTOCOLS = ['http:', 'https:'] as const;
|
|
const SAFE_CLASS_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
const KEYFRAME_STEP_PATTERN = /^(from|to|(?:\d|[1-9]\d|100)%)$/;
|
|
const ELEMENT_STYLE_KEYS: readonly ThemeElementStyleProperty[] = [
|
|
'width',
|
|
'height',
|
|
'minWidth',
|
|
'minHeight',
|
|
'maxWidth',
|
|
'maxHeight',
|
|
'position',
|
|
'top',
|
|
'right',
|
|
'bottom',
|
|
'left',
|
|
'opacity',
|
|
'padding',
|
|
'margin',
|
|
'border',
|
|
'borderRadius',
|
|
'backgroundColor',
|
|
'color',
|
|
'backgroundImage',
|
|
'backgroundSize',
|
|
'backgroundPosition',
|
|
'backgroundRepeat',
|
|
'gradient',
|
|
'boxShadow',
|
|
'backdropFilter',
|
|
'icon',
|
|
'textOverride',
|
|
'link',
|
|
'animationClass'
|
|
];
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function validateUnknownKeys(
|
|
value: Record<string, unknown>,
|
|
allowedKeys: readonly string[],
|
|
path: string,
|
|
errors: string[]
|
|
): void {
|
|
for (const key of Object.keys(value)) {
|
|
if (!allowedKeys.includes(key)) {
|
|
errors.push(`${path}.${key} is not part of the supported theme schema.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateString(value: unknown, path: string, errors: string[], allowEmpty = false): value is string {
|
|
if (typeof value !== 'string') {
|
|
errors.push(`${path} must be a string.`);
|
|
return false;
|
|
}
|
|
|
|
if (!allowEmpty && value.trim().length === 0) {
|
|
errors.push(`${path} cannot be empty.`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function validateEnum<T extends readonly string[]>(
|
|
value: unknown,
|
|
allowedValues: T,
|
|
path: string,
|
|
errors: string[]
|
|
): value is T[number] {
|
|
if (typeof value !== 'string' || !allowedValues.includes(value)) {
|
|
errors.push(`${path} must be one of: ${allowedValues.join(', ')}.`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function validateInteger(
|
|
value: unknown,
|
|
path: string,
|
|
errors: string[],
|
|
minimum: number,
|
|
allowZero: boolean
|
|
): value is number {
|
|
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
errors.push(`${path} must be an integer.`);
|
|
return false;
|
|
}
|
|
|
|
if ((allowZero && value < minimum) || (!allowZero && value <= minimum)) {
|
|
errors.push(`${path} must be ${allowZero ? 'greater than or equal to' : 'greater than'} ${minimum}.`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function validateNumberRange(
|
|
value: unknown,
|
|
path: string,
|
|
errors: string[],
|
|
minimum: number,
|
|
maximum: number
|
|
): value is number {
|
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
errors.push(`${path} must be a number.`);
|
|
return false;
|
|
}
|
|
|
|
if (value < minimum || value > maximum) {
|
|
errors.push(`${path} must be between ${minimum} and ${maximum}.`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function validateStringRecord(value: unknown, path: string, errors: string[]): value is Record<string, string> {
|
|
if (!isPlainObject(value)) {
|
|
errors.push(`${path} must be an object containing string values.`);
|
|
return false;
|
|
}
|
|
|
|
for (const [key, recordValue] of Object.entries(value)) {
|
|
validateString(recordValue, `${path}.${key}`, errors);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function validateElementStyles(value: unknown, path: string, errors: string[]): value is ThemeElementStyles {
|
|
if (!isPlainObject(value)) {
|
|
errors.push(`${path} must be an object.`);
|
|
return false;
|
|
}
|
|
|
|
validateUnknownKeys(value, ELEMENT_STYLE_KEYS, path, errors);
|
|
|
|
for (const [key, fieldValue] of Object.entries(value)) {
|
|
if (key === 'opacity') {
|
|
validateNumberRange(fieldValue, `${path}.${key}`, errors, 0, 1);
|
|
continue;
|
|
}
|
|
|
|
if (key === 'position') {
|
|
validateEnum(fieldValue, POSITION_VALUES, `${path}.${key}`, errors);
|
|
continue;
|
|
}
|
|
|
|
if (key === 'link') {
|
|
if (validateString(fieldValue, `${path}.${key}`, errors)) {
|
|
try {
|
|
const parsedUrl = new URL(fieldValue);
|
|
|
|
if (!SAFE_LINK_PROTOCOLS.includes(parsedUrl.protocol as (typeof SAFE_LINK_PROTOCOLS)[number])) {
|
|
errors.push(`${path}.${key} must use http or https.`);
|
|
}
|
|
} catch {
|
|
errors.push(`${path}.${key} must be a valid absolute URL.`);
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (key === 'animationClass') {
|
|
if (validateString(fieldValue, `${path}.${key}`, errors) && !SAFE_CLASS_PATTERN.test(fieldValue)) {
|
|
errors.push(`${path}.${key} must be a safe CSS class token.`);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
validateString(fieldValue, `${path}.${key}`, errors, key === 'backgroundImage' || key === 'gradient');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function validateAnimationDefinition(value: unknown, path: string, errors: string[]): value is ThemeAnimationDefinition {
|
|
if (!isPlainObject(value)) {
|
|
errors.push(`${path} must be an object.`);
|
|
return false;
|
|
}
|
|
|
|
validateUnknownKeys(value, ANIMATION_KEYS, path, errors);
|
|
|
|
if (value['duration'] !== undefined) {
|
|
validateString(value['duration'], `${path}.duration`, errors);
|
|
}
|
|
|
|
if (value['easing'] !== undefined) {
|
|
validateString(value['easing'], `${path}.easing`, errors);
|
|
}
|
|
|
|
if (value['delay'] !== undefined) {
|
|
validateString(value['delay'], `${path}.delay`, errors);
|
|
}
|
|
|
|
if (value['iterationCount'] !== undefined) {
|
|
validateString(value['iterationCount'], `${path}.iterationCount`, errors);
|
|
}
|
|
|
|
if (value['fillMode'] !== undefined) {
|
|
validateEnum(value['fillMode'], FILL_MODE_VALUES, `${path}.fillMode`, errors);
|
|
}
|
|
|
|
if (value['direction'] !== undefined) {
|
|
validateEnum(value['direction'], DIRECTION_VALUES, `${path}.direction`, errors);
|
|
}
|
|
|
|
if (value['keyframes'] !== undefined) {
|
|
if (!isPlainObject(value['keyframes'])) {
|
|
errors.push(`${path}.keyframes must be an object.`);
|
|
} else {
|
|
for (const [step, declarations] of Object.entries(value['keyframes'])) {
|
|
if (!KEYFRAME_STEP_PATTERN.test(step)) {
|
|
errors.push(`${path}.keyframes.${step} is not a supported keyframe step.`);
|
|
continue;
|
|
}
|
|
|
|
if (!isPlainObject(declarations)) {
|
|
errors.push(`${path}.keyframes.${step} must be an object of CSS declarations.`);
|
|
continue;
|
|
}
|
|
|
|
for (const [cssProperty, cssValue] of Object.entries(declarations)) {
|
|
if (typeof cssValue !== 'string' && typeof cssValue !== 'number') {
|
|
errors.push(`${path}.keyframes.${step}.${cssProperty} must be a string or number.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function normaliseThemeDocument(input: Partial<ThemeDocument>): ThemeDocument {
|
|
const document = createDefaultThemeDocument();
|
|
|
|
document.meta = {
|
|
...document.meta,
|
|
...input.meta
|
|
};
|
|
|
|
document.tokens = {
|
|
colors: {
|
|
...document.tokens.colors,
|
|
...(input.tokens?.colors ?? {})
|
|
},
|
|
spacing: {
|
|
...document.tokens.spacing,
|
|
...(input.tokens?.spacing ?? {})
|
|
},
|
|
radii: {
|
|
...document.tokens.radii,
|
|
...(input.tokens?.radii ?? {})
|
|
},
|
|
effects: {
|
|
...document.tokens.effects,
|
|
...(input.tokens?.effects ?? {})
|
|
}
|
|
};
|
|
|
|
document.layout = {
|
|
...document.layout,
|
|
...(input.layout ?? {})
|
|
};
|
|
|
|
document.elements = {
|
|
...document.elements,
|
|
...(input.elements ?? {})
|
|
};
|
|
|
|
document.animations = {
|
|
...document.animations,
|
|
...(input.animations ?? {})
|
|
};
|
|
|
|
return document;
|
|
}
|
|
|
|
export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
|
const errors: string[] = [];
|
|
|
|
if (!isPlainObject(input)) {
|
|
return {
|
|
valid: false,
|
|
errors: ['Theme document must be a JSON object.'],
|
|
value: null
|
|
};
|
|
}
|
|
|
|
validateUnknownKeys(input, TOP_LEVEL_KEYS, 'theme', errors);
|
|
|
|
const meta = input['meta'];
|
|
|
|
if (!isPlainObject(meta)) {
|
|
errors.push('theme.meta must be an object.');
|
|
} else {
|
|
validateUnknownKeys(meta, META_KEYS, 'theme.meta', errors);
|
|
validateString(meta['name'], 'theme.meta.name', errors);
|
|
validateString(meta['version'], 'theme.meta.version', errors);
|
|
|
|
if (meta['description'] !== undefined) {
|
|
validateString(meta['description'], 'theme.meta.description', errors, true);
|
|
}
|
|
}
|
|
|
|
const tokens = input['tokens'];
|
|
|
|
if (tokens !== undefined) {
|
|
if (!isPlainObject(tokens)) {
|
|
errors.push('theme.tokens must be an object.');
|
|
} else {
|
|
validateUnknownKeys(tokens, TOKEN_GROUP_KEYS, 'theme.tokens', errors);
|
|
|
|
if (tokens['colors'] !== undefined) {
|
|
validateStringRecord(tokens['colors'], 'theme.tokens.colors', errors);
|
|
}
|
|
|
|
if (tokens['spacing'] !== undefined) {
|
|
validateStringRecord(tokens['spacing'], 'theme.tokens.spacing', errors);
|
|
}
|
|
|
|
if (tokens['radii'] !== undefined) {
|
|
validateStringRecord(tokens['radii'], 'theme.tokens.radii', errors);
|
|
}
|
|
|
|
if (tokens['effects'] !== undefined) {
|
|
validateStringRecord(tokens['effects'], 'theme.tokens.effects', errors);
|
|
}
|
|
}
|
|
}
|
|
|
|
const layout = input['layout'];
|
|
|
|
if (layout !== undefined) {
|
|
if (!isPlainObject(layout)) {
|
|
errors.push('theme.layout must be an object.');
|
|
} else {
|
|
const allowedLayoutKeys = getLayoutEditableThemeKeys();
|
|
|
|
validateUnknownKeys(layout, allowedLayoutKeys, 'theme.layout', errors);
|
|
|
|
for (const [key, value] of Object.entries(layout)) {
|
|
const basePath = `theme.layout.${key}`;
|
|
|
|
if (!isPlainObject(value)) {
|
|
errors.push(`${basePath} must be an object.`);
|
|
continue;
|
|
}
|
|
|
|
validateUnknownKeys(value, LAYOUT_ENTRY_KEYS, basePath, errors);
|
|
|
|
if (value['container'] !== undefined) {
|
|
validateEnum(
|
|
value['container'],
|
|
THEME_LAYOUT_CONTAINERS.map((container) => container.key) as unknown as readonly string[],
|
|
`${basePath}.container`,
|
|
errors
|
|
);
|
|
}
|
|
|
|
const grid = value['grid'];
|
|
|
|
if (!isPlainObject(grid)) {
|
|
errors.push(`${basePath}.grid must be an object.`);
|
|
continue;
|
|
}
|
|
|
|
validateUnknownKeys(grid, GRID_KEYS, `${basePath}.grid`, errors);
|
|
validateInteger(grid['x'], `${basePath}.grid.x`, errors, 0, true);
|
|
validateInteger(grid['y'], `${basePath}.grid.y`, errors, 0, true);
|
|
validateInteger(grid['w'], `${basePath}.grid.w`, errors, 0, false);
|
|
validateInteger(grid['h'], `${basePath}.grid.h`, errors, 0, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
const elements = input['elements'];
|
|
|
|
if (elements !== undefined) {
|
|
if (!isPlainObject(elements)) {
|
|
errors.push('theme.elements must be an object.');
|
|
} else {
|
|
const allowedElementKeys = THEME_REGISTRY.map((entry) => entry.key);
|
|
|
|
validateUnknownKeys(elements, allowedElementKeys, 'theme.elements', errors);
|
|
|
|
for (const [key, value] of Object.entries(elements)) {
|
|
validateElementStyles(value, `theme.elements.${key}`, errors);
|
|
}
|
|
}
|
|
}
|
|
|
|
const animations = input['animations'];
|
|
|
|
if (animations !== undefined) {
|
|
if (!isPlainObject(animations)) {
|
|
errors.push('theme.animations must be an object.');
|
|
} else {
|
|
for (const [key, value] of Object.entries(animations)) {
|
|
if (!SAFE_CLASS_PATTERN.test(key)) {
|
|
errors.push(`theme.animations.${key} must use a safe CSS class token.`);
|
|
continue;
|
|
}
|
|
|
|
validateAnimationDefinition(value, `theme.animations.${key}`, errors);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return {
|
|
valid: false,
|
|
errors,
|
|
value: null
|
|
};
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
errors: [],
|
|
value: normaliseThemeDocument(input as Partial<ThemeDocument>)
|
|
};
|
|
}
|