import _ from 'lodash'; import path from 'path'; import { omitByNil, omitByUndefined } from '@stackbit/utils'; import * as CSITypes from '@stackbit/types'; import * as ContentStoreTypes from '../types'; export function mapDocumentsToLocalizedApiObjects({ documents, locale, delegate }: { documents: ContentStoreTypes.Document[]; locale?: string; delegate: CSITypes.ConfigDelegate; }): ContentStoreTypes.APIDocumentObject[] { return documents.map((document) => documentToLocalizedApiObject({ document, locale, delegate }) ); } function documentToLocalizedApiObject({ document, locale, delegate }: { document: ContentStoreTypes.Document; locale?: string; delegate: CSITypes.ConfigDelegate; }): ContentStoreTypes.APIDocumentObject { const { fields, getPreview, ...rest } = document; return omitByUndefined({ // for historical reasons, stackbit app works with { type: 'object' } not { type: 'document' } type: 'object', ..._.omit(rest, ['type']), srcObjectLabel: getPreview({ delegate, locale }).previewTitle, fields: toLocalizedAPIFields({ docFields: fields, delegate, locale }) }); } function toLocalizedAPIFields({ docFields, delegate, locale }: { docFields: Record; delegate: CSITypes.ConfigDelegate; locale?: string; }): Record { return _.mapValues(docFields, (docField) => { return omitByUndefined( toLocalizedAPIField({ docField, delegate, locale }) ); }); } /** * For historical reasons, Stackit app works with fields that can have only one * locale. When Stackbit app fetches documents, it passes the current locale. * This function converts the internal document field (DocumentField), possibly * with nested locales, into an API field (DocumentFieldAPI) with single locale. * * DocumentField: * { * type: "string", * localized: true, * locales: { * 'en-US': { * locale: 'en-US', * value: 'hello * }, * 'fr-FR': { * locale: 'fr-FR', * valie: 'bonjour' * } * } * } * * DocumentFieldAPI: * { * type: "string", * localized: true, * locale: 'fr-FR', * value: 'bonjour' * } * * It also does some conversions between field types: * type: 'model' => 'object', * type: 'reference' => 'unresolved_reference', * refType: 'asset' => 'image' * refType: 'document' => 'object' */ function toLocalizedAPIField({ docField, delegate, locale, isListItem = false }: { docField: ContentStoreTypes.DocumentField; delegate: CSITypes.ConfigDelegate; locale?: string; isListItem?: boolean; }): ContentStoreTypes.DocumentFieldAPI { type ToBoolean = T extends true ? true : false; function localeFields(localized: T): null | { localized: false } | { localized: true; locale: string } { const isLocalized = !!localized as ToBoolean; if (isListItem) { return null; } if (!isLocalized) { return { localized: false }; } if (!locale) { return null; } return { localized: true, locale: locale }; } switch (docField.type) { case 'string': case 'text': case 'url': case 'slug': case 'html': case 'number': case 'boolean': case 'enum': case 'date': case 'datetime': case 'color': case 'style': { if (docField.localized) { const { localized, locales, ...base } = docField; const localeProps = locales && locale ? locales[locale] : undefined; return { ...base, ...localeProps, ...localeFields(localized) } as ContentStoreTypes.DocumentStringLikeFieldAPI; } return { ...docField, ...localeFields(docField.localized) }; } case 'file': case 'json': case 'markdown': case 'richText': { if (docField.localized) { const { localized, locales, ...base } = docField; const localeProps = locales && locale ? locales[locale] : undefined; if (localeProps) { return { ...base, ...localeProps, ...localeFields(localized) }; } return { ...base, isUnset: true, ...localeFields(localized) }; } return { ...docField, ...localeFields(docField.localized) }; } case 'image': { let result: ContentStoreTypes.DocumentFieldAPI; if (docField.localized) { const { localized, locales, ...base } = docField; const localeProps = locales && locale ? locales[locale] : undefined; if (localeProps) { const fields = toLocalizedAPIFields({ docFields: localeProps.fields, delegate, locale }) as ContentStoreTypes.ImageFieldsAPI; result = { ...base, ...localeProps, fields, ...localeFields(localized) }; } else { result = { ...base, isUnset: true, ...localeFields(localized) }; } } else { if (docField.isUnset) { result = { ...docField, type: 'image', ...localeFields(docField.localized) }; } else { const fields = toLocalizedAPIFields({ docFields: docField.fields, delegate, locale }) as ContentStoreTypes.ImageFieldsAPI; result = { ...docField, type: 'image', fields, ...localeFields(docField.localized) }; } } return result; } case 'object': { if (docField.localized) { const { localized, locales, ...base } = docField; const localeProps = locales && locale ? locales[locale] : undefined; if (localeProps) { const { fields, getPreview, locale } = localeProps; return { ...base, ...localeFields(localized), srcObjectLabel: getPreview({ delegate, locale }).previewTitle ?? (isListItem ? 'Item' : 'Object'), fields: toLocalizedAPIFields({ docFields: fields, delegate, locale }) }; } return { ...base, ...localeFields(localized), isUnset: true }; } if (docField.isUnset) { return { ...docField, type: 'object', ...localeFields(docField.localized) }; } const { fields, getPreview, ...rest } = docField; return { ...rest, type: 'object', srcObjectLabel: getPreview({ delegate, locale }).previewTitle ?? (isListItem ? 'Item' : 'Object'), fields: toLocalizedAPIFields({ docFields: fields, delegate, locale }), ...localeFields(docField.localized) }; } case 'model': { if (docField.localized) { const { localized, locales, ...base } = docField; const localeProps = locales && locale ? locales[locale] : undefined; if (localeProps) { const { fields, getPreview, ...rest } = localeProps; return { ...base, ...rest, type: 'object', // API uses 'object' not 'model' ...localeFields(localized), srcObjectLabel: getPreview({ delegate, locale }).previewTitle, fields: toLocalizedAPIFields({ docFields: fields, delegate, locale }) }; } return { ...base, type: 'object', // API uses 'object' not 'model' isUnset: true, ...localeFields(localized) }; } if (docField.isUnset) { return { ...docField, type: 'object', // API uses 'object' not 'model' ...localeFields(docField.localized) }; } else { const { fields, getPreview, ...rest } = docField; return { ...rest, type: 'object', // API uses 'object' not 'model' ...localeFields(docField.localized), srcObjectLabel: getPreview({ delegate, locale }).previewTitle, fields: toLocalizedAPIFields({ docFields: fields, delegate, locale }) }; } } case 'reference': { if (docField.localized) { const { type, refType, localized, locales, ...base } = docField; const localeProps = locales && locale ? locales[locale] : undefined; // if reference field isUnset === true, it behaves like a regular object if (!localeProps) { return { type: refType === 'asset' ? 'image' : 'object', isUnset: true, ...base, ...localeFields(localized) } as ContentStoreTypes.DocumentObjectFieldAPI | ContentStoreTypes.DocumentImageFieldAPI; } return { type: 'unresolved_reference', refType: refType === 'asset' ? 'image' : 'object', ...base, ...localeProps, ...localeFields(localized) }; } const { type, refType, ...base } = docField; if (base.isUnset) { return { type: refType === 'asset' ? 'image' : 'object', ...base, ...localeFields(docField.localized) }; } return { type: 'unresolved_reference', refType: refType === 'asset' ? 'image' : 'object', ...base, ...localeFields(docField.localized) }; } case 'cross-reference': { if (docField.localized) { const { type, refType, localized, locales, ...base } = docField; const localeProps = locales && locale ? locales[locale] : undefined; // if reference field isUnset === true, it behaves like a regular object if (!localeProps) { return { type: 'object', isUnset: true, ...base, ...localeFields(localized) }; } return { type: 'cross-reference', refType: refType, ...base, ...localeProps, ...localeFields(localized) }; } const { type, refType, ...base } = docField; if (base.isUnset) { return { type: 'object', ...base, ...localeFields(docField.localized) }; } return { type: 'cross-reference', refType: refType, ...base, ...localeFields(docField.localized) }; } case 'list': { if (docField.localized) { const { localized, locales, ...base } = docField; const localeProps = locales && locale ? locales[locale] : undefined; return { ...base, ...localeProps, items: (localeProps?.items ?? []).map( (field) => toLocalizedAPIField({ docField: field, delegate, locale, isListItem: true }) as ContentStoreTypes.DocumentListFieldItemsAPI ), ...localeFields(localized) }; } const { items, ...rest } = docField; return { ...rest, ...localeFields(docField.localized), items: items.map( (field) => toLocalizedAPIField({ docField: field, delegate, locale, isListItem: true }) as ContentStoreTypes.DocumentListFieldItemsAPI ) }; } default: { const _exhaustiveCheck: never = docField; console.error(`toLocalizedAPIField _exhaustiveCheck failed, docField.type: ${docField['type']}`); return _exhaustiveCheck; } } } export function mapAssetsToLocalizedApiImages( assets: ContentStoreTypes.Asset[], staticAssetsPublicPath: string, locale?: string ): ContentStoreTypes.APIImageObject[] { return assets.map((asset) => assetToLocalizedApiImage(asset, staticAssetsPublicPath, locale)); } function assetToLocalizedApiImage(asset: ContentStoreTypes.Asset, staticAssetsPublicPath: string, locale?: string): ContentStoreTypes.APIImageObject { const { type, fields, ...rest } = asset; return { type: 'image', ...rest, fields: localizeAssetFields(fields, staticAssetsPublicPath, locale) }; } function localizeAssetFields(assetFields: ContentStoreTypes.AssetFields, staticAssetsPublicPath: string, locale?: string): ContentStoreTypes.AssetFieldsAPI { const fields: ContentStoreTypes.AssetFieldsAPI = { title: { type: 'string' as const, value: null as any }, url: { type: 'string' as const, value: null as any } }; const titleField = assetFields.title; if (titleField.localized) { if (locale) { fields.title = { type: 'string', value: titleField.locales?.[locale]?.value ?? null, localized: true, locale: locale }; } } else { fields.title.value = titleField.value; } const assetFileField = assetFields.file; if (assetFileField.localized) { if (locale) { const url = assetFileField.locales?.[locale]?.url ?? null; fields.url = { type: 'string', value: url ? replaceAssetUrlIfNeeded(staticAssetsPublicPath, url) : url, localized: true, locale: locale }; } } else { fields.url.value = replaceAssetUrlIfNeeded(staticAssetsPublicPath, assetFileField.url); } return fields; } export function mapStoreAssetsToAPIAssets(assets: ContentStoreTypes.Asset[], staticAssetsPublicPath: string, locale?: string): ContentStoreTypes.APIAsset[] { return assets.map((asset) => storeAssetToAPIAsset(asset, staticAssetsPublicPath, locale)).filter((asset): asset is ContentStoreTypes.APIAsset => !!asset); } function storeAssetToAPIAsset(asset: ContentStoreTypes.Asset, staticAssetsPublicPath: string, locale?: string): ContentStoreTypes.APIAsset | null { const assetTitleField = asset.fields.title; const assetFileField = asset.fields.file; const localizedTitleField = assetTitleField.localized ? assetTitleField.locales?.[locale!] : assetTitleField; const localizedFileField = assetFileField.localized ? assetFileField.locales?.[locale!] : assetFileField; if (!localizedFileField) { return null; } return { objectId: asset.srcObjectId, createdAt: asset.createdAt, url: replaceAssetUrlIfNeeded(staticAssetsPublicPath, localizedFileField.url) ?? staticAssetsPublicPath, ...omitByNil({ title: localizedTitleField?.value ?? undefined, fileName: localizedFileField.fileName, contentType: localizedFileField.contentType, size: localizedFileField.size, width: localizedFileField.dimensions?.width, height: localizedFileField.dimensions?.height }) }; } export function replaceAssetUrlIfNeeded(staticAssetsPublicPath: string, value: string | undefined) { let url = value; const normalizedUrl = url?.toLowerCase(); if (normalizedUrl && !normalizedUrl.startsWith('http:') && !normalizedUrl.startsWith('https:') && !normalizedUrl.startsWith('//')) { url = path.join(staticAssetsPublicPath, url!); } return url; }