@stackbit/cms-sanity
Version:
Stackbit Sanity CMS Interface
702 lines (674 loc) • 27.8 kB
text/typescript
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<ModelContext>;
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<string, SanityTypes.IntrinsicTypeName | undefined>[];
documentAndObjectModels: (SanityTypes.DocumentDefinition | SanityTypes.ObjectDefinition)[];
typeAliasModels: SanityTypes.TypeAliasDefinition<string, SanityTypes.IntrinsicTypeName | undefined>[];
};
// 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<ModelContext> | StackbitTypes.ObjectModel<ModelContext> => {
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<string, any>;
i18nArrayModelMap: Record<string, any>;
localizedFieldsModelMap: LocalizedFieldsModelMap;
fieldAliasMap: FieldAliasMap;
seenAliases: string[];
};
type SanityObjectFieldOrArrayItem = SanityIntrinsicFieldOrArrayItem<'object'>;
type MapObjectModelOptions<Type extends SanityTypes.DocumentDefinition | SanityTypes.ObjectDefinition | SanityObjectFieldOrArrayItem> = {
model: Type;
} & CommonProps;
function mapObjectModel(
options: MapObjectModelOptions<SanityTypes.DocumentDefinition | SanityTypes.ObjectDefinition>
): StackbitTypes.DataModel | StackbitTypes.ObjectModel;
function mapObjectModel(options: MapObjectModelOptions<SanityObjectFieldOrArrayItem>): StackbitTypes.FieldObject;
function mapObjectModel({
model,
...rest
}: MapObjectModelOptions<SanityTypes.DocumentDefinition | SanityTypes.ObjectDefinition | SanityObjectFieldOrArrayItem>):
| 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 = _.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 (!_.has(fieldConverterMap, type)) {
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<SanityTypes.IntrinsicTypeName, 'document'>;
type AliasFieldTypes = 'model' | 'color' | 'markdown' | 'json' | 'cloudinary.asset' | 'bynder.asset' | 'aprimo.cdnasset';
type SanityIntrinsicFieldOrArrayItem<Type extends SanityFieldTypes> =
| (SanityTypes.InlineFieldDefinition[Type] & SanityTypes.FieldDefinitionBase)
| SanityTypes.IntrinsicArrayOfDefinition[Type];
type SanityAliasFieldOrArrayItemDefinition<Type extends string> =
| (SanityTypes.TypeAliasDefinition<Type, SanityTypes.IntrinsicTypeName | undefined> & SanityTypes.FieldDefinitionBase)
| SanityTypes.ArrayOfEntry<SanityTypes.TypeAliasDefinition<Type, SanityTypes.IntrinsicTypeName | undefined>>;
const fieldConverterMap: {
[Type in SanityFieldTypes]: (options: { field: SanityIntrinsicFieldOrArrayItem<Type> } & CommonProps) => StackbitTypes.FieldSpecificProps;
} & {
[Type in AliasFieldTypes]: (options: { field: SanityAliasFieldOrArrayItemDefinition<Type> } & 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' && _.has(item, 'models') && _.size(item) === 2) {
modelNames = modelNames.concat(_.get(item, 'models'));
} else if (type === 'reference' && _.has(item, 'models') && _.size(item) === 2) {
referenceModelName = referenceModelName.concat(_.get(item, 'models'));
} 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<string, string>, model: any) => {
if (model?.options?.languages && Array.isArray(model?.options?.languages)) {
return model?.options?.languages.reduce((localeMap: Record<string, string>, 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<string, any>;
} {
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<string, any>
}
);
}