import _ from 'lodash'; import * as StackbitTypes from '@stackbit/types'; import type * as SanityTypes from '@sanity/types'; import { deepMap, omitByNil, omitByUndefined } from '@stackbit/utils'; import { resolveLabelFieldForModel } from './utils'; // Sanity's cloudinary and other 3rd party DRM plugins add the following models. // These models are removed when converting the schema and the fields referencing // these models are replaced with our regular "image" field with a "source" property // matching to the name of the supported DRM. const thirdPartyImageModels = ['cloudinary.asset', 'cloudinary.assetDerived', 'bynder.asset', 'aprimo.asset', 'aprimo.cdnasset']; const skipModels = [...thirdPartyImageModels, 'media.tag', 'slug', 'markdown', 'json', 'color', 'hslaColor', 'hsvaColor', 'rgbaColor']; const internationalizedArrayPrefix = 'internationalizedArray'; export type SchemaContext = null; export type ModelWithContext = StackbitTypes.Model; export type ModelContext = { localizedFieldsModelMap?: LocalizedFieldsModelMap; fieldAliasMap?: FieldAliasMap; } | null; /** * Maps model fields to the Internationalized Array types * The key is the field path of the field within the model. */ export type LocalizedFieldsModelMap = Record< string, { arrayModelName: string; arrayValueModelName: string; } >; export type FieldAliasMap = Record< string, { origTypeName: string; resolvedTypeName: string; }[] >; /** * Converts Sanity schema to Netlify Create Schema. * * @param schema Schema as received from sanity-schema-converter.js * @param defaultLocale The default locale specified in the SanityContentSource constructor. */ export function convertSchema({ schema, logger, defaultLocale }: { schema: { models: SanityTypes.SchemaTypeDefinition[] }; logger: StackbitTypes.Logger; defaultLocale?: string; }): { models: ModelWithContext[]; locales: StackbitTypes.Locale[]; } { // First, skip all Sanity internal models with names starting with "sanity." // and models defined in skipModels. const filteredModels = schema.models.filter((model) => { return !model.name.startsWith('sanity.') && !skipModels.includes(model.name); }); // Split models into three groups: // 1. Regular "document" and "objets" models that map to Netlify Create's // "data" and "object" models respectively. // 2. Models with names starting with "internationalizedArray...". These are // special models produced by Sanity's internationalized-array plugin // https://www.sanity.io/plugins/internationalized-array // 3. Type alias models, which are aliases to regular fields like "array", // "string", etc. const { i18nModels = [], documentAndObjectModels = [], typeAliasModels = [] } = _.groupBy(filteredModels, (model) => { if (model.name.startsWith(internationalizedArrayPrefix)) { return 'i18nModels'; } else if (['document', 'object'].includes(model.type)) { return 'documentAndObjectModels'; } else { return 'typeAliasModels'; } }) as unknown as { i18nModels: SanityTypes.TypeAliasDefinition[]; documentAndObjectModels: (SanityTypes.DocumentDefinition | SanityTypes.ObjectDefinition)[]; typeAliasModels: SanityTypes.TypeAliasDefinition[]; }; // Get all locale codes from Internationalized Array models const locales = getLocalesFromInternationalizedArrays(i18nModels, defaultLocale); // Create map of Internationalized Array const { i18nArrayModelMap, i18nValueModelMap } = getLocalizedModelMap(i18nModels); const typeAliasMap = _.keyBy(typeAliasModels, 'name'); const models = documentAndObjectModels.map((model): StackbitTypes.DataModel | StackbitTypes.ObjectModel => { const localizedFieldsModelMap = {}; const fieldAliasMap = {}; try { const stackbitModel = mapObjectModel({ model, modelFieldPath: [], typeAliasMap, i18nArrayModelMap, localizedFieldsModelMap, fieldAliasMap, seenAliases: [] }); let context: ModelContext = null; if (!_.isEmpty(localizedFieldsModelMap) || !_.isEmpty(fieldAliasMap)) { context = { ...(_.isEmpty(localizedFieldsModelMap) ? null : { localizedFieldsModelMap }), ...(_.isEmpty(fieldAliasMap) ? null : { fieldAliasMap }) }; } return { ...stackbitModel, context }; } catch (error: any) { logger.error(`Error converting model '${model.name}'. ${error.message}`); throw error; } }); return { models, locales }; } type CommonProps = { modelFieldPath: string[]; typeAliasMap: Record; i18nArrayModelMap: Record; localizedFieldsModelMap: LocalizedFieldsModelMap; fieldAliasMap: FieldAliasMap; seenAliases: string[]; }; type SanityObjectFieldOrArrayItem = SanityIntrinsicFieldOrArrayItem<'object'>; type MapObjectModelOptions = { model: Type; } & CommonProps; function mapObjectModel( options: MapObjectModelOptions ): StackbitTypes.DataModel | StackbitTypes.ObjectModel; function mapObjectModel(options: MapObjectModelOptions): StackbitTypes.FieldObject; function mapObjectModel({ model, ...rest }: MapObjectModelOptions): | StackbitTypes.DataModel | StackbitTypes.ObjectModel | StackbitTypes.FieldObject { const modelName = _.get(model, 'name', null); const modelLabel = _.get(model, 'title', modelName ? _.startCase(modelName) : null); const modelDescription = _.get(model, 'description', null); const sanityFieldGroups: StackbitTypes.FieldGroupItem[] = _.get(model, 'groups', []).map((group: any) => ({ name: group.name, label: group.title })); const fields = _.get(model, 'fields', []); const mappedFields = mapObjectFields({ fields, ...rest }); return omitByNil({ // TODO: ensure type aliases work for documents type: getNormalizedModelType(model), name: modelName, label: modelLabel, description: modelDescription, labelField: resolveLabelFieldForModel(model, 'preview.select.title', mappedFields), fieldGroups: sanityFieldGroups, fields: mappedFields }) as StackbitTypes.DataModel | StackbitTypes.ObjectModel | StackbitTypes.FieldObject; } function mapObjectFields({ fields, modelFieldPath, ...rest }: { fields: SanityTypes.FieldDefinition[] } & CommonProps): StackbitTypes.Field[] { return _.map(fields, (field) => { return mapField({ field, modelFieldPath: modelFieldPath.concat(field.name), ...rest }); }); } /** * Maps Sanity FieldDefinition or ArrayOfType to the {@link StackbitTypes.Field} * or the {@link StackbitTypes.FieldListItems} respectively. * * The `mapField()` can be called for object fields or array items. * When called for array items, the 'name' property is optional and the 'hidden' * attribute is not present. */ function mapField({ field, modelFieldPath, typeAliasMap, i18nArrayModelMap, localizedFieldsModelMap, fieldAliasMap, seenAliases }: { field: SanityTypes.FieldDefinition | SanityTypes.ArrayOfType } & CommonProps): StackbitTypes.Field { let type = _.get(field, 'type'); const name = _.get(field, 'name'); const label = _.get(field, 'title', name ? _.startCase(name) : undefined); const modelFieldPathStr = modelFieldPath.join('.'); let localized: true | undefined; // TODO: can the Internationalized Array type have an alias? // e.g.: localizedString an alias to internationalizedArrayString? // { name: 'localizedString', type: 'internationalizedArrayString' } if (type in i18nArrayModelMap) { // The localization model map can reference a field definition, or a type alias. // i18nArrayModelMap['internationalizedArrayString'] => { valueModelName: 'internationalizedArrayStringValue', valueField: { type: 'string', name: 'value' } } // i18nArrayModelMap['internationalizedArrayBoolean'] => { valueModelName: 'internationalizedArrayBooleanValue', valueField: { type: 'boolean', name: 'value' } } // i18nArrayModelMap['internationalizedArrayInlineReference'] => { valueModelName: 'internationalizedArrayInlineReferenceValue', valueField: { type: 'reference', to: [...], name: 'value' } } // i18nArrayModelMap['internationalizedArrayCustomTypeAlias'] => { valueModelName: 'internationalizedArrayCustomTypeAliasValue', valueField: { type: 'customTypeAlias', name: 'value' } } // etc. if (seenAliases.includes(type)) { throw new Error(`Circular Array aliases are not supported, the Array alias '${type}' is recursively referenced in field '${modelFieldPathStr}'.`); } seenAliases = seenAliases.concat(type); localized = true; field = { ...i18nArrayModelMap[type].valueField, ...field, type: i18nArrayModelMap[type].valueField.type }; localizedFieldsModelMap[modelFieldPathStr] = { arrayModelName: type, arrayValueModelName: i18nArrayModelMap[type].valueModelName }; type = field.type; } const visitedTypes: string[] = []; let addedAlias = false; while (type in typeAliasMap) { if (seenAliases.includes(type)) { throw new Error(`Circular Array aliases not supported, the Array alias ${type} is recursively referenced in field ${modelFieldPathStr}.`); } seenAliases = seenAliases.concat(type); // In Sanity, the properties of the field, override the properties of // the alias. However, the final field type is the type of the alias. if (visitedTypes.includes(type)) { throw new Error(`Circular type alias detected in field ${modelFieldPathStr}: ${visitedTypes.join(' => ')} => ${type}.`); } visitedTypes.push(type); field = { ...typeAliasMap[type], ...field, type: typeAliasMap[type].type }; if (!fieldAliasMap[modelFieldPathStr]) { fieldAliasMap[modelFieldPathStr] = []; } const fieldAliases = fieldAliasMap[modelFieldPathStr]!; // Non list fields should have only one alias entry. // List fields, can have multiple aliases per list item type. if (!addedAlias) { addedAlias = true; fieldAliases.push({ origTypeName: type, resolvedTypeName: field.type }); } else { fieldAliases[fieldAliases.length - 1]!.resolvedTypeName = field.type; } type = field.type; } const description = _.get(field, 'description'); const readOnly = _.get(field, 'readOnly'); const isRequired = _.get(field, 'validation.isRequired'); const options = _.get(field, 'options'); const sanityFieldGroup = _.get(field, 'group'); const group = Array.isArray(sanityFieldGroup) ? sanityFieldGroup[0] : sanityFieldGroup; let hidden = _.get(field, 'hidden'); // compute default and const values let defaultValue = convertDefaultValue(_.get(field, 'initialValue')); let constValue; if (isRequired) { if (!_.isUndefined(defaultValue) && hidden) { constValue = defaultValue; defaultValue = undefined; } else if (type === 'string') { const optionsList: any = _.get(options, 'list'); if (_.isArray(optionsList) && optionsList.length === 1) { hidden = true; // constValue = _.head(optionsList); // constValue = _.get(constValue, 'value', constValue); // defaultValue = undefined; } } } const extra = convertField({ field, modelFieldPath, typeAliasMap, i18nArrayModelMap, localizedFieldsModelMap, fieldAliasMap, seenAliases }); return _.assign( omitByUndefined({ type: null, name: name, label: label, description: description, group: group, required: isRequired || undefined, default: defaultValue, const: constValue, readOnly: readOnly, hidden: hidden, localized: localized }), extra ); } function convertDefaultValue(defaultValue: any) { if (!_.isPlainObject(defaultValue) && !_.isArray(defaultValue)) { return defaultValue; } return deepMap( defaultValue, (value) => { if (!_.isPlainObject(value)) { return value; } let result = _.omit(value, ['_ref', '_type', '_key']); if ('_ref' in value) { result['$$ref'] = value._ref; } else if ('_type' in value && !['cloudinary.asset', 'bynder.asset', 'aprimo.cdnasset', 'block', 'geopoint'].includes(value._type)) { // TODO: instead of using [...].includes(value._type) pass modelMap, and check "value._type in modelMap" // only then we know for sure that _type points to the actual model, and not a field type if (value._type === 'image') { result = { $$ref: value.asset?._ref }; } else if (value._type === 'color') { return value?.hex; } else { result['$$type'] = value._type; } } return result; }, { iteratePrimitives: false, includeKeyPath: false } ); } function convertField({ field, ...rest }: { field: SanityTypes.FieldDefinition | SanityTypes.ArrayOfType } & CommonProps): StackbitTypes.FieldSpecificProps { const type = _.get(field, 'type'); if (!(type in fieldConverterMap)) { return fieldConverterMap.model({ field: field as SanityAliasFieldOrArrayItemDefinition<'model'>, ...rest }); } return _.get(fieldConverterMap, type)({ field, ...rest }); } function getEnumOptions(field: any) { // A list of predefined values that the user can choose from. // The array can either include string values ['sci-fi', 'western'] // or objects [{title: 'Sci-Fi', value: 'sci-fi'}, ...] const list = _.get(field, 'options.list', []); if (!_.isEmpty(list)) { return _.map(list, (item) => { return _.has(item, 'title') ? { label: item.title, value: item.value } : item; }); } else { return null; } } type SanityFieldTypes = Exclude; type AliasFieldTypes = 'model' | 'color' | 'markdown' | 'json' | 'cloudinary.asset' | 'bynder.asset' | 'aprimo.cdnasset'; type SanityIntrinsicFieldOrArrayItem = | (SanityTypes.InlineFieldDefinition[Type] & SanityTypes.FieldDefinitionBase) | SanityTypes.IntrinsicArrayOfDefinition[Type]; type SanityAliasFieldOrArrayItemDefinition = | (SanityTypes.TypeAliasDefinition & SanityTypes.FieldDefinitionBase) | SanityTypes.ArrayOfEntry>; const fieldConverterMap: { [Type in SanityFieldTypes]: (options: { field: SanityIntrinsicFieldOrArrayItem } & CommonProps) => StackbitTypes.FieldSpecificProps; } & { [Type in AliasFieldTypes]: (options: { field: SanityAliasFieldOrArrayItemDefinition } & CommonProps) => StackbitTypes.FieldSpecificProps; } = { string: ({ field }) => { const options = getEnumOptions(field); if (options) { return { type: 'enum', options: options }; } else { return { type: 'string' }; } }, slug: () => { return { type: 'slug' }; }, url: () => { return { type: 'url' }; }, text: () => { return { type: 'text' }; }, email: () => { return { type: 'string' }; }, color: () => { return { type: 'color' }; }, markdown: () => { return { type: 'markdown' }; }, block: () => { return { type: 'richText' }; }, number: ({ field }) => { const validation = _.get(field, 'validation'); const isInteger = _.get(validation, 'isInteger'); return omitByNil({ type: 'number', subtype: isInteger ? 'int' : 'float', min: _.get(validation, ['min']), max: _.get(validation, ['max']) }); }, boolean: () => { return { type: 'boolean' }; }, date: () => { return { type: 'date' }; }, datetime: () => { return { type: 'datetime' }; }, file: () => { return { type: 'file' }; }, image: () => { return { type: 'image' }; }, 'cloudinary.asset': () => { return { type: 'image', source: 'cloudinary' }; }, 'bynder.asset': () => { return { type: 'image', source: 'bynder' }; }, // aprimo.asset is not supported yet. If need to support, we will use ModelContext to differentiate between different // sanity types like in datocms 'aprimo.cdnasset': () => { return { type: 'image', source: 'aprimo' }; }, geopoint: () => { return { type: 'model', models: ['geopoint'] }; }, reference: ({ field }) => { const toItems = _.castArray(_.get(field, 'to', [])); return { type: 'reference', models: _.map(toItems, (item) => item.type) }; }, crossDatasetReference: ({ field }) => { // TODO: implement cross-reference // Sanity crossDatasetReference fields can reference between datasets // of the same project. But Stackbit cross-reference fields cannot // differentiate environments of the same project as the object in the // models array only contains the srcType and srcProjectId return { type: 'cross-reference', models: [] // models: field.to.map((toItem) => { // return { // modelName: toItem.type, // srcType: 'sanity', // srcProjectId: '...', // the projectId should be the same as the current one // // Stackbit cross-references do not have a way to specify different environment // srcEnvironment: field.dataset // } // }) }; }, json: () => { return { type: 'json' }; }, object: ({ field, ...rest }) => { return mapObjectModel({ model: field, ...rest }) as StackbitTypes.FieldObjectProps; }, model: ({ field }) => { const type = _.get(field, 'type'); if (thirdPartyImageModels.includes(type)) { const fn = (fieldConverterMap[type as keyof typeof fieldConverterMap] as any) ?? fieldConverterMap.image; return fn(); } return { type: 'model', models: [type] }; }, /** * Sanity 'Array' field type can hold multiple field types. * * For example, Sanity Arrays can simultaneously include items of `model` * and `reference` types. https://www.sanity.io/docs/array-type#wT47gyCx * * With that, Sanity Arrays cannot include both primitive and complex types: * https://www.sanity.io/docs/array-type#fNBIr84P * * TODO: * This is not yet supported by Stackbit's TypeScript types, so the `any` * must be used. Additionally, if a Sanity array has multiple types of items one * of which is the 'object' type, then it will also have the 'name' property to * allow matching 'object' items to their types. * * However, Stackbit client app should be able to render this types of lists correctly. * * @example A list that can include items of type 'model', 'reference' and 'object'. * { * type: 'list', * items: [{ * type: 'model', * models: [...] * }, { * type: 'reference', * models: [...] * }, { * type: 'object', * name: 'nested_object_name', * fields: {...} * }] * } */ array: ({ field, ...rest }) => { const options = getEnumOptions(field); if (options) { return { type: 'list', controlType: 'checkbox', items: { type: 'enum', options: options } }; } const ofItems = _.get(field, 'of', []); if (_.some(ofItems, { type: 'block' })) { return { type: 'richText' }; } const items = _.map(ofItems, (item, index) => { const { modelFieldPath, ...props } = rest; const listModelFieldPath = modelFieldPath.concat('items'); const modelFieldPathStr = listModelFieldPath.join('.'); // Sanity array fields may consist of anonymous 'object' with names. // When creating such objects, the '_type' should be set to their // name to identify them among other 'object' types. if (item && 'name' in item && item.name && 'type' in item && item.type === 'object') { if (!props.fieldAliasMap[modelFieldPathStr]) { props.fieldAliasMap[modelFieldPathStr] = []; } props.fieldAliasMap[modelFieldPathStr]!.push({ origTypeName: item.name, resolvedTypeName: 'object' }); } const listItems = mapField({ field: item, modelFieldPath: listModelFieldPath, ...props }); return _.omit(listItems, ['name', 'label', 'description', 'group', 'required', 'default', 'const', 'readOnly', 'hidden', 'localized']); }); let modelNames: string[] = []; let referenceModelName: string[] = []; const consolidatedItems = []; _.forEach(items, (item) => { const type = _.get(item, 'type'); // If the converted items have only two properties // - type: 'model' and models: [...], // - type: 'reference' and models: [...] // Then, consolidate all their models under the same 'model' or // 'reference' item. Otherwise, if the field has additional properties // like, "name", "label", etc., then add it as a separate items. if (type === 'model' && 'models' in item && _.size(item) === 2) { modelNames = modelNames.concat(item.models as string[]); } else if (type === 'reference' && 'models' in item && _.size(item) === 2) { referenceModelName = referenceModelName.concat(item.models as string[]); } else { consolidatedItems.push(item); } }); if (!_.isEmpty(modelNames)) { consolidatedItems.push({ type: 'model', models: modelNames }); } if (!_.isEmpty(referenceModelName)) { consolidatedItems.push({ type: 'reference', models: referenceModelName }); } if (consolidatedItems.length === 1) { return { type: 'list', items: _.head(consolidatedItems) } as StackbitTypes.FieldList; } return { type: 'list', items: consolidatedItems } as unknown as StackbitTypes.FieldList; } }; function getNormalizedModelType(sanityModel: any) { const modelType = _.get(sanityModel, 'type'); return modelType === 'document' ? 'data' : 'object'; } function getLocalesFromInternationalizedArrays(internationalizedArrayModels: any, defaultLocale?: string): StackbitTypes.Locale[] { const localeMap = internationalizedArrayModels .filter((model: any) => model.type === 'array') .reduce((localeMap: Record, model: any) => { if (model?.options?.languages && Array.isArray(model?.options?.languages)) { return model?.options?.languages.reduce((localeMap: Record, locale: { id: string; title: string }) => { if (!(locale.id in localeMap)) { localeMap[locale.id] = locale.title; } return localeMap; }, localeMap); } return localeMap; }, {}); let defaultLocaleFound = false; const locales = Object.entries(localeMap).map(([localeId, localeTitle]) => { const locale: StackbitTypes.Locale = { code: localeId }; if (defaultLocale && localeId === defaultLocale) { defaultLocaleFound = true; locale.default = true; } return locale; }); if (!defaultLocaleFound && locales.length > 0) { locales[0]!.default = true; } return locales; } function getLocalizedModelMap(i18nModels: any[]): { i18nArrayModelMap: Record< string, { valueModelName: string; valueField: any; } >; i18nValueModelMap: Record; } { const [i18nArrayModels, i18nObjectModels] = _.partition(i18nModels, { type: 'array' }); const i18nObjectModelsByName = _.keyBy(i18nObjectModels, 'name'); return _.reduce( i18nArrayModels, (accum, i18nArrayModel) => { if (Array.isArray(i18nArrayModel.of) && i18nArrayModel.of.length === 1) { const localizedArrayOfItem = i18nArrayModel.of[0]; if (localizedArrayOfItem && localizedArrayOfItem.type in i18nObjectModelsByName) { const valueModelName = localizedArrayOfItem.type; const localizedArrayValueModel = i18nObjectModelsByName[valueModelName]; const valueField = localizedArrayValueModel.fields[0]; // valueField.name === 'value' accum.i18nArrayModelMap[i18nArrayModel.name] = { valueModelName: valueModelName, valueField: valueField }; accum.i18nValueModelMap[valueModelName] = valueField; } } return accum; }, { i18nArrayModelMap: {} as Record< string, { valueModelName: string; valueField: any; } >, i18nValueModelMap: {} as Record } ); }