UNPKG

@stackbit/cms-sanity

Version:
581 lines 24.2 kB
"use strict"; 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