@stackbit/cms-contentful
Version:
Stackbit Contentful CMS Interface
602 lines (567 loc) • 19.2 kB
text/typescript
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: EntryProps[];
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
};
});
}