@stackbit/sdk
Version:
1,126 lines (1,043 loc) • 41.6 kB
text/typescript
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)
);