UNPKG

@stackbit/sdk

Version:
1,126 lines (1,043 loc) 41.6 kB
import Joi from 'joi'; import _ from 'lodash'; import { append } from '@stackbit/utils'; import { ContentfulImport, SanityImport, Assets, ContentModelMap, ContentModel, PresetSourceFiles, PresetSource, ModelsSource, ModelsSourceFiles, ModelsSourceContentful, ModelsSourceSanity, ModelLocalized, Field, FieldObjectProps, FieldGroupItem, FieldSpecificProps, FieldCrossReferenceModel } from '@stackbit/types'; import { CMS_NAMES, FIELD_TYPES, SSG_NAMES } from './config-consts'; import { styleFieldPartialSchema } from './config-schema/style-field-schema'; import { Config, StackbitConfigWithPaths, Model, PageModel, ObjectModel, DataModel, ConfigModel } from './config-types'; function getConfigFromValidationState(state: Joi.State): Config { return _.last(state.ancestors)!; } function getModelsFromValidationState(state: Joi.State): Model[] { const config = getConfigFromValidationState(state); return config.models ?? []; } const arrayOfStrings = Joi.array().items(Joi.string()); const fieldNamePattern = /^[a-zA-Z0-9_$]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/; const fieldNameError = 'Invalid field name "{{#value}}" at "{{#label}}". A field name must contain only alphanumeric characters, ' + 'hyphens and underscores, must start and end with an alphanumeric character.'; const fieldNameSchema = Joi.string() .required() .pattern(fieldNamePattern) .prefs({ messages: { 'string.pattern.base': fieldNameError }, errors: { wrap: { label: false } } }); const objectModelNameErrorCode = 'model.not.object.model'; const validObjectModelNames = Joi.custom((value, { error, state }) => { const models = getModelsFromValidationState(state); const objectModelNames = models.filter((model) => model.type === 'object').map((model) => model.name); if (!objectModelNames.includes(value)) { return error(objectModelNameErrorCode); } return value; }).prefs({ messages: { [objectModelNameErrorCode]: '{{#label}} must reference the name of an existing model of type "object", got "{{#value}}"' }, errors: { wrap: { label: false } } }); const documentModelNameErrorCode = 'model.not.document.model'; const validReferenceModelNames = Joi.custom((value, { error, state }) => { const models = getModelsFromValidationState(state); const documentModelNames = models.filter((model) => ['page', 'data'].includes(model.type)).map((model) => model.name); if (!documentModelNames.includes(value)) { return error(documentModelNameErrorCode); } return value; }).prefs({ messages: { [documentModelNameErrorCode]: '{{#label}} must reference the name of an existing model of type "page" or "data", got "{{#value}}"' }, errors: { wrap: { label: false } } }); const validCrossReferenceModelNames = Joi.object<FieldCrossReferenceModel>({ modelName: Joi.string().required(), srcType: Joi.string(), srcProjectId: Joi.string() }) .custom((allowedModels, { error, state }) => { // TODO: implement custom validation logic that checks unambiguous model matching // similar to how config models are matched to content-source models in content-store.ts return allowedModels; }) .prefs({ messages: { [documentModelNameErrorCode]: '{{#label}} must reference the name of an existing model of type "page" or "data", got "{{#value}}"' }, errors: { wrap: { label: false } } }); const groupNotFoundErrorCode = 'group.not.found'; const groupNotObjectModelErrorCode = 'group.not.object.model'; const validModelFieldGroups = Joi.string() .custom((group, { error, state }) => { const config = getConfigFromValidationState(state); const groupModels = getModelNamesForGroup(group, config); if (!_.isEmpty(groupModels.documentModels)) { return error(groupNotObjectModelErrorCode, { nonObjectModels: groupModels.documentModels.join(', ') }); } if (_.isEmpty(groupModels.objectModels)) { return error(groupNotFoundErrorCode); } return group; }) .prefs({ messages: { [groupNotObjectModelErrorCode]: '{{#label}} of a "model" field must reference a group with only models ' + 'of type "object", the "{{#value}}" group includes models of type "page" or "data" ({{#nonObjectModels}})', [groupNotFoundErrorCode]: '{{#label}} of a "model" field must reference the name of an existing group, got "{{#value}}"' }, errors: { wrap: { label: false } } }); const groupNotDocumentModelErrorCode = 'group.not.document.model'; const validReferenceFieldGroups = Joi.string() .custom((group, { error, state }) => { const config = getConfigFromValidationState(state); const groupModels = getModelNamesForGroup(group, config); if (!_.isEmpty(groupModels.objectModels)) { return error(groupNotDocumentModelErrorCode, { nonDocumentModels: groupModels.objectModels.join(', ') }); } if (_.isEmpty(groupModels.documentModels)) { return error(groupNotFoundErrorCode); } return group; }) .prefs({ messages: { [groupNotDocumentModelErrorCode]: '{{#label}} of a "reference" field must reference a group with only models of type "page" or "data", ' + 'the "{{#value}}" group includes models of type "object" ({{#nonDocumentModels}})', [groupNotFoundErrorCode]: '{{#label}} of a "reference" field must reference the name of an existing group, got "{{#value}}"' }, errors: { wrap: { label: false } } }); function getModelNamesForGroup(group: string, config: Config) { const models = config.models ?? []; return _.reduce( models, (result: { objectModels: string[]; documentModels: string[] }, model) => { if (model?.groups && _.includes(model.groups, group)) { if (model?.type === 'object') { result.objectModels.push(model.name); } else { result.documentModels.push(model.name); } } return result; }, { objectModels: [], documentModels: [] } ); } const labelFieldNotFoundError = 'labelField.not.found'; const labelFieldNotSimple = 'labelField.not.simple'; const labelFieldSchema = Joi.custom((value, { error, state }) => { const modelOrObjectField: Model | FieldObjectProps = _.head(state.ancestors)!; const fields = modelOrObjectField?.fields ?? []; if (!_.isArray(fields)) { return error(labelFieldNotFoundError); } const field = _.find(fields, (field) => field.name === value); if (!field) { return error(labelFieldNotFoundError); } if (['object', 'model', 'reference', 'cross-reference', 'list'].includes(field.type)) { return error(labelFieldNotSimple, { fieldType: field.type }); } return value; }).prefs({ messages: { [labelFieldNotFoundError]: '{{#label}} must be one of model field names, got "{{#value}}"', [labelFieldNotSimple]: '{{#label}} can not reference complex field, got "{{#value}}" field of type "{{#fieldType}}"' }, errors: { wrap: { label: false } } }); const variantFieldNotFoundError = 'variantField.not.found'; const variantFieldNotEnum = 'variantField.not.enum'; const variantFieldSchema = Joi.custom((value, { error, state }) => { const modelOrObjectField: Model | FieldObjectProps = _.head(state.ancestors)!; const fields = modelOrObjectField?.fields ?? []; if (!_.isArray(fields)) { return error(variantFieldNotFoundError); } const field = _.find(fields, (field) => field.name === value); if (!field) { return error(variantFieldNotFoundError); } if (field.type !== 'enum') { return error(variantFieldNotEnum, { fieldType: field.type }); } return value; }).prefs({ messages: { [variantFieldNotFoundError]: '{{#label}} must be one of model field names, got "{{#value}}"', [variantFieldNotEnum]: '{{#label}} should reference "enum" field, got "{{#value}}" field of type "{{#fieldType}}"' }, errors: { wrap: { label: false } } }); const styleObjectModelReferenceError = 'styleObjectModelName.model.missing'; const styleObjectModelNotObject = 'styleObjectModelName.model.type'; const styleObjectModelNameSchema = Joi.string() .allow('', null) .custom((value, { error, state }) => { const config = getConfigFromValidationState(state); const externalModels = config.cmsName && ['contentful', 'sanity'].includes(config.cmsName); if (externalModels) { return value; } const models = getModelsFromValidationState(state); const styleObjectModel = models.find((model) => model.name === value); if (!styleObjectModel) { return error(styleObjectModelReferenceError); } if (styleObjectModel.type !== 'data') { return error(styleObjectModelNotObject); } return value; }) .prefs({ messages: { [styleObjectModelReferenceError]: '{{#label}} must reference an existing model', [styleObjectModelNotObject]: 'Model defined in {{#label}} must be of type data - {{#value}}' }, errors: { wrap: { label: false } } }); const contentfulImportSchema = Joi.object<ContentfulImport>({ type: Joi.string().valid('contentful').required(), contentFile: Joi.string().required(), uploadAssets: Joi.boolean(), assetsDirectory: Joi.string(), spaceIdEnvVar: Joi.string(), accessTokenEnvVar: Joi.string(), deliveryTokenEnvVar: Joi.string(), previewTokenEnvVar: Joi.string() }).and('uploadAssets', 'assetsDirectory'); const sanityImportSchema = Joi.object<SanityImport>({ type: Joi.string().valid('sanity').required(), contentFile: Joi.string().required(), sanityStudioPath: Joi.string().required(), deployStudio: Joi.boolean(), deployGraphql: Joi.boolean(), projectIdEnvVar: Joi.string(), datasetEnvVar: Joi.string(), tokenEnvVar: Joi.string() }); const importSchema = Joi.alternatives().conditional('.type', { switch: [ { is: 'contentful', then: contentfulImportSchema }, { is: 'sanity', then: sanityImportSchema } ] }); const presetSourceFilesSchema = Joi.object<PresetSourceFiles>({ type: Joi.string().valid(Joi.override, 'files').required(), presetDirs: arrayOfStrings.required() }); const presetSourceSchema = Joi.object<PresetSource>({ type: Joi.string().valid('files').required() }).when('.type', { switch: [{ is: 'files', then: presetSourceFilesSchema }] }); const modelsSourceFilesSchema = Joi.object<ModelsSourceFiles>({ type: Joi.string().valid(Joi.override, 'files').required(), modelDirs: arrayOfStrings.required() }); const modelsSourceContentfulSchema = Joi.object<ModelsSourceContentful>({ type: Joi.string().valid(Joi.override, 'contentful').required(), module: Joi.string() }); const modelsSourceSanitySchema = Joi.object<ModelsSourceSanity>({ type: Joi.string().valid(Joi.override, 'sanity').required(), sanityStudioPath: Joi.string().required(), module: Joi.string() }); const modelsSourceSchema = Joi.object<ModelsSource>({ type: Joi.string().valid('files', 'contentful', 'sanity').required() }).when('.type', { switch: [ { is: 'files', then: modelsSourceFilesSchema }, { is: 'contentful', then: modelsSourceContentfulSchema }, { is: 'sanity', then: modelsSourceSanitySchema } ] }); const assetsSchema = Joi.object<Assets>({ referenceType: Joi.string().valid('static', 'relative').required(), assetsDir: Joi.string().allow('').when('referenceType', { is: 'relative', then: Joi.required() }), staticDir: Joi.string().allow('').when('referenceType', { is: 'static', then: Joi.required() }), publicPath: Joi.string().allow('').when('referenceType', { is: 'static', then: Joi.required() }), uploadDir: Joi.string().allow('') }); const fieldGroupsSchema = Joi.array() .items( Joi.object<FieldGroupItem>({ name: Joi.string().required(), label: Joi.string().required(), icon: Joi.string().optional() }) ) .unique('name') .prefs({ messages: { 'array.unique': '{{#label}} contains a duplicate group name "{{#value.name}}"' }, errors: { wrap: { label: false } } }); const inGroups = Joi.string() .valid( // 4 dots "...." => // ".." for the parent field where "group" property is defined // + "." for the fields array // + "." for the parent model Joi.in('....fieldGroups', { adjust: (groups) => (_.isArray(groups) ? groups.map((group) => group.name) : []) }) ) .prefs({ messages: { 'any.only': '{{#label}} must be one of model field groups, got "{{#value}}"' }, errors: { wrap: { label: false } } }); export const objectPreviewSchema = Joi.object({ title: Joi.string(), subtitle: Joi.string(), image: Joi.string() }); const customActionPermissionsSchema = Joi.object({ canExecute: Joi.boolean().optional() }); const customActionBaseSchema = Joi.object({ name: Joi.string().required(), label: Joi.string(), icon: Joi.string(), preferredStyle: Joi.string().valid('button-primary', 'button-secondary'), state: Joi.function(), run: Joi.function().required(), inputFields: Joi.link('#fieldsSchema'), hidden: Joi.boolean(), permissions: Joi.function() }); const customActionGlobalAndBulkSchema = customActionBaseSchema.concat( Joi.object({ type: Joi.string().valid('global', 'bulk').required() }) ); const customActionPageAndDataModelSchema = customActionBaseSchema.concat( Joi.object({ type: Joi.string().valid('document').required() }) ); const customActionObjectModelSchema = customActionBaseSchema.concat( Joi.object({ type: Joi.string().valid('object').required() }) ); const customActionObjectFieldSchema = customActionBaseSchema.concat( Joi.object({ type: Joi.string().valid('field', 'object') }) ); const customActionFieldSchema = customActionBaseSchema.concat( Joi.object({ type: Joi.string().valid('field') }) ); const stringOrNumber = Joi.alternatives().try(Joi.string(), Joi.number()); const fieldValidationSchema = Joi.object({ unique: Joi.boolean(), validate: Joi.alternatives().try(Joi.function(), Joi.array().items(Joi.function())), // string and text regexp: Joi.string(), regexpNot: Joi.string(), regexpPattern: Joi.string(), // ranges min: stringOrNumber, max: stringOrNumber, after: Joi.string(), before: Joi.string(), exact: Joi.number(), step: Joi.number(), lessThan: Joi.number(), greaterThan: Joi.number(), // file fileMinSize: Joi.number(), fileMaxSize: Joi.number(), fileTypes: arrayOfStrings, fileTypeGroups: arrayOfStrings, // image minWidth: Joi.number(), maxWidth: Joi.number(), minHeight: Joi.number(), maxHeight: Joi.number(), minWidthToHeightRatio: stringOrNumber, maxWidthToHeightRatio: stringOrNumber, errors: Joi.object({ unique: Joi.string(), regexp: Joi.string(), regexpNot: Joi.string(), regexpPattern: Joi.string(), min: Joi.string(), max: Joi.string(), after: Joi.string(), before: Joi.string(), exact: Joi.string(), step: Joi.string(), lessThan: Joi.string(), greaterThan: Joi.string(), fileMinSize: Joi.string(), fileMaxSize: Joi.string(), fileTypes: Joi.string(), fileTypeGroups: Joi.string(), minWidth: Joi.string(), maxWidth: Joi.string(), minHeight: Joi.string(), maxHeight: Joi.string(), minWidthToHeightRatio: Joi.string(), maxWidthToHeightRatio: Joi.string() }) }); const customControlTypesSchema = Joi.string().valid('custom-modal-html', 'custom-inline-html', 'custom-modal-script', 'custom-inline-script'); const fieldCommonPropsSchema = Joi.object({ type: Joi.string() .valid(...FIELD_TYPES) .required(), name: fieldNameSchema, label: Joi.string(), description: Joi.string().allow(''), required: Joi.boolean(), validations: fieldValidationSchema, default: Joi.any(), group: inGroups, const: Joi.any(), hidden: Joi.boolean(), readOnly: Joi.boolean(), localized: Joi.boolean(), actions: Joi.array().items(customActionFieldSchema), controlType: customControlTypesSchema }) .oxor('const', 'default') .when('.controlType', { is: customControlTypesSchema.required(), then: Joi.object({ controlUrl: Joi.string(), controlFilePath: Joi.string() }).xor('controlUrl', 'controlFilePath') }); const numberFieldPartialSchema = Joi.object({ type: Joi.string().valid('number').required(), controlType: Joi.string().valid('slider'), subtype: Joi.string().valid('int', 'float'), min: Joi.number(), max: Joi.number(), step: Joi.number(), unit: Joi.string() }); const enumFieldBaseOptionSchema = Joi.object({ label: Joi.string().required(), value: Joi.alternatives().try(Joi.string(), Joi.number()).required() }); const imageFieldPartialSchema = Joi.object({ source: Joi.string() }); const enumFieldPartialSchema = Joi.object({ type: Joi.string().valid('enum').required(), controlType: Joi.string().valid('dropdown', 'button-group', 'thumbnails', 'palette', 'palette-colors'), options: Joi.any() .when('..controlType', { switch: [ { is: 'thumbnails', then: Joi.array().items( enumFieldBaseOptionSchema.append({ thumbnail: Joi.string().required() }) ) }, { is: 'palette', then: Joi.array().items( enumFieldBaseOptionSchema.append({ textColor: Joi.string(), backgroundColor: Joi.string(), borderColor: Joi.string() }) ) }, { is: 'palette-colors', then: Joi.array().items( enumFieldBaseOptionSchema.append({ colors: arrayOfStrings.required() }) ) } ], otherwise: Joi.alternatives().try(Joi.array().items(Joi.string(), Joi.number()), Joi.array().items(enumFieldBaseOptionSchema)) }) .required() .prefs({ messages: { 'alternatives.types': '{{#label}} must be an array of strings or numbers, or an array of objects with label and value properties', 'alternatives.match': '{{#label}} must be an array of strings or numbers, or an array of objects with label and value properties' }, errors: { wrap: { label: false } } }) }); const booleanFieldPartialSchema = Joi.object({ type: Joi.string().valid('boolean').required(), controlType: Joi.string().valid('button-group', 'checkbox') }); const objectFieldPartialSchema = Joi.object({ type: Joi.string().valid('object').required(), labelField: labelFieldSchema, preview: Joi.alternatives().try(objectPreviewSchema, Joi.function()), thumbnail: Joi.string(), variantField: variantFieldSchema, actions: Joi.array().items(customActionObjectFieldSchema), fieldGroups: fieldGroupsSchema, fields: Joi.link('#fieldsSchema').required() }); const modelFieldPartialSchema = Joi.object({ type: Joi.string().valid('model').required(), models: Joi.array().items(validObjectModelNames).when('groups', { not: Joi.exist(), then: Joi.required() }), groups: Joi.array().items(validModelFieldGroups) }); const referenceFieldPartialSchema = Joi.object({ type: Joi.string().valid('reference').required(), models: Joi.array().items(validReferenceModelNames).when('groups', { not: Joi.exist(), then: Joi.required() }), groups: Joi.array().items(validReferenceFieldGroups) }); const crossReferenceFieldPartialSchema = Joi.object({ type: Joi.string().valid('cross-reference').required(), models: Joi.array().items(validCrossReferenceModelNames).required() }); const listItemsSchema = Joi.object({ // 'style' and 'list' are not allowed inside lists type: Joi.string() .valid(..._.without(FIELD_TYPES, 'list', 'style')) .required(), validations: fieldValidationSchema }).when('.type', { switch: [ { is: 'number', then: numberFieldPartialSchema }, { is: 'enum', then: enumFieldPartialSchema }, { is: 'boolean', then: booleanFieldPartialSchema }, { is: 'image', then: imageFieldPartialSchema }, { is: 'object', then: objectFieldPartialSchema }, { is: 'model', then: modelFieldPartialSchema }, { is: 'reference', then: referenceFieldPartialSchema }, { is: 'cross-reference', then: crossReferenceFieldPartialSchema } ] }); const listFieldPartialSchema = Joi.object({ type: Joi.string().valid('list').required(), controlType: Joi.string().valid('checkbox'), items: Joi.any().when('..controlType', { switch: [ { is: 'checkbox', then: enumFieldPartialSchema } ], otherwise: listItemsSchema }) }); const fieldSchema: Joi.ObjectSchema<Field> = fieldCommonPropsSchema.when('.type', { switch: [ { is: 'number', then: numberFieldPartialSchema }, { is: 'enum', then: enumFieldPartialSchema }, { is: 'boolean', then: booleanFieldPartialSchema }, { is: 'image', then: imageFieldPartialSchema }, { is: 'object', then: objectFieldPartialSchema }, { is: 'model', then: modelFieldPartialSchema }, { is: 'reference', then: referenceFieldPartialSchema }, { is: 'cross-reference', then: crossReferenceFieldPartialSchema }, { is: 'style', then: styleFieldPartialSchema }, { is: 'list', then: listFieldPartialSchema } ] }); const fieldsSchema = Joi.array().items(fieldSchema).unique('name').id('fieldsSchema'); const contentModelKeyNotFound = 'contentModel.model.not.found'; const contentModelTypeNotPage = 'contentModel.type.not.page'; const contentModelTypeNotData = 'contentModel.type.not.data'; const contentModelSchema = Joi.object<ContentModel>({ isPage: Joi.boolean(), newFilePath: Joi.string(), singleInstance: Joi.boolean(), file: Joi.string(), folder: Joi.string(), match: arrayOfStrings.single(), exclude: arrayOfStrings.single() }) .without('file', ['folder', 'match', 'exclude']) .when('.isPage', { is: true, then: Joi.object({ urlPath: Joi.string(), hideContent: Joi.boolean() }) }) .custom((contentModel, { error, state, prefs }) => { const modelMap = _.get(prefs, 'context.modelMap'); if (!modelMap) { return contentModel; } const modelName = _.last(state.path)!; const model = modelMap[modelName]; if (!model) { return error(contentModelKeyNotFound, { modelName }); } else if (contentModel.isPage && model.type && !['page', 'object'].includes(model.type)) { return error(contentModelTypeNotPage, { modelName, modelType: model.type }); } else if (!contentModel.isPage && model.type && !['data', 'object'].includes(model.type)) { return error(contentModelTypeNotData, { modelName, modelType: model.type }); } return contentModel; }) .prefs({ messages: { [contentModelKeyNotFound]: 'The key "{{#modelName}}" of contentModels must reference the name of an existing model', [contentModelTypeNotPage]: 'The contentModels.{{#modelName}}.isPage is set to true, but the "{{#modelName}}" model\'s type is "{{#modelType}}". ' + 'The contentModels should reference models of "object" type only. ' + 'Set the "{{#modelName}}" model\'s type property to "object" or delete it use the default "object"', [contentModelTypeNotData]: 'The contentModels.{{#modelName}} references a model of type "{{#modelType}}". ' + 'The contentModels should reference models of "object" type only. ' + 'Set the "{{#modelName}}" model\'s type property to "object" or delete it use the default "object"' }, errors: { wrap: { label: false } } }); export const contentModelsSchema = Joi.object({ contentModels: Joi.object<ContentModelMap>().pattern(Joi.string(), contentModelSchema) }); const modelNamePattern = /^[a-zA-Z]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/; const modelNameError = 'Invalid model name "{{#value}}" at "{{#label}}". A model name must contain only alphanumeric characters, dashes ' + 'and underscores, must start with a letter, and end with alphanumeric character.'; const modelNameSchema = Joi.string() .required() .pattern(modelNamePattern) .prefs({ messages: { 'string.pattern.base': modelNameError }, errors: { wrap: { label: false } } }); const modelPermissionsSchema = Joi.object({ canView: Joi.boolean().optional(), canEdit: Joi.boolean().optional(), canPublish: Joi.boolean().optional() }); const baseModelSchema = { __metadata: Joi.object({ filePath: Joi.string() }), name: modelNameSchema, srcType: Joi.string(), srcProjectId: Joi.string(), label: Joi.string(), description: Joi.string().allow(''), thumbnail: Joi.string(), extends: Joi.array().items(validObjectModelNames).single(), readOnly: Joi.boolean(), labelField: labelFieldSchema, variantField: variantFieldSchema, groups: arrayOfStrings, context: Joi.any(), preview: Joi.alternatives().try(objectPreviewSchema, Joi.function()), fieldGroups: fieldGroupsSchema, fields: Joi.link('#fieldsSchema'), permissions: Joi.alternatives().try(modelPermissionsSchema, Joi.function()) }; const localizedModelSchema = { localized: Joi.boolean(), locale: Joi.function() }; const objectModelSchema: Joi.ObjectSchema<ObjectModel & { srcType: string; srcProjectId: string }> = Joi.object({ ...baseModelSchema, type: Joi.string().valid('object').required(), actions: Joi.array().items(customActionObjectModelSchema) }); const dataModelSchema: Joi.ObjectSchema<DataModel & { srcType: string; srcProjectId: string } & ModelLocalized> = Joi.object({ ...baseModelSchema, ...localizedModelSchema, type: Joi.string().valid('data').required(), actions: Joi.array().items(customActionPageAndDataModelSchema), filePath: Joi.alternatives().try(Joi.string(), Joi.function()), singleInstance: Joi.boolean(), file: Joi.string(), folder: Joi.string(), match: arrayOfStrings.single(), exclude: arrayOfStrings.single(), isList: Joi.boolean(), canDelete: Joi.boolean() }) .when('.isList', { is: true, then: Joi.object({ items: listItemsSchema.required(), fields: Joi.forbidden() }) }) .when('.file', { is: Joi.exist(), then: Joi.object({ folder: Joi.forbidden(), match: Joi.forbidden(), exclude: Joi.forbidden() }) }); const configModelSchema: Joi.ObjectSchema<ConfigModel & { srcType: string; srcProjectId: string } & ModelLocalized> = Joi.object({ ...baseModelSchema, ...localizedModelSchema, type: Joi.string().valid('config').required(), file: Joi.string() }); const pageModelSchema: Joi.ObjectSchema<PageModel & { srcType: string; srcProjectId: string } & ModelLocalized> = Joi.object({ ...baseModelSchema, ...localizedModelSchema, type: Joi.string().valid('page').required(), actions: Joi.array().items(customActionPageAndDataModelSchema), layout: Joi.string(), //.when(Joi.ref('/pageLayoutKey'), { is: Joi.string().exist(), then: Joi.required() }), urlPath: Joi.string(), filePath: Joi.alternatives().try(Joi.string(), Joi.function()), singleInstance: Joi.boolean(), file: Joi.string(), folder: Joi.string(), match: arrayOfStrings.single(), exclude: arrayOfStrings.single(), hideContent: Joi.boolean(), canDelete: Joi.boolean() }) .when('.file', { is: Joi.exist(), then: { singleInstance: Joi.valid(true).required(), folder: Joi.forbidden(), match: Joi.forbidden(), exclude: Joi.forbidden() } }) .when('.singleInstance', { is: true, then: { file: Joi.required() } }); const modelFileExclusiveErrorCode = 'model.file.only'; const modelIsListItemsRequiredErrorCode = 'model.isList.items.required'; const modelIsListFieldsForbiddenErrorCode = 'model.isList.fields.forbidden'; const modelListForbiddenErrorCode = 'model.items.forbidden'; const fieldNameUnique = 'field.name.unique'; function errorLabelFromModelAndFieldPath(model: Model, modelIndex: number, fieldPath: (string | number | object)[]): string { let fieldSpecificProps: Model | FieldSpecificProps | undefined = model; let fields: Field[] | undefined; let label = `models[${model.name ? `name='${model.name}'` : modelIndex}]`; for (const pathPart of fieldPath) { if (typeof pathPart === 'string') { if (pathPart === 'fields' && fieldSpecificProps && 'fields' in fieldSpecificProps) { fields = fieldSpecificProps.fields; fieldSpecificProps = undefined; label += '.fields'; } else if (pathPart === 'items' && fieldSpecificProps && 'items' in fieldSpecificProps) { fieldSpecificProps = fieldSpecificProps.items; label += '.items'; } else { fieldSpecificProps = undefined; label += `.${pathPart}`; } } else if (fields && typeof pathPart === 'number') { const field = fields[pathPart]; label += '[' + (field?.name ? `name='${field.name}'` : pathPart) + ']'; fieldSpecificProps = field; fields = undefined; } else { // when the schema is marked as Joi.array().items(...).single() // and the validated value is not an array, Joi injects (new Number(0)) // which is an object. Don't use it to generate path if (typeof pathPart === 'object') { continue; } label += `[${pathPart}]`; } } return label; } const modelSchema = Joi.object<Model>({ type: Joi.string().valid('page', 'data', 'config', 'object').required(), name: Joi.string() }) .when('.type', { switch: [ { is: 'object', then: objectModelSchema }, { is: 'data', then: dataModelSchema }, { is: 'config', then: configModelSchema }, { is: 'page', then: pageModelSchema } ] }) .error(((errors: Joi.ErrorReport[]): Joi.ErrorReport[] => { return _.map(errors, (error) => { if (error.path[0] === 'models' && typeof error.path[1] === 'number') { const modelIndex = error.path[1]; const config = error.prefs.context?.config as Config; if (config && config.models[modelIndex]) { const model = config.models[modelIndex]!; error.path = error.path.slice(); error.path.splice(1, 1, model.name); const label = errorLabelFromModelAndFieldPath(model, modelIndex, error.path.slice(2)); _.set(error, 'local.label', label); // const label = _.get(error, 'local.label', '') as string; // _.set(error, 'local.label', label.replace(/models\[(\d+)]/, (match, indexMatch) => { // if (Number(indexMatch) !== modelIndex) { // return match; // } // return `models[name='${model.name}']`; // })); } } if ( error.code === 'any.unknown' && error.path.length === 3 && error.path[0] === 'models' && error.path[2] && ['folder', 'match', 'exclude'].includes(error.path[2]) ) { error.code = modelFileExclusiveErrorCode; } else if (error.code === 'any.required' && error.path.length === 3 && error.path[0] === 'models' && error.path[2] === 'items') { error.code = modelIsListItemsRequiredErrorCode; } else if (error.code === 'any.unknown' && error.path.length === 3 && error.path[0] === 'models' && error.path[2] === 'fields') { error.code = modelIsListFieldsForbiddenErrorCode; } else if (error.code === 'object.unknown' && error.path.length === 3 && error.path[0] === 'models' && error.path[2] === 'items') { error.code = modelListForbiddenErrorCode; } else if (error.code === 'array.unique' && error.path.length > 3 && error.path[0] === 'models' && _.nth(error.path, -2) === 'fields') { error.code = fieldNameUnique; } return error; }); }) as any) // the type definition of Joi.ValidationErrorFunction is wrong, so we override .prefs({ messages: { [modelFileExclusiveErrorCode]: '{{#label}} cannot be used with "file"', [modelIsListItemsRequiredErrorCode]: '{{#label}} is required when "isList" is true', [modelIsListFieldsForbiddenErrorCode]: '{{#label}} is not allowed when "isList" is true', [modelListForbiddenErrorCode]: '{{#label}} is not allowed when "isList" is not true', [fieldNameUnique]: '{{#label}} contains a duplicate field name "{{#value.name}}"' }, errors: { wrap: { label: false } } }); const groupModelsIncompatibleError = 'group.models.incompatible'; const modelsSchema = Joi.array() .items(modelSchema) .custom((models: Model[], { error }) => { const groupMap: Record<string, Record<'objectModels' | 'documentModels', string[]>> = {}; _.forEach(models, (model) => { const key = model?.type === 'object' ? 'objectModels' : 'documentModels'; _.forEach(model.groups, (groupName) => { append(groupMap, [groupName, key], model.name); }); }); const errors = _.reduce( groupMap, (errors: string[], group, groupName) => { if (group.objectModels && group.documentModels) { const objectModels = group.objectModels.join(', '); const documentModels = group.documentModels.join(', '); errors.push( `group "${groupName}" include models of type "object" (${objectModels}) and objects of type "page" or "data" (${documentModels})` ); } return errors; }, [] ); if (!_.isEmpty(errors)) { return error(groupModelsIncompatibleError, { incompatibleGroups: errors.join(', ') }); } return models; }) .prefs({ messages: { [groupModelsIncompatibleError]: 'Model groups must include models of the same type. The following groups have incompatible models: {{#incompatibleGroups}}' }, errors: { wrap: { label: false } } }); const sidebarButtonSchema = Joi.object({ label: Joi.string().required(), icon: Joi.string().required(), type: Joi.string().valid('link', 'document', 'model').required() }).when('.type', { switch: [ { is: 'link', then: Joi.object({ url: Joi.string().required() }) }, { is: 'document', then: Joi.object({ documentId: Joi.string().required(), srcType: Joi.string(), srcProjectId: Joi.string() }) }, { is: 'model', then: Joi.object({ modelName: Joi.string().required(), srcType: Joi.string(), srcProjectId: Joi.string() }) } ] }); const viewportSchema = Joi.object({ label: Joi.string().required(), size: Joi.object({ width: Joi.number(), height: Joi.number() }).required() }); const contentEngineSchema = Joi.object({ host: Joi.string(), port: Joi.alternatives(Joi.number(), Joi.string()) }); const AssetSourceSchema = Joi.object({ type: Joi.string().required(), name: Joi.string(), default: Joi.boolean(), transform: Joi.function(), preview: Joi.alternatives().try( Joi.object({ title: Joi.string(), image: Joi.string().required() }), Joi.function() ) }).when('.type', { is: 'iframe', then: Joi.object({ name: Joi.required(), url: Joi.string().required() }) }); export const stackbitConfigBaseSchema = Joi.object<StackbitConfigWithPaths>({ stackbitVersion: Joi.string().required(), ssgName: Joi.string().valid(...SSG_NAMES), ssgVersion: Joi.string(), nodeVersion: Joi.string(), useESM: Joi.boolean(), postGitCloneCommand: Joi.string(), preInstallCommand: Joi.string(), postInstallCommand: Joi.string(), installCommand: Joi.string(), devCommand: Joi.string(), cmsName: Joi.string().valid(...CMS_NAMES), import: importSchema, buildCommand: Joi.string(), publishDir: Joi.string(), cacheDir: Joi.string(), staticDir: Joi.string().allow(''), uploadDir: Joi.string(), assets: assetsSchema, pagesDir: Joi.string().allow('', null), dataDir: Joi.string().allow('', null), pageLayoutKey: Joi.string().allow(null), objectTypeKey: Joi.string(), excludePages: arrayOfStrings.single(), styleObjectModelName: Joi.string(), logicFields: arrayOfStrings, contentModels: Joi.any(), // contentModels should have been already validated by now presetSource: presetSourceSchema, modelsSource: modelsSourceSchema, sidebarButtons: Joi.array().items(sidebarButtonSchema), viewports: Joi.array().items(viewportSchema), actions: Joi.array().items(customActionGlobalAndBulkSchema.shared(fieldsSchema)), models: Joi.any(), modelExtensions: Joi.array().items(Joi.any()), presetReferenceBehavior: Joi.string().valid('copyReference', 'duplicateContents'), nonDuplicatableModels: arrayOfStrings.when('presetReferenceBehavior', { is: 'copyReference', then: Joi.forbidden() }), duplicatableModels: arrayOfStrings.when('presetReferenceBehavior', { is: 'duplicateContents', then: Joi.forbidden() }), customContentReload: Joi.boolean(), experimental: Joi.any(), contentSources: Joi.array().items(Joi.any()), connectors: Joi.array().items(Joi.any()), assetSources: Joi.array().items(AssetSourceSchema), contentEngine: contentEngineSchema, siteMap: Joi.function(), transformSitemap: Joi.function(), sitemap: Joi.function(), treeViews: Joi.function(), transformTreeViews: Joi.function(), mapModels: Joi.function(), permissionsForModel: Joi.function(), permissionsForDocument: Joi.function(), filterAsset: Joi.function(), mapDocuments: Joi.function(), onContentCreate: Joi.function(), onDocumentCreate: Joi.function(), onDocumentUpdate: Joi.function(), onDocumentDelete: Joi.function(), onDocumentsPublish: Joi.function(), // internal properties added by load dirPath: Joi.string(), filePath: Joi.string() }) .without('assets', ['staticDir', 'uploadDir']) .without('contentSources', ['cmsName', 'assets', 'contentModels', 'modelsSource']) .when('.modelExtensions', { is: Joi.exist(), then: Joi.object({ models: Joi.forbidden() }) }) .when('.cmsName', { is: ['contentful', 'sanity'], then: Joi.object({ assets: Joi.forbidden(), staticDir: Joi.forbidden(), uploadDir: Joi.forbidden(), pagesDir: Joi.forbidden(), dataDir: Joi.forbidden(), excludePages: Joi.forbidden() }) }); export const stackbitConfigFullSchema = stackbitConfigBaseSchema.concat( Joi.object({ styleObjectModelName: styleObjectModelNameSchema, models: modelsSchema }) .unknown(true) .shared(fieldsSchema) );