@stackbit/cms-contentful
Version:
Stackbit Contentful CMS Interface
629 lines (596 loc) • 24.6 kB
text/typescript
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;
}