UNPKG

@stackbit/sdk

Version:
289 lines (260 loc) 11.5 kB
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); }