@stackbit/sdk
Version:
289 lines (260 loc) • 11.5 kB
text/typescript
import path from 'path';
import fse from 'fs-extra';
import yaml from 'js-yaml';
import _ from 'lodash';
import { parseFile, readDirRecursively, reducePromise, getFirstExistingFile } from '@stackbit/utils';
import { StackbitConfig, Field, FieldObjectProps, FieldSpecificProps, FieldEnumProps, FieldEnumThumbnailsProps } from '@stackbit/types';
import { ConfigLoadError, ModelLoadError, ConfigValidationError, StackbitConfigNotFoundError, REFER_TO_STACKBIT_CONFIG_DOCS } from './config-errors';
import { Config, Model, YamlModelMap, YamlModel, FieldList } from './config-types';
import { isEnumField, isListField, isObjectField, normalizeListField, mapModelFieldsRecursively, extendModelArray } from '../utils';
export const STACKBIT_CONFIG_YAML_FILES = ['stackbit.yaml', 'stackbit.yml'];
export const STACKBIT_CONFIG_JS_FILES = ['stackbit.config.js', 'stackbit.config.cjs', 'stackbit.config.mjs', 'stackbit.config.ts'];
export const STACKBIT_CONFIG_FILES = [...STACKBIT_CONFIG_YAML_FILES, ...STACKBIT_CONFIG_JS_FILES];
export const LATEST_STACKBIT_VERSION = '0.7.0';
export type LoadStackbitConfigResult =
| {
config: StackbitConfig;
error: null;
}
| {
config: null;
error: ConfigLoadError | StackbitConfigNotFoundError;
};
export type DestroyConfigWatch = {
reload?: () => Promise<LoadStackbitConfigResult>;
destroy?: () => Promise<void>;
};
export type LoadStackbitConfigResultWithReloadDestroy = WithReloadAndDestroy<LoadStackbitConfigResult>;
export type WithReloadAndDestroy<ConfigResult> = ConfigResult & {
reload?: () => Promise<ConfigResult>;
destroy?: () => Promise<void>;
};
export async function loadStackbitYamlFromDir(dirPath: string): Promise<LoadStackbitConfigResult> {
const stackbitYamlPath = await getFirstExistingFile(STACKBIT_CONFIG_YAML_FILES, dirPath);
if (!stackbitYamlPath) {
return {
config: null,
error: new StackbitConfigNotFoundError()
};
}
return await loadConfigFromStackbitYaml(stackbitYamlPath);
}
export async function loadConfigFromStackbitYaml(stackbitYamlPath: string): Promise<LoadStackbitConfigResult> {
const stackbitYaml = await fse.readFile(stackbitYamlPath, 'utf8');
const config = yaml.load(stackbitYaml, { schema: yaml.JSON_SCHEMA });
if (!config || typeof config !== 'object') {
const fileName = path.basename(stackbitYamlPath);
return {
config: null,
error: new ConfigLoadError(`error parsing ${fileName}, ${REFER_TO_STACKBIT_CONFIG_DOCS}`)
};
}
return {
config: config as any,
error: null
};
}
export async function findStackbitConfigFile(dirs: string[]): Promise<string | null> {
for (const dir of dirs) {
for (const fileName of STACKBIT_CONFIG_FILES) {
const filePath = path.resolve(dir, fileName);
if (await fse.pathExists(filePath)) {
return filePath;
}
}
}
return null;
}
export function isStackbitYamlFile(filePath: string) {
const pathObject = path.parse(filePath);
return pathObject.base === 'stackbit.yaml' || pathObject.dir.split(path.sep).includes('.stackbit');
}
export function convertToYamlConfig({ config }: { config: Config }): StackbitConfig {
const yamlConfig: StackbitConfig = _.cloneDeep(_.omit(config, ['models', 'dirPath', 'filePath', 'presets']));
if (!_.isEmpty(config.models)) {
yamlConfig.models = _.reduce(
config.models,
(yamlModels: YamlModelMap, model: Model) => {
const yamlModel = _.omit(model, ['name', '__metadata', 'presets']) as YamlModel;
switch (yamlModel.type) {
case 'page':
if (!yamlModel.hideContent && yamlModel.fields) {
_.remove(yamlModel.fields, (field) => field.name === 'markdown_content');
}
if (yamlModel.fields) {
_.remove(yamlModel.fields, (field) => field.name === (config.pageLayoutKey || 'layout'));
}
yamlModels[model.name] = yamlModel;
break;
case 'data':
if (yamlModel.fields) {
_.remove(yamlModel.fields, (field) => field.name === (config.objectTypeKey || 'type'));
}
break;
case 'object':
case 'config':
yamlModels[model.name] = yamlModel;
break;
default: {
const _exhaustiveCheck: never = yamlModel;
return _exhaustiveCheck;
}
}
yamlModels[model.name] = yamlModel;
return yamlModels;
},
{}
);
}
return yamlConfig;
}
export async function loadYamlModelsFromFiles(config: Config): Promise<{ models: Model[]; errors: ModelLoadError[] }> {
const dirPath = config.dirPath;
const modelDirs = getYamlModelDirs(config);
const modelFiles = await reducePromise(
modelDirs,
async (modelFiles: string[], modelDir) => {
const absModelsDir = path.join(dirPath, modelDir);
const dirExists = await fse.pathExists(absModelsDir);
if (!dirExists) {
return modelFiles;
}
const files = await readYamlModelFilesFromDir(absModelsDir);
return modelFiles.concat(files.map((filePath) => path.join(modelDir, filePath)));
},
[]
);
const result = await reducePromise(
modelFiles,
async (result: { modelMap: Record<string, Model>; errors: ModelLoadError[] }, modelFile) => {
let model;
try {
model = await parseFile(path.join(dirPath, modelFile));
} catch (error) {
return {
modelMap: result.modelMap,
errors: result.errors.concat(new ModelLoadError(`error parsing model, file: ${modelFile}`))
};
}
const modelName = model?.name;
if (!modelName) {
return {
modelMap: result.modelMap,
errors: result.errors.concat(new ModelLoadError(`model does not have a name, file: ${modelFile}`))
};
}
result.modelMap[modelName] = {
__metadata: {
filePath: modelFile
},
...model
};
return result;
},
{ modelMap: {}, errors: [] }
);
return {
models: Object.values(result.modelMap),
errors: result.errors
};
}
export function getYamlModelDirs(config: Config): string[] {
const modelsSource = _.get(config, 'modelsSource', {});
const sourceType = _.get(modelsSource, 'type', 'files');
const defaultModelDirs = ['node_modules/@stackbit/components/models', '.stackbit/models'];
const modelDirs = _.get(modelsSource, 'modelDirs', defaultModelDirs);
return sourceType === 'files' ? _.castArray(modelDirs).map((modelDir) => _.trim(modelDir, '/')) : defaultModelDirs;
}
async function readYamlModelFilesFromDir(modelsDir: string) {
return await readDirRecursively(modelsDir, {
filter: (filePath, stats) => {
if (stats.isDirectory()) {
return true;
}
const extension = path.extname(filePath).substring(1);
return stats.isFile() && ['yaml', 'yml'].includes(extension);
}
});
}
export function mergeConfigModelsWithModelsFromFiles(configModels: Model[], modelsFromFiles: Model[]): { models: Model[]; errors: ConfigValidationError[] } {
const configModelsByName = _.keyBy(configModels, 'name');
const mergedModelsFromFiles = modelsFromFiles.map((modelFromFile) => {
// resolve thumbnails of models loaded from files
const modelFilePath = modelFromFile.__metadata?.filePath;
modelFromFile = resolveThumbnailPathForModelOrObjectField(modelFromFile, modelFilePath);
modelFromFile = mapModelFieldsRecursively(modelFromFile, (field: Field) => {
if (isListField(field)) {
field = normalizeListField(field);
field = {
...field,
items: resolveThumbnailForEnumField(field.items!, modelFilePath)
} as FieldList;
} else {
field = resolveThumbnailForEnumField(field, modelFilePath);
}
return field;
});
const configModel = _.get(configModelsByName, modelFromFile.name);
if (!configModel) {
return modelFromFile;
}
return _.assign({}, modelFromFile, configModel, {
fields: _.unionBy(configModel?.fields ?? [], modelFromFile?.fields ?? [], 'name')
});
});
const mergedModels = _.unionBy(mergedModelsFromFiles, configModels, 'name');
// extend config models having the "extends" property
// this must be done before any validation as some properties like
// the labelField will not work when validating models without extending them first
return extendModelArray(mergedModels);
}
function resolveThumbnailForEnumField<T extends FieldSpecificProps>(field: T, modelFilePath: string | undefined): T {
if (isObjectField(field)) {
field = resolveThumbnailPathForModelOrObjectField(field, modelFilePath);
} else if (isEnumField(field)) {
field = resolveThumbnailPathForEnumField(field, modelFilePath);
}
return field;
}
function resolveThumbnailPathForModelOrObjectField<T extends Model | FieldObjectProps>(modelOrField: T, modelFilePath: string | undefined): T {
if (modelOrField.thumbnail && modelFilePath) {
const modelDirPath = path.dirname(modelFilePath);
modelOrField = {
...modelOrField,
thumbnail: resolveThumbnailPath(modelOrField.thumbnail, modelDirPath)
};
}
return modelOrField;
}
function resolveThumbnailPathForEnumField<T extends FieldEnumProps>(enumField: T, modelFilePath: string | undefined) {
if (enumField.controlType === 'thumbnails' && modelFilePath) {
const modelDirPath = path.dirname(modelFilePath);
enumField = {
...enumField,
options: _.map((enumField as FieldEnumThumbnailsProps).options, (option) => {
if (option.thumbnail) {
option = {
...option,
thumbnail: resolveThumbnailPath(option.thumbnail, modelDirPath)
};
}
return option;
})
};
}
return enumField;
}
function resolveThumbnailPath(thumbnail: string, modelDirPath: string) {
if (thumbnail.startsWith('//') || /https?:\/\//.test(thumbnail)) {
return thumbnail;
}
if (thumbnail.startsWith('/')) {
if (modelDirPath.endsWith('@stackbit/components/models')) {
modelDirPath = modelDirPath.replace(/\/models$/, '');
} else {
modelDirPath = '';
}
thumbnail = thumbnail.replace(/^\//, '');
}
return path.join(modelDirPath, thumbnail);
}