@stackbit/sdk
Version:
1,262 lines (1,139 loc) • 48.2 kB
text/typescript
import path from 'path';
import fse from 'fs-extra';
import chokidar from 'chokidar';
import semver from 'semver';
import _ from 'lodash';
import * as StackbitTypes from '@stackbit/types';
import {
ModelsSource,
Field,
ModelExtension,
FieldExtension,
FieldType,
DistributePartialListItems,
FieldListItems,
CustomActionDocument,
CustomActionObjectModel,
Experimental
} from '@stackbit/types';
import { append, fieldPathToString, getFirstExistingFile, omitByNil, prepend, rename } from '@stackbit/utils';
import { ConfigValidationResult, validateBaseConfig, validateConfig, validateContentModels } from './config-validator';
import { ConfigError, ConfigLoadError, ModelLoadError, ConfigValidationError, StackbitConfigNotFoundError } from './config-errors';
import { loadStackbitConfigFromJs } from './config-loader-esbuild';
import {
assignLabelFieldIfNeeded,
getListItemsOrSelf,
getModelFieldForModelKeyPath,
isCustomModelField,
isDataModel,
isListDataModel,
isModelField,
isObjectField,
isObjectListItems,
isPageModel,
isReferenceField,
mapModelFieldsRecursively,
normalizeListFieldInPlace,
mapListItemsPropsOrSelfSpecificProps,
Logger
} from '../utils';
import type { Config, StackbitConfigWithPaths, SSGRunOptions, Model, PageModel, DataModel, ObjectModel, DataModelSingle } from './config-types';
import { extendModelsWithPresetsIds, loadPresets } from './presets-loader';
import {
LoadStackbitConfigResult,
WithReloadAndDestroy,
LATEST_STACKBIT_VERSION,
STACKBIT_CONFIG_JS_FILES,
STACKBIT_CONFIG_YAML_FILES,
loadStackbitYamlFromDir,
loadYamlModelsFromFiles,
mergeConfigModelsWithModelsFromFiles
} from './config-loader-utils';
const CONTENT_ENGINE_DEFAULT_PORT = 8000;
export type ConfigWithModelsPresetsResult = {
valid: boolean;
config: Config | null;
errors: ConfigError[];
};
export type ConfigWithModelsPresetsResultWithReloadDestroy = WithReloadAndDestroy<ConfigWithModelsPresetsResult>;
export async function loadConfigWithModelsPresetsAndValidate({
dirPath,
modelsSource,
stackbitConfigESBuildOutDir,
watchCallback,
logger,
isForcedGitCSI
}: {
dirPath: string;
modelsSource?: ModelsSource;
stackbitConfigESBuildOutDir?: string;
watchCallback?: (result: ConfigWithModelsPresetsResult) => void;
logger?: Logger;
isForcedGitCSI?: boolean;
}): Promise<ConfigWithModelsPresetsResultWithReloadDestroy> {
const configResult = await loadConfigWithModels({
dirPath,
stackbitConfigESBuildOutDir,
watchCallback: watchCallback
? async (configResult) => {
const configLoaderResult = await processConfigLoaderResult({ configResult, dirPath, modelsSource, logger, isForcedGitCSI });
watchCallback(configLoaderResult);
}
: undefined,
logger
});
const configLoaderResult = await processConfigLoaderResult({ configResult, dirPath, modelsSource, logger, isForcedGitCSI });
const reload = configResult.reload;
return {
...configLoaderResult,
destroy: configResult.destroy,
reload: reload
? async () => {
const configResult = await reload();
return processConfigLoaderResult({ configResult, dirPath, modelsSource, logger, isForcedGitCSI });
}
: undefined
};
}
export type LoadConfigWithModelsResult = {
config: Config | null;
errors: (ConfigLoadError | StackbitConfigNotFoundError | ModelLoadError | ConfigValidationError)[];
};
export type LoadConfigWithModelsResultWithReloadDestroy = WithReloadAndDestroy<LoadConfigWithModelsResult>;
export async function loadConfigWithModels({
dirPath,
stackbitConfigESBuildOutDir,
watchCallback,
logger
}: {
dirPath: string;
stackbitConfigESBuildOutDir?: string;
watchCallback?: (result: LoadConfigWithModelsResult) => void;
logger?: Logger;
}): Promise<LoadConfigWithModelsResultWithReloadDestroy> {
const wrapConfigResult = async (configResult: LoadConfigResult): Promise<LoadConfigWithModelsResult> => {
if (!configResult.config) {
return {
config: null,
errors: configResult.errors
};
}
return await loadAndMergeModelsFromFiles(configResult.config);
};
const rawConfigResult = await loadConfig({
dirPath,
stackbitConfigESBuildOutDir,
logger,
watchCallback: watchCallback
? async (configResult: LoadConfigResult) => {
const wrappedResult = await wrapConfigResult(configResult);
watchCallback(wrappedResult);
}
: undefined
});
const wrappedResult = await wrapConfigResult(rawConfigResult);
const reload = rawConfigResult.reload;
return {
...wrappedResult,
destroy: rawConfigResult.destroy,
reload: reload
? async () => {
const configResult = await reload();
return wrapConfigResult(configResult);
}
: undefined
};
}
export type LoadConfigResult =
| {
config: Config;
errors: ConfigValidationError[];
}
| {
config: null;
errors: (ConfigLoadError | StackbitConfigNotFoundError)[];
};
export type LoadConfigResultWithReloadDestroy = WithReloadAndDestroy<LoadConfigResult>;
export async function loadConfig({
dirPath,
stackbitConfigESBuildOutDir,
watchCallback,
logger
}: {
dirPath: string;
stackbitConfigESBuildOutDir?: string;
watchCallback?: (result: LoadConfigResult) => void;
logger?: Logger;
}): Promise<LoadConfigResultWithReloadDestroy> {
const normalizeConfigResult = (rawConfigResult: LoadConfigFromDirResult): LoadConfigResult => {
if (!rawConfigResult.config) {
return {
config: null,
errors: [rawConfigResult.error]
};
}
const validationResult = validateBaseConfig(rawConfigResult.config);
const config = normalizeConfig(rawConfigResult.config);
return {
config: config,
errors: validationResult.errors
};
};
const rawConfigResult = await loadConfigFromDir({
dirPath,
stackbitConfigESBuildOutDir,
watchCallback: watchCallback
? async (rawConfigResult: LoadConfigFromDirResult) => {
const normalizedResult = await normalizeConfigResult(rawConfigResult);
watchCallback(normalizedResult);
}
: undefined,
logger
});
const normalizedResult = await normalizeConfigResult(rawConfigResult);
const reload = rawConfigResult.reload;
return {
...normalizedResult,
destroy: rawConfigResult.destroy,
reload: reload
? async () => {
const rawConfigResult = await reload();
return normalizeConfigResult(rawConfigResult);
}
: undefined
};
}
async function processConfigLoaderResult({
configResult,
dirPath,
modelsSource,
logger,
isForcedGitCSI
}: {
configResult: LoadConfigWithModelsResult;
dirPath: string;
modelsSource?: ModelsSource;
logger?: Logger;
isForcedGitCSI?: boolean;
}): Promise<ConfigWithModelsPresetsResult> {
const { config, errors: configLoadErrors } = configResult;
if (!config) {
return {
valid: false,
config: null,
errors: configLoadErrors
};
}
if (isForcedGitCSI) {
const normalizedResult = validateAndNormalizeConfig(config, isForcedGitCSI);
return {
valid: normalizedResult.valid,
config: {
...normalizedResult.config,
models: normalizedResult.config.models,
presets: {}
},
errors: [...configLoadErrors, ...normalizedResult.errors]
};
}
const { models: externalModels, errors: externalModelsLoadErrors } = await loadModelsFromExternalSource(config, dirPath, modelsSource);
const mergedModels =
externalModels.length === 0
? config.models
: mergeConfigModelsWithExternalModels({ configModels: config.models as ModelExtension[], externalModels, logger });
const mergedConfig: Config = {
...config,
models: mergedModels
};
const normalizedResult = validateAndNormalizeConfig(mergedConfig);
const presetsResult = await loadPresets({ config: normalizedResult.config });
const modelsWithPresetIds = extendModelsWithPresetsIds({
models: normalizedResult.config.models,
presets: presetsResult.presets
});
const configWithPresets = {
...normalizedResult.config,
models: modelsWithPresetIds,
presets: presetsResult.presets
};
return {
valid: normalizedResult.valid,
config: configWithPresets,
errors: [...configLoadErrors, ...externalModelsLoadErrors, ...normalizedResult.errors, ...presetsResult.errors]
};
}
export async function loadAndMergeModelsFromFiles(config: Config): Promise<{ config: Config; errors: (ModelLoadError | ConfigValidationError)[] }> {
const { models: modelsFromFiles, errors: modelLoadErrors } = await loadYamlModelsFromFiles(config);
const { models: mergedModels, errors: mergeModelErrors } = mergeConfigModelsWithModelsFromFiles(config.models, modelsFromFiles);
const extendedConfig: Config = {
...config,
models: mergedModels
};
return {
config: extendedConfig,
errors: [...modelLoadErrors, ...mergeModelErrors]
};
}
export function validateAndNormalizeConfig(config: Config, isForcedGitCSI?: boolean): ConfigValidationResult {
// validate the "contentModels" and extend config models with "contentModels"
// this must be done before main config validation to make it independent of "contentModels".
const { config: configWithContentModels, errors: contentModelsErrors } = validateAndExtendContentModels(config);
// normalize config - backward compatibility updates, adding extra fields like "markdown_content", "type" and "layout",
// and setting other default values.
const configWithNormalizedModels = normalizeModels(configWithContentModels, isForcedGitCSI);
// validate config
const { config: validatedConfig, errors: validationErrors } = validateConfig(configWithNormalizedModels);
const errors = [...contentModelsErrors, ...validationErrors];
return normalizeValidationResult({
valid: _.isEmpty(errors),
config: validatedConfig,
errors: errors
});
}
export type LoadConfigFromDirResult =
| {
config: StackbitConfigWithPaths;
error: null;
}
| {
config: null;
error: ConfigLoadError | StackbitConfigNotFoundError;
};
export type LoadConfigFromDirResultWithReloadDestroy = WithReloadAndDestroy<LoadConfigFromDirResult>;
export async function loadConfigFromDir({
dirPath,
stackbitConfigESBuildOutDir,
watchCallback,
logger
}: {
dirPath: string;
stackbitConfigESBuildOutDir?: string;
watchCallback?: (result: LoadConfigFromDirResult) => void;
logger?: Logger;
}): Promise<LoadConfigFromDirResultWithReloadDestroy> {
function wrapResult(result: LoadStackbitConfigResult, configFilePath: string): LoadConfigFromDirResult {
if (result.error) {
return {
config: null,
error: result.error
};
} else {
return {
config: {
...result.config,
dirPath: dirPath,
filePath: configFilePath
},
error: null
};
}
}
// try to load stackbit config from YAML files
try {
const stackbitYamlPath = await getFirstExistingFile(STACKBIT_CONFIG_YAML_FILES, dirPath);
if (stackbitYamlPath) {
logger?.debug(`loading Stackbit configuration from ${stackbitYamlPath}`);
const result = await loadStackbitYamlFromDir(dirPath);
let close: () => Promise<void> = async () => void 0;
let stopped = false;
if (watchCallback) {
const watcher = chokidar.watch([...STACKBIT_CONFIG_YAML_FILES, '.stackbit/models'], {
cwd: dirPath,
persistent: true,
ignoreInitial: true
});
const throttledFileChange = _.throttle(async () => {
const result = await loadStackbitYamlFromDir(dirPath);
watchCallback(wrapResult(result, stackbitYamlPath));
}, 1000);
const handleFileChange = (path: string) => {
logger?.debug(`identified change in stackbit config file: ${path}, reloading config...`);
throttledFileChange();
};
watcher.on('add', handleFileChange);
watcher.on('change', handleFileChange);
watcher.on('unlink', handleFileChange);
watcher.on('addDir', handleFileChange);
watcher.on('unlinkDir', handleFileChange);
watcher.on('error', (error) => {
watchCallback({
config: null,
error: new ConfigLoadError(`Error loading Stackbit configuration: ${error.message}`, { originalError: error })
});
});
close = async () => {
if (stopped) {
return;
}
stopped = true;
throttledFileChange.cancel();
watcher.close();
};
}
return {
...wrapResult(result, stackbitYamlPath),
destroy: close,
reload: async () => {
const result = await loadStackbitYamlFromDir(dirPath);
return wrapResult(result, stackbitYamlPath);
}
};
}
} catch (error: any) {
return {
config: null,
error: new ConfigLoadError(`Error loading Stackbit configuration: ${error.message}`, { originalError: error })
};
}
// try to load stackbit config from JavaScript files
try {
const configFilePath = await getFirstExistingFile(STACKBIT_CONFIG_JS_FILES, dirPath);
if (configFilePath) {
logger?.debug(`loading Stackbit configuration from: ${configFilePath}`);
const configResult = await loadStackbitConfigFromJs({
configPath: configFilePath,
outDir: stackbitConfigESBuildOutDir ?? '.stackbit/cache',
watch: !!watchCallback,
logger: logger,
callback: watchCallback
? (result: LoadStackbitConfigResult) => {
watchCallback(wrapResult(result, configFilePath));
}
: undefined
});
const reload = configResult.reload;
return {
...wrapResult(configResult, configFilePath),
destroy: configResult.destroy,
reload: reload
? async () => {
const result = await reload();
return wrapResult(result, configFilePath);
}
: undefined
};
}
} catch (error: any) {
return {
config: null,
error: new ConfigLoadError(`Error loading Stackbit configuration: ${error.message}`, { originalError: error })
};
}
return {
config: null,
error: new StackbitConfigNotFoundError()
};
}
async function loadModelsFromExternalSource(
config: Config,
dirPath: string,
modelsSource?: ModelsSource
): Promise<{ models: Model[]; errors: ModelLoadError[] }> {
modelsSource = _.assign({}, modelsSource, config.modelsSource);
const sourceType = _.get(modelsSource, 'type', 'files');
if (sourceType === 'files') {
// we already loaded models from files inside loadConfigFromDir function
return { models: [], errors: [] };
} else if (sourceType === 'contentful') {
const contentfulModule = _.get(modelsSource, 'module', '@stackbit/cms-contentful');
const modulePath = path.resolve(dirPath, 'node_modules', contentfulModule);
const module = await import(modulePath);
try {
const { models } = await module.fetchAndConvertSchema(_.omit(modelsSource, ['type', 'module']));
return {
models: models,
errors: []
};
} catch (error: any) {
return {
models: [],
errors: [new ModelLoadError(`Error fetching and converting Contentful schema, error: ${error.message}`, { originalError: error })]
};
}
}
return {
models: [],
errors: [new ModelLoadError(`modelsSource ${modelsSource} is unsupported`)]
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function loadConfigFromDotStackbit(dirPath: string) {
const stackbitDotPath = path.join(dirPath, '.stackbit');
const stackbitDotExists = await fse.pathExists(stackbitDotPath);
if (!stackbitDotExists) {
return null;
}
const config = {};
const themeYaml = path.join(stackbitDotPath, 'theme.yaml');
const themeYamlExists = await fse.readFile(themeYaml);
if (themeYamlExists) {
const themeConfig = await fse.readFile(themeYaml);
_.assign(config, themeConfig);
}
const studioYaml = path.join(stackbitDotPath, 'studio.yaml');
const studioYamlExists = await fse.readFile(themeYaml);
if (studioYamlExists) {
const studioConfig = await fse.readFile(studioYaml);
_.assign(config, studioConfig);
}
const schemaYaml = path.join(stackbitDotPath, 'schema.yaml');
const schemaYamlExists = await fse.readFile(themeYaml);
if (schemaYamlExists) {
const schemaConfig = await fse.readFile(schemaYaml);
_.assign(config, schemaConfig);
}
return _.isEmpty(config) ? null : config;
}
export function mergeConfigModelsWithExternalModels({
configModels,
externalModels,
logger
}: {
configModels: ModelExtension[];
externalModels: Model[];
logger?: Logger;
}): Model[] {
if (configModels.length === 0) {
return externalModels;
}
const mergedModelsByName: Record<string, Model> = _.keyBy(externalModels, 'name');
for (const configModel of configModels) {
const externalModel = mergedModelsByName[configModel.name];
if (!externalModel) {
logger?.warn(`Can't remap model '${configModel.name}', the model doesn't exist in content source`);
continue;
}
const modelType = configModel.type ? (configModel.type === 'config' ? 'data' : configModel.type) : externalModel.type ?? 'object';
let mergedModel: Model = Object.assign(
{},
externalModel,
_.pick(configModel, [
'__metadata',
'urlPath',
'label',
'description',
'thumbnail',
'singleInstance',
'readOnly',
'canDelete',
'labelField',
'preview',
'permissions',
'localized'
]),
{ type: modelType }
);
if (mergedModel.type === 'page' && !mergedModel.urlPath) {
mergedModel.urlPath = '/{slug}';
}
const externalFieldGroups = externalModel.fieldGroups && Array.isArray(externalModel.fieldGroups) ? externalModel.fieldGroups : [];
const configFieldGroups = configModel.fieldGroups && Array.isArray(configModel.fieldGroups) ? configModel.fieldGroups : [];
if (externalFieldGroups.length || configFieldGroups.length) {
mergedModel.fieldGroups = _.uniqBy([...externalFieldGroups, ...configFieldGroups], 'name');
}
const externalActions = 'actions' in externalModel && Array.isArray(externalModel.actions) ? externalModel.actions : [];
const configActions = 'actions' in configModel && Array.isArray(configModel.actions) ? configModel.actions : [];
if (externalActions.length || configActions.length) {
(mergedModel as PageModel | DataModel | ObjectModel).actions = _.uniqBy([...externalActions, ...configActions], 'name') as
| CustomActionDocument[]
| CustomActionObjectModel[];
}
mergedModel = mapModelFieldsRecursively(mergedModel, (externalField, modelKeyPath): Field => {
const stackbitField = getModelFieldForModelKeyPath(configModel as Model, modelKeyPath) as FieldExtension;
if (!stackbitField) {
return externalField;
}
const FieldRemapMatrix: Record<string, FieldType[]> = {
string: ['text', 'html', 'markdown', 'slug', 'url', 'color', 'date', 'datetime', 'enum', 'image', 'json', 'style', 'cross-reference'],
text: ['html', 'markdown', 'image', 'json', 'style', 'cross-reference'],
json: ['image', 'style', 'cross-reference']
};
let stackbitFieldProps: FieldExtension | DistributePartialListItems<FieldListItems>;
if (externalField.type === 'list') {
if (!stackbitField.type || stackbitField.type === 'list') {
stackbitFieldProps = 'items' in stackbitField ? stackbitField.items ?? {} : {};
} else {
stackbitFieldProps = {};
logger?.warn(
`Can't remap field of model '${mergedModel.name}' ` +
`at path '${fieldPathToString(modelKeyPath)}' ` +
`from 'list' type to '${stackbitField.type}' type. ` +
'List fields can only be mapped to list fields'
);
}
} else if (stackbitField.type === 'list' || 'items' in stackbitField) {
stackbitFieldProps = {};
logger?.warn(
`Can't remap field of model '${mergedModel.name}' ` +
`at path '${fieldPathToString(modelKeyPath)}' ` +
`from '${externalField.type}' type to 'list' type. ` +
'List fields can only be mapped from list fields'
);
} else {
stackbitFieldProps = stackbitField;
}
externalField = mapListItemsPropsOrSelfSpecificProps(externalField, (externalFieldProps) => {
// override field type if allowed, otherwise show a warning message
let fieldType: FieldType = externalFieldProps.type;
const allowedOverrideTypes = FieldRemapMatrix[externalFieldProps.type];
if (stackbitFieldProps.type && stackbitFieldProps.type !== externalFieldProps.type) {
if (allowedOverrideTypes && allowedOverrideTypes.includes(stackbitFieldProps.type)) {
fieldType = stackbitFieldProps.type;
} else {
logger?.warn(
`Can't remap field of model '${mergedModel.name}' at path '${fieldPathToString(modelKeyPath)}' ` +
`from '${externalFieldProps.type}' type to '${stackbitFieldProps.type}' type. ` +
`The '${externalFieldProps.type}' fields can be only mapped to ` +
(!allowedOverrideTypes
? 'the same type.'
: allowedOverrideTypes.length === 1
? `'${allowedOverrideTypes[0]}' type`
: `one of [${allowedOverrideTypes.join(', ')}] types.`)
);
}
}
// add field specific properties
let fieldSpecificProps = {};
switch (fieldType) {
case 'number':
fieldSpecificProps = _.pick(stackbitFieldProps, ['subtype', 'min', 'max', 'step', 'unit']);
break;
case 'enum':
fieldSpecificProps = _.pick(stackbitFieldProps, ['options']);
break;
case 'image':
fieldSpecificProps = _.pick(stackbitFieldProps, ['source']);
break;
case 'style':
fieldSpecificProps = _.pick(stackbitFieldProps, ['styles']);
break;
case 'object': {
fieldSpecificProps = _.pick(stackbitFieldProps, ['labelField', 'preview', 'thumbnail']);
const externalFieldGroups =
'fieldGroups' in externalFieldProps && Array.isArray(externalFieldProps.fieldGroups) ? externalFieldProps.fieldGroups : [];
const configFieldGroups =
'fieldGroups' in stackbitFieldProps && Array.isArray(stackbitFieldProps.fieldGroups) ? stackbitFieldProps.fieldGroups : [];
if (externalFieldGroups.length || configFieldGroups.length) {
fieldSpecificProps = {
...fieldSpecificProps,
fieldGroups: _.uniqBy([...externalFieldGroups, ...configFieldGroups], 'name')
};
}
break;
}
case 'model':
fieldSpecificProps = _.pick(stackbitFieldProps, ['models']);
break;
case 'reference':
fieldSpecificProps = _.pick(stackbitFieldProps, ['models']);
break;
case 'cross-reference':
fieldSpecificProps = _.pick(stackbitFieldProps, ['models']);
break;
}
const validations = mergeFieldValidations(externalFieldProps, stackbitFieldProps);
return Object.assign({}, externalFieldProps, fieldSpecificProps, { type: fieldType }, validations ? { validations } : undefined);
});
const mergedField = Object.assign(
{},
externalField,
_.pick(stackbitField, [
'label',
'description',
'required',
'default',
'group',
'const',
'hidden',
'readOnly',
'controlType',
'controlUrl',
'controlFilePath'
])
);
// Merge list field specific properties that weren't merged in mapListItemsPropsOrSelfSpecificProps
if (externalField.type === 'list') {
const validations = mergeFieldValidations(externalField, stackbitField);
if (validations) {
mergedField.validations = validations;
}
}
const externalActions = 'actions' in externalField && Array.isArray(externalField.actions) ? externalField.actions : [];
const configActions = 'actions' in stackbitField && Array.isArray(stackbitField.actions) ? stackbitField.actions : [];
if (externalActions.length || configActions.length) {
mergedField.actions = _.uniqBy([...externalActions, ...configActions], 'name');
}
return mergedField;
});
mergedModelsByName[configModel.name] = mergedModel;
}
return Object.values(mergedModelsByName);
}
function mergeFieldValidations<T extends StackbitTypes.FieldSpecificProps>(
externalFieldProps: T,
stackbitFieldProps: FieldExtension | DistributePartialListItems<FieldListItems>
): T['validations'] {
const externalFieldValidations = externalFieldProps.validations;
const configFieldValidation = stackbitFieldProps.validations;
if (externalFieldValidations && configFieldValidation) {
const extendedValidations: StackbitTypes.FieldValidationsCustom = {};
// concatenate custom validation functions
if (
'validate' in externalFieldValidations &&
externalFieldValidations.validate &&
'validate' in configFieldValidation &&
configFieldValidation.validate
) {
const externalFileTypes = Array.isArray(externalFieldValidations.validate)
? externalFieldValidations.validate
: [externalFieldValidations.validate];
const stackbitFileTypes = Array.isArray(configFieldValidation.validate) ? configFieldValidation.validate : [configFieldValidation.validate];
extendedValidations.validate = [...externalFileTypes, ...stackbitFileTypes];
}
return Object.assign({}, externalFieldValidations, configFieldValidation, extendedValidations);
} else if (configFieldValidation) {
return configFieldValidation;
} else {
return externalFieldValidations;
}
}
function normalizeConfig(rawConfig: StackbitConfigWithPaths): Config {
const stackbitVersion = String(_.get(rawConfig, 'stackbitVersion', LATEST_STACKBIT_VERSION));
const ver = semver.coerce(stackbitVersion);
const isGTEStackbitYamlV5 = ver ? semver.satisfies(ver, '>=0.5.0') : false;
const { logicFields, models: modelMap, sitemap, ...restConfig } = rawConfig;
// 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 models = _.reduce(modelMap, (accum: Model[], model, modelName) => accum.concat(Object.assign({ name: modelName }, model)), []);
return {
...restConfig,
stackbitVersion: stackbitVersion,
models: models,
...(sitemap ? { siteMap: sitemap } : {}),
noEncodeFields: logicFields,
hcrHandled: !stackbitVersion || _.get(rawConfig, 'customContentReload', _.get(rawConfig, 'hcrHandled', !isGTEStackbitYamlV5)),
contentEngine: rawConfig?.contentEngine || { port: CONTENT_ENGINE_DEFAULT_PORT },
internalStackbitRunnerOptions: getInternalStackbitRunnerOptions(rawConfig)
};
}
function normalizeModels(config: Config, isForcedGitCSI?: boolean): Config {
const pageLayoutKey = config.pageLayoutKey ?? 'layout';
const objectTypeKey = config.objectTypeKey ?? 'type';
const stackbitYamlVersion = String(config.stackbitVersion ?? '');
const ver = semver.coerce(stackbitYamlVersion);
const isStackbitYamlV2 = ver ? semver.satisfies(ver, '<0.3.0') : false;
const models = config.models;
const modelsByName = _.keyBy(models, 'name');
const gitCMS = isGitCMS(config);
const mappedModels = models.map((model): Model => {
// create shallow copy of the model to prevent mutation of original models
model = { ...model };
if (!_.has(model, 'type')) {
model.type = 'object';
}
// add model label if not set
if (!_.has(model, 'label')) {
model.label = _.startCase(model.name);
}
if (_.has(model, 'fields') && !Array.isArray(model.fields)) {
model.fields = [];
}
if (isPageModel(model)) {
// rename old 'template' property to 'layout'
rename(model, 'template', 'layout');
updatePageUrlPath(model);
if (gitCMS) {
updatePageFilePath(model, config);
addMarkdownContentField(model);
}
} else if (isDataModel(model) && gitCMS) {
updateDataFilePath(model, config);
}
if (gitCMS && !isForcedGitCSI) {
// TODO: do not add pageLayoutKey and objectTypeKey fields to models,
// The content validator should always assume these fields.
// And when new objects created from UI, it should add these fields automatically.
if (isPageModel(model)) {
addLayoutFieldToPageModel(model, pageLayoutKey, model.name);
} else if (isDataModel(model) && !isListDataModel(model)) {
addObjectTypeKeyField(model, objectTypeKey, model.name);
}
}
if (isListDataModel(model)) {
// 'items.type' of list model defaults to 'string', set it explicitly
normalizeListFieldInPlace(model);
if (isObjectListItems(model.items)) {
assignLabelFieldIfNeeded(model.items);
}
} else if (!_.has(model, 'labelField')) {
assignLabelFieldIfNeeded(model);
}
return mapModelFieldsRecursively(model, (field: Field): Field => {
// create shallow copy of the field to prevent mutation of original field
field = { ...field };
// add field label if label is not set
if (!_.has(field, 'label')) {
field.label = _.startCase(field.name);
}
return mapListItemsPropsOrSelfSpecificProps(field, (fieldSpecificProps) => {
if (isObjectField(fieldSpecificProps)) {
assignLabelFieldIfNeeded(fieldSpecificProps);
} else if (isCustomModelField(fieldSpecificProps, modelsByName)) {
// stackbit v0.2.0 compatibility
// convert the old custom model field type: { type: 'action' }
// to the new 'model' field type: { type: 'model', models: ['action'] }
fieldSpecificProps = {
...fieldSpecificProps,
type: 'model',
models: [fieldSpecificProps.type]
};
} else if ((fieldSpecificProps as any).type === 'models') {
// stackbit v0.2.0 compatibility
// convert the old 'models' field type: { type: 'models', models: ['link', 'button'] }
// to the new 'model' field type: { type: 'model', models: ['link', 'button'] }
fieldSpecificProps = {
...fieldSpecificProps,
type: 'model',
models: _.get(fieldSpecificProps, 'models', [])
};
} else if (fieldSpecificProps.type === 'model' && _.has(fieldSpecificProps, 'model')) {
// stackbit v0.2.0 compatibility
// convert the old 'model' field type: { type: 'model', model: 'link' }
// to the new 'model' field type: { type: 'model', models: ['link'] }
const { model, ...rest } = fieldSpecificProps as any;
fieldSpecificProps = {
...rest,
models: [model]
};
}
if (isStackbitYamlV2) {
// in stackbit.yaml v0.2.x, the 'reference' field was what we have today as 'model' field:
if (isReferenceField(fieldSpecificProps)) {
fieldSpecificProps = {
...fieldSpecificProps,
type: 'model',
models: _.get(fieldSpecificProps, 'models', [])
};
}
}
return fieldSpecificProps;
});
});
});
return {
...config,
models: mappedModels
};
}
function updatePageUrlPath(model: PageModel) {
// set default urlPath if not set
if (!model.urlPath) {
model.urlPath = '/{slug}';
}
}
/**
* Sets the page model's filePath pattern.
* If the model has `filePath` property, it is prefixed with `pagesDir` and returned.
* If the model has no `filePath` property, then `filePath` is naively inferred by
* prefixing `urlPath` with `pagesDir` and appending the `.md` extension.
*/
function updatePageFilePath(model: PageModel, config: Config) {
let filePath: string;
if (model.filePath) {
if (typeof model.filePath === 'function') {
return;
}
filePath = model.filePath;
} else if (model.file) {
filePath = model.file;
} else {
const urlPath = model.urlPath;
if (urlPath === '/') {
filePath = 'index.md';
} else if (_.trim(urlPath, '/') === 'posts/{slug}' && config.ssgName === 'jekyll') {
filePath = '_posts/{moment_format("YYYY-MM-DD")}-{slug}.md';
} else {
filePath = _.trim(urlPath, '/') + '.md';
}
}
const parentDir = _.trim(config.pagesDir ?? '', '/');
model.filePath = path.join(parentDir, filePath);
}
function updateDataFilePath(model: DataModel, config: Config) {
let filePath;
if (model.filePath) {
if (typeof model.filePath === 'function') {
return;
}
filePath = model.filePath;
} else if (model.file) {
filePath = model.file;
} else {
const folder = _.trim(_.get(model, 'folder'), '/');
filePath = _.trim(`${folder}/{slug}.json`, '/');
}
const parentDir = _.trim(config.dataDir ?? '', '/');
model.filePath = path.join(parentDir, filePath);
}
function addMarkdownContentField(model: PageModel) {
if (model.hideContent) {
return;
}
const hasMarkdownContent = _.find(_.get(model, 'fields'), { name: 'markdown_content' });
if (hasMarkdownContent) {
return;
}
append(model, 'fields', {
type: 'markdown',
name: 'markdown_content',
label: 'Content',
description: 'Page content'
});
}
function addLayoutFieldToPageModel(model: PageModel, pageLayoutKey: string, modelName: string) {
if (_.intersection(_.keys(model), ['file', 'folder', 'match', 'exclude']).length === 0 && !_.get(model, 'layout')) {
model.layout = modelName;
}
const modelLayout = _.get(model, 'layout');
if (!modelLayout) {
return;
}
const hasLayoutField = _.find(_.get(model, 'fields'), { name: pageLayoutKey });
if (hasLayoutField) {
return;
}
prepend(model, 'fields', {
type: 'string',
name: pageLayoutKey,
label: _.startCase(pageLayoutKey),
const: modelLayout,
hidden: true
});
}
function addObjectTypeKeyField(model: DataModelSingle, objectTypeKey: string, modelName: string) {
const hasObjectTypeField = _.find(_.get(model, 'fields'), { name: objectTypeKey });
if (hasObjectTypeField) {
return;
}
prepend(model, 'fields', {
type: 'string',
name: objectTypeKey,
label: 'Object Type',
description: 'The type of the object',
const: modelName,
hidden: true
});
}
/**
* Returns model names referenced by polymorphic 'model' and 'reference' fields.
*
* @param field
*/
function getReferencedModelNames(field: Field) {
const fieldSpecificProps = getListItemsOrSelf(field);
// TODO: add type field to model fields inside container update/create object logic rather adding type to schema
// 'object' models referenced by 'model' fields should have 'type' field
// if these fields have than 1 model.
// 'data' models referenced by 'reference' fields should always have 'type' field.
let referencedModelNames: string[] = [];
if (isModelField(fieldSpecificProps) && fieldSpecificProps.models?.length > 1) {
const modelNames = fieldSpecificProps.models;
referencedModelNames = _.union(referencedModelNames, modelNames);
} else if (isReferenceField(fieldSpecificProps) && fieldSpecificProps.models?.length > 0) {
const modelNames = fieldSpecificProps.models;
referencedModelNames = _.union(referencedModelNames, modelNames);
}
return referencedModelNames;
}
function validateAndExtendContentModels(config: Config): {
config: Config;
errors: ConfigValidationError[];
} {
const contentModels = config.contentModels ?? {};
const models = config.models ?? [];
// external models already merged in mergeConfigModelsWithExternalModels function
const externalModels = !isGitCMS(config);
const emptyContentModels = _.isEmpty(contentModels);
if (externalModels || emptyContentModels) {
return {
config: config,
errors: []
};
}
const validationResult = validateContentModels(contentModels, models);
if (_.isEmpty(models)) {
return {
config: config,
errors: validationResult.errors
};
}
const extendedModels = models.map((model): Model => {
const contentModel = validationResult.contentModels![model.name];
if (!contentModel) {
return model;
}
if (_.get(contentModel, '__metadata.invalid')) {
return model;
}
const { isPage, newFilePath, ...restContentModel } = contentModel;
const { type, ...restModel } = model;
if (isPage && (!type || ['object', 'page'].includes(type))) {
return {
type: 'page',
...(newFilePath ? { filePath: newFilePath } : {}),
...restContentModel,
...restModel
} as PageModel;
} else if (!isPage && (!type || ['object', 'data'].includes(type))) {
return {
type: 'data',
...(newFilePath ? { filePath: newFilePath } : {}),
...restContentModel,
...restModel
} as DataModel;
} else {
return model;
}
});
return {
config: {
...config,
models: extendedModels
},
errors: validationResult.errors
};
}
function normalizeValidationResult(validationResult: ConfigValidationResult): ConfigValidationResult {
validationResult = filterAndOrderConfigFields(validationResult);
return convertModelGroupsToModelListInPlace(validationResult);
}
function filterAndOrderConfigFields(validationResult: ConfigValidationResult): ConfigValidationResult {
// TODO: check if we can move filtering and sorting to Joi
return {
...validationResult,
config: _.pick(validationResult.config, [
'stackbitVersion',
'ssgName',
'ssgVersion',
'cmsName',
'import',
'buildCommand',
'publishDir',
'nodeVersion',
'postGitCloneCommand',
'preInstallCommand',
'postInstallCommand',
'installCommand',
'devCommand',
'cacheDir',
'staticDir',
'uploadDir',
'assets',
'pagesDir',
'dataDir',
'pageLayoutKey',
'objectTypeKey',
'styleObjectModelName',
'excludePages',
'logicFields',
'contentModels',
'presetSource',
'modelsSource',
'mapModels',
'presetReferenceBehavior',
'nonDuplicatableModels',
'duplicatableModels',
'contentSources',
'hcrHandled',
'internalStackbitRunnerOptions',
'dirPath',
'filePath',
'models',
'presets',
'sidebarButtons',
'pageData', // obsolete, left for backward compatibility
'pageModels', // obsolete, left for backward compatibility
'encodedFieldTypes', // obsolete, left for backward compatibility
'noEncodeFields', // obsolete, left for backward compatibility
'omitFields' // obsolete, left for backward compatibility
]) as Config
};
}
/**
* Collects models groups and injects them into the `models` array of the
* `reference` and `model` field types
*/
function convertModelGroupsToModelListInPlace(validationResult: ConfigValidationResult): ConfigValidationResult {
const models = validationResult.config?.models ?? [];
const groupMap = _.reduce(
models,
(groupMap, model) => {
if (!model.groups) {
return groupMap;
}
const key = model?.type === 'object' ? 'objectModels' : 'documentModels';
_.forEach(model.groups, (groupName) => {
append(groupMap, [groupName, key], model.name);
});
delete model.groups;
return groupMap;
},
{} as Record<string, { objectModels?: string[]; documentModels?: string[] }>
);
// update groups to have unique model names
_.forEach(groupMap, (group) => {
_.forEach(group, (modelGroup, key) => {
_.set(group, key, _.uniq(modelGroup));
});
});
const mappedModels = models.map((model) => {
return mapModelFieldsRecursively(model, (field): Field => {
return mapListItemsPropsOrSelfSpecificProps(field, (fieldSpecificProps) => {
if (!isModelField(fieldSpecificProps) && !isReferenceField(fieldSpecificProps)) {
return fieldSpecificProps;
}
const { ...cloned } = fieldSpecificProps;
const key = isModelField(fieldSpecificProps) ? 'objectModels' : 'documentModels';
const modelNames = _.reduce(
cloned.groups,
(modelNames, groupName) => {
const objectModelNames = _.get(groupMap, [groupName, key], []);
return _.uniq(modelNames.concat(objectModelNames));
},
fieldSpecificProps.models || []
);
delete cloned.groups;
return Object.assign(cloned, { models: modelNames });
});
});
});
return {
...validationResult,
config: {
...validationResult.config,
models: mappedModels
}
};
}
function isGitCMS(config: Config) {
return !config.contentSources && (!config.cmsName || config.cmsName === 'git');
}
function getInternalStackbitRunnerOptions(config: StackbitConfigWithPaths): SSGRunOptions | undefined {
const experimentalSsgData = config?.experimental?.ssg || ((config as any).__unsafe_internal_stackbitRunnerOptions as Required<Experimental>['ssg']);
if (!experimentalSsgData) {
return;
}
let doneStart = experimentalSsgData.logPatterns?.up as string | string[] | undefined;
if (typeof doneStart === 'string') {
doneStart = [doneStart];
}
const ssgRunOptions = omitByNil({
displayName: experimentalSsgData.name,
triggerInstallFiles: experimentalSsgData.watch?.reinstallPackages,
directPaths: experimentalSsgData.passthrough,
directRoutes: experimentalSsgData.directRoutes,
proxyWebsockets: experimentalSsgData.proxyWebsockets,
...(doneStart ? { patterns: { doneStart } } : undefined)
});
return _.isEmpty(ssgRunOptions) ? undefined : ssgRunOptions;
}