diff --git a/packages/cssg-plugin-assets/README.md b/packages/cssg-plugin-assets/README.md index de22605..af22b39 100644 --- a/packages/cssg-plugin-assets/README.md +++ b/packages/cssg-plugin-assets/README.md @@ -20,29 +20,31 @@ You can specify global defaults for ratios and focus areas, defaults per content // Use center as default focus area default: 'center', contentTypes: { - 'media-content-type': { - // Use center as default focus area for media-content-type + content_type_id: { + // Use top as default focus area for content_type_id default: 'top', - // create overwrites per field + // Create overwrites per field fields: { - // Use the largest face detected as focus area for fieldId in media-content-type - fieldId: 'face', + // Use the largest face detected as focus area for field_id in content_type_id + field_id: 'face', + // Use the value from field custom_focus_area in content_type_id + alt_field_id: 'field:custom_focus_area' } } } }, ratios: { - // square and landscape derivatives when nothing else is specified. The 'original' ratio is always available. + // Square and landscape derivatives when nothing else is specified. The 'original' ratio is always available. default: {square: 1/1, landscape: 16/9}, // Define overwrites per content-type contentTypes: { - 'media-content-type': { - // default ratio for media-content-type should be rectangle () + content_type_id: { + // Default ratio forcontent_type_id should be rectangle () default: {rectangle: 4/3}, - // create overwrites per field + // Create overwrites per field fields: { - // fieldId in this contentType is generated with original and square derivatives - fieldId: {square: 1/1}, + // field_id in content_type_id is generated with original and square derivatives + field_id: {square: 1/1}, } } } @@ -111,7 +113,6 @@ plugins: [ ## Options - | Name | Type | Default | Description | | -------------------- | ---------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | download | `boolean` | `false` | Download assets to bypass the contentful cdn on your production site. | @@ -143,7 +144,18 @@ plugins: [ cacheFolder: '.cache', extraTypes: ['image/webp'], ratios: { default: { square: 1 / 1, portrait: 3 / 4, landscape: 16 / 9 } }, - focusAreas: { default: 'face' }, + focusAreas: { + default: 'face', + contentTypes: { + c_media: { + default: 'center', + fields: { + mobile_src: 'field:mobile_focus_area', + desktop_src: 'field:desktop_focus_area', + }, + } + }, + }, }, }, ]; diff --git a/packages/cssg-plugin-assets/src/helper/image.test.ts b/packages/cssg-plugin-assets/src/helper/image.test.ts new file mode 100644 index 0000000..26f4549 --- /dev/null +++ b/packages/cssg-plugin-assets/src/helper/image.test.ts @@ -0,0 +1,223 @@ +import { Asset } from '@jungvonmatt/contentful-ssg'; +import { mapAssetLink } from '@jungvonmatt/contentful-ssg/mapper/map-reference-field'; +import { localizeEntry } from '@jungvonmatt/contentful-ssg/tasks/localize'; +import { + getContent, + getRuntimeContext, + getTransformContext, +} from '@jungvonmatt/contentful-ssg/__test__/mock'; + +import { EntryConfig, FocusAreaConfig, PluginConfig } from '../types.js'; + +import { getRatioConfig, getFocusArea } from './image.js'; + +/** + * Get transformcontext with a mock entry andf fieldId media + * @param fields fields available in entry + * @returns + */ +const getTransformContextMock = async (fields: Record = {}) => { + const content = await getContent(); + const runtimeContext = getRuntimeContext(); + const entry = localizeEntry(content.entry, 'en-US', runtimeContext.data); + + entry.sys.contentType.sys.id = 'ct'; + + const transformContext = getTransformContext({ + entry: { ...entry, fields }, + fieldId: 'media', + }); + + return transformContext; +}; + +const getTestFocusArea = async ( + test: + | { + fields?: Record; + config?: FocusAreaConfig; + } + | undefined +) => { + const transformContext = await getTransformContextMock(test?.fields ?? {}); + return getFocusArea(transformContext, test?.config ?? {}); +}; + +describe('getFocusArea', () => { + test('default', async () => { + const focusArea = await getTestFocusArea({ + config: {}, + fields: {}, + }); + + expect(focusArea).toEqual('center'); + }); + + test('backwards compatibility', async () => { + const focusArea = await getTestFocusArea({ + config: {}, + fields: { focus_area: 'face' }, + }); + + expect(focusArea).toEqual('face'); + }); + + test('media_focus_area field', async () => { + const focusArea = await getTestFocusArea({ + config: {}, + fields: { media_focus_area: 'top' }, + }); + + expect(focusArea).toEqual('top'); + }); + + test('configured field - default', async () => { + const focusArea = await getTestFocusArea({ + config: { default: 'field:reference' }, + fields: { reference: 'bottom' }, + }); + + expect(focusArea).toEqual('bottom'); + }); + + test('configured field - content-type default', async () => { + const focusArea = await getTestFocusArea({ + config: { default: 'field:a', contentTypes: { ct: { default: 'field:b' } } }, + fields: { a: 'bottom', b: 'right' }, + }); + + expect(focusArea).toEqual('right'); + }); + + test('configured field - content-type fieldId', async () => { + const focusArea = await getTestFocusArea({ + config: { + default: 'field:a', + contentTypes: { ct: { default: 'field:b', fields: { media: 'field:c' } } }, + }, + fields: { a: 'bottom', b: 'right', c: 'top_right' }, + }); + + expect(focusArea).toEqual('top_right'); + }); + + test('field by naming convention', async () => { + const focusArea = await getTestFocusArea({ + config: { + default: 'field:a', + contentTypes: { ct: { default: 'field:b', fields: { media: 'top' } } }, + }, + fields: { a: 'bottom', b: 'right', media_focus_area: 'top_left' }, + }); + + expect(focusArea).toEqual('top_left'); + }); + + test('config default', async () => { + const focusArea = await getTestFocusArea({ + config: { default: 'bottom' }, + fields: {}, + }); + + expect(focusArea).toEqual('bottom'); + }); + + test('config content-type default', async () => { + const focusArea = await getTestFocusArea({ + config: { default: 'top_left', contentTypes: { ct: { default: 'top_right' } } }, + fields: {}, + }); + + expect(focusArea).toEqual('top_right'); + }); + + test('config field', async () => { + const focusArea = await getTestFocusArea({ + config: { + default: 'top_left', + contentTypes: { ct: { default: 'top_right', fields: { media: 'top' } } }, + }, + fields: {}, + }); + + expect(focusArea).toEqual('top'); + }); + + test('config field fallback ct', async () => { + const focusArea = await getTestFocusArea({ + config: { + default: 'top_left', + contentTypes: { ct: { default: 'top_right', fields: { media: 'field:b' } } }, + }, + fields: {}, + }); + + expect(focusArea).toEqual('top_right'); + }); + + test('config field fallback default', async () => { + const focusArea = await getTestFocusArea({ + config: { + default: 'top_left', + contentTypes: { ct: { default: 'field:a', fields: { media: 'field:b' } } }, + }, + fields: {}, + }); + + expect(focusArea).toEqual('top_left'); + }); + + test('config field fallback center', async () => { + const focusArea = await getTestFocusArea({ + config: { + contentTypes: { ct: { default: 'field:a', fields: { media: 'field:b' } } }, + }, + fields: {}, + }); + + expect(focusArea).toEqual('center'); + }); +}); + +describe('getRatioConfig', () => { + test('empty', async () => { + const transformContext = await getTransformContextMock(); + const c = undefined; + const ratios = getRatioConfig(transformContext, c); + + expect(ratios).toEqual({}); + }); + + test('default', async () => { + const transformContext = await getTransformContextMock(); + const ratios = getRatioConfig(transformContext, { + default: { square: 1 }, + }); + + expect(ratios).toEqual({ square: 1 }); + }); + + test('ct default', async () => { + const transformContext = await getTransformContextMock(); + const ratios = getRatioConfig(transformContext, { + default: { square: 1 }, + contentTypes: { + ct: { default: { rect: 4 / 3 } }, + }, + }); + + expect(ratios).toEqual({ rect: 4 / 3 }); + }); + + test('field default', async () => { + const transformContext = await getTransformContextMock(); + const ratios = getRatioConfig(transformContext, { + default: { square: 1 }, + contentTypes: { + ct: { default: { rect: 4 / 3 }, fields: { media: { portrait: 2 / 3 } } }, + }, + }); + + expect(ratios).toEqual({ portrait: 2 / 3 }); + }); +}); diff --git a/packages/cssg-plugin-assets/src/helper/image.ts b/packages/cssg-plugin-assets/src/helper/image.ts index b8b8ad6..c43ff56 100644 --- a/packages/cssg-plugin-assets/src/helper/image.ts +++ b/packages/cssg-plugin-assets/src/helper/image.ts @@ -4,7 +4,15 @@ import type { RuntimeContext, TransformContext, } from '@jungvonmatt/contentful-ssg'; -import type { Derivative, FocusArea, PluginConfig, ProcessedImage, Ratios } from '../types.js'; +import type { + Derivative, + FocusArea, + FocusAreaConfig, + PluginConfig, + ProcessedImage, + RatioConfig, + Ratios, +} from '../types.js'; import { getAssetHelper } from './asset.js'; // Max width that can be handled by the contentful image api @@ -14,6 +22,51 @@ const maxMegaPixels = { avif: 9, }; +export const getRatioConfig = ( + transformContext: TransformContext, + config: RatioConfig | undefined +): Ratios => { + const { entry, fieldId } = transformContext; + const contentTypeId = entry?.sys?.contentType?.sys?.id ?? 'default'; + + // Try to get configuration from plugin configuration + const defaultConfig = config?.default; + const contentTypeDefaultConfig = config?.contentTypes?.[contentTypeId]?.default; + const fieldConfig = config?.contentTypes?.[contentTypeId]?.fields?.[fieldId]; + return fieldConfig ?? contentTypeDefaultConfig ?? defaultConfig ?? {}; +}; + +export const getFocusArea = ( + transformContext: TransformContext, + config: FocusAreaConfig | undefined +): FocusArea => { + const { entry, fieldId } = transformContext; + const contentTypeId = entry?.sys?.contentType?.sys?.id ?? 'default'; + + const defaultConfig = config?.default; + const contentTypeDefaultConfig = config?.contentTypes?.[contentTypeId]?.default; + const fieldConfig = config?.contentTypes?.[contentTypeId]?.fields?.[fieldId]; + const value = fieldConfig ?? contentTypeDefaultConfig ?? defaultConfig; + + const [, referenceFieldId = `${fieldId}_focus_area`] = value?.split(':') ?? []; + + const fallback = + (!contentTypeDefaultConfig?.startsWith('field:') && (contentTypeDefaultConfig as FocusArea)) || + (!defaultConfig?.startsWith('field:') && (defaultConfig as FocusArea)) || + (entry?.fields?.focus_area as FocusArea) || + 'center'; + + if (Object.keys(entry.fields).includes(referenceFieldId)) { + return (entry?.fields?.[referenceFieldId] as FocusArea) || fallback; + } + + if (value?.startsWith('field:')) { + return fallback; + } + + return (value as FocusArea) || fallback; +}; + export const getImageHelper = (options: PluginConfig) => { const { getLocalSrc } = getAssetHelper(options); @@ -125,36 +178,22 @@ export const getImageHelper = (options: PluginConfig) => { const mapAssetLink = async ( transformContext: TransformContext, - runtimeContext: RuntimeContext, + _runtimeContext: RuntimeContext, content: MapAssetLink ): Promise => { - const { asset, entry, fieldId } = transformContext; + const { asset } = transformContext; const { mimeType = '' } = content; // Get ratio from config - const defaultRatio = (entry?.fields?.ratio as number) ?? options?.ratios?.default; - const contentTypeDefaultRatio = - (options?.ratios?.[entry?.sys?.contentType?.sys?.id ?? 'unknown']?.default as Ratios) ?? - defaultRatio; - - const { [entry?.sys?.contentType?.sys?.id ?? 'unknown']: contentTypeRatios } = - options?.ratios?.contentTypes ?? {}; - const ratioConfig = contentTypeRatios?.fields?.[fieldId] ?? contentTypeDefaultRatio; + const ratioConfig = getRatioConfig(transformContext, options?.ratios); // Get focusArea from config - const defaultFocusArea = - (entry?.fields?.focus_area as FocusArea) ?? options?.focusAreas?.default ?? 'center'; - const contentTypeDefaultFocusArea = - (options?.focusAreas?.[entry?.sys?.contentType?.sys?.id ?? 'unknown'] - ?.default as FocusArea) ?? defaultFocusArea; - const { [entry?.sys?.contentType?.sys?.id ?? 'unknown']: focusAreaConfig } = - options?.focusAreas?.contentTypes ?? {}; - const focusArea = focusAreaConfig?.fields?.[fieldId] ?? contentTypeDefaultFocusArea; + const focusArea = getFocusArea(transformContext, options?.focusAreas); if (mimeType.startsWith('image')) { const original = getImageData(asset, undefined, focusArea); const derivatives = Object.fromEntries( - Object.entries(ratioConfig || {}).map(([name, ratio]) => [ + Object.entries(ratioConfig).map(([name, ratio]) => [ name, getImageData(asset, ratio, focusArea), ]) diff --git a/packages/cssg-plugin-assets/src/index.test.ts b/packages/cssg-plugin-assets/src/index.test.ts index 3a11912..f81bf1d 100644 --- a/packages/cssg-plugin-assets/src/index.test.ts +++ b/packages/cssg-plugin-assets/src/index.test.ts @@ -1,3 +1,5 @@ +import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg'; +import { Asset } from '@jungvonmatt/contentful-ssg'; import { mapAssetLink } from '@jungvonmatt/contentful-ssg/mapper/map-reference-field'; import { localizeEntry } from '@jungvonmatt/contentful-ssg/tasks/localize'; import { @@ -5,7 +7,6 @@ import { getRuntimeContext, getTransformContext, } from '@jungvonmatt/contentful-ssg/__test__/mock'; -import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg'; import { existsSync } from 'fs'; import { remove } from 'fs-extra'; import got from 'got'; @@ -48,7 +49,7 @@ const getMockData = async (type) => { const runtimeContext = getRuntimeContext(); const entry = localizeEntry(content.entry, 'en-US', runtimeContext.data); const asset = localizeEntry( - content.assets.find((asset) => asset?.fields?.file?.['en-US']?.contentType === type), + content.assets.find((asset) => asset?.fields?.file?.['en-US']?.contentType === type) as Asset, 'en-US', runtimeContext.data ); @@ -238,10 +239,10 @@ describe('cssg-plugin-assets', () => { const fileName = basename(`${result.src.replace(/\.\w+$/, '')}-poster.jpg`); expect(result.mimeType).toEqual('video/mp4'); - expect(basename(result.poster)).toEqual(fileName); + expect(basename(result?.poster ?? '')).toEqual(fileName); - expect(existsSync(join(cacheFolder, result.poster))).toBe(true); - expect(existsSync(join(assetFolder, result.poster))).toBe(true); + expect(existsSync(join(cacheFolder, result?.poster ?? ''))).toBe(true); + expect(existsSync(join(assetFolder, result?.poster ?? ''))).toBe(true); await remove(cacheFolder); await remove(assetFolder); @@ -270,11 +271,12 @@ describe('cssg-plugin-assets', () => { const fileName = basename(`${result.src.replace(/\.\w+$/, '')}-poster.jpg`); expect(result.mimeType).toEqual('video/mp4'); - expect(basename(result.poster)).toEqual(fileName); + expect(basename(result?.poster ?? '')).toEqual(fileName); - expect(existsSync(join(cacheFolder, result.poster))).toBe(true); - expect(existsSync(join(assetFolder, result.poster))).toBe(true); + expect(existsSync(join(cacheFolder, result?.poster ?? ''))).toBe(true); + expect(existsSync(join(assetFolder, result?.poster ?? ''))).toBe(true); + // @ts-ignore const mock = mockedCreateFFmpeg.getMockImplementation()(); expect(mock.run).toHaveBeenCalledWith( '-i', @@ -327,7 +329,7 @@ describe('cssg-plugin-assets', () => { const instance = plugin(); const result = (await instance.mapAssetLink( - { ...transformContext, asset: null }, + { ...transformContext, asset: undefined }, runtimeContext, defaultValue )) as ProcessedSvg; diff --git a/packages/cssg-plugin-assets/src/types.ts b/packages/cssg-plugin-assets/src/types.ts index 800f77d..25c490e 100644 --- a/packages/cssg-plugin-assets/src/types.ts +++ b/packages/cssg-plugin-assets/src/types.ts @@ -2,6 +2,7 @@ import { Asset, MapAssetLink, TransformContext } from '@jungvonmatt/contentful-s import { Plugin } from 'svgo'; export type Ratios = Record; +export type FocusAreaReference = `field:${string}`; export type FocusArea = | 'center' | 'top' @@ -15,27 +16,22 @@ export type FocusArea = | 'face' | 'faces'; -export type RatioConfig = { - default?: Ratios; +export type EntryConfig = { + default?: T; contentTypes?: Record< string, { - default?: Ratios; - fields?: Record; + default?: T; + fields?: Record; } >; }; -export type FocusAreaConfig = { - default?: FocusArea; - contentTypes?: Record< - string, - { - default?: FocusArea; - fields?: Record; - } - >; -}; +export type EntryConfigKey = keyof Pick; + +export type RatioConfig = EntryConfig; + +export type FocusAreaConfig = EntryConfig; export type SizesCallback = (asset: Asset, ratio: number, focusArea: string) => number;