@stackbit/sdk
Advanced tools
Comparing version 0.1.19 to 0.2.0
import { ConfigValidationError } from './config-validator'; | ||
import { YamlConfigModel, YamlDataModel, YamlObjectModel, YamlPageModel, YamlConfig } from './config-schema'; | ||
import { YamlConfig, YamlConfigModel, YamlDataModel, YamlObjectModel, YamlPageModel } from './config-schema'; | ||
import { ConfigLoadError } from './config-errors'; | ||
import { StricterUnion } from '../utils'; | ||
@@ -7,2 +8,3 @@ export declare type BaseModel = { | ||
__metadata?: { | ||
filePath?: string; | ||
invalid?: boolean; | ||
@@ -22,8 +24,2 @@ }; | ||
} | ||
export interface ConfigLoadError { | ||
name: 'ConfigLoadError'; | ||
message: string; | ||
internalError?: Error; | ||
normFieldPath?: undefined; | ||
} | ||
export declare type ConfigError = ConfigLoadError | ConfigNormalizedValidationError; | ||
@@ -30,0 +26,0 @@ export interface ConfigLoaderOptions { |
@@ -13,8 +13,9 @@ "use strict"; | ||
const config_validator_1 = require("./config-validator"); | ||
const config_errors_1 = require("./config-errors"); | ||
const utils_1 = require("../utils"); | ||
const utils_2 = require("@stackbit/utils"); | ||
async function loadConfig({ dirPath }) { | ||
let config; | ||
let configLoadResult; | ||
try { | ||
config = await loadConfigFromDir(dirPath); | ||
configLoadResult = await loadConfigFromDir(dirPath); | ||
} | ||
@@ -25,31 +26,20 @@ catch (error) { | ||
config: null, | ||
errors: [ | ||
{ | ||
name: 'ConfigLoadError', | ||
message: `Error loading Stackbit configuration: ${error.message}`, | ||
internalError: error | ||
} | ||
] | ||
errors: [new config_errors_1.ConfigLoadError(`Error loading Stackbit configuration: ${error.message}`, { originalError: error })] | ||
}; | ||
} | ||
if (!config) { | ||
if (!configLoadResult.config) { | ||
return { | ||
valid: false, | ||
config: null, | ||
errors: [ | ||
{ | ||
name: 'ConfigLoadError', | ||
message: 'stackbit.yaml not found, please refer Stackbit documentation: https://www.stackbit.com/docs/stackbit-yaml/' | ||
} | ||
] | ||
errors: configLoadResult.errors | ||
}; | ||
} | ||
config = normalizeConfig(config); | ||
const config = normalizeConfig(configLoadResult.config); | ||
const validationResult = config_validator_1.validate(config); | ||
const normalizedConfig = convertToTypedConfig(validationResult); | ||
const normalizedErrors = normalizeErrors(normalizedConfig, validationResult.errors); | ||
const convertedResult = convertModelsToArray(validationResult); | ||
const errors = [...configLoadResult.errors, ...convertedResult.errors]; | ||
return { | ||
valid: validationResult.valid, | ||
config: normalizedConfig, | ||
errors: normalizedErrors | ||
config: convertedResult.config, | ||
errors: errors | ||
}; | ||
@@ -59,9 +49,9 @@ } | ||
async function loadConfigFromDir(dirPath) { | ||
let config = await loadConfigFromStackbitYaml(dirPath); | ||
if (!config) { | ||
return null; | ||
let { config, error } = await loadConfigFromStackbitYaml(dirPath); | ||
if (error) { | ||
return { errors: [error] }; | ||
} | ||
const models = await loadExternalModels(dirPath, config); | ||
config.models = lodash_1.default.assign(models, config.models); | ||
return config; | ||
const externalModelsResult = await loadExternalModels(dirPath, config); | ||
config.models = lodash_1.default.assign(externalModelsResult.models, config.models); | ||
return { config, errors: externalModelsResult.errors }; | ||
} | ||
@@ -72,3 +62,5 @@ async function loadConfigFromStackbitYaml(dirPath) { | ||
if (!stackbitYamlExists) { | ||
return null; | ||
return { | ||
error: new config_errors_1.ConfigLoadError('stackbit.yaml was not found, please refer Stackbit documentation: https://www.stackbit.com/docs/stackbit-yaml/') | ||
}; | ||
} | ||
@@ -78,5 +70,7 @@ const stackbitYaml = await fs_extra_1.default.readFile(stackbitYamlPath); | ||
if (!config || typeof config !== 'object') { | ||
return null; | ||
return { | ||
error: new config_errors_1.ConfigLoadError('error parsing stackbit.yaml, please refer Stackbit documentation: https://www.stackbit.com/docs/stackbit-yaml/') | ||
}; | ||
} | ||
return config; | ||
return { config }; | ||
} | ||
@@ -98,13 +92,28 @@ async function loadExternalModels(dirPath, config) { | ||
}, []); | ||
return utils_2.reducePromise(modelFiles, async (models, modelFile) => { | ||
const model = await utils_2.parseFile(path_1.default.join(dirPath, modelFile)); | ||
return utils_2.reducePromise(modelFiles, async (result, modelFile) => { | ||
let model; | ||
try { | ||
model = await utils_2.parseFile(path_1.default.join(dirPath, modelFile)); | ||
} | ||
catch (error) { | ||
return { | ||
models: result.models, | ||
errors: result.errors.concat(new config_errors_1.ConfigLoadError(`error parsing model, file: ${modelFile}`)) | ||
}; | ||
} | ||
const modelName = model.name; | ||
if (!modelName) { | ||
return models; | ||
return { | ||
models: result.models, | ||
errors: result.errors.concat(new config_errors_1.ConfigLoadError(`model does not have a name, file: ${modelFile}`)) | ||
}; | ||
} | ||
models[modelName] = lodash_1.default.omit(model, 'name'); | ||
return models; | ||
}, {}); | ||
result.models[modelName] = lodash_1.default.omit(model, 'name'); | ||
result.models[modelName].__metadata = { | ||
filePath: modelFile | ||
}; | ||
return result; | ||
}, { models: {}, errors: [] }); | ||
} | ||
return null; | ||
return { models: {}, errors: [] }; | ||
} | ||
@@ -161,2 +170,3 @@ async function readModelFilesFromDir(modelsDir) { | ||
lodash_1.default.forEach(models, (model) => { | ||
var _a; | ||
if (!model) { | ||
@@ -184,3 +194,5 @@ return; | ||
} | ||
normalizeThumbnailPathForModel(model, (_a = model === null || model === void 0 ? void 0 : model.__metadata) === null || _a === void 0 ? void 0 : _a.filePath); | ||
utils_1.iterateModelFieldsRecursively(model, (field, fieldPath) => { | ||
var _a; | ||
// add field label if label is not set | ||
@@ -199,2 +211,3 @@ if (!lodash_1.default.has(field, 'label')) { | ||
utils_1.assignLabelFieldIfNeeded(field); | ||
normalizeThumbnailPathForModel(field, (_a = model === null || model === void 0 ? void 0 : model.__metadata) === null || _a === void 0 ? void 0 : _a.filePath); | ||
} | ||
@@ -240,2 +253,4 @@ else if (utils_1.isCustomModelField(field, models)) { | ||
// TODO: update schema-editor to not show type field | ||
// TODO: do not add objectTypeKey field to models, API/container should | ||
// be able to add it automatically when data object or polymorphic nested model is added | ||
addObjectTypeKeyField(model, objectTypeKey, modelName); | ||
@@ -286,2 +301,7 @@ }); | ||
} | ||
function normalizeThumbnailPathForModel(modelOrField, filePath) { | ||
if (modelOrField.thumbnail && filePath) { | ||
modelOrField.thumbnail = path_1.default.join(path_1.default.dirname(filePath), modelOrField.thumbnail); | ||
} | ||
} | ||
/** | ||
@@ -313,36 +333,19 @@ * Returns model names referenced by polymorphic 'model' and 'reference' fields. | ||
} | ||
function convertToTypedConfig(validationResult) { | ||
function convertModelsToArray(validationResult) { | ||
var _a; | ||
const config = lodash_1.default.cloneDeep(validationResult.value); | ||
const invalidModelNames = lodash_1.default.reduce(validationResult.errors, (modelNames, error) => { | ||
if (error.fieldPath[0] === 'models' && typeof error.fieldPath[1] == 'string') { | ||
const modelName = error.fieldPath[1]; | ||
modelNames.push(modelName); | ||
} | ||
return modelNames; | ||
}, []); | ||
// in stackbit.yaml 'models' are defined as object where keys are model names, | ||
// convert 'models' to array of objects while 'name' property set to the | ||
// in stackbit.yaml 'models' are defined as object where keys are the model names, | ||
// convert 'models' to array of objects and set their 'name' property to the | ||
// model name | ||
const modelMap = (_a = config.models) !== null && _a !== void 0 ? _a : {}; | ||
let models = lodash_1.default.map(modelMap, (yamlModel, modelName) => { | ||
const model = { | ||
let modelArray = lodash_1.default.map(modelMap, (yamlModel, modelName) => { | ||
return { | ||
name: modelName, | ||
...yamlModel | ||
}; | ||
if (invalidModelNames.includes(modelName)) { | ||
lodash_1.default.set(model, '__metadata.invalid', true); | ||
} | ||
return model; | ||
}); | ||
return { | ||
...config, | ||
models: models | ||
}; | ||
} | ||
function normalizeErrors(config, errors) { | ||
return lodash_1.default.map(errors, (error) => { | ||
const convertedErrors = lodash_1.default.map(validationResult.errors, (error) => { | ||
if (error.fieldPath[0] === 'models' && typeof error.fieldPath[1] == 'string') { | ||
const modelName = error.fieldPath[1]; | ||
const modelIndex = lodash_1.default.findIndex(config.models, { name: modelName }); | ||
const modelIndex = lodash_1.default.findIndex(modelArray, { name: modelName }); | ||
const normFieldPath = error.fieldPath.slice(); | ||
@@ -360,3 +363,10 @@ normFieldPath[1] = modelIndex; | ||
}); | ||
return { | ||
config: { | ||
...config, | ||
models: modelArray | ||
}, | ||
errors: convertedErrors | ||
}; | ||
} | ||
//# sourceMappingURL=config-loader.js.map |
@@ -52,2 +52,3 @@ import Joi from 'joi'; | ||
default?: unknown; | ||
group?: string; | ||
const?: unknown; | ||
@@ -65,2 +66,3 @@ hidden?: boolean; | ||
type: 'enum'; | ||
controlType?: 'dropdown' | 'button-group' | 'thumbnails' | 'palette'; | ||
options: FieldSchemaEnumOptions; | ||
@@ -71,2 +73,5 @@ } | ||
labelField?: string; | ||
thumbnail?: string; | ||
fieldGroups?: string; | ||
variantField?: string; | ||
fields: Field[]; | ||
@@ -116,7 +121,17 @@ } | ||
export declare type Field = FieldPartialProps & FieldCommonProps; | ||
export interface FieldGroupItem { | ||
name: string; | ||
label: string; | ||
} | ||
export interface YamlBaseModel { | ||
__metadata?: { | ||
filePath?: string; | ||
}; | ||
label: string; | ||
description?: string; | ||
thumbnail?: string; | ||
extends?: string | string[]; | ||
labelField?: string; | ||
fieldGroups?: FieldGroupItem[]; | ||
variantField?: string; | ||
fields?: Field[]; | ||
@@ -123,0 +138,0 @@ } |
@@ -43,3 +43,2 @@ "use strict"; | ||
const objectModelNameErrorCode = 'model.name.of.object.models'; | ||
const documentModelNameErrorCode = 'model.name.of.document.models'; | ||
const validObjectModelNames = joi_1.default.custom((value, { error, state }) => { | ||
@@ -54,3 +53,9 @@ var _a; | ||
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.name.of.document.models'; | ||
const validPageOrDataModelNames = joi_1.default.custom((value, { error, state }) => { | ||
@@ -65,2 +70,7 @@ var _a; | ||
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 } } | ||
}); | ||
@@ -72,10 +82,50 @@ const logicField = joi_1.default.string(); | ||
// }); | ||
const inFields = joi_1.default.string() | ||
.valid(joi_1.default.in('fields', { | ||
adjust: (fields) => (lodash_1.default.isArray(fields) ? fields.map((field) => field.name) : []) | ||
})) | ||
.prefs({ | ||
messages: { 'any.only': '{{#label}} must be one of model field names, got "{{#value}}"' }, | ||
const labelFieldNotFoundError = 'labelField.not.found'; | ||
const labelFieldNotSimple = 'labelField.not.simple'; | ||
const labelFieldSchema = joi_1.default.custom((value, { error, state }) => { | ||
var _a; | ||
const modelOrObjectField = lodash_1.default.head(state.ancestors); | ||
const fields = (_a = modelOrObjectField === null || modelOrObjectField === void 0 ? void 0 : modelOrObjectField.fields) !== null && _a !== void 0 ? _a : []; | ||
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', '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 }) => { | ||
var _a; | ||
const modelOrObjectField = lodash_1.default.head(state.ancestors); | ||
const fields = (_a = modelOrObjectField === null || modelOrObjectField === void 0 ? void 0 : modelOrObjectField.fields) !== null && _a !== void 0 ? _a : []; | ||
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 contentfulImportSchema = joi_1.default.object({ | ||
@@ -125,2 +175,27 @@ type: joi_1.default.string().valid('contentful').required(), | ||
}); | ||
const fieldGroupsSchema = joi_1.default.array() | ||
.items(joi_1.default.object({ | ||
name: joi_1.default.string().required(), | ||
label: joi_1.default.string().required() | ||
})) | ||
.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 } } | ||
}); | ||
const fieldCommonPropsSchema = joi_1.default.object({ | ||
@@ -135,2 +210,3 @@ type: joi_1.default.string() | ||
default: joi_1.default.any(), | ||
group: inGroups, | ||
const: joi_1.default.any(), | ||
@@ -147,12 +223,35 @@ hidden: joi_1.default.boolean(), | ||
}); | ||
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 enumFieldPartialSchema = joi_1.default.object({ | ||
type: joi_1.default.string().valid('enum').required(), | ||
options: joi_1.default.alternatives() | ||
.try(joi_1.default.array().items(joi_1.default.string(), joi_1.default.number()), joi_1.default.array().items(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() | ||
}))) | ||
controlType: joi_1.default.string().valid('dropdown', 'button-group', 'thumbnails', 'palette'), | ||
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() | ||
})) | ||
} | ||
], | ||
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 array of objects with label and value properties' }, | ||
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 } } | ||
@@ -163,3 +262,6 @@ }) | ||
type: joi_1.default.string().valid('object').required(), | ||
labelField: inFields, | ||
labelField: labelFieldSchema, | ||
thumbnail: joi_1.default.string(), | ||
fieldGroups: fieldGroupsSchema, | ||
variantField: variantFieldSchema, | ||
fields: joi_1.default.link('#fieldsSchema').required() | ||
@@ -197,7 +299,13 @@ }); | ||
const baseModelSchema = joi_1.default.object({ | ||
__metadata: joi_1.default.object({ | ||
filePath: joi_1.default.string() | ||
}), | ||
type: joi_1.default.string().valid('page', 'data', 'config', 'object').required(), | ||
label: joi_1.default.string().required().when(joi_1.default.ref('/import'), { is: joi_1.default.exist(), then: joi_1.default.optional() }), | ||
description: joi_1.default.string(), | ||
thumbnail: joi_1.default.string(), | ||
extends: joi_1.default.array().items(validObjectModelNames).single(), | ||
labelField: inFields, | ||
labelField: labelFieldSchema, | ||
fieldGroups: fieldGroupsSchema, | ||
variantField: variantFieldSchema, | ||
fields: joi_1.default.link('#fieldsSchema') | ||
@@ -312,4 +420,2 @@ }); | ||
[modelListForbiddenErrorCode]: '{{#label}} is not allowed when "isList" is not true', | ||
[objectModelNameErrorCode]: '{{#label}} must reference the name of an existing model of type "object", got "{{#value}}"', | ||
[documentModelNameErrorCode]: '{{#label}} must reference the name of an existing model of type "page" or "data", got "{{#value}}"', | ||
[fieldNameUnique]: '{{#label}} contains a duplicate field name "{{#value.name}}"' | ||
@@ -316,0 +422,0 @@ }, |
@@ -6,2 +6,3 @@ export interface ConfigValidationError { | ||
fieldPath: (string | number)[]; | ||
normFieldPath?: (string | number)[]; | ||
value?: any; | ||
@@ -8,0 +9,0 @@ } |
@@ -25,2 +25,3 @@ "use strict"; | ||
}); | ||
markInvalidModels(value, errors); | ||
const valid = lodash_1.default.isEmpty(errors); | ||
@@ -34,2 +35,23 @@ return { | ||
exports.validate = validate; | ||
function markInvalidModels(config, errors) { | ||
var _a; | ||
const invalidModelNames = getInvalidModelNames(errors); | ||
const models = (_a = config.models) !== null && _a !== void 0 ? _a : {}; | ||
lodash_1.default.forEach(models, (model, modelName) => { | ||
if (invalidModelNames.includes(modelName)) { | ||
lodash_1.default.set(model, '__metadata.invalid', true); | ||
} | ||
}); | ||
} | ||
function getInvalidModelNames(errors) { | ||
// get array of invalid model names by iterating errors and filtering these | ||
// having fieldPath starting with ['models', modelName] | ||
return lodash_1.default.reduce(errors, (modelNames, error) => { | ||
if (error.fieldPath[0] === 'models' && typeof error.fieldPath[1] == 'string') { | ||
const modelName = error.fieldPath[1]; | ||
modelNames.push(modelName); | ||
} | ||
return modelNames; | ||
}, []); | ||
} | ||
//# sourceMappingURL=config-validator.js.map |
export * from './config/config-schema'; | ||
export * from './content/content-errors'; | ||
export { loadConfig, ObjectModel, DataModel, ConfigModel, PageModel, Model, ConfigLoaderOptions, ConfigLoaderResult, Config, ConfigError, ConfigLoadError, ConfigNormalizedValidationError } from './config/config-loader'; | ||
export { loadConfig, ObjectModel, DataModel, ConfigModel, PageModel, Model, ConfigLoaderOptions, ConfigLoaderResult, Config, ConfigError, ConfigNormalizedValidationError } from './config/config-loader'; | ||
export * from './config/config-errors'; | ||
export { writeConfig, WriteConfigOptions, convertToYamlConfig } from './config/config-writer'; | ||
@@ -5,0 +6,0 @@ export { loadContent, ContentItem, ContentLoaderOptions, ContentLoaderResult } from './content/content-loader'; |
@@ -18,2 +18,3 @@ "use strict"; | ||
Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_loader_1.loadConfig; } }); | ||
__exportStar(require("./config/config-errors"), exports); | ||
var config_writer_1 = require("./config/config-writer"); | ||
@@ -20,0 +21,0 @@ Object.defineProperty(exports, "writeConfig", { enumerable: true, get: function () { return config_writer_1.writeConfig; } }); |
{ | ||
"name": "@stackbit/sdk", | ||
"version": "0.1.19", | ||
"version": "0.2.0", | ||
"description": "Stackbit SDK", | ||
@@ -5,0 +5,0 @@ "main": "dist/index.js", |
@@ -7,18 +7,19 @@ import path from 'path'; | ||
import { validate, ConfigValidationResult, ConfigValidationError } from './config-validator'; | ||
import { YamlConfigModel, YamlDataModel, YamlModel, YamlObjectModel, YamlPageModel, YamlConfig, Field, FieldModel, FieldListModel } from './config-schema'; | ||
import { ConfigValidationError, ConfigValidationResult, validate } from './config-validator'; | ||
import { FieldModel, FieldObjectProps, YamlConfig, YamlConfigModel, YamlDataModel, YamlModel, YamlObjectModel, YamlPageModel } from './config-schema'; | ||
import { ConfigLoadError } from './config-errors'; | ||
import { | ||
assignLabelFieldIfNeeded, | ||
extendModelMap, | ||
getListItemsField, | ||
isCustomModelField, | ||
isListDataModel, | ||
isListField, | ||
isModelField, | ||
isObjectField, | ||
isObjectListItems, | ||
isObjectField, | ||
StricterUnion, | ||
isCustomModelField, | ||
isModelField, | ||
isPageModel, | ||
isReferenceField, | ||
getListItemsField, | ||
assignLabelFieldIfNeeded, | ||
extendModelMap, | ||
isPageModel, | ||
isListField, | ||
iterateModelFieldsRecursively | ||
iterateModelFieldsRecursively, | ||
StricterUnion | ||
} from '../utils'; | ||
@@ -30,2 +31,3 @@ import { append, parseFile, readDirRecursively, reducePromise, rename } from '@stackbit/utils'; | ||
__metadata?: { | ||
filePath?: string; | ||
invalid?: boolean; | ||
@@ -48,9 +50,2 @@ }; | ||
export interface ConfigLoadError { | ||
name: 'ConfigLoadError'; | ||
message: string; | ||
internalError?: Error; | ||
normFieldPath?: undefined; | ||
} | ||
export type ConfigError = ConfigLoadError | ConfigNormalizedValidationError; | ||
@@ -69,5 +64,5 @@ | ||
export async function loadConfig({ dirPath }: ConfigLoaderOptions): Promise<ConfigLoaderResult> { | ||
let config; | ||
let configLoadResult; | ||
try { | ||
config = await loadConfigFromDir(dirPath); | ||
configLoadResult = await loadConfigFromDir(dirPath); | ||
} catch (error) { | ||
@@ -77,51 +72,44 @@ return { | ||
config: null, | ||
errors: [ | ||
{ | ||
name: 'ConfigLoadError', | ||
message: `Error loading Stackbit configuration: ${error.message}`, | ||
internalError: error | ||
} | ||
] | ||
errors: [new ConfigLoadError(`Error loading Stackbit configuration: ${error.message}`, { originalError: error })] | ||
}; | ||
} | ||
if (!config) { | ||
if (!configLoadResult.config) { | ||
return { | ||
valid: false, | ||
config: null, | ||
errors: [ | ||
{ | ||
name: 'ConfigLoadError', | ||
message: 'stackbit.yaml not found, please refer Stackbit documentation: https://www.stackbit.com/docs/stackbit-yaml/' | ||
} | ||
] | ||
errors: configLoadResult.errors | ||
}; | ||
} | ||
config = normalizeConfig(config); | ||
const config = normalizeConfig(configLoadResult.config); | ||
const validationResult = validate(config); | ||
const normalizedConfig = convertToTypedConfig(validationResult); | ||
const normalizedErrors = normalizeErrors(normalizedConfig, validationResult.errors); | ||
const convertedResult = convertModelsToArray(validationResult); | ||
const errors = [...configLoadResult.errors, ...convertedResult.errors]; | ||
return { | ||
valid: validationResult.valid, | ||
config: normalizedConfig, | ||
errors: normalizedErrors | ||
config: convertedResult.config, | ||
errors: errors | ||
}; | ||
} | ||
async function loadConfigFromDir(dirPath: string) { | ||
let config = await loadConfigFromStackbitYaml(dirPath); | ||
if (!config) { | ||
return null; | ||
async function loadConfigFromDir(dirPath: string): Promise<{ config?: any; errors: ConfigLoadError[] }> { | ||
let { config, error } = await loadConfigFromStackbitYaml(dirPath); | ||
if (error) { | ||
return { errors: [error] }; | ||
} | ||
const models = await loadExternalModels(dirPath, config); | ||
config.models = _.assign(models, config.models); | ||
return config; | ||
const externalModelsResult = await loadExternalModels(dirPath, config); | ||
config.models = _.assign(externalModelsResult.models, config.models); | ||
return { config, errors: externalModelsResult.errors }; | ||
} | ||
async function loadConfigFromStackbitYaml(dirPath: string): Promise<any> { | ||
type LoadConfigFromStackbitYamlResult = { config: any; error?: undefined } | { config?: undefined; error: ConfigLoadError }; | ||
async function loadConfigFromStackbitYaml(dirPath: string): Promise<LoadConfigFromStackbitYamlResult> { | ||
const stackbitYamlPath = path.join(dirPath, 'stackbit.yaml'); | ||
const stackbitYamlExists = await fse.pathExists(stackbitYamlPath); | ||
if (!stackbitYamlExists) { | ||
return null; | ||
return { | ||
error: new ConfigLoadError('stackbit.yaml was not found, please refer Stackbit documentation: https://www.stackbit.com/docs/stackbit-yaml/') | ||
}; | ||
} | ||
@@ -131,5 +119,7 @@ const stackbitYaml = await fse.readFile(stackbitYamlPath); | ||
if (!config || typeof config !== 'object') { | ||
return null; | ||
return { | ||
error: new ConfigLoadError('error parsing stackbit.yaml, please refer Stackbit documentation: https://www.stackbit.com/docs/stackbit-yaml/') | ||
}; | ||
} | ||
return config; | ||
return { config }; | ||
} | ||
@@ -158,15 +148,29 @@ | ||
modelFiles, | ||
async (models: any, modelFile) => { | ||
const model = await parseFile(path.join(dirPath, modelFile)); | ||
async (result: { models: any; errors: ConfigLoadError[] }, modelFile) => { | ||
let model; | ||
try { | ||
model = await parseFile(path.join(dirPath, modelFile)); | ||
} catch (error) { | ||
return { | ||
models: result.models, | ||
errors: result.errors.concat(new ConfigLoadError(`error parsing model, file: ${modelFile}`)) | ||
}; | ||
} | ||
const modelName = model.name; | ||
if (!modelName) { | ||
return models; | ||
return { | ||
models: result.models, | ||
errors: result.errors.concat(new ConfigLoadError(`model does not have a name, file: ${modelFile}`)) | ||
}; | ||
} | ||
models[modelName] = _.omit(model, 'name'); | ||
return models; | ||
result.models[modelName] = _.omit(model, 'name'); | ||
result.models[modelName].__metadata = { | ||
filePath: modelFile | ||
}; | ||
return result; | ||
}, | ||
{} | ||
{ models: {}, errors: [] } | ||
); | ||
} | ||
return null; | ||
return { models: {}, errors: [] }; | ||
} | ||
@@ -258,2 +262,4 @@ | ||
normalizeThumbnailPathForModel(model, model?.__metadata?.filePath); | ||
iterateModelFieldsRecursively(model, (field: any, fieldPath) => { | ||
@@ -275,2 +281,3 @@ // add field label if label is not set | ||
assignLabelFieldIfNeeded(field); | ||
normalizeThumbnailPathForModel(field, model?.__metadata?.filePath); | ||
} else if (isCustomModelField(field, models)) { | ||
@@ -317,2 +324,4 @@ // stackbit v0.2.0 compatibility | ||
// TODO: update schema-editor to not show type field | ||
// TODO: do not add objectTypeKey field to models, API/container should | ||
// be able to add it automatically when data object or polymorphic nested model is added | ||
addObjectTypeKeyField(model, objectTypeKey, modelName); | ||
@@ -368,2 +377,8 @@ }); | ||
function normalizeThumbnailPathForModel(modelOrField: Model | FieldObjectProps, filePath: string | undefined) { | ||
if (modelOrField.thumbnail && filePath) { | ||
modelOrField.thumbnail = path.join(path.dirname(filePath), modelOrField.thumbnail); | ||
} | ||
} | ||
/** | ||
@@ -394,46 +409,23 @@ * Returns model names referenced by polymorphic 'model' and 'reference' fields. | ||
function convertToTypedConfig(validationResult: ConfigValidationResult): Config { | ||
function convertModelsToArray(validationResult: ConfigValidationResult): { config: Config; errors: ConfigNormalizedValidationError[] } { | ||
const config = _.cloneDeep(validationResult.value); | ||
const invalidModelNames = _.reduce( | ||
validationResult.errors, | ||
(modelNames: string[], error: ConfigValidationError) => { | ||
if (error.fieldPath[0] === 'models' && typeof error.fieldPath[1] == 'string') { | ||
const modelName = error.fieldPath[1]; | ||
modelNames.push(modelName); | ||
} | ||
return modelNames; | ||
}, | ||
[] | ||
); | ||
// in stackbit.yaml 'models' are defined as object where keys are model names, | ||
// convert 'models' to array of objects while 'name' property set to the | ||
// in stackbit.yaml 'models' are defined as object where keys are the model names, | ||
// convert 'models' to array of objects and set their 'name' property to the | ||
// model name | ||
const modelMap = config.models ?? {}; | ||
let models: Model[] = _.map( | ||
let modelArray: Model[] = _.map( | ||
modelMap, | ||
(yamlModel: YamlModel, modelName: string): Model => { | ||
const model: Model = { | ||
return { | ||
name: modelName, | ||
...yamlModel | ||
}; | ||
if (invalidModelNames.includes(modelName)) { | ||
_.set(model, '__metadata.invalid', true); | ||
} | ||
return model; | ||
} | ||
); | ||
return { | ||
...config, | ||
models: models | ||
}; | ||
} | ||
function normalizeErrors(config: Config, errors: ConfigValidationError[]): ConfigNormalizedValidationError[] { | ||
return _.map(errors, (error: ConfigValidationError) => { | ||
const convertedErrors = _.map(validationResult.errors, (error: ConfigValidationError) => { | ||
if (error.fieldPath[0] === 'models' && typeof error.fieldPath[1] == 'string') { | ||
const modelName = error.fieldPath[1]; | ||
const modelIndex = _.findIndex(config.models, { name: modelName }); | ||
const modelIndex = _.findIndex(modelArray, { name: modelName }); | ||
const normFieldPath = error.fieldPath.slice(); | ||
@@ -451,2 +443,10 @@ normFieldPath[1] = modelIndex; | ||
}); | ||
return { | ||
config: { | ||
...config, | ||
models: modelArray | ||
}, | ||
errors: convertedErrors | ||
}; | ||
} |
@@ -46,4 +46,2 @@ import Joi from 'joi'; | ||
const objectModelNameErrorCode = 'model.name.of.object.models'; | ||
const documentModelNameErrorCode = 'model.name.of.document.models'; | ||
const validObjectModelNames = Joi.custom((value, { error, state }) => { | ||
@@ -57,4 +55,10 @@ const models = _.last<YamlConfig>(state.ancestors)!.models ?? {}; | ||
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.name.of.document.models'; | ||
const validPageOrDataModelNames = Joi.custom((value, { error, state }) => { | ||
@@ -68,2 +72,7 @@ const models = _.last<YamlConfig>(state.ancestors)!.models ?? {}; | ||
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 } } | ||
}); | ||
@@ -79,13 +88,50 @@ | ||
const inFields = Joi.string() | ||
.valid( | ||
Joi.in('fields', { | ||
adjust: (fields) => (_.isArray(fields) ? fields.map((field) => field.name) : []) | ||
}) | ||
) | ||
.prefs({ | ||
messages: { 'any.only': '{{#label}} must be one of model field names, got "{{#value}}"' }, | ||
errors: { wrap: { label: false } } | ||
}); | ||
const labelFieldNotFoundError = 'labelField.not.found'; | ||
const labelFieldNotSimple = 'labelField.not.simple'; | ||
const labelFieldSchema = Joi.custom((value, { error, state }) => { | ||
const modelOrObjectField: YamlBaseModel | 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', '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: YamlBaseModel | 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 } } | ||
}); | ||
export interface ContentfulImport { | ||
@@ -191,2 +237,3 @@ type: 'contentful'; | ||
default?: unknown; | ||
group?: string; | ||
const?: unknown; | ||
@@ -208,2 +255,3 @@ hidden?: boolean; | ||
type: 'enum'; | ||
controlType?: 'dropdown' | 'button-group' | 'thumbnails' | 'palette'; | ||
options: FieldSchemaEnumOptions; | ||
@@ -215,2 +263,5 @@ } | ||
labelField?: string; | ||
thumbnail?: string; | ||
fieldGroups?: string; | ||
variantField?: string; | ||
fields: Field[]; | ||
@@ -273,2 +324,32 @@ } | ||
const fieldGroupsSchema = Joi.array() | ||
.items( | ||
Joi.object({ | ||
name: Joi.string().required(), | ||
label: Joi.string().required() | ||
}) | ||
) | ||
.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 } } | ||
}); | ||
const fieldCommonPropsSchema = Joi.object({ | ||
@@ -283,2 +364,3 @@ type: Joi.string() | ||
default: Joi.any(), | ||
group: inGroups, | ||
const: Joi.any(), | ||
@@ -297,17 +379,40 @@ hidden: Joi.boolean(), | ||
const enumFieldBaseOptionSchema = Joi.object({ | ||
label: Joi.string().required(), | ||
value: Joi.alternatives().try(Joi.string(), Joi.number()).required() | ||
}); | ||
const enumFieldPartialSchema = Joi.object({ | ||
type: Joi.string().valid('enum').required(), | ||
options: Joi.alternatives() | ||
.try( | ||
Joi.array().items(Joi.string(), Joi.number()), | ||
Joi.array().items( | ||
Joi.object({ | ||
label: Joi.string().required(), | ||
value: Joi.alternatives().try(Joi.string(), Joi.number()).required() | ||
}) | ||
) | ||
) | ||
controlType: Joi.string().valid('dropdown', 'button-group', 'thumbnails', 'palette'), | ||
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() | ||
}) | ||
) | ||
} | ||
], | ||
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 array of objects with label and value properties' }, | ||
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 } } | ||
@@ -319,3 +424,6 @@ }) | ||
type: Joi.string().valid('object').required(), | ||
labelField: inFields, | ||
labelField: labelFieldSchema, | ||
thumbnail: Joi.string(), | ||
fieldGroups: fieldGroupsSchema, | ||
variantField: variantFieldSchema, | ||
fields: Joi.link('#fieldsSchema').required() | ||
@@ -361,7 +469,18 @@ }); | ||
export interface FieldGroupItem { | ||
name: string; | ||
label: string; | ||
} | ||
export interface YamlBaseModel { | ||
__metadata?: { | ||
filePath?: string; | ||
}, | ||
label: string; | ||
description?: string; | ||
thumbnail?: string; | ||
extends?: string | string[]; | ||
labelField?: string; | ||
fieldGroups?: FieldGroupItem[]; | ||
variantField?: string; | ||
fields?: Field[]; | ||
@@ -377,7 +496,13 @@ } | ||
const baseModelSchema = Joi.object({ | ||
__metadata: Joi.object({ | ||
filePath: Joi.string() | ||
}), | ||
type: Joi.string().valid('page', 'data', 'config', 'object').required(), | ||
label: Joi.string().required().when(Joi.ref('/import'), { is: Joi.exist(), then: Joi.optional() }), | ||
description: Joi.string(), | ||
thumbnail: Joi.string(), | ||
extends: Joi.array().items(validObjectModelNames).single(), | ||
labelField: inFields, | ||
labelField: labelFieldSchema, | ||
fieldGroups: fieldGroupsSchema, | ||
variantField: variantFieldSchema, | ||
fields: Joi.link('#fieldsSchema') | ||
@@ -562,4 +687,2 @@ }); | ||
[modelListForbiddenErrorCode]: '{{#label}} is not allowed when "isList" is not true', | ||
[objectModelNameErrorCode]: '{{#label}} must reference the name of an existing model of type "object", got "{{#value}}"', | ||
[documentModelNameErrorCode]: '{{#label}} must reference the name of an existing model of type "page" or "data", got "{{#value}}"', | ||
[fieldNameUnique]: '{{#label}} contains a duplicate field name "{{#value.name}}"' | ||
@@ -566,0 +689,0 @@ }, |
import _ from 'lodash'; | ||
import { stackbitConfigSchema } from './config-schema'; | ||
import { stackbitConfigSchema, YamlModel } from './config-schema'; | ||
import { Model } from './config-loader'; | ||
@@ -9,2 +10,3 @@ export interface ConfigValidationError { | ||
fieldPath: (string | number)[]; | ||
normFieldPath?: (string | number)[]; | ||
value?: any; | ||
@@ -35,2 +37,3 @@ } | ||
); | ||
markInvalidModels(value, errors); | ||
const valid = _.isEmpty(errors); | ||
@@ -43,1 +46,27 @@ return { | ||
} | ||
function markInvalidModels(config: any, errors: ConfigValidationError[]) { | ||
const invalidModelNames = getInvalidModelNames(errors); | ||
const models = config.models ?? {}; | ||
_.forEach(models, (model: any, modelName: string): any => { | ||
if (invalidModelNames.includes(modelName)) { | ||
_.set(model, '__metadata.invalid', true); | ||
} | ||
}); | ||
} | ||
function getInvalidModelNames(errors: ConfigValidationError[]) { | ||
// get array of invalid model names by iterating errors and filtering these | ||
// having fieldPath starting with ['models', modelName] | ||
return _.reduce( | ||
errors, | ||
(modelNames: string[], error: ConfigValidationError) => { | ||
if (error.fieldPath[0] === 'models' && typeof error.fieldPath[1] == 'string') { | ||
const modelName = error.fieldPath[1]; | ||
modelNames.push(modelName); | ||
} | ||
return modelNames; | ||
}, | ||
[] | ||
); | ||
} |
@@ -14,5 +14,5 @@ export * from './config/config-schema'; | ||
ConfigError, | ||
ConfigLoadError, | ||
ConfigNormalizedValidationError | ||
} from './config/config-loader'; | ||
export * from './config/config-errors'; | ||
export { writeConfig, WriteConfigOptions, convertToYamlConfig } from './config/config-writer'; | ||
@@ -19,0 +19,0 @@ export { loadContent, ContentItem, ContentLoaderOptions, ContentLoaderResult } from './content/content-loader'; |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Native code
Supply chain riskContains native code (e.g., compiled binaries or shared libraries). Including native code can obscure malicious behavior.
Found 1 instance in 1 package
603057
99
10846
0