UNPKG

@stackbit/cms-contentful

Version:

Stackbit Contentful CMS Interface

629 lines (596 loc) 24.6 kB
import _ from 'lodash'; import { omitByNil } from '@stackbit/utils'; import * as StackbitTypes from '@stackbit/types'; import type { Field, FieldSpecificProps, RequiredBy } from '@stackbit/types'; import type { FieldType, ContentFields, ContentTypeProps, Control, EditorInterfaceProps, ContentTypeFieldValidation } from 'contentful-management'; import { CONTENTFUL_NODE_TYPES_MAP, CONTENTFUL_MARKS_TYPES_MAP, CONTENTFUL_CLOUDINARY_APP, CONTENTFUL_BYNDER_APP, CONTENTFUL_APRIMO_APP } from './contentful-consts'; type ExtendedFieldType = FieldType | ResourceLink | ResourceLinkArray; type ContentfulField = ContentFields & ExtendedFieldType; declare module 'contentful-management' { interface ContentTypeFieldValidation { message?: string; } } interface ResourceLink { type: 'ResourceLink'; allowedResources: AllowedResource[]; } interface ResourceLinkArray { type: 'Array'; items: { type: 'ResourceLink'; }; allowedResources: AllowedResource[]; } interface AllowedResource { type: 'Contentful:Entry'; source: string; // "crn:contentful:::content:spaces/{space_id}" contentTypes: string[]; // ["button", "badge"] } export interface ConvertSchemaOptions { contentTypes: ContentTypeProps[]; editorInterfaces: EditorInterfaceProps[]; defaultLocaleCode: string; cloudinaryImagesAsList: boolean; bynderImagesAsList: boolean; } export function convertSchema({ contentTypes, editorInterfaces, defaultLocaleCode, cloudinaryImagesAsList, bynderImagesAsList }: ConvertSchemaOptions): { models: StackbitTypes.Model[]; } { const editorInterfaceByContentTypeId = _.chain(editorInterfaces) .filter({ sys: { type: 'EditorInterface', contentType: { sys: { type: 'Link', linkType: 'ContentType' } } } }) .keyBy('sys.contentType.sys.id') .value(); const models = _.map(contentTypes, (contentType) => mapModel({ contentType, editorInterfaceByContentTypeId, defaultLocaleCode, cloudinaryImagesAsList, bynderImagesAsList }) ); return { models }; } interface MapModelOptions { contentType: ContentTypeProps; editorInterfaceByContentTypeId: Record<string, EditorInterfaceProps>; defaultLocaleCode: string; cloudinaryImagesAsList: boolean; bynderImagesAsList: boolean; } function mapModel(options: MapModelOptions): StackbitTypes.Model { const contentType = options.contentType; const contentTypeId = contentType.sys.id; const mappedFields = mapFields(options); return Object.assign( { type: 'data' as const, name: contentTypeId }, omitByNil({ label: contentType.name ?? _.startCase(contentTypeId), description: contentType.description ?? null, labelField: resolveLabelFieldForModel(contentType, 'displayField', mappedFields), fields: mappedFields }) ); } interface MapFieldsOptions { contentType: ContentTypeProps; editorInterfaceByContentTypeId: Record<string, EditorInterfaceProps>; defaultLocaleCode: string; cloudinaryImagesAsList: boolean; bynderImagesAsList: boolean; } function mapFields({ contentType, editorInterfaceByContentTypeId, defaultLocaleCode, cloudinaryImagesAsList, bynderImagesAsList }: MapFieldsOptions) { const contentTypeId = contentType.sys.id; const fields = contentType.fields as ContentfulField[]; const editorInterfaceControls = editorInterfaceByContentTypeId[contentTypeId]?.controls; const editorInterfaceControlsByFieldId = _.keyBy(editorInterfaceControls, 'fieldId'); return _.map(fields, (field) => mapField({ field, editorInterfaceControlsByFieldId, defaultLocaleCode, cloudinaryImagesAsList, bynderImagesAsList }) ); } interface MapFieldOptions { field: ContentfulField; editorInterfaceControlsByFieldId: Record<string, Control>; defaultLocaleCode: string; cloudinaryImagesAsList: boolean; bynderImagesAsList: boolean; } function mapField({ field, editorInterfaceControlsByFieldId, defaultLocaleCode, cloudinaryImagesAsList, bynderImagesAsList }: MapFieldOptions): Field { const fieldId = field.id; const editorInterfaceControl = editorInterfaceControlsByFieldId[fieldId]; const isRequired = field.required; const isReadonly = _.get(editorInterfaceControl, 'settings.readOnly'); const isHidden = field.disabled ?? isReadonly; const validations = field.validations; const localized = field.localized; const defaultValue = getDefaultValue(field, defaultLocaleCode, editorInterfaceControl); const defaultAsConst = isRequired && isReadonly && !_.isUndefined(defaultValue); const fieldSpecificProps = convertField({ field, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList }); return _.assign( { type: null, name: fieldId }, omitByNil({ label: field.name ?? _.startCase(fieldId), description: _.get(editorInterfaceControl, 'settings.helpText'), required: isRequired, default: defaultAsConst ? undefined : defaultValue, const: defaultAsConst ? defaultValue : undefined, readOnly: isReadonly, hidden: isHidden, validations: convertValidations(validations), localized: localized }), fieldSpecificProps ); } function getDefaultValue(field: ContentfulField, defaultLocaleCode: string, editorInterfaceControl?: Control) { // Contentful defaultValue is an object with locale keys and default values per locale const defaultValue = field.defaultValue; if (typeof defaultValue !== 'undefined') { return _.isPlainObject(defaultValue) && defaultLocaleCode in defaultValue ? defaultValue[defaultLocaleCode] : undefined; } const widgetId = editorInterfaceControl?.widgetId; if (widgetId === 'default-boolean-value') { return _.get(editorInterfaceControl, 'settings.defaultValue'); } else if (widgetId === 'default-field-value') { return _.get(editorInterfaceControl, 'settings.defaultValue'); } else { return undefined; } } interface ConvertFieldOptions { field: ContentfulField; editorInterfaceControl?: Control; cloudinaryImagesAsList: boolean; bynderImagesAsList: boolean; } function convertField({ field, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList }: ConvertFieldOptions): FieldSpecificProps { const typedField = field; const type = typedField.type; // return fieldConverterMap[typedField.type](typedField, editorInterfaceControl, cloudinaryImagesAsList); switch (typedField.type) { case 'Symbol': return fieldConverterMap.Symbol(typedField, editorInterfaceControl); case 'Text': return fieldConverterMap.Text(typedField, editorInterfaceControl); case 'Integer': return fieldConverterMap.Integer(typedField, editorInterfaceControl); case 'Number': return fieldConverterMap.Number(typedField, editorInterfaceControl); case 'Date': return fieldConverterMap.Date(typedField, editorInterfaceControl); case 'Location': return fieldConverterMap.Location(typedField, editorInterfaceControl); case 'Boolean': return fieldConverterMap.Boolean(typedField, editorInterfaceControl); case 'Link': return fieldConverterMap.Link(typedField, editorInterfaceControl); case 'ResourceLink': return fieldConverterMap.ResourceLink(typedField, editorInterfaceControl); case 'Array': return fieldConverterMap.Array(typedField, editorInterfaceControl); case 'Object': return fieldConverterMap.Object(typedField, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList); case 'RichText': return fieldConverterMap.RichText(typedField, editorInterfaceControl); default: throw new Error(`field type '${type}' not supported, fieldId: ${field.id}`); } } type FieldConverterMap = { [CtflField in ContentfulField as CtflField['type']]: ( field: CtflField, editorInterfaceControl?: Control, cloudinaryImagesAsList?: boolean, bynderImagesAsList?: boolean ) => FieldSpecificProps; }; const fieldConverterMap: FieldConverterMap = { Symbol: function ( field, editorInterfaceControl ): StackbitTypes.FieldStringProps | StackbitTypes.FieldSlugProps | StackbitTypes.FieldUrlProps | StackbitTypes.FieldEnumProps { const options = getOptions(field); if (options) { return { type: 'enum', ...options }; } else { const widgetId = _.get(editorInterfaceControl, 'widgetId'); if (widgetId === 'slugEditor') { return { type: 'slug' }; } else if (widgetId === 'urlEditor') { return { type: 'url' }; } else { return { type: 'string' }; } } }, Text: function ( field, editorInterfaceControl ): StackbitTypes.FieldStringProps | StackbitTypes.FieldTextProps | StackbitTypes.FieldMarkdownProps | StackbitTypes.FieldEnumProps { const widgetId = _.get(editorInterfaceControl, 'widgetId'); const options = getOptions(field); if (options) { return { type: 'enum', ...(widgetId === 'radio' ? { controlType: 'button-group' } : null), ...options }; } else if (widgetId === 'singleLine') { return { type: 'string' }; } else { if (widgetId === 'multipleLine') { // can also be {type: 'html'} but we have no way of knowing that return { type: 'text' }; } else if (widgetId === 'markdown') { return { type: 'markdown' }; } else { return { type: 'text' }; } } }, Integer: function (field, editorInterfaceControl): StackbitTypes.FieldNumberProps | StackbitTypes.FieldEnumProps { const options = getOptions(field); if (options) { return { type: 'enum', ...options }; } else { return { type: 'number', subtype: 'int', ...getMinMax(field) // backward compatibility }; } }, Number: function (field, editorInterfaceControl): StackbitTypes.FieldNumberProps | StackbitTypes.FieldEnumProps { const options = getOptions(field); if (options) { return { type: 'enum', ...options }; } else { return { type: 'number', subtype: 'float', ...getMinMax(field) // backward compatibility }; } }, Date: function (field, editorInterfaceControl): StackbitTypes.FieldDateProps | StackbitTypes.FieldDatetimeProps { const format = _.get(editorInterfaceControl, 'settings.format'); const type = format === 'dateonly' ? 'date' : 'datetime'; return { type: type }; }, Location: function () { return { type: 'json' }; }, Boolean: function (): StackbitTypes.FieldBooleanProps { return { type: 'boolean' }; }, Link: function (field): StackbitTypes.FieldImageProps | StackbitTypes.FieldReferenceProps { const linkType = field.linkType; if (linkType === 'Asset') { return { type: 'image' }; } else if (linkType === 'Entry') { const validations = field.validations ?? []; const linkContentTypeValidation = _.find(validations, 'linkContentType'); const linkContentTypes = linkContentTypeValidation?.linkContentType ?? []; return { type: 'reference', models: linkContentTypes }; } else { throw new Error(`not supported linkType: ${linkType}`); } }, ResourceLink: function (field): StackbitTypes.FieldCrossReferenceProps { return { type: 'cross-reference', models: field.allowedResources.reduce((models: StackbitTypes.FieldCrossReferenceModel[], allowedResource) => { const match = allowedResource.source.match(/crn:contentful:::content:spaces\/(.+)$/); if (!match) { return models; } const spaceId = match[1]!; return models.concat( allowedResource.contentTypes.reduce((models: StackbitTypes.FieldCrossReferenceModel[], contentType) => { return models.concat({ modelName: contentType, srcType: 'contentful', srcProjectId: spaceId }); }, []) ); }, []) }; }, Array: function (field, editorInterfaceControl): StackbitTypes.FieldListProps { const widgetId = _.get(editorInterfaceControl, 'widgetId'); const items = getListItems(field) as StackbitTypes.FieldListItems; const validations = convertValidations(field.items.validations); if (validations) { items.validations = validations; } if (widgetId === 'checkbox' && items.type === 'enum') { return { type: 'list', controlType: 'checkbox', items }; } return { type: 'list', items }; }, Object: function (field, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList): StackbitTypes.FieldSpecificProps { const widgetId = _.get(editorInterfaceControl, 'widgetId'); if (widgetId === CONTENTFUL_CLOUDINARY_APP) { if (cloudinaryImagesAsList) { return { type: 'list', items: { type: 'image', source: 'cloudinary' } }; } return { type: 'image', source: 'cloudinary' }; } else if (widgetId === CONTENTFUL_BYNDER_APP) { if (bynderImagesAsList) { return { type: 'list', items: { type: 'image', source: 'bynder' } }; } return { type: 'image', source: 'bynder' }; } else if (widgetId === CONTENTFUL_APRIMO_APP) { return { type: 'list', items: { type: 'image', source: 'aprimo' } }; } return { type: 'json' }; }, RichText: function (field) { const validations = _.get(field, 'validations'); const nodeTypesValidation = _.find(validations, 'enabledNodeTypes'); const marksValidation = _.find(validations, 'enabledMarks'); return { type: 'richText', options: { nodes: (nodeTypesValidation?.enabledNodeTypes ?? []).map((node) => _.get(CONTENTFUL_NODE_TYPES_MAP, node, 'unsupported')), marks: (marksValidation?.enabledMarks ?? []).map((mark) => _.get(CONTENTFUL_MARKS_TYPES_MAP, mark, 'unsupported')) } }; } }; function getMinMax(field: ContentfulField) { const validations = field.validations; const rangeValidation = _.find(validations, 'range'); return rangeValidation ? { min: rangeValidation.range!.min, max: rangeValidation.range!.max } : null; } function getOptions(field: ContentfulField) { const validations = field.validations; const inValidation = _.find(validations, 'in'); return inValidation?.in && inValidation.in.length > 0 ? { options: inValidation.in } : null; } function getListItems(field: ContentfulField & Extract<ExtendedFieldType, { type: 'Array' }>) { const items = field.items; const itemType = items.type; if (items.type === 'Symbol') { return fieldConverterMap.Symbol(items as ContentfulField & Extract<ExtendedFieldType, { type: 'Symbol' }>); } else if (items.type === 'Link') { return fieldConverterMap.Link(items as ContentfulField & Extract<ExtendedFieldType, { type: 'Link' }>); } else if (items.type === 'ResourceLink') { return fieldConverterMap.ResourceLink({ ...items, allowedResources: (field as ResourceLinkArray).allowedResources } as ContentfulField & Extract<ExtendedFieldType, { type: 'ResourceLink' }>); } else { throw new Error(`not supported list items.type: ${itemType}, fieldId: ${field.id}`); } } function resolveLabelFieldForModel(contentType: ContentTypeProps, modelLabelFieldPath: string, fields: Field[]) { let labelField = contentType.displayField ?? null; if (labelField) { return labelField; } // see if there is a field named 'title' let titleField = _.find(fields, (field: Field) => field.name === 'title' && ['string', 'text'].includes(field.type)); if (!titleField) { // see if there is a field named 'label' titleField = _.find(fields, (field: Field) => field.name === 'label' && ['string', 'text'].includes(field.type)); } if (!titleField) { // get the first 'string' field titleField = _.find(fields, { type: 'string' }); } if (titleField) { labelField = _.get(titleField, 'name'); } return labelField; } type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never; type StackbitFieldValidations = | StackbitTypes.FieldValidationsUnique | StackbitTypes.FieldValidationsRegExp | StackbitTypes.FieldValidationsStringLength | StackbitTypes.FieldValidationsListLength | StackbitTypes.FieldValidationsDateRange | StackbitTypes.FieldValidationsNumberRange | StackbitTypes.FieldValidationsFile | StackbitTypes.FieldValidationsImage; type ValidationsIntersection = UnionToIntersection<StackbitFieldValidations>; function convertValidations(validations: ContentTypeFieldValidation[] | undefined): StackbitTypes.Field['validations'] { if (!validations) { return undefined; } const stackbitValidations: RequiredBy<ValidationsIntersection, 'errors'> = { errors: {} }; for (const validation of validations) { if (validation.unique) { stackbitValidations.unique = true; } else if (validation.size) { const size = validation.size; const message = validation.message; if (!_.isNil(size.min)) { (stackbitValidations as StackbitTypes.FieldValidationsStringLength).min = size.min; stackbitValidations.errors.min = message; } if (!_.isNil(size.max)) { (stackbitValidations as StackbitTypes.FieldValidationsStringLength).max = size.max; stackbitValidations.errors.max = message; } } else if (validation.range) { const range = validation.range; const message = validation.message; if (!_.isNil(range.min)) { (stackbitValidations as StackbitTypes.FieldValidationsNumberRange).min = range.min; stackbitValidations.errors.min = message; } if (!_.isNil(range.max)) { (stackbitValidations as StackbitTypes.FieldValidationsNumberRange).max = range.max; stackbitValidations.errors.max = message; } } else if (validation.dateRange) { const dateRange = validation.dateRange; const message = validation.message; if (!_.isNil(dateRange.min)) { (stackbitValidations as StackbitTypes.FieldValidationsDateRange).min = dateRange.min; stackbitValidations.errors.min = message; } if (!_.isNil(dateRange.max)) { (stackbitValidations as StackbitTypes.FieldValidationsDateRange).max = dateRange.max; stackbitValidations.errors.max = message; } } else if (validation.regexp) { stackbitValidations.regexp = validation.regexp.pattern; stackbitValidations.errors.regexp = validation.message; } else if (validation.prohibitRegexp) { stackbitValidations.regexpNot = validation.prohibitRegexp.pattern; stackbitValidations.errors.regexpNot = validation.message; } else if (validation.assetImageDimensions) { const message = validation.message; const { width, height } = validation.assetImageDimensions; stackbitValidations.minWidth = width?.min; stackbitValidations.maxWidth = width?.max; stackbitValidations.minHeight = height?.min; stackbitValidations.maxHeight = height?.max; stackbitValidations.errors.minWidth = _.isNil(width?.min) ? undefined : message; stackbitValidations.errors.maxWidth = _.isNil(width?.max) ? undefined : message; stackbitValidations.errors.minHeight = _.isNil(height?.min) ? undefined : message; stackbitValidations.errors.maxHeight = _.isNil(height?.max) ? undefined : message; } else if (validation.assetFileSize) { const message = validation.message; const { min, max } = validation.assetFileSize; stackbitValidations.fileMinSize = min; stackbitValidations.fileMaxSize = max; stackbitValidations.errors.fileMinSize = _.isNil(min) ? undefined : message; stackbitValidations.errors.fileMaxSize = _.isNil(max) ? undefined : message; } else if (validation.linkMimetypeGroup) { stackbitValidations.fileTypeGroups = convertMimeTypesToFileTypeGroups(validation.linkMimetypeGroup); stackbitValidations.errors.fileTypeGroups = validation.message; } } return undefinedIfEmpty( omitByNil({ ...stackbitValidations, errors: undefinedIfEmpty(omitByNil(stackbitValidations.errors)) }) ); } function convertMimeTypesToFileTypeGroups(mimeTypes?: string[]): StackbitTypes.FieldValidationsFileTypesGroup[] | undefined { // contentful mimeTypes: // "image", "audio", "video", "plaintext", "markup", "richtext", "code", // "pdfdocument", "presentation", "spreadsheet", "attachment", "archive" const map: Record<string, StackbitTypes.FieldValidationsFileTypesGroup> = { image: 'image', video: 'video', audio: 'audio', plaintext: 'text', markup: 'markup', code: 'code', pdfdocument: 'document', presentation: 'presentation', spreadsheet: 'spreadsheet', archive: 'archive' }; const fileTypeGroups = (mimeTypes ?? []).reduce((accum: StackbitTypes.FieldValidationsFileTypesGroup[], mimeType) => { const fileTypeGroup = map[mimeType]; if (fileTypeGroup) { accum.push(fileTypeGroup); } return accum; }, []); return undefinedIfEmpty(fileTypeGroups); } function wrapWithMessageIfNeeded<V extends string | number | string[]>( value: V | undefined, message: string | undefined ): V | { value: V; message: string } | undefined { if (typeof value === 'undefined' || value === null) { return undefined; } if (message) { return { value, message }; } return value; } function undefinedIfEmpty<T extends Record<string, unknown> | unknown[]>(value: T): T | undefined { return _.isEmpty(value) ? undefined : value; }