@stackbit/cms-sanity
Version:
Stackbit Sanity CMS Interface
581 lines • 24.2 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertSchema = void 0;
const lodash_1 = __importDefault(require("lodash"));
const utils_1 = require("@stackbit/utils");
const utils_2 = require("./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';
/**
* 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.
*/
function convertSchema({ schema, logger, defaultLocale }) {
// 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 = [] } = lodash_1.default.groupBy(filteredModels, (model) => {
if (model.name.startsWith(internationalizedArrayPrefix)) {
return 'i18nModels';
}
else if (['document', 'object'].includes(model.type)) {
return 'documentAndObjectModels';
}
else {
return 'typeAliasModels';
}
});
// 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 = lodash_1.default.keyBy(typeAliasModels, 'name');
const models = documentAndObjectModels.map((model) => {
const localizedFieldsModelMap = {};
const fieldAliasMap = {};
try {
const stackbitModel = mapObjectModel({
model,
modelFieldPath: [],
typeAliasMap,
i18nArrayModelMap,
localizedFieldsModelMap,
fieldAliasMap,
seenAliases: []
});
let context = null;
if (!lodash_1.default.isEmpty(localizedFieldsModelMap) || !lodash_1.default.isEmpty(fieldAliasMap)) {
context = {
...(lodash_1.default.isEmpty(localizedFieldsModelMap) ? null : { localizedFieldsModelMap }),
...(lodash_1.default.isEmpty(fieldAliasMap) ? null : { fieldAliasMap })
};
}
return {
...stackbitModel,
context
};
}
catch (error) {
logger.error(`Error converting model '${model.name}'. ${error.message}`);
throw error;
}
});
return {
models,
locales
};
}
exports.convertSchema = convertSchema;
function mapObjectModel({ model, ...rest }) {
const modelName = lodash_1.default.get(model, 'name', null);
const modelLabel = lodash_1.default.get(model, 'title', modelName ? lodash_1.default.startCase(modelName) : null);
const modelDescription = lodash_1.default.get(model, 'description', null);
const sanityFieldGroups = lodash_1.default.get(model, 'groups', []).map((group) => ({ name: group.name, label: group.title }));
const fields = lodash_1.default.get(model, 'fields', []);
const mappedFields = mapObjectFields({
fields,
...rest
});
return (0, utils_1.omitByNil)({
// TODO: ensure type aliases work for documents
type: getNormalizedModelType(model),
name: modelName,
label: modelLabel,
description: modelDescription,
labelField: (0, utils_2.resolveLabelFieldForModel)(model, 'preview.select.title', mappedFields),
fieldGroups: sanityFieldGroups,
fields: mappedFields
});
}
function mapObjectFields({ fields, modelFieldPath, ...rest }) {
return lodash_1.default.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 }) {
let type = lodash_1.default.get(field, 'type');
const name = lodash_1.default.get(field, 'name');
const label = lodash_1.default.get(field, 'title', name ? lodash_1.default.startCase(name) : undefined);
const modelFieldPathStr = modelFieldPath.join('.');
let localized;
// 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 = [];
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 = lodash_1.default.get(field, 'description');
const readOnly = lodash_1.default.get(field, 'readOnly');
const isRequired = lodash_1.default.get(field, 'validation.isRequired');
const options = lodash_1.default.get(field, 'options');
const sanityFieldGroup = lodash_1.default.get(field, 'group');
const group = Array.isArray(sanityFieldGroup) ? sanityFieldGroup[0] : sanityFieldGroup;
let hidden = lodash_1.default.get(field, 'hidden');
// compute default and const values
let defaultValue = convertDefaultValue(lodash_1.default.get(field, 'initialValue'));
let constValue;
if (isRequired) {
if (!lodash_1.default.isUndefined(defaultValue) && hidden) {
constValue = defaultValue;
defaultValue = undefined;
}
else if (type === 'string') {
const optionsList = lodash_1.default.get(options, 'list');
if (lodash_1.default.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 lodash_1.default.assign((0, utils_1.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) {
if (!lodash_1.default.isPlainObject(defaultValue) && !lodash_1.default.isArray(defaultValue)) {
return defaultValue;
}
return (0, utils_1.deepMap)(defaultValue, (value) => {
if (!lodash_1.default.isPlainObject(value)) {
return value;
}
let result = lodash_1.default.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 }) {
const type = lodash_1.default.get(field, 'type');
if (!lodash_1.default.has(fieldConverterMap, type)) {
return fieldConverterMap.model({ field: field, ...rest });
}
return lodash_1.default.get(fieldConverterMap, type)({ field, ...rest });
}
function getEnumOptions(field) {
// 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 = lodash_1.default.get(field, 'options.list', []);
if (!lodash_1.default.isEmpty(list)) {
return lodash_1.default.map(list, (item) => {
return lodash_1.default.has(item, 'title') ? { label: item.title, value: item.value } : item;
});
}
else {
return null;
}
}
const fieldConverterMap = {
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 = lodash_1.default.get(field, 'validation');
const isInteger = lodash_1.default.get(validation, 'isInteger');
return (0, utils_1.omitByNil)({
type: 'number',
subtype: isInteger ? 'int' : 'float',
min: lodash_1.default.get(validation, 'min'),
max: lodash_1.default.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 = lodash_1.default.castArray(lodash_1.default.get(field, 'to', []));
return {
type: 'reference',
models: lodash_1.default.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 });
},
model: ({ field }) => {
const type = lodash_1.default.get(field, 'type');
if (thirdPartyImageModels.includes(type)) {
const fn = fieldConverterMap[type] ?? 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 = lodash_1.default.get(field, 'of', []);
if (lodash_1.default.some(ofItems, { type: 'block' })) {
return { type: 'richText' };
}
const items = lodash_1.default.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 lodash_1.default.omit(listItems, ['name', 'label', 'description', 'group', 'required', 'default', 'const', 'readOnly', 'hidden', 'localized']);
});
let modelNames = [];
let referenceModelName = [];
const consolidatedItems = [];
lodash_1.default.forEach(items, (item) => {
const type = lodash_1.default.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' && lodash_1.default.has(item, 'models') && lodash_1.default.size(item) === 2) {
modelNames = modelNames.concat(lodash_1.default.get(item, 'models'));
}
else if (type === 'reference' && lodash_1.default.has(item, 'models') && lodash_1.default.size(item) === 2) {
referenceModelName = referenceModelName.concat(lodash_1.default.get(item, 'models'));
}
else {
consolidatedItems.push(item);
}
});
if (!lodash_1.default.isEmpty(modelNames)) {
consolidatedItems.push({
type: 'model',
models: modelNames
});
}
if (!lodash_1.default.isEmpty(referenceModelName)) {
consolidatedItems.push({
type: 'reference',
models: referenceModelName
});
}
if (consolidatedItems.length === 1) {
return {
type: 'list',
items: lodash_1.default.head(consolidatedItems)
};
}
return {
type: 'list',
items: consolidatedItems
};
}
};
function getNormalizedModelType(sanityModel) {
const modelType = lodash_1.default.get(sanityModel, 'type');
return modelType === 'document' ? 'data' : 'object';
}
function getLocalesFromInternationalizedArrays(internationalizedArrayModels, defaultLocale) {
const localeMap = internationalizedArrayModels
.filter((model) => model.type === 'array')
.reduce((localeMap, model) => {
if (model?.options?.languages && Array.isArray(model?.options?.languages)) {
return model?.options?.languages.reduce((localeMap, locale) => {
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 = {
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) {
const [i18nArrayModels, i18nObjectModels] = lodash_1.default.partition(i18nModels, { type: 'array' });
const i18nObjectModelsByName = lodash_1.default.keyBy(i18nObjectModels, 'name');
return lodash_1.default.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: {},
i18nValueModelMap: {}
});
}
//# sourceMappingURL=sanity-schema-converter.js.map