Files
Toju/toju-app/src/app/domains/theme/domain/logic/theme-validation.logic.ts
2026-04-11 15:01:39 +02:00

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>)
};
}