import _ from 'lodash'; import * as StackbitTypes from '@stackbit/types'; import { FieldCrossReferenceModel, Logger, ModelWithSource, CustomActionField, CustomActionObjectField, FieldValidationsFileTypesGroup, FieldValidationsRegExpPatterns } from '@stackbit/types'; import { Model, assignLabelFieldIfNeeded, isObjectField, isCrossReferenceField, isPageModel, mapModelFieldsRecursively, mapListItemsPropsOrSelfSpecificProps, validateConfig } from '@stackbit/sdk'; import { omitByNil, undefinedIfEmpty } from '@stackbit/utils'; export function normalizeModels({ models, logger }: { models: ModelWithSource[]; logger: Logger }): ModelWithSource[] { return models.map((model) => { model = { ...model }; if (!('name' in model)) { logger.warn('model does not have a name'); } if (!('type' in model)) { logger.warn(`model '${model['name']}' does not have a type, using 'object'`); _.set(model, 'type', 'object'); } // add model label if not set if (!('label' in model)) { model.label = _.startCase(model.name); } if (!('fields' in model) || !Array.isArray(model.fields)) { model.fields = []; } if (isPageModel(model)) { // set default urlPath if not set if (!model.urlPath) { model.urlPath = '/{slug}'; } } assignLabelFieldIfNeeded(model); // Ensure that actions always have types and labels if ((model.type === 'data' || model.type === 'page') && model.actions) { model.actions = model.actions.map((action) => ({ ...action, type: action.type ?? 'document', label: action.label ?? _.startCase(action.name) })) as (StackbitTypes.CustomActionDocument | StackbitTypes.CustomActionModel)[]; } else if (model.type === 'object' && model.actions) { model.actions = model.actions.map((action) => ({ ...action, type: action.type ?? 'object', label: action.label ?? _.startCase(action.name) })) as (StackbitTypes.CustomActionObjectModel | StackbitTypes.CustomActionModelObject)[]; } model = mapModelFieldsRecursively(model, (field) => { field = { ...field }; if (!('label' in field)) { field.label = _.startCase(field.name); } if (field.actions) { field.actions = field.actions.map((action) => ({ ...action, type: action.type ?? 'field', label: action.label ?? _.startCase(action.name) })) as (CustomActionField | CustomActionObjectField)[]; } mapListItemsPropsOrSelfSpecificProps(field, (listItemsPropsOrField) => { backwardCompatibleValidations(listItemsPropsOrField); extendRegExpValidationsFromPatterns(listItemsPropsOrField); extendFileTypeValidationsFromGroups(listItemsPropsOrField); if (isObjectField(listItemsPropsOrField)) { assignLabelFieldIfNeeded(listItemsPropsOrField); } else if (isCrossReferenceField(listItemsPropsOrField)) { listItemsPropsOrField.models = validateAndNormalizeCrossReferenceModels({ crossReferenceModels: listItemsPropsOrField.models, models, logger }); } return listItemsPropsOrField; }); return field; }); return model; }); } function backwardCompatibleValidations(listItemsPropsOrField: Exclude) { if (listItemsPropsOrField.type === 'number') { const validations = listItemsPropsOrField.validations; const min = validations?.min ?? listItemsPropsOrField.min; const max = validations?.max ?? listItemsPropsOrField.max; const step = validations?.step ?? listItemsPropsOrField.step; if (!_.isNil(min) || !_.isNil(max) || !_.isNil(step)) { if (!_.isNil(min)) { listItemsPropsOrField.min = min; } if (!_.isNil(max)) { listItemsPropsOrField.max = max; } if (!_.isNil(step)) { listItemsPropsOrField.step = step; } listItemsPropsOrField.validations = { ...listItemsPropsOrField.validations, ...undefinedIfEmpty(omitByNil({ min, max, step })) }; } } } const RegExpPatternMap: Record, { value: string; message: string }> = { email: { value: '^\\w[\\w.%+-]*@([\\w-]+\\.)+[\\w-]+$', message: 'The value must be an E-mail' }, url: { value: '^(ftp|http|https):\\/\\/(\\w+:{0,1}\\w*@)?(\\S+)(:[0-9]+)?(\\/|\\/([\\w#!:.?+=&%@!\\-/]))?$', message: 'The value must be a URL' }, 'date-eu': { value: '^(0?[1-9]|[12][0-9]|3[01])[- /.](0?[1-9]|1[012])[- /.](19|20)?\\d\\d$', message: 'The value must be a European Date' }, 'date-us': { value: '^(0?[1-9]|1[012])[- /.](0?[1-9]|[12][0-9]|3[01])[- /.](19|20)?\\d\\d$', message: 'The value must be a US Date' }, 'phone-us': { value: '^\\d[ -.]?\\(?\\d\\d\\d\\)?[ -.]?\\d\\d\\d[ -.]?\\d\\d\\d\\d$', message: 'The value must be a US Phone number' }, 'zip-code-us': { value: '^\\d{5}$|^\\d{5}-\\d{4}$', message: 'The value must be a US zip code' }, 'time-12h': { value: '^(0?[1-9]|1[012]):[0-5][0-9](:[0-5][0-9])?\\s*[aApP][mM]$', message: 'The value must be a 12h Time' }, 'time-24h': { value: '^(0?[0-9]|1[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$', message: 'The value must be a 24h Time' } }; const RegExpNotPatternMap: Record, { value: string; message: string }> = { lowercase: { value: '[A-Z]', message: 'All characters must be uppercase.' }, uppercase: { value: '[a-z]', message: 'All characters must be lowercase.' } }; function extendRegExpValidationsFromPatterns(listItemsPropsOrField: Exclude) { if (listItemsPropsOrField.validations && 'regexpPattern' in listItemsPropsOrField.validations && listItemsPropsOrField.validations.regexpPattern) { const { regexpPattern, ...restValidations } = listItemsPropsOrField.validations; if (regexpPattern in RegExpPatternMap) { const regexp = RegExpPatternMap[regexpPattern as keyof typeof RegExpPatternMap]; listItemsPropsOrField.validations = { ...restValidations, regexp: regexp.value, errors: { regexp: listItemsPropsOrField.validations.errors?.regexpPattern ?? regexp.message } }; } else if (regexpPattern in RegExpNotPatternMap) { const regexpNot = RegExpNotPatternMap[regexpPattern as keyof typeof RegExpNotPatternMap]; listItemsPropsOrField.validations = { ...restValidations, regexpNot: regexpNot.value, errors: { regexpNot: listItemsPropsOrField.validations.errors?.regexpPattern ?? regexpNot.message } }; } } } function extendFileTypeValidationsFromGroups(listItemsPropsOrField: Exclude) { if ( listItemsPropsOrField.validations && 'fileTypeGroups' in listItemsPropsOrField.validations && Array.isArray(listItemsPropsOrField.validations.fileTypeGroups) ) { const { fileTypeGroups, ...restValidations } = listItemsPropsOrField.validations; const existingFileTypes = 'fileTypes' in restValidations ? restValidations.fileTypes : undefined; const fileTypesArr = Array.isArray(existingFileTypes) ? { value: existingFileTypes } : existingFileTypes; const groupedFileTypes = mapFileTypeGroupToFileType(fileTypeGroups); if (groupedFileTypes.length > 0) { const mergedFileTypes = [...(fileTypesArr?.value ?? []), ...groupedFileTypes]; const message = `The file must be of the following types: ${mergedFileTypes.join(', ')}`; listItemsPropsOrField.validations = { ...restValidations, fileTypes: mergedFileTypes, errors: { fileTypes: listItemsPropsOrField.validations.errors?.fileTypeGroups ?? message } }; } } } const FileTypeGroupMap: Record = { image: ['gif', 'jpg', 'jpeg', 'png', 'svg', 'webp', 'bmp', 'ico', 'tif', 'tiff', 'ps', 'eps'], video: ['flv', 'avi', '3gp', '3g2', 'h264', 'm4v', 'mkv', 'mov', 'mp4', 'mpg', 'mpeg', 'amv', 'rm', 'swf', 'vob', 'webm', 'wmv'], audio: ['aif', 'mid', 'midi', 'mp3', 'mpa', 'ogg', 'aac', 'wav', 'wma', 'wpl'], text: ['txt', 'log'], markup: ['md', 'mdx'], code: ['json', 'js', 'ts', 'html', 'htm', 'xhtml', 'css', 'xml', 'py', 'c', 'h', 'cpp', 'swift', 'java', 'class', 'php', 'sh', 'vb'], document: ['txt', 'doc', 'docx', 'odt', 'pdf', 'csv', 'rtf', 'tex', 'wpd'], presentation: ['key', 'odp', 'pps', 'ppt', 'pptx'], spreadsheet: ['ods', 'xls', 'xlsm', 'xlsx'], archive: ['7z', 'arj', 'deb', 'pkg', 'rar', 'rpm', 'tar', 'tar.gz', 'z', 'zip'] }; function mapFileTypeGroupToFileType(fileTypeGroups: FieldValidationsFileTypesGroup[]): string[] { return _.uniq( fileTypeGroups.reduce((accum: string[], group) => { if (group in FileTypeGroupMap) { accum = accum.concat(FileTypeGroupMap[group]); } return accum; }, []) ); } function validateAndNormalizeCrossReferenceModels({ crossReferenceModels, models, logger }: { crossReferenceModels: FieldCrossReferenceModel[]; models: ModelWithSource[]; logger: Logger; }): FieldCrossReferenceModel[] { const modelGroupsByModelName = models.reduce((modelGroups: Record, model) => { if (!(model.name in modelGroups)) { modelGroups[model.name] = []; } modelGroups[model.name]!.push(model); return modelGroups; }, {}); // Match cross-reference models to the group of content source models with // the same name. Then, match the cross-reference model to content source // model by comparing srcType and srcProjectId. If after the comparison, // there are more than one model left, log a warning and filter out that // cross-reference model so it won't cause any model ambiguity. const nonMatchedCrossReferenceModels: { crossReferenceModel: FieldCrossReferenceModel; matchedModels: ModelWithSource[]; }[] = []; const normalizedCrossReferenceModels = crossReferenceModels.reduce((matchedCrossReferenceModels: FieldCrossReferenceModel[], crossReferenceModel) => { const models = modelGroupsByModelName[crossReferenceModel.modelName]; if (!models) { nonMatchedCrossReferenceModels.push({ crossReferenceModel, matchedModels: [] }); return matchedCrossReferenceModels; } const matchedModels = models.filter((model) => { const matchesType = !crossReferenceModel.srcType || model.srcType === crossReferenceModel.srcType; const matchesId = !crossReferenceModel.srcProjectId || model.srcProjectId === crossReferenceModel.srcProjectId; return matchesType && matchesId; }); if (matchedModels.length !== 1) { nonMatchedCrossReferenceModels.push({ crossReferenceModel, matchedModels }); return matchedCrossReferenceModels; } const matchedModel = matchedModels[0]!; matchedCrossReferenceModels.push({ modelName: crossReferenceModel.modelName, srcType: matchedModel.srcType, srcProjectId: matchedModel.srcProjectId }); return matchedCrossReferenceModels; }, []); // Log model matching warnings using user logger for (const { crossReferenceModel, matchedModels } of nonMatchedCrossReferenceModels) { let message = `a model of cross-reference field: '${crossReferenceModel.modelName}'`; if (crossReferenceModel.srcType) { message += `, srcType: '${crossReferenceModel.srcType}'`; } if (crossReferenceModel.srcProjectId) { message += `, srcProjectId: '${crossReferenceModel.srcProjectId}'`; } message = message + ` defined in stackbit config`; let contentSourceModelsMessage; if (matchedModels.length) { const matchesModelsMessage = matchedModels.map((model) => `srcType: '${model.srcType}', srcProjectId: '${model.srcProjectId}'`).join('; '); contentSourceModelsMessage = ` matches more that 1 model in the following content sources: ${matchesModelsMessage}`; } else { contentSourceModelsMessage = ' does not match any content source model'; } logger.warn(message + contentSourceModelsMessage); } return normalizedCrossReferenceModels; } export function validateModels({ models, logger }: { models: T[]; logger: Logger }): T[] { const { config, errors } = validateConfig({ stackbitVersion: '0.5.0', models: models, dirPath: '.', filePath: 'stackbit.config.js' }); for (const error of errors) { logger.warn(error.message); } return config.models as T[]; } export function getModelMap({ models }: { models: T[] }): Record { return models.reduce((res, model) => { res[model.name] = model; return res; }, {} as Record); }