@stackbit/sdk
Version:
526 lines (496 loc) • 19.5 kB
text/typescript
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);
}