UNPKG

@stackbit/sdk

Version:
998 lines 43.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.stackbitConfigFullSchema = exports.stackbitConfigBaseSchema = exports.contentModelsSchema = exports.objectPreviewSchema = void 0; const joi_1 = __importDefault(require("joi")); const lodash_1 = __importDefault(require("lodash")); const utils_1 = require("@stackbit/utils"); const config_consts_1 = require("./config-consts"); const style_field_schema_1 = require("./config-schema/style-field-schema"); function getConfigFromValidationState(state) { return lodash_1.default.last(state.ancestors); } function getModelsFromValidationState(state) { const config = getConfigFromValidationState(state); return config.models ?? []; } const arrayOfStrings = joi_1.default.array().items(joi_1.default.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_1.default.string() .required() .pattern(fieldNamePattern) .prefs({ messages: { 'string.pattern.base': fieldNameError }, errors: { wrap: { label: false } } }); const objectModelNameErrorCode = 'model.not.object.model'; const validObjectModelNames = joi_1.default.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_1.default.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_1.default.object({ modelName: joi_1.default.string().required(), srcType: joi_1.default.string(), srcProjectId: joi_1.default.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_1.default.string() .custom((group, { error, state }) => { const config = getConfigFromValidationState(state); const groupModels = getModelNamesForGroup(group, config); if (!lodash_1.default.isEmpty(groupModels.documentModels)) { return error(groupNotObjectModelErrorCode, { nonObjectModels: groupModels.documentModels.join(', ') }); } if (lodash_1.default.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_1.default.string() .custom((group, { error, state }) => { const config = getConfigFromValidationState(state); const groupModels = getModelNamesForGroup(group, config); if (!lodash_1.default.isEmpty(groupModels.objectModels)) { return error(groupNotDocumentModelErrorCode, { nonDocumentModels: groupModels.objectModels.join(', ') }); } if (lodash_1.default.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, config) { const models = config.models ?? []; return lodash_1.default.reduce(models, (result, model) => { if (model?.groups && lodash_1.default.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_1.default.custom((value, { error, state }) => { const modelOrObjectField = lodash_1.default.head(state.ancestors); const fields = modelOrObjectField?.fields ?? []; if (!lodash_1.default.isArray(fields)) { return error(labelFieldNotFoundError); } const field = lodash_1.default.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_1.default.custom((value, { error, state }) => { const modelOrObjectField = lodash_1.default.head(state.ancestors); const fields = modelOrObjectField?.fields ?? []; if (!lodash_1.default.isArray(fields)) { return error(variantFieldNotFoundError); } const field = lodash_1.default.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_1.default.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_1.default.object({ type: joi_1.default.string().valid('contentful').required(), contentFile: joi_1.default.string().required(), uploadAssets: joi_1.default.boolean(), assetsDirectory: joi_1.default.string(), spaceIdEnvVar: joi_1.default.string(), accessTokenEnvVar: joi_1.default.string(), deliveryTokenEnvVar: joi_1.default.string(), previewTokenEnvVar: joi_1.default.string() }).and('uploadAssets', 'assetsDirectory'); const sanityImportSchema = joi_1.default.object({ type: joi_1.default.string().valid('sanity').required(), contentFile: joi_1.default.string().required(), sanityStudioPath: joi_1.default.string().required(), deployStudio: joi_1.default.boolean(), deployGraphql: joi_1.default.boolean(), projectIdEnvVar: joi_1.default.string(), datasetEnvVar: joi_1.default.string(), tokenEnvVar: joi_1.default.string() }); const importSchema = joi_1.default.alternatives().conditional('.type', { switch: [ { is: 'contentful', then: contentfulImportSchema }, { is: 'sanity', then: sanityImportSchema } ] }); const presetSourceFilesSchema = joi_1.default.object({ type: joi_1.default.string().valid(joi_1.default.override, 'files').required(), presetDirs: arrayOfStrings.required() }); const presetSourceSchema = joi_1.default.object({ type: joi_1.default.string().valid('files').required() }).when('.type', { switch: [{ is: 'files', then: presetSourceFilesSchema }] }); const modelsSourceFilesSchema = joi_1.default.object({ type: joi_1.default.string().valid(joi_1.default.override, 'files').required(), modelDirs: arrayOfStrings.required() }); const modelsSourceContentfulSchema = joi_1.default.object({ type: joi_1.default.string().valid(joi_1.default.override, 'contentful').required(), module: joi_1.default.string() }); const modelsSourceSanitySchema = joi_1.default.object({ type: joi_1.default.string().valid(joi_1.default.override, 'sanity').required(), sanityStudioPath: joi_1.default.string().required(), module: joi_1.default.string() }); const modelsSourceSchema = joi_1.default.object({ type: joi_1.default.string().valid('files', 'contentful', 'sanity').required() }).when('.type', { switch: [ { is: 'files', then: modelsSourceFilesSchema }, { is: 'contentful', then: modelsSourceContentfulSchema }, { is: 'sanity', then: modelsSourceSanitySchema } ] }); const assetsSchema = joi_1.default.object({ referenceType: joi_1.default.string().valid('static', 'relative').required(), assetsDir: joi_1.default.string().allow('').when('referenceType', { is: 'relative', then: joi_1.default.required() }), staticDir: joi_1.default.string().allow('').when('referenceType', { is: 'static', then: joi_1.default.required() }), publicPath: joi_1.default.string().allow('').when('referenceType', { is: 'static', then: joi_1.default.required() }), uploadDir: joi_1.default.string().allow('') }); const fieldGroupsSchema = joi_1.default.array() .items(joi_1.default.object({ name: joi_1.default.string().required(), label: joi_1.default.string().required(), icon: joi_1.default.string().optional() })) .unique('name') .prefs({ messages: { 'array.unique': '{{#label}} contains a duplicate group name "{{#value.name}}"' }, errors: { wrap: { label: false } } }); const inGroups = joi_1.default.string() .valid( // 4 dots "...." => // ".." for the parent field where "group" property is defined // + "." for the fields array // + "." for the parent model joi_1.default.in('....fieldGroups', { adjust: (groups) => (lodash_1.default.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 } } }); exports.objectPreviewSchema = joi_1.default.object({ title: joi_1.default.string(), subtitle: joi_1.default.string(), image: joi_1.default.string() }); const customActionPermissionsSchema = joi_1.default.object({ canExecute: joi_1.default.boolean().optional() }); const customActionBaseSchema = joi_1.default.object({ name: joi_1.default.string().required(), label: joi_1.default.string(), icon: joi_1.default.string(), preferredStyle: joi_1.default.string().valid('button-primary', 'button-secondary'), state: joi_1.default.function(), run: joi_1.default.function().required(), inputFields: joi_1.default.link('#fieldsSchema'), hidden: joi_1.default.boolean(), permissions: joi_1.default.function() }); const customActionGlobalAndBulkSchema = customActionBaseSchema.concat(joi_1.default.object({ type: joi_1.default.string().valid('global', 'bulk').required() })); const customActionPageAndDataModelSchema = customActionBaseSchema.concat(joi_1.default.object({ type: joi_1.default.string().valid('document').required() })); const customActionObjectModelSchema = customActionBaseSchema.concat(joi_1.default.object({ type: joi_1.default.string().valid('object').required() })); const customActionObjectFieldSchema = customActionBaseSchema.concat(joi_1.default.object({ type: joi_1.default.string().valid('field', 'object') })); const customActionFieldSchema = customActionBaseSchema.concat(joi_1.default.object({ type: joi_1.default.string().valid('field') })); const stringOrNumber = joi_1.default.alternatives().try(joi_1.default.string(), joi_1.default.number()); const fieldValidationSchema = joi_1.default.object({ unique: joi_1.default.boolean(), validate: joi_1.default.alternatives().try(joi_1.default.function(), joi_1.default.array().items(joi_1.default.function())), // string and text regexp: joi_1.default.string(), regexpNot: joi_1.default.string(), regexpPattern: joi_1.default.string(), // ranges min: stringOrNumber, max: stringOrNumber, after: joi_1.default.string(), before: joi_1.default.string(), exact: joi_1.default.number(), step: joi_1.default.number(), lessThan: joi_1.default.number(), greaterThan: joi_1.default.number(), // file fileMinSize: joi_1.default.number(), fileMaxSize: joi_1.default.number(), fileTypes: arrayOfStrings, fileTypeGroups: arrayOfStrings, // image minWidth: joi_1.default.number(), maxWidth: joi_1.default.number(), minHeight: joi_1.default.number(), maxHeight: joi_1.default.number(), minWidthToHeightRatio: stringOrNumber, maxWidthToHeightRatio: stringOrNumber, errors: joi_1.default.object({ unique: joi_1.default.string(), regexp: joi_1.default.string(), regexpNot: joi_1.default.string(), regexpPattern: joi_1.default.string(), min: joi_1.default.string(), max: joi_1.default.string(), after: joi_1.default.string(), before: joi_1.default.string(), exact: joi_1.default.string(), step: joi_1.default.string(), lessThan: joi_1.default.string(), greaterThan: joi_1.default.string(), fileMinSize: joi_1.default.string(), fileMaxSize: joi_1.default.string(), fileTypes: joi_1.default.string(), fileTypeGroups: joi_1.default.string(), minWidth: joi_1.default.string(), maxWidth: joi_1.default.string(), minHeight: joi_1.default.string(), maxHeight: joi_1.default.string(), minWidthToHeightRatio: joi_1.default.string(), maxWidthToHeightRatio: joi_1.default.string() }) }); const customControlTypesSchema = joi_1.default.string().valid('custom-modal-html', 'custom-inline-html', 'custom-modal-script', 'custom-inline-script'); const fieldCommonPropsSchema = joi_1.default.object({ type: joi_1.default.string() .valid(...config_consts_1.FIELD_TYPES) .required(), name: fieldNameSchema, label: joi_1.default.string(), description: joi_1.default.string().allow(''), required: joi_1.default.boolean(), validations: fieldValidationSchema, default: joi_1.default.any(), group: inGroups, const: joi_1.default.any(), hidden: joi_1.default.boolean(), readOnly: joi_1.default.boolean(), localized: joi_1.default.boolean(), actions: joi_1.default.array().items(customActionFieldSchema), controlType: customControlTypesSchema }) .oxor('const', 'default') .when('.controlType', { is: customControlTypesSchema.required(), then: joi_1.default.object({ controlUrl: joi_1.default.string(), controlFilePath: joi_1.default.string() }).xor('controlUrl', 'controlFilePath') }); const numberFieldPartialSchema = joi_1.default.object({ type: joi_1.default.string().valid('number').required(), controlType: joi_1.default.string().valid('slider'), subtype: joi_1.default.string().valid('int', 'float'), min: joi_1.default.number(), max: joi_1.default.number(), step: joi_1.default.number(), unit: joi_1.default.string() }); const enumFieldBaseOptionSchema = joi_1.default.object({ label: joi_1.default.string().required(), value: joi_1.default.alternatives().try(joi_1.default.string(), joi_1.default.number()).required() }); const imageFieldPartialSchema = joi_1.default.object({ source: joi_1.default.string() }); const enumFieldPartialSchema = joi_1.default.object({ type: joi_1.default.string().valid('enum').required(), controlType: joi_1.default.string().valid('dropdown', 'button-group', 'thumbnails', 'palette', 'palette-colors'), options: joi_1.default.any() .when('..controlType', { switch: [ { is: 'thumbnails', then: joi_1.default.array().items(enumFieldBaseOptionSchema.append({ thumbnail: joi_1.default.string().required() })) }, { is: 'palette', then: joi_1.default.array().items(enumFieldBaseOptionSchema.append({ textColor: joi_1.default.string(), backgroundColor: joi_1.default.string(), borderColor: joi_1.default.string() })) }, { is: 'palette-colors', then: joi_1.default.array().items(enumFieldBaseOptionSchema.append({ colors: arrayOfStrings.required() })) } ], otherwise: joi_1.default.alternatives().try(joi_1.default.array().items(joi_1.default.string(), joi_1.default.number()), joi_1.default.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_1.default.object({ type: joi_1.default.string().valid('boolean').required(), controlType: joi_1.default.string().valid('button-group', 'checkbox') }); const objectFieldPartialSchema = joi_1.default.object({ type: joi_1.default.string().valid('object').required(), labelField: labelFieldSchema, preview: joi_1.default.alternatives().try(exports.objectPreviewSchema, joi_1.default.function()), thumbnail: joi_1.default.string(), variantField: variantFieldSchema, actions: joi_1.default.array().items(customActionObjectFieldSchema), fieldGroups: fieldGroupsSchema, fields: joi_1.default.link('#fieldsSchema').required() }); const modelFieldPartialSchema = joi_1.default.object({ type: joi_1.default.string().valid('model').required(), models: joi_1.default.array().items(validObjectModelNames).when('groups', { not: joi_1.default.exist(), then: joi_1.default.required() }), groups: joi_1.default.array().items(validModelFieldGroups) }); const referenceFieldPartialSchema = joi_1.default.object({ type: joi_1.default.string().valid('reference').required(), models: joi_1.default.array().items(validReferenceModelNames).when('groups', { not: joi_1.default.exist(), then: joi_1.default.required() }), groups: joi_1.default.array().items(validReferenceFieldGroups) }); const crossReferenceFieldPartialSchema = joi_1.default.object({ type: joi_1.default.string().valid('cross-reference').required(), models: joi_1.default.array().items(validCrossReferenceModelNames).required() }); const listItemsSchema = joi_1.default.object({ // 'style' and 'list' are not allowed inside lists type: joi_1.default.string() .valid(...lodash_1.default.without(config_consts_1.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_1.default.object({ type: joi_1.default.string().valid('list').required(), controlType: joi_1.default.string().valid('checkbox'), items: joi_1.default.any().when('..controlType', { switch: [ { is: 'checkbox', then: enumFieldPartialSchema } ], otherwise: listItemsSchema }) }); const fieldSchema = 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: style_field_schema_1.styleFieldPartialSchema }, { is: 'list', then: listFieldPartialSchema } ] }); const fieldsSchema = joi_1.default.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_1.default.object({ isPage: joi_1.default.boolean(), newFilePath: joi_1.default.string(), singleInstance: joi_1.default.boolean(), file: joi_1.default.string(), folder: joi_1.default.string(), match: arrayOfStrings.single(), exclude: arrayOfStrings.single() }) .without('file', ['folder', 'match', 'exclude']) .when('.isPage', { is: true, then: joi_1.default.object({ urlPath: joi_1.default.string(), hideContent: joi_1.default.boolean() }) }) .custom((contentModel, { error, state, prefs }) => { const modelMap = lodash_1.default.get(prefs, 'context.modelMap'); if (!modelMap) { return contentModel; } const modelName = lodash_1.default.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 } } }); exports.contentModelsSchema = joi_1.default.object({ contentModels: joi_1.default.object().pattern(joi_1.default.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_1.default.string() .required() .pattern(modelNamePattern) .prefs({ messages: { 'string.pattern.base': modelNameError }, errors: { wrap: { label: false } } }); const modelPermissionsSchema = joi_1.default.object({ canView: joi_1.default.boolean().optional(), canEdit: joi_1.default.boolean().optional(), canPublish: joi_1.default.boolean().optional() }); const baseModelSchema = { __metadata: joi_1.default.object({ filePath: joi_1.default.string() }), name: modelNameSchema, srcType: joi_1.default.string(), srcProjectId: joi_1.default.string(), label: joi_1.default.string(), description: joi_1.default.string().allow(''), thumbnail: joi_1.default.string(), extends: joi_1.default.array().items(validObjectModelNames).single(), readOnly: joi_1.default.boolean(), labelField: labelFieldSchema, variantField: variantFieldSchema, groups: arrayOfStrings, context: joi_1.default.any(), preview: joi_1.default.alternatives().try(exports.objectPreviewSchema, joi_1.default.function()), fieldGroups: fieldGroupsSchema, fields: joi_1.default.link('#fieldsSchema'), permissions: joi_1.default.alternatives().try(modelPermissionsSchema, joi_1.default.function()) }; const localizedModelSchema = { localized: joi_1.default.boolean(), locale: joi_1.default.function() }; const objectModelSchema = joi_1.default.object({ ...baseModelSchema, type: joi_1.default.string().valid('object').required(), actions: joi_1.default.array().items(customActionObjectModelSchema) }); const dataModelSchema = joi_1.default.object({ ...baseModelSchema, ...localizedModelSchema, type: joi_1.default.string().valid('data').required(), actions: joi_1.default.array().items(customActionPageAndDataModelSchema), filePath: joi_1.default.alternatives().try(joi_1.default.string(), joi_1.default.function()), singleInstance: joi_1.default.boolean(), file: joi_1.default.string(), folder: joi_1.default.string(), match: arrayOfStrings.single(), exclude: arrayOfStrings.single(), isList: joi_1.default.boolean(), canDelete: joi_1.default.boolean() }) .when('.isList', { is: true, then: joi_1.default.object({ items: listItemsSchema.required(), fields: joi_1.default.forbidden() }) }) .when('.file', { is: joi_1.default.exist(), then: joi_1.default.object({ folder: joi_1.default.forbidden(), match: joi_1.default.forbidden(), exclude: joi_1.default.forbidden() }) }); const configModelSchema = joi_1.default.object({ ...baseModelSchema, ...localizedModelSchema, type: joi_1.default.string().valid('config').required(), file: joi_1.default.string() }); const pageModelSchema = joi_1.default.object({ ...baseModelSchema, ...localizedModelSchema, type: joi_1.default.string().valid('page').required(), actions: joi_1.default.array().items(customActionPageAndDataModelSchema), layout: joi_1.default.string(), urlPath: joi_1.default.string(), filePath: joi_1.default.alternatives().try(joi_1.default.string(), joi_1.default.function()), singleInstance: joi_1.default.boolean(), file: joi_1.default.string(), folder: joi_1.default.string(), match: arrayOfStrings.single(), exclude: arrayOfStrings.single(), hideContent: joi_1.default.boolean(), canDelete: joi_1.default.boolean() }) .when('.file', { is: joi_1.default.exist(), then: { singleInstance: joi_1.default.valid(true).required(), folder: joi_1.default.forbidden(), match: joi_1.default.forbidden(), exclude: joi_1.default.forbidden() } }) .when('.singleInstance', { is: true, then: { file: joi_1.default.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, modelIndex, fieldPath) { let fieldSpecificProps = model; let fields; 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_1.default.object({ type: joi_1.default.string().valid('page', 'data', 'config', 'object').required(), name: joi_1.default.string() }) .when('.type', { switch: [ { is: 'object', then: objectModelSchema }, { is: 'data', then: dataModelSchema }, { is: 'config', then: configModelSchema }, { is: 'page', then: pageModelSchema } ] }) .error(((errors) => { return lodash_1.default.map(errors, (error) => { if (error.path[0] === 'models' && typeof error.path[1] === 'number') { const modelIndex = error.path[1]; const config = error.prefs.context?.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)); lodash_1.default.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' && lodash_1.default.nth(error.path, -2) === 'fields') { error.code = fieldNameUnique; } return error; }); })) // 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_1.default.array() .items(modelSchema) .custom((models, { error }) => { const groupMap = {}; lodash_1.default.forEach(models, (model) => { const key = model?.type === 'object' ? 'objectModels' : 'documentModels'; lodash_1.default.forEach(model.groups, (groupName) => { (0, utils_1.append)(groupMap, [groupName, key], model.name); }); }); const errors = lodash_1.default.reduce(groupMap, (errors, 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 (!lodash_1.default.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_1.default.object({ label: joi_1.default.string().required(), icon: joi_1.default.string().required(), type: joi_1.default.string().valid('link', 'document', 'model').required() }).when('.type', { switch: [ { is: 'link', then: joi_1.default.object({ url: joi_1.default.string().required() }) }, { is: 'document', then: joi_1.default.object({ documentId: joi_1.default.string().required(), srcType: joi_1.default.string(), srcProjectId: joi_1.default.string() }) }, { is: 'model', then: joi_1.default.object({ modelName: joi_1.default.string().required(), srcType: joi_1.default.string(), srcProjectId: joi_1.default.string() }) } ] }); const viewportSchema = joi_1.default.object({ label: joi_1.default.string().required(), size: joi_1.default.object({ width: joi_1.default.number(), height: joi_1.default.number() }).required() }); const contentEngineSchema = joi_1.default.object({ host: joi_1.default.string(), port: joi_1.default.alternatives(joi_1.default.number(), joi_1.default.string()) }); const AssetSourceSchema = joi_1.default.object({ type: joi_1.default.string().required(), name: joi_1.default.string(), default: joi_1.default.boolean(), transform: joi_1.default.function(), preview: joi_1.default.alternatives().try(joi_1.default.object({ title: joi_1.default.string(), image: joi_1.default.string().required() }), joi_1.default.function()) }).when('.type', { is: 'iframe', then: joi_1.default.object({ name: joi_1.default.required(), url: joi_1.default.string().required() }) }); exports.stackbitConfigBaseSchema = joi_1.default.object({ stackbitVersion: joi_1.default.string().required(), ssgName: joi_1.default.string().valid(...config_consts_1.SSG_NAMES), ssgVersion: joi_1.default.string(), nodeVersion: joi_1.default.string(), useESM: joi_1.default.boolean(), postGitCloneCommand: joi_1.default.string(), preInstallCommand: joi_1.default.string(), postInstallCommand: joi_1.default.string(), installCommand: joi_1.default.string(), devCommand: joi_1.default.string(), cmsName: joi_1.default.string().valid(...config_consts_1.CMS_NAMES), import: importSchema, buildCommand: joi_1.default.string(), publishDir: joi_1.default.string(), cacheDir: joi_1.default.string(), staticDir: joi_1.default.string().allow(''), uploadDir: joi_1.default.string(), assets: assetsSchema, pagesDir: joi_1.default.string().allow('', null), dataDir: joi_1.default.string().allow('', null), pageLayoutKey: joi_1.default.string().allow(null), objectTypeKey: joi_1.default.string(), excludePages: arrayOfStrings.single(), styleObjectModelName: joi_1.default.string(), logicFields: arrayOfStrings, contentModels: joi_1.default.any(), presetSource: presetSourceSchema, modelsSource: modelsSourceSchema, sidebarButtons: joi_1.default.array().items(sidebarButtonSchema), viewports: joi_1.default.array().items(viewportSchema), actions: joi_1.default.array().items(customActionGlobalAndBulkSchema.shared(fieldsSchema)), models: joi_1.default.any(), modelExtensions: joi_1.default.array().items(joi_1.default.any()), presetReferenceBehavior: joi_1.default.string().valid('copyReference', 'duplicateContents'), nonDuplicatableModels: arrayOfStrings.when('presetReferenceBehavior', { is: 'copyReference', then: joi_1.default.forbidden() }), duplicatableModels: arrayOfStrings.when('presetReferenceBehavior', { is: 'duplicateContents', then: joi_1.default.forbidden() }), customContentReload: joi_1.default.boolean(), experimental: joi_1.default.any(), contentSources: joi_1.default.array().items(joi_1.default.any()), connectors: joi_1.default.array().items(joi_1.default.any()), assetSources: joi_1.default.array().items(AssetSourceSchema), contentEngine: contentEngineSchema, siteMap: joi_1.default.function(), transformSitemap: joi_1.default.function(), sitemap: joi_1.default.function(), treeViews: joi_1.default.function(), transformTreeViews: joi_1.default.function(), mapModels: joi_1.default.function(), permissionsForModel: joi_1.default.function(), permissionsForDocument: joi_1.default.function(), filterAsset: joi_1.default.function(), mapDocuments: joi_1.default.function(), onContentCreate: joi_1.default.function(), onDocumentCreate: joi_1.default.function(), onDocumentUpdate: joi_1.default.function(), onDocumentDelete: joi_1.default.function(), onDocumentsPublish: joi_1.default.function(), // internal properties added by load dirPath: joi_1.default.string(), filePath: joi_1.default.string() }) .without('assets', ['staticDir', 'uploadDir']) .without('contentSources', ['cmsName', 'assets', 'contentModels', 'modelsSource']) .when('.modelExtensions', { is: joi_1.default.exist(), then: joi_1.default.object({ models: joi_1.default.forbidden() }) }) .when('.cmsName', { is: ['contentful', 'sanity'], then: joi_1.default.object({ assets: joi_1.default.forbidden(), staticDir: joi_1.default.forbidden(), uploadDir: joi_1.default.forbidden(), pagesDir: joi_1.default.forbidden(), dataDir: joi_1.default.forbidden(), excludePages: joi_1.default.forbidden() }) }); exports.stackbitConfigFullSchema = exports.stackbitConfigBaseSchema.concat(joi_1.default.object({ styleObjectModelName: styleObjectModelNameSchema, models: modelsSchema }) .unknown(true) .shared(fieldsSchema)); //# sourceMappingURL=config-schema.js.map