UNPKG

@stackbit/sdk

Version:
526 lines (496 loc) 19.5 kB
import Joi, { CustomHelpers, ErrorReport } from 'joi'; import _ from 'lodash'; import { append } from '@stackbit/utils'; import { STYLE_PROPS_VALUES } from '../config/config-consts'; import { isDataModel, isPageModel } from '../utils'; import { Config, Field, FieldEnumOptionObject, FieldEnumOptionPalette, FieldEnumOptionThumbnails, FieldEnumOptionValue, FieldEnumProps, FieldListItems, FieldListProps, FieldModelProps, FieldNumberProps, FieldObjectProps, FieldReferenceProps, FieldStyleProps, Model, StyleProps } from '../config/config-types'; import { FieldCrossReferenceProps } from '@stackbit/types'; type FieldPath = (string | number)[]; type ModelSchemaMap = Record<string, Joi.ObjectSchema>; const metadataSchema = Joi.object({ modelName: Joi.string().allow(null), filePath: Joi.string(), error: Joi.string() }); export function joiSchemasForModels(config: Config) { const modelSchemas = _.reduce( config.models, (modelSchemas: ModelSchemaMap, model: Model) => { let joiSchema: Joi.ObjectSchema; if (model.__metadata?.invalid) { // if root model is invalid, replace the label with "file" otherwise joi outputs "value" which is not descriptive let objectLabel = '{{#label}}'; if (isDataModel(model) || isPageModel(model)) { objectLabel = 'file'; } joiSchema = Joi.object() .forbidden() .messages({ 'any.unknown': `${objectLabel} cannot be validated, the model "${model.name}" is invalid. Fix the model to validate the content.` }); } else { joiSchema = joiSchemaForModel(model, config); } modelSchemas[model.name] = joiSchema.id(`${model.name}_model_schema`); return modelSchemas; }, {} ); // Allow linking between recursive schemas by calling shared() on every schema with every other schema // https://joi.dev/api/?v=17.4.0#anysharedschema // Example: given three schemas: pageSchema, sectionSchema, authorSchema // pageSchema.shared(sectionSchema).shared(authorSchema) // sectionSchema.shared(pageSchema).shared(authorSchema) // authorSchema.shared(pageSchema).shared(sectionSchema) // Future optimization - no need to link between all schemas, but only these that have internal links return _.reduce( modelSchemas, (accum: ModelSchemaMap, modelSchema: Joi.ObjectSchema, modelName: string) => { const otherModelSchemas = _.omit(modelSchemas, modelName); accum[modelName] = _.reduce( otherModelSchemas, (modelSchema: Joi.ObjectSchema, otherModelSchema: Joi.ObjectSchema) => { return modelSchema.shared(otherModelSchema); }, modelSchema ); return accum; }, {} ); } export function joiSchemaForModel(model: Model, config: Config) { if (isDataModel(model) && model.isList) { return Joi.object({ items: Joi.array().items(joiSchemaForField(model.items, config, [model.name, 'items'])) }); } else { return joiSchemaForModelFields(model.fields, config, [model.name]); } } function joiSchemaForModelFields(fields: Field[] | undefined, config: Config, fieldPath: FieldPath) { return Joi.object( _.reduce( fields, (schema: Record<string, Joi.Schema>, field) => { const childFieldPath = fieldPath.concat(`[name='${field.name}']`); schema[field.name] = joiSchemaForField(field, config, childFieldPath); return schema; }, {} ) ); } function assertUnreachable(field: never): never { throw new Error('Unhandled field.type case'); } function joiSchemaForField(field: Field | FieldListItems, config: Config, fieldPath: FieldPath) { let fieldSchema; switch (field.type) { case 'string': case 'url': case 'slug': case 'text': case 'markdown': case 'html': case 'file': case 'color': fieldSchema = Joi.string().allow('', null); break; case 'image': fieldSchema = Joi.alternatives([Joi.string().allow('', null), Joi.object()]); break; case 'boolean': fieldSchema = Joi.boolean(); break; case 'date': case 'datetime': fieldSchema = Joi.date(); break; case 'enum': fieldSchema = enumFieldValueSchema(field); break; case 'number': fieldSchema = numberFieldValueSchema(field); break; case 'object': fieldSchema = objectFieldValueSchema(field, config, fieldPath); break; case 'model': fieldSchema = modelFieldValueSchema(field, config); break; case 'reference': fieldSchema = referenceFieldValueSchema(field); break; case 'cross-reference': fieldSchema = crossReferenceFieldValueSchema(field); break; case 'style': fieldSchema = styleFieldValueSchema(field); break; case 'list': fieldSchema = listFieldValueSchema(field, config, fieldPath); break; case 'json': case 'richText': return Joi.any().forbidden(); default: assertUnreachable(field); } if ('const' in field && typeof field.const !== 'function') { fieldSchema = fieldSchema.valid(field.const).invalid(null, '').required(); } else if ('required' in field && field.required === true) { fieldSchema = fieldSchema.required(); } return fieldSchema; } function enumFieldValueSchema(field: FieldEnumProps): Joi.Schema { if (field.options) { const values = field.options.map((option: FieldEnumOptionValue | FieldEnumOptionObject | FieldEnumOptionThumbnails | FieldEnumOptionPalette) => { return typeof option === 'object' ? option.value : option; }); return Joi.valid(...values); } return Joi.any().forbidden(); } function numberFieldValueSchema(field: FieldNumberProps): Joi.Schema { let result = Joi.number(); if (field.subtype !== 'float') { result = result.integer(); } if (field.min) { result = result.min(field.min); } if (field.max) { result = result.max(field.max); } // TODO: fix step validation, multiple of non 0 minimum is wrong // if (field.step) { // result = result.multiple((field.min || 0) + field.step); // } return result; } function objectFieldValueSchema(field: FieldObjectProps, config: Config, fieldPath: FieldPath): Joi.Schema { const childFieldPath = fieldPath.concat('fields'); return joiSchemaForModelFields(field.fields, config, childFieldPath); } function modelFieldValueSchema(field: FieldModelProps, config: Config): Joi.Schema { if (field.models.length === 0) { return Joi.any().forbidden(); } const objectTypeKey = config.objectTypeKey || 'type'; const typeSchema = Joi.string().valid(...field.models); if (field.models.length === 1 && field.models[0]) { const modelName = field.models[0]; return Joi.link() .ref(`#${modelName}_model_schema`) .concat( Joi.object({ __metadata: metadataSchema, [objectTypeKey]: typeSchema }) ); } else { // if there is more than one model in models, then 'type' field is // required to identify the object return Joi.alternatives() .conditional(`.${objectTypeKey}`, { switch: _.map(field.models, (modelName) => { return { is: modelName, then: Joi.link() .ref(`#${modelName}_model_schema`) .concat( Joi.object({ __metadata: metadataSchema, [objectTypeKey]: Joi.string() }) ) }; }) }) .prefs({ messages: { 'alternatives.any': `{{#label}}.${objectTypeKey} is required and must be one of [${field.models.join(', ')}].` }, errors: { wrap: { label: false } } }); } } function referenceFieldValueSchema(field: FieldReferenceProps): Joi.Schema { // TODO: validate reference by looking if referenced filePath actually exists // and the stored object has the correct type return Joi.string(); } function crossReferenceFieldValueSchema(field: FieldCrossReferenceProps): Joi.Schema { return Joi.alternatives().try( Joi.string(), Joi.object({ srcType: Joi.string(), srcProjectId: Joi.string(), srcDocumentId: Joi.string() }) ); } function listFieldValueSchema(field: FieldListProps, config: Config, fieldPath: FieldPath): Joi.Schema { if (field.items) { const childFieldPath = fieldPath.concat('items'); const itemsSchema = joiSchemaForField(field.items, config, childFieldPath); return Joi.array().items(itemsSchema); } return Joi.array().items(Joi.string()); } function styleFieldValueSchema(field: FieldStyleProps): Joi.Schema { const styleFieldSchema = _.mapValues(field.styles, (fieldStyles) => { const styleProps = _.keys(fieldStyles) as StyleProps[]; const objectSchema = _.reduce( styleProps, (schema: Partial<Record<StyleProps, Joi.Schema>>, styleProp) => { const createSchema = StylePropContentSchemas[styleProp]; if (!createSchema) { return schema; } const styleConfig = fieldStyles[styleProp]; const valueSchema = createSchema(styleConfig); if (!valueSchema) { return schema; } schema[styleProp] = valueSchema; return schema; }, {} ); return Joi.object(objectSchema); }); return Joi.object(styleFieldSchema); } const StylePropContentSchemas: Record<StyleProps, (styleConfig: any) => Joi.Schema | null> = { objectFit: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.objectFit), objectPosition: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.nineRegions), flexDirection: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.flexDirection), justifyContent: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.justifyContent), justifyItems: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.justifyItems), justifySelf: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.justifySelf), alignContent: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.alignContent), alignItems: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.alignItems), alignSelf: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.alignSelf), padding: stylePropSizeSchema, margin: stylePropSizeSchema, width: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.width), height: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.height), fontFamily: stylePropObjectValueSchema, fontSize: stylePropFontSizeSchema, fontStyle: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.fontStyle), fontWeight: stylePropFontWeightSchema, textAlign: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.textAlign), textColor: stylePropObjectValueSchema, textDecoration: stylePropTextDecorationSchema, backgroundColor: stylePropObjectValueSchema, backgroundPosition: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.nineRegions), backgroundSize: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.backgroundSize), borderRadius: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.borderRadius), borderWidth: stylePropBorderWidthSchema, borderColor: stylePropObjectValueSchema, borderStyle: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.borderStyle), boxShadow: stylePropSchemaWithValidValues(STYLE_PROPS_VALUES.boxShadow), opacity: stylePropOpacitySchema }; function stylePropSchemaWithValidValues(validValues: any[]) { return (styleConfig: any) => { if (styleConfig === '*') { return Joi.string().valid(...validValues); } if (Array.isArray(styleConfig)) { return Joi.string().valid(...styleConfig); } return null; }; } const textDecorationWrongValue = 'textDecoration.wrong.value'; function stylePropTextDecorationSchema(styleConfig: any) { const values = styleConfig === '*' ? STYLE_PROPS_VALUES.textDecoration : styleConfig; return Joi.string() .custom((value: string, { error, errorsArray }: CustomHelpers & { errorsArray?: () => ErrorReport[] }) => { const parts = value.split?.(' '); const errors = errorsArray!(); parts.forEach((part) => { if (!values.includes(part)) { errors.push(error(textDecorationWrongValue)); } }); return errors && errors.length ? errors : value; }) .prefs({ messages: { [textDecorationWrongValue]: `{{#label}} must be a space separated string values from [${values.join(', ')}]` } }); } function stylePropSizeSchema(styleConfig: any) { if (styleConfig === '*') { return Joi.object({ top: Joi.number(), bottom: Joi.number(), left: Joi.number(), right: Joi.number() }); } styleConfig = _.castArray(styleConfig); if (_.some(styleConfig, (style) => _.startsWith(style, 'tw'))) { return Joi.array().items(Joi.string()); } const dirSchemas = _.reduce( styleConfig, (dirSchemas: any, pattern: any) => { if (typeof pattern !== 'string') { return dirSchemas; } const directionMatch = pattern.match(/^[xylrtb]/); const directions = []; if (!directionMatch) { directions.push('top', 'bottom', 'left', 'right'); } else { const dirMap = { x: ['left', 'right'], y: ['top', 'bottom'], l: ['left'], r: ['right'], t: ['top'], b: ['bottom'] }; const dirMatch = directionMatch[0] as 'x' | 'y' | 'l' | 'r' | 't' | 'b'; directions.push(...dirMap[dirMatch]); pattern = pattern.substring(1); } let valueSchema = Joi.number(); if (pattern) { const parts = pattern.split(':').map((value: string) => Number(value)); if (_.some(parts, _.isNaN)) { return dirSchemas; } if (parts.length === 1) { valueSchema = valueSchema.valid(parts[0]); } else { valueSchema = valueSchema.min(parts[0]).max(parts[1]); if (parts.length === 3) { valueSchema = valueSchema.multiple(parts[2]); } } } _.forEach(directions, (direction) => { append(dirSchemas, direction, valueSchema); }); return dirSchemas; }, {} ); const objectSchema = _.mapValues(dirSchemas, (schema) => Joi.alternatives(...schema)); return Joi.object(objectSchema); } function stylePropBorderWidthSchema(styleConfig: any) { if (styleConfig === '*') { return Joi.number(); } styleConfig = _.castArray(styleConfig); const alternativeSchemas = _.reduce( styleConfig, (alternativeSchemas: Joi.Schema[], value) => { let valueSchema = Joi.number(); if (_.isNumber(value)) { return alternativeSchemas.concat(valueSchema.valid(value)); } if (!_.isString(value)) { return alternativeSchemas; } if (_.isEmpty(value)) { return alternativeSchemas; } const parts = value.split(':').map((value) => Number(value)); if (_.some(parts, _.isNaN)) { return alternativeSchemas; } if (parts.length === 1) { return alternativeSchemas.concat(valueSchema.valid(value)); } const start = parts[0]!; const end = parts[1]!; valueSchema = valueSchema.min(start).max(end); if (parts.length === 3) { valueSchema = valueSchema.multiple(parts[2]!); } return alternativeSchemas.concat(valueSchema); }, [] ); return Joi.alternatives(...alternativeSchemas); } function stylePropObjectValueSchema(styleConfig: any) { return Joi.valid(..._.map(styleConfig, (object) => _.get(object, 'value'))); } function stylePropFontSizeSchema(styleConfig: any) { if (styleConfig === '*') { return Joi.number().integer().min(0).max(100).multiple(1); } return Joi.number().valid(...parseRange(styleConfig)); } function stylePropFontWeightSchema(styleConfig: any) { const values = STYLE_PROPS_VALUES.fontWeight.map((valueStr) => Number(valueStr)); if (styleConfig === '*') { return Joi.number().valid(...values); } return Joi.number().valid(...parseRange(styleConfig, { defaultStep: 100 })); } function stylePropOpacitySchema(styleConfig: any) { if (styleConfig === '*') { return Joi.number().integer().min(0).max(100).multiple(5); } return Joi.number().valid(...parseRange(styleConfig, { userDefinedStep: false, defaultStep: 5 })); } function parseRange(styleConfig: any, { userDefinedStep = true, defaultStep = 1 } = {}) { const values = _.reduce( _.castArray(styleConfig), (validValues, value) => { if (_.isNumber(value)) { return validValues.add(value); } if (!_.isString(value)) { return validValues; } if (_.isEmpty(value)) { return validValues; } const parts = value.split(':').map((value) => Number(value)); if (_.some(parts, _.isNaN)) { return validValues; } if (parts.length === 1) { return validValues.add(parts[0]!); } const [start, end, userStep = defaultStep] = parts; const step = userDefinedStep ? userStep : defaultStep; for (let i = start!; i <= end!; i += step) { validValues.add(i); } return validValues; }, new Set<number>() ); return Array.from(values); }