UNPKG

@stackbit/cms-contentful

Version:

Stackbit Contentful CMS Interface

602 lines (567 loc) 19.2 kB
import _ from 'lodash'; import type { EntryProps, AssetProps, UserProps, SnapshotProps } from 'contentful-management'; import type { Model, FieldSpecificProps, Document, Asset, DocumentField, DocumentListFieldItems, DocumentFieldBaseProps, AssetFileFieldBase, DocumentFieldSpecificProps, Cache, DocumentVersionWithDocument, AssetFileFieldProps } from '@stackbit/types'; import { CONTENTFUL_BUILT_IN_IMAGE_SOURCES } from './contentful-consts'; export type DocumentContext = { sys: { type: string; version: number; publishedVersion?: number; archivedVersion?: number; }; }; export type AssetContext = DocumentContext; export type ContextualDocument = Document<DocumentContext>; export type ContextualAsset = Asset<AssetContext>; export type EntityProps = EntryProps | AssetProps; export type GetModelByName = Cache['getModelByName']; export type ConvertEntriesOptions = { entries: EntityProps[]; getModelByName: GetModelByName; userMap: Record<string, UserProps>; defaultLocale: string; projectUrl: string; }; export function convertEntities({ entries, getModelByName, userMap, defaultLocale, projectUrl }: ConvertEntriesOptions): ContextualDocument[] { return entries .map((entry) => { return convertEntry({ entry, getModelByName, userMap, defaultLocale, projectUrl }); }) .filter((document): document is ContextualDocument => !!document); } export type ConvertAssetsOptions = { assets: AssetProps[]; userMap: Record<string, UserProps>; defaultLocale: string; projectUrl: string; }; export function convertAssets({ assets, userMap, defaultLocale, projectUrl }: ConvertAssetsOptions) { return assets .map((asset) => { return convertAsset({ asset, userMap, defaultLocale, projectUrl }); }) .filter((asset): asset is ContextualAsset => !!asset); } export type ConvertEntryOptions = { entry: EntryProps; getModelByName: GetModelByName; userMap: Record<string, UserProps>; defaultLocale: string; projectUrl: string; }; export function convertEntry({ entry, getModelByName, userMap, defaultLocale, projectUrl }: ConvertEntryOptions): ContextualDocument | undefined { if (!entry) { return; } const contentTypeId = getEntryContentTypeId(entry); const model = getModelByName(contentTypeId); if (!model) { return; } const entryId = getEntityId(entry); const environment = getEntryEnvironmentId(entry); const envUrlPath = environment && environment !== 'master' ? `/environments/${environment}` : ''; const objectUrl = `${projectUrl}${envUrlPath}/entries/${entryId}`; return { type: 'document', id: entryId, manageUrl: objectUrl, modelName: model.name, ...commonFields(entry, userMap), fields: convertFields({ fields: entry.fields, model, defaultLocale }), context: { sys: { type: entry.sys.type, version: entry.sys.version, publishedVersion: entry.sys.publishedVersion, archivedVersion: entry.sys.archivedVersion } } }; } export type ConvertAssetOptions = { asset: AssetProps; userMap: Record<string, UserProps>; defaultLocale: string; projectUrl: string; }; export function convertAsset({ asset, userMap, defaultLocale, projectUrl }: ConvertAssetOptions): ContextualAsset | undefined { if (!asset) { return; } const assetId = getEntityId(asset); const environment = getEntryEnvironmentId(asset); const envUrlPath = environment && environment !== 'master' ? `/environments/${environment}` : ''; const objectUrl = `${projectUrl}${envUrlPath}/assets/${assetId}`; // Remap Contentful Asset fields to Stackbit AssetFields // ContentfulAsset.fields: { // title: { // "en-US": "...", // "en-GB": "..." // }, // file: { // "en-US": { url: "..." }, // "en-GB": { url: "..." } // } // } // => StackbitAsset.fields: { // title: { // "type": "string", // "localized": true, // "locales": [ // { "locale": "en-US", "value": "..." }, // { "locale": "en-GB", "value": "..." } // ] // }, // url: { // "type": "string", // "localized": true, // "locales": [ // { "locale": "en-US", "value": "..." }, // { "locale": "en-GB", "value": "..." } // ] // } // } const fileField = toDocumentField( asset.fields.file, true, defaultLocale, { type: 'assetFile' }, (val: AssetProps['fields']['file'][string]) => ({ url: val.url ?? '', fileName: val.fileName, contentType: val.contentType, size: val.details?.size, dimensions: { width: val.details?.image?.width, height: val.details?.image?.height } }) ); if (!fileField) { return; } const titleField = toDocumentField( asset.fields.title, true, defaultLocale, { type: 'string' }, (val) => ({ value: val }) ) ?? { type: 'string', value: null }; const descriptionField = toDocumentField( asset.fields.description, true, defaultLocale, { type: 'text' }, (val) => ({ value: val }) ) ?? { type: 'text', value: null }; return { type: 'asset', id: assetId, manageUrl: objectUrl, ...commonFields(asset, userMap), fields: { title: titleField, description: descriptionField, file: fileField }, context: { sys: { type: asset.sys.type, version: asset.sys.version, publishedVersion: asset.sys.publishedVersion, archivedVersion: asset.sys.archivedVersion } } }; } function commonFields( entity: EntityProps, userMap: Record<string, UserProps> ): { status: ContextualDocument['status']; createdAt: string; createdBy?: string; updatedAt: string; updatedBy?: string[]; } { // currently we only expose isChanged that is treated as isDraft and isChanged because in Sanity there is no way to tell the difference const isArchived = !!entity.sys?.archivedVersion; const isPublished = !!entity.sys?.publishedVersion; // unpublished and deleted appear the same in contentful's API response. const isChanged = !!entity.sys?.publishedVersion && entity.sys?.version >= entity.sys?.publishedVersion + 2; const status = isChanged ? 'modified' : isArchived ? 'archived' : isPublished ? 'published' : 'added'; // draft is the remaining possible status due to isArchived and isPublished being false. const createdByEmail = getUserEmailById(entity.sys?.createdBy?.sys?.id, userMap); const updatedByEmail = getUserEmailById(entity.sys?.updatedBy?.sys?.id, userMap); const updatedByList = updatedByEmail ? [updatedByEmail] : []; return { status: status, createdAt: entity.sys?.createdAt ?? null, createdBy: createdByEmail, updatedAt: entity.sys?.updatedAt ?? null, updatedBy: updatedByList }; } function getEntityId(entity: EntityProps) { return entity.sys?.id; } function getEntryEnvironmentId(entity: EntityProps) { return entity.sys?.environment?.sys?.id; } function getEntryContentTypeId(entry: EntryProps) { return entry.sys?.contentType?.sys?.id; } function getUserEmailById(userId: string | undefined, userMap: Record<string, UserProps>): string | undefined { if (!userId) { return; } return userMap[userId]?.email; } interface ConvertFieldsOptions { fields: Record<string, any>; model: Model; defaultLocale: string; } function convertFields({ fields, model, defaultLocale }: ConvertFieldsOptions): Record<string, DocumentField> { const fieldsByName = _.keyBy(model.fields, 'name'); return _.reduce( fields, (documentFields: Record<string, DocumentField>, value, fieldName) => { const modelField = fieldsByName[fieldName]; if (!modelField) { return documentFields; // TODO: log user error // throw new Error(`Error in contentful-entries-converter, no model field found for entry field name ${fieldName}`); } const documentField = convertFieldType({ value, modelField, defaultLocale, localized: !!modelField.localized }); if (documentField) { documentFields[fieldName] = documentField; } return documentFields; }, {} ); } interface ConvertFieldTypeOptions { value: any; modelField: FieldSpecificProps; defaultLocale: string; localized: boolean; } function convertFieldType({ value, modelField, defaultLocale, localized }: ConvertFieldTypeOptions): DocumentField | null { if (modelField.type === 'list') { const itemsModel = modelField.items ?? { type: 'string' }; return toDocumentField( value, localized, defaultLocale, { type: 'list' }, (val) => ({ items: _.map(val, (item) => { if (itemsModel.type === 'reference') { return { type: 'reference', refType: 'document', refId: _.get(item, 'sys.id') }; } else if (itemsModel.type === 'cross-reference') { const urn = _.get(item, 'sys.urn'); return { type: 'cross-reference', refType: 'document', ...crossReferenceFieldPropsFromUrn(urn) }; } else if (itemsModel.type === 'image') { if (itemsModel.source === 'cloudinary') { return { type: 'image', source: itemsModel.source, sourceData: item }; } if (itemsModel.source === 'bynder') { return { type: 'image', source: itemsModel.source, sourceData: item, fields: { title: { type: 'string', value: item?.name }, url: { type: 'string', value: item?.src } } }; } if (itemsModel.source === 'aprimo') { return { type: 'image', source: itemsModel.source, sourceData: item, fields: { title: { type: 'string', value: item?.title }, url: { type: 'string', value: item?.rendition?.publicuri } } }; } return { type: 'reference', refType: 'asset', refId: _.get(item, 'sys.id') }; } else if (itemsModel.type === 'richText') { return { type: 'richText', hint: flattenRichText(item).substring(0, 200), value: item }; } return { type: itemsModel.type, value: item }; }) as DocumentListFieldItems[] }) ); } else if (modelField.type === 'reference') { return toDocumentField( value, localized, defaultLocale, { type: 'reference', refType: 'document' }, (val) => ({ refId: _.get(val, 'sys.id') }) ); } else if (modelField.type === 'cross-reference') { return toDocumentField( value, localized, defaultLocale, { type: 'cross-reference', refType: 'document' }, (val) => { const urn = _.get(val, 'sys.urn'); return crossReferenceFieldPropsFromUrn(urn); } ); } else if (modelField.type === 'image') { if (modelField.source && CONTENTFUL_BUILT_IN_IMAGE_SOURCES.includes(modelField.source)) { if (modelField.source === 'bynder') { return toDocumentField( value, localized, defaultLocale, { type: 'image', source: modelField.source }, (val) => ({ fields: { title: { type: 'string' as const, value: val[0]?.name }, url: { type: 'string' as const, value: val[0]?.src } }, sourceData: val[0] }) ); } return toDocumentField( value, localized, defaultLocale, { type: 'image', source: modelField.source }, (val) => ({ sourceData: val[0] }) ); } return toDocumentField( value, localized, defaultLocale, { type: 'reference', refType: 'asset' }, (val) => ({ refId: _.get(val, 'sys.id') }) ); } else if (modelField.type === 'richText') { return toDocumentField( value, localized, defaultLocale, { type: 'richText' }, (val) => ({ hint: flattenRichText(val).substring(0, 200), value: val }) ); } else if (modelField.type === 'object' || modelField.type === 'model') { throw new Error('Stackbit object and model field types can not be used with Contentful'); } return toDocumentField( value, localized, defaultLocale, { type: modelField.type }, (val) => ({ value: val }) ) as DocumentField | null; } function toDocumentField<T extends DocumentFieldBaseProps | AssetFileFieldBase, U extends DocumentFieldSpecificProps | AssetFileFieldProps>( fieldValue: Record<string, any> | undefined, localized: boolean, defaultLocale: string, documentFieldBaseProps: T, documentFieldSpecificProps: (val: any) => U ): (T & (U | { localized: true; locales: Record<string, { locale: string } & U> })) | null { if (localized) { return { ...documentFieldBaseProps, localized: true, locales: _.mapValues(fieldValue, (val, locale) => ({ locale, ...documentFieldSpecificProps(val) })) }; } if (!fieldValue || !(defaultLocale in fieldValue)) { return null; // TODO: log user error // throw new Error(`Error in contentful-entries-converter, non localized field has no default locale`); } const defaultLocaleValue = fieldValue[defaultLocale]; return { ...documentFieldBaseProps, ...documentFieldSpecificProps(defaultLocaleValue) }; } function crossReferenceFieldPropsFromUrn(urn: string): { refId: string; refSrcType: string; refProjectId: string; } { const match = urn.match(/crn:contentful:::content:spaces\/([^/]+)\/entries\/(.+)$/); const spaceId = match?.[1] ?? 'ERROR_PARSING_URN'; const entryId = match?.[2] ?? 'ERROR_PARSING_URN'; return { refId: entryId, refSrcType: 'contentful', refProjectId: spaceId }; } function flattenRichText(node: any): string { if (_.get(node, 'nodeType') === 'text') { return _.get(node, 'value', ''); } const content = _.get(node, 'content'); if (content) { return _.reduce( content, (accum, node) => { return accum + flattenRichText(node); }, '' ); } return ''; } export function convertDocumentVersions({ entries, userMap, defaultLocale, getModelByName, projectUrl }: { entries: SnapshotProps<EntryProps>[]; userMap: Record<string, UserProps>; getModelByName: GetModelByName; defaultLocale: string; projectUrl: string; }): DocumentVersionWithDocument[] { return entries.map((entry) => { const document = convertEntry({ entry: entry.snapshot, getModelByName, userMap, defaultLocale, projectUrl }); if (!document) { throw new Error(`Could not convert entry ${entry.sys.id} to Document`); } return { id: entry.sys.id, documentId: entry.snapshot.sys.id, srcType: 'contentful', srcProjectId: entry.snapshot.sys.space.sys.id, createdAt: entry.sys.createdAt, createdBy: getUserEmailById(entry.sys?.createdBy?.sys?.id, userMap), document }; }); }