From 0b8b37d5e93242336e2d03d13867dac6a3f5f5d1 Mon Sep 17 00:00:00 2001 From: PierpaoloIannone Date: Thu, 2 Jul 2026 14:51:00 +0200 Subject: [PATCH 1/5] feat: add provider-aware media url builders for cloudflare and cloudinary --- packages/contentchef-media/README.md | 45 +++++ packages/contentchef-media/package.json | 2 +- .../src/__tests__/mediaUrl.test.ts | 152 +++++++++++++++ packages/contentchef-media/src/cloudflare.ts | 179 ++++++++++++++++++ packages/contentchef-media/src/index.ts | 34 +++- packages/contentchef-media/src/types.ts | 26 +++ 6 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 packages/contentchef-media/src/__tests__/mediaUrl.test.ts create mode 100644 packages/contentchef-media/src/cloudflare.ts create mode 100644 packages/contentchef-media/src/types.ts diff --git a/packages/contentchef-media/README.md b/packages/contentchef-media/README.md index 72f6e9a..6fe1542 100644 --- a/packages/contentchef-media/README.md +++ b/packages/contentchef-media/README.md @@ -35,3 +35,48 @@ const transformations = { } const mediaUrl = createUrl(mediaPublicId, transformations); ``` + +### Provider-aware urls (Cloudinary or Cloudflare) + +Media in ContentChef can be hosted on **Cloudinary** (legacy) or **Cloudflare**. The +functions above always build Cloudinary urls. The provider-aware functions instead take the +whole media object (`{ publicId, provider, metadata }`) as it appears in a published content +payload, detect the provider, and build the right url: + +* `mediaImageUrl(media, options?)` +* `mediaVideoUrl(media, options?)` +* `mediaRawFileUrl(media, options?)` + +The provider is read from `media.provider` (falling back to `media.metadata.provider`). +Anything that isn't explicitly `cloudflare` is treated as Cloudinary, so existing media keep +working unchanged. + +```typescript +import { mediaImageUrl, mediaVideoUrl, mediaRawFileUrl } from '@contentchef/contentchef-media'; + +// A media field taken straight from a published content payload +const media = content.payload.hero; // { publicId, provider, metadata } + +const image = mediaImageUrl(media, { resize: { width: 200, height: 100, type: 'fill' } }); +const video = mediaVideoUrl(media); +const rawFile = mediaRawFileUrl(media); +``` + +**Transformations use the Cloudinary option types in every case.** For Cloudflare media they +are mapped to [Cloudflare Image Resizing](https://developers.cloudflare.com/images/transform-images/transform-via-url/) +parameters and rendered as `https://media.contentchef.io/cdn-cgi/image//`. +For example the call above yields: + +``` +https://media.contentchef.io/cdn-cgi/image/width=200,height=100,fit=cover/ +``` + +Notes for the Cloudflare provider: + +* The base host defaults to `https://media.contentchef.io`; override it per call with + `{ baseUrl: 'https://your-zone.example.com' }`. +* Image resizing is image-only, so `mediaVideoUrl`/`mediaRawFileUrl` return the plain delivery + url `https://media.contentchef.io/`. +* Only Cloudinary options with a Cloudflare counterpart are mapped (dimensions, `fit`, + `gravity`, `quality`, `format`, `dpr`, `rotate`, `background`, and common `effect`s); + unmappable options are ignored rather than producing a broken url. diff --git a/packages/contentchef-media/package.json b/packages/contentchef-media/package.json index dfaca80..7a3f087 100644 --- a/packages/contentchef-media/package.json +++ b/packages/contentchef-media/package.json @@ -1,6 +1,6 @@ { "name": "@contentchef/contentchef-media", - "version": "8.0.0", + "version": "9.0.0-alpha.1", "description": "Package for helping managing media with ContentChef", "author": "ContentChef", "maintainers": [ diff --git a/packages/contentchef-media/src/__tests__/mediaUrl.test.ts b/packages/contentchef-media/src/__tests__/mediaUrl.test.ts new file mode 100644 index 0000000..896da12 --- /dev/null +++ b/packages/contentchef-media/src/__tests__/mediaUrl.test.ts @@ -0,0 +1,152 @@ +import { + IMedia, + mediaImageUrl, + mediaRawFileUrl, + mediaVideoUrl, + MediaProvider, +} from '..'; +import { + buildCloudflareImageUrl, + DEFAULT_CLOUDFLARE_BASE_URL, + toCloudflareOptions, +} from '../cloudflare'; + +const cloudinaryMedia = (publicId = 'test-public-id'): IMedia => ({ + publicId, + provider: MediaProvider.cloudinary, + metadata: { provider: MediaProvider.cloudinary }, +}); + +const cloudflareMedia = (publicId = 'space/img/logo.png'): IMedia => ({ + publicId, + provider: MediaProvider.cloudflare, + metadata: { provider: MediaProvider.cloudflare }, +}); + +describe('provider detection', () => { + it('treats media without a provider as cloudinary (legacy)', () => { + const url = mediaImageUrl({ publicId: 'legacy-id' }); + expect(url).toContain('res.cloudinary.com'); + expect(url).toContain('/image/'); + }); + + it('reads the provider from metadata when the top-level field is absent', () => { + const url = mediaImageUrl({ publicId: 'space/img/x.png', metadata: { provider: MediaProvider.cloudflare } }); + expect(url).toContain('media.contentchef.io'); + }); + + it('lets the top-level provider win over metadata', () => { + const url = mediaImageUrl({ + publicId: 'space/img/x.png', + provider: MediaProvider.cloudflare, + metadata: { provider: MediaProvider.cloudinary }, + }); + expect(url).toContain('media.contentchef.io'); + }); +}); + +describe('mediaImageUrl (cloudinary)', () => { + it('builds a secure cloudinary image url', () => { + const url = mediaImageUrl(cloudinaryMedia(), { cloud_name: 'myCloud', resize: { width: 100 } }); + expect(url.startsWith('https://')).toBe(true); + expect(url).toContain('myCloud'); + expect(url).toContain('/image/'); + }); +}); + +describe('mediaImageUrl (cloudflare)', () => { + it('builds a /cdn-cgi/image/ url off the default base', () => { + const url = mediaImageUrl(cloudflareMedia('space/img/logo.png'), { + resize: { width: 100, height: 200, type: 'fill' }, + }); + expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/cdn-cgi/image/width=100,height=200,fit=cover/space/img/logo.png`); + }); + + it('returns a plain delivery url when no options map to cloudflare params', () => { + const url = mediaImageUrl(cloudflareMedia('space/img/logo.png')); + expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/space/img/logo.png`); + }); + + it('honours a baseUrl override and strips redundant slashes', () => { + const url = mediaImageUrl(cloudflareMedia('/space/img/logo.png'), { + baseUrl: 'https://cdn.example.com/', + resize: { width: 50 }, + }); + expect(url).toBe('https://cdn.example.com/cdn-cgi/image/width=50/space/img/logo.png'); + }); + + it('does not leak baseUrl into cloudinary transformations', () => { + const url = mediaImageUrl(cloudinaryMedia(), { baseUrl: 'https://cdn.example.com', resize: { width: 50 } }); + expect(url).not.toContain('baseUrl'); + expect(url).not.toContain('cdn.example.com'); + }); +}); + +describe('mediaVideoUrl', () => { + it('serves cloudflare video directly from the delivery host', () => { + const url = mediaVideoUrl(cloudflareMedia('space/video/clip.mp4')); + expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/space/video/clip.mp4`); + }); + + it('keeps the cloudinary video pipeline', () => { + const url = mediaVideoUrl(cloudinaryMedia()); + expect(url).toContain('/video/'); + }); +}); + +describe('mediaRawFileUrl', () => { + it('serves cloudflare raw files directly', () => { + const url = mediaRawFileUrl(cloudflareMedia('space/raw/doc.pdf')); + expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/space/raw/doc.pdf`); + }); + + it('keeps the cloudinary raw pipeline', () => { + const url = mediaRawFileUrl(cloudinaryMedia()); + expect(url).toContain('/raw/'); + }); +}); + +describe('toCloudflareOptions mapping', () => { + it('maps resize dimensions and fit', () => { + expect(toCloudflareOptions({ resize: { width: 10, height: 20, type: 'fit' } })) + .toEqual(['width=10', 'height=20', 'fit=contain']); + }); + + it('maps compass gravity to sides and corners', () => { + expect(toCloudflareOptions({ gravity: 'north' as any })).toEqual(['gravity=top']); + expect(toCloudflareOptions({ gravity: 'south_east' as any })).toEqual(['gravity=1x1']); + expect(toCloudflareOptions({ gravity: 'auto' as any })).toEqual(['gravity=auto']); + }); + + it('maps format, falling back to fetchFormat, and normalises jpg', () => { + expect(toCloudflareOptions({ format: 'auto' })).toEqual(['format=auto']); + expect(toCloudflareOptions({ fetchFormat: 'jpg' })).toEqual(['format=jpeg']); + }); + + it('passes numeric quality through and drops auto quality', () => { + expect(toCloudflareOptions({ quality: 75 })).toEqual(['quality=75']); + expect(toCloudflareOptions({ quality: 'auto' })).toEqual([]); + }); + + it('only allows cloudflare-supported rotations', () => { + expect(toCloudflareOptions({ rotate: 90 })).toEqual(['rotate=90']); + expect(toCloudflareOptions({ rotate: 45 })).toEqual([]); + }); + + it('escapes the hash in a hex background', () => { + expect(toCloudflareOptions({ background: '#ff0000' })).toEqual(['background=%23ff0000']); + }); + + it('maps common effects with approximate scaling', () => { + expect(toCloudflareOptions({ effect: { name: 'brightness', value: 50 } })).toEqual(['brightness=1.5']); + expect(toCloudflareOptions({ effect: { name: 'grayscale' } })).toEqual(['saturation=0']); + expect(toCloudflareOptions({ effect: { name: 'sepia', value: 80 } })).toEqual([]); + }); +}); + +describe('buildCloudflareImageUrl', () => { + it('joins multiple options with commas', () => { + const url = buildCloudflareImageUrl('a/b.png', { resize: { width: 100 }, format: 'auto', quality: 80 }); + expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/cdn-cgi/image/width=100,format=auto,quality=80/a/b.png`); + }); +}); diff --git a/packages/contentchef-media/src/cloudflare.ts b/packages/contentchef-media/src/cloudflare.ts new file mode 100644 index 0000000..414617e --- /dev/null +++ b/packages/contentchef-media/src/cloudflare.ts @@ -0,0 +1,179 @@ +import { TransformerOption } from '@cld-apis/types'; + +export const DEFAULT_CLOUDFLARE_BASE_URL = 'https://media.contentchef.io'; + +const FIT_MAP: { [resizeType: string]: string } = { + crop: 'crop', + imaggaCrop: 'crop', + fill: 'cover', + fill_pad: 'cover', + lfill: 'cover', + thumb: 'cover', + fit: 'contain', + mfit: 'contain', + scale: 'contain', + limit: 'scale-down', + imaggaScale: 'scale-down', + pad: 'pad', + lpad: 'pad', + mpad: 'pad', +}; + +const GRAVITY_MAP: { [gravity: string]: string } = { + auto: 'auto', + 'auto:subject': 'auto', + faces: 'auto', + 'faces:center': 'auto', + face: 'face', + 'face:center': 'face', + north: 'top', + south: 'bottom', + east: 'right', + west: 'left', + center: '0.5x0.5', + north_west: '0x0', + north_east: '1x0', + south_west: '0x1', + south_east: '1x1', +}; + +const FORMAT_MAP: { [format: string]: string } = { + auto: 'auto', + webp: 'webp', + avif: 'avif', + jpg: 'jpeg', + jpeg: 'jpeg', + png: 'png', + gif: 'gif', + json: 'json', +}; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function toNumber(value: unknown): number | undefined { + if (value === null || value === undefined || value === '') { + return undefined; + } + const n = Number(value); + return isNaN(n) ? undefined : n; +} + +function mapEffect(effect: { name: string; value?: number | string | string[] | number[] }): string | undefined { + const raw = Array.isArray(effect.value) ? effect.value[0] : effect.value; + const value = toNumber(raw); + switch (effect.name) { + case 'blur': + return value !== undefined ? `blur=${clamp(Math.round(value / 8), 1, 250)}` : undefined; + case 'brightness': + return value !== undefined ? `brightness=${round2(1 + value / 100)}` : undefined; + case 'contrast': + return value !== undefined ? `contrast=${round2(1 + value / 100)}` : undefined; + case 'saturation': + return value !== undefined ? `saturation=${round2(1 + value / 100)}` : undefined; + case 'sharpen': + return `sharpen=${value !== undefined ? clamp(value, 0, 10) : 1}`; + case 'grayscale': + case 'blackwhite': + return 'saturation=0'; + default: + return undefined; + } +} + +function encodeBackground(background: string): string { + return background.replace(/#/g, '%23'); +} + +export function toCloudflareOptions(options: TransformerOption = {}): string[] { + const params: string[] = []; + const { resize, gravity, quality, format, fetchFormat, dpr, rotate, background, effect } = options; + + if (resize) { + if (resize.width !== undefined && resize.width !== null) { + params.push(`width=${resize.width}`); + } + if (resize.height !== undefined && resize.height !== null) { + params.push(`height=${resize.height}`); + } + if (resize.type !== undefined && resize.type !== null) { + const fit = FIT_MAP[String(resize.type)]; + if (fit) { + params.push(`fit=${fit}`); + } + } + } + + if (gravity !== undefined && gravity !== null) { + const mapped = GRAVITY_MAP[String(gravity)]; + if (mapped) { + params.push(`gravity=${mapped}`); + } + } + + const fmt = format || fetchFormat; + if (fmt) { + const mapped = FORMAT_MAP[String(fmt).toLowerCase()]; + if (mapped) { + params.push(`format=${mapped}`); + } + } + + if (quality !== undefined && quality !== null) { + const numericQuality = toNumber(quality); + if (numericQuality !== undefined) { + params.push(`quality=${clamp(Math.round(numericQuality), 1, 100)}`); + } + } + + const dprValue = toNumber(dpr); + if (dprValue !== undefined) { + params.push(`dpr=${dprValue}`); + } + + const rotateValue = toNumber(rotate); + if (rotateValue !== undefined && (rotateValue === 90 || rotateValue === 180 || rotateValue === 270)) { + params.push(`rotate=${rotateValue}`); + } + + if (background) { + params.push(`background=${encodeBackground(background)}`); + } + + if (effect && effect.name) { + const mappedEffect = mapEffect(effect); + if (mappedEffect) { + params.push(mappedEffect); + } + } + + return params; +} + +function normalizeBase(baseUrl?: string): string { + const base = baseUrl || DEFAULT_CLOUDFLARE_BASE_URL; + return base.replace(/\/+$/, ''); +} + +function normalizeSource(publicId: string): string { + return String(publicId).replace(/^\/+/, ''); +} + +export function buildCloudflareImageUrl(publicId: string, options: TransformerOption = {}, baseUrl?: string): string { + const base = normalizeBase(baseUrl); + const source = normalizeSource(publicId); + const params = toCloudflareOptions(options); + if (params.length === 0) { + return `${base}/${source}`; + } + return `${base}/cdn-cgi/image/${params.join(',')}/${source}`; +} + +export function buildCloudflareDeliveryUrl(publicId: string, baseUrl?: string): string { + return `${normalizeBase(baseUrl)}/${normalizeSource(publicId)}`; +} diff --git a/packages/contentchef-media/src/index.ts b/packages/contentchef-media/src/index.ts index dcd20d6..05559da 100644 --- a/packages/contentchef-media/src/index.ts +++ b/packages/contentchef-media/src/index.ts @@ -1,10 +1,17 @@ import { TransformerOption, TransformerVideoOption } from '@cld-apis/types'; import buildUrl from 'cloudinary-build-url'; +import { buildCloudflareDeliveryUrl, buildCloudflareImageUrl } from './cloudflare'; +import { IMedia, MediaProvider, resolveProvider } from './types'; export type IMediaOptions = (TransformerOption | TransformerVideoOption) & {cloud_name?: string}; export type IImageOptions = TransformerOption & {cloud_name?: string}; export type IVideoOptions = TransformerVideoOption & {cloud_name?: string}; +export type ICloudflareAware = {baseUrl?: string}; +export type IMediaImageOptions = IImageOptions & ICloudflareAware; +export type IMediaVideoOptions = IVideoOptions & ICloudflareAware; +export type IMediaFileOptions = IMediaOptions & ICloudflareAware; + export enum ResourceType { image = 'image', video = 'video', @@ -30,6 +37,32 @@ export function rawFileUrl(publicId: string, options?: IMediaOptions) { return createUrl(publicId, options, ResourceType.raw); } +export function mediaImageUrl(media: IMedia, options: IMediaImageOptions = {}): string { + const {baseUrl, ...transformations} = options; + if (resolveProvider(media) === MediaProvider.cloudflare) { + return buildCloudflareImageUrl(media.publicId, transformations, baseUrl); + } + return createUrl(media.publicId, transformations, ResourceType.image); +} + +export function mediaVideoUrl(media: IMedia, options: IMediaVideoOptions = {}): string { + const {baseUrl, ...transformations} = options; + if (resolveProvider(media) === MediaProvider.cloudflare) { + return buildCloudflareDeliveryUrl(media.publicId, baseUrl); + } + return createUrl(media.publicId, transformations, ResourceType.video); +} + +export function mediaRawFileUrl(media: IMedia, options: IMediaFileOptions = {}): string { + const {baseUrl, ...transformations} = options; + if (resolveProvider(media) === MediaProvider.cloudflare) { + return buildCloudflareDeliveryUrl(media.publicId, baseUrl); + } + return createUrl(media.publicId, transformations, ResourceType.raw); +} + +export { IMedia, IMediaMetadata, MediaProvider, resolveProvider } from './types'; + export { AudioCodec, Border, ColorSpace, CompassGravity, Condition, ConditionExpression, CustomFunction, Effect, Expression, @@ -43,4 +76,3 @@ export { TransformerOption, TransformerVideoOption, Variable, VColorSpace, VEffect, VFlag } from '@cld-apis/types'; - diff --git a/packages/contentchef-media/src/types.ts b/packages/contentchef-media/src/types.ts new file mode 100644 index 0000000..59ceb10 --- /dev/null +++ b/packages/contentchef-media/src/types.ts @@ -0,0 +1,26 @@ +export enum MediaProvider { + cloudinary = 'cloudinary', + cloudflare = 'cloudflare', +} + +export interface IMediaMetadata { + provider?: MediaProvider | string; + resourceType?: string; + format?: string; + width?: number; + height?: number; + aspectRatio?: number; + name?: string; + [key: string]: unknown; +} + +export interface IMedia { + publicId: string; + provider?: MediaProvider | string; + metadata?: IMediaMetadata; +} + +export function resolveProvider(media: IMedia): MediaProvider { + const provider = (media && media.provider) || (media && media.metadata && media.metadata.provider); + return provider === MediaProvider.cloudflare ? MediaProvider.cloudflare : MediaProvider.cloudinary; +} From 9904ed5f7d440a5645f4a71eb27f225dc105117c Mon Sep 17 00:00:00 2001 From: PierpaoloIannone Date: Fri, 3 Jul 2026 10:12:25 +0200 Subject: [PATCH 2/5] fix(media): rename type to BaseUrlAware --- packages/contentchef-media/src/index.ts | 144 ++++++++++++++++-------- 1 file changed, 96 insertions(+), 48 deletions(-) diff --git a/packages/contentchef-media/src/index.ts b/packages/contentchef-media/src/index.ts index 05559da..20a7b84 100644 --- a/packages/contentchef-media/src/index.ts +++ b/packages/contentchef-media/src/index.ts @@ -1,78 +1,126 @@ import { TransformerOption, TransformerVideoOption } from '@cld-apis/types'; import buildUrl from 'cloudinary-build-url'; -import { buildCloudflareDeliveryUrl, buildCloudflareImageUrl } from './cloudflare'; +import { + buildCloudflareDeliveryUrl, + buildCloudflareImageUrl, +} from './cloudflare'; import { IMedia, MediaProvider, resolveProvider } from './types'; -export type IMediaOptions = (TransformerOption | TransformerVideoOption) & {cloud_name?: string}; -export type IImageOptions = TransformerOption & {cloud_name?: string}; -export type IVideoOptions = TransformerVideoOption & {cloud_name?: string}; +export type IMediaOptions = (TransformerOption | TransformerVideoOption) & { + cloud_name?: string; +}; +export type IImageOptions = TransformerOption & { cloud_name?: string }; +export type IVideoOptions = TransformerVideoOption & { cloud_name?: string }; -export type ICloudflareAware = {baseUrl?: string}; -export type IMediaImageOptions = IImageOptions & ICloudflareAware; -export type IMediaVideoOptions = IVideoOptions & ICloudflareAware; -export type IMediaFileOptions = IMediaOptions & ICloudflareAware; +export type BaseUrlAware = { baseUrl?: string }; +export type IMediaImageOptions = IImageOptions & BaseUrlAware; +export type IMediaVideoOptions = IVideoOptions & BaseUrlAware; +export type IMediaFileOptions = IMediaOptions & BaseUrlAware; export enum ResourceType { - image = 'image', - video = 'video', - raw = 'raw' -}; + image = 'image', + video = 'video', + raw = 'raw', +} const defaultCloudName = 'contentchef'; -export function createUrl(publicId: string, options: IMediaOptions = {cloud_name: defaultCloudName}, resourceType: ResourceType = ResourceType.image): string { - const {cloud_name, ...transformations} = options; - return buildUrl(publicId, {cloud: {cloudName: cloud_name || defaultCloudName, resourceType, secure: true}, transformations}); +export function createUrl( + publicId: string, + options: IMediaOptions = { cloud_name: defaultCloudName }, + resourceType: ResourceType = ResourceType.image, +): string { + const { cloud_name, ...transformations } = options; + return buildUrl(publicId, { + cloud: { + cloudName: cloud_name || defaultCloudName, + resourceType, + secure: true, + }, + transformations, + }); } export function imageUrl(publicId: string, options?: IImageOptions) { - return createUrl(publicId, options, ResourceType.image); + return createUrl(publicId, options, ResourceType.image); } export function videoUrl(publicId: string, options?: IVideoOptions) { - return createUrl(publicId, options, ResourceType.video); + return createUrl(publicId, options, ResourceType.video); } export function rawFileUrl(publicId: string, options?: IMediaOptions) { - return createUrl(publicId, options, ResourceType.raw); + return createUrl(publicId, options, ResourceType.raw); } -export function mediaImageUrl(media: IMedia, options: IMediaImageOptions = {}): string { - const {baseUrl, ...transformations} = options; - if (resolveProvider(media) === MediaProvider.cloudflare) { - return buildCloudflareImageUrl(media.publicId, transformations, baseUrl); - } - return createUrl(media.publicId, transformations, ResourceType.image); +export function mediaImageUrl( + media: IMedia, + options: IMediaImageOptions = {}, +): string { + const { baseUrl, ...transformations } = options; + if (resolveProvider(media) === MediaProvider.cloudflare) { + return buildCloudflareImageUrl(media.publicId, transformations, baseUrl); + } + return createUrl(media.publicId, transformations, ResourceType.image); } -export function mediaVideoUrl(media: IMedia, options: IMediaVideoOptions = {}): string { - const {baseUrl, ...transformations} = options; - if (resolveProvider(media) === MediaProvider.cloudflare) { - return buildCloudflareDeliveryUrl(media.publicId, baseUrl); - } - return createUrl(media.publicId, transformations, ResourceType.video); +export function mediaVideoUrl( + media: IMedia, + options: IMediaVideoOptions = {}, +): string { + const { baseUrl, ...transformations } = options; + if (resolveProvider(media) === MediaProvider.cloudflare) { + return buildCloudflareDeliveryUrl(media.publicId, baseUrl); + } + return createUrl(media.publicId, transformations, ResourceType.video); } -export function mediaRawFileUrl(media: IMedia, options: IMediaFileOptions = {}): string { - const {baseUrl, ...transformations} = options; - if (resolveProvider(media) === MediaProvider.cloudflare) { - return buildCloudflareDeliveryUrl(media.publicId, baseUrl); - } - return createUrl(media.publicId, transformations, ResourceType.raw); +export function mediaRawFileUrl( + media: IMedia, + options: IMediaFileOptions = {}, +): string { + const { baseUrl, ...transformations } = options; + if (resolveProvider(media) === MediaProvider.cloudflare) { + return buildCloudflareDeliveryUrl(media.publicId, baseUrl); + } + return createUrl(media.publicId, transformations, ResourceType.raw); } -export { IMedia, IMediaMetadata, MediaProvider, resolveProvider } from './types'; +export { + IMedia, + IMediaMetadata, + MediaProvider, + resolveProvider, +} from './types'; export { - AudioCodec, Border, ColorSpace, CompassGravity, Condition, - ConditionExpression, CustomFunction, Effect, Expression, - Flag, FPS, FPSType, Gravity, - Offset, - Position, - Radius, - Resize, - ResizeType, - Rotation, StringValue, TextStyle, Transformation, TransformerBaseOptions, - TransformerOption, - TransformerVideoOption, Variable, VColorSpace, VEffect, VFlag + AudioCodec, + Border, + ColorSpace, + CompassGravity, + Condition, + ConditionExpression, + CustomFunction, + Effect, + Expression, + Flag, + FPS, + FPSType, + Gravity, + Offset, + Position, + Radius, + Resize, + ResizeType, + Rotation, + StringValue, + TextStyle, + Transformation, + TransformerBaseOptions, + TransformerOption, + TransformerVideoOption, + Variable, + VColorSpace, + VEffect, + VFlag, } from '@cld-apis/types'; From e14566593f4d2d9d9de02b9eca12b30cdeb4ecbf Mon Sep 17 00:00:00 2001 From: PierpaoloIannone Date: Fri, 3 Jul 2026 10:14:11 +0200 Subject: [PATCH 3/5] publish @contentchef/contentchef-media@9.0.0-alpha.2 --- packages/contentchef-media/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contentchef-media/package.json b/packages/contentchef-media/package.json index 7a3f087..51a767f 100644 --- a/packages/contentchef-media/package.json +++ b/packages/contentchef-media/package.json @@ -1,6 +1,6 @@ { "name": "@contentchef/contentchef-media", - "version": "9.0.0-alpha.1", + "version": "9.0.0-alpha.2", "description": "Package for helping managing media with ContentChef", "author": "ContentChef", "maintainers": [ From dbfdb042bd92027de4310e37449112d09b95765f Mon Sep 17 00:00:00 2001 From: PierpaoloIannone Date: Fri, 3 Jul 2026 11:04:33 +0200 Subject: [PATCH 4/5] feat!: provider-aware media urls via media object, drop legacy string-based helpers --- packages/contentchef-media/README.md | 76 +++--- .../src/__tests__/createUrl.test.ts | 225 +++++++++++++----- .../src/__tests__/mediaUrl.test.ts | 152 ------------ packages/contentchef-media/src/cloudflare.ts | 6 +- packages/contentchef-media/src/index.ts | 128 +++++----- 5 files changed, 272 insertions(+), 315 deletions(-) delete mode 100644 packages/contentchef-media/src/__tests__/mediaUrl.test.ts diff --git a/packages/contentchef-media/README.md b/packages/contentchef-media/README.md index 6fe1542..14a5708 100644 --- a/packages/contentchef-media/README.md +++ b/packages/contentchef-media/README.md @@ -8,75 +8,59 @@ yarn @contentchef/contentchef-media ### Usage -This package provides methods to help you manage and interact with ContentChef's media +This package builds ContentChef media urls. Media can be hosted on **Cloudinary** (legacy) or +**Cloudflare** — you don't pick the provider: pass the media object and the right url is built +for you. -* `createUrl` helps you generate a proper url given a media publicId -* `imageUrl` helps you create an url for an image given a media publicId -* `videoUrl` helps you create an url for a video given a media publicId -* `rawFileUrl` helps you create an url for a raw file (pdf, zip, ecc.) given a media publicId +`createUrl` takes the whole media object (`{ publicId, provider, metadata }`) as it appears in a +published content payload: ```typescript -import { createUrl, imageUrl, videoUrl, rawFileUrl } from '@contentchef/contentchef-node'; +import { createUrl, ResourceType } from '@contentchef/contentchef-media'; -const mediaPublicId = 'publicId'; - -const mediaUrl = createUrl(mediaPublicId); - -const image = imageUrl(mediaPublicId); +// A media field taken straight from a published content payload +const media = content.payload.hero; // { publicId, provider, metadata } -const video = videoUrl(mediaPublicId); +// Provider and resource type (image / video / raw) are detected from the media object +const url = createUrl(media); -const rawFile = rawFileUrl(mediaPublicId); +// Pass Cloudinary transformation options in the second argument +const resized = createUrl(media, { resize: { width: 200, height: 100, type: 'fill' } }); -// If you'd like to pass transformations you can do so in the second argument of each method -const transformations = { - height: 100, - width: 200 -} -const mediaUrl = createUrl(mediaPublicId, transformations); +// Override the detected resource type with the optional third argument +const asVideo = createUrl(media, {}, ResourceType.video); ``` -### Provider-aware urls (Cloudinary or Cloudflare) - -Media in ContentChef can be hosted on **Cloudinary** (legacy) or **Cloudflare**. The -functions above always build Cloudinary urls. The provider-aware functions instead take the -whole media object (`{ publicId, provider, metadata }`) as it appears in a published content -payload, detect the provider, and build the right url: +Three per-type helpers wrap `createUrl` when you want to force the resource type explicitly: -* `mediaImageUrl(media, options?)` -* `mediaVideoUrl(media, options?)` -* `mediaRawFileUrl(media, options?)` +* `imageUrl(media, options?)` +* `videoUrl(media, options?)` +* `rawFileUrl(media, options?)` -The provider is read from `media.provider` (falling back to `media.metadata.provider`). -Anything that isn't explicitly `cloudflare` is treated as Cloudinary, so existing media keep -working unchanged. +> **Migrating from v8:** these functions now take the whole media object instead of a bare +> `publicId` string. If you only have a `publicId`, wrap it: `createUrl({ publicId })`. -```typescript -import { mediaImageUrl, mediaVideoUrl, mediaRawFileUrl } from '@contentchef/contentchef-media'; +### How urls are built -// A media field taken straight from a published content payload -const media = content.payload.hero; // { publicId, provider, metadata } - -const image = mediaImageUrl(media, { resize: { width: 200, height: 100, type: 'fill' } }); -const video = mediaVideoUrl(media); -const rawFile = mediaRawFileUrl(media); -``` +The provider is read from `media.provider` (falling back to `media.metadata.provider`), and the +resource type from `media.metadata.resourceType` (defaulting to image). Anything that isn't +explicitly `cloudflare` is treated as Cloudinary, so existing media keep working unchanged. -**Transformations use the Cloudinary option types in every case.** For Cloudflare media they -are mapped to [Cloudflare Image Resizing](https://developers.cloudflare.com/images/transform-images/transform-via-url/) +**Transformations always use the Cloudinary option types.** For Cloudflare media they are mapped +to [Cloudflare Image Resizing](https://developers.cloudflare.com/images/transform-images/transform-via-url/) parameters and rendered as `https://media.contentchef.io/cdn-cgi/image//`. -For example the call above yields: +For example the resized call above yields: ``` https://media.contentchef.io/cdn-cgi/image/width=200,height=100,fit=cover/ ``` -Notes for the Cloudflare provider: +Notes for Cloudflare media: * The base host defaults to `https://media.contentchef.io`; override it per call with `{ baseUrl: 'https://your-zone.example.com' }`. -* Image resizing is image-only, so `mediaVideoUrl`/`mediaRawFileUrl` return the plain delivery - url `https://media.contentchef.io/`. +* Image resizing is image-only. Video and raw files are served as plain delivery urls + (`https://media.contentchef.io/`) with transformations ignored. * Only Cloudinary options with a Cloudflare counterpart are mapped (dimensions, `fit`, `gravity`, `quality`, `format`, `dpr`, `rotate`, `background`, and common `effect`s); unmappable options are ignored rather than producing a broken url. diff --git a/packages/contentchef-media/src/__tests__/createUrl.test.ts b/packages/contentchef-media/src/__tests__/createUrl.test.ts index d176930..518ddb2 100644 --- a/packages/contentchef-media/src/__tests__/createUrl.test.ts +++ b/packages/contentchef-media/src/__tests__/createUrl.test.ts @@ -1,81 +1,194 @@ -import { createUrl, imageUrl, videoUrl, rawFileUrl } from '..'; +import { + createUrl, + imageUrl, + IMedia, + rawFileUrl, + ResourceType, + videoUrl, +} from '..'; +import { + buildCloudflareUrl, + DEFAULT_CLOUDFLARE_BASE_URL, + toCloudflareOptions, +} from '../cloudflare'; -describe('createUrl should', () => { - const publicId = 'test-public-id'; - it('successfully created an url for a resource with https as protocol', () => { - const resource = createUrl(publicId); - const groups = resource.match(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)((\/\w+)*\/)([\w\-.]+[^#?\s]+)(.*)?(#[\w-]+)?$/); - const protocol = groups[2]; - expect(protocol).toEqual('https'); +const cloudinaryMedia = ( + publicId = 'test-public-id', + resourceType = 'image', +): IMedia => ({ + publicId, + provider: 'cloudinary', + metadata: { provider: 'cloudinary', resourceType }, +}); + +const cloudflareMedia = ( + publicId = 'space/img/logo.png', + resourceType = 'image', +): IMedia => ({ + publicId, + provider: 'cloudflare', + metadata: { provider: 'cloudflare', resourceType }, +}); + +describe('createUrl provider detection', () => { + it('treats media without a provider as cloudinary (legacy)', () => { + const url = createUrl({ publicId: 'legacy-id' }); + expect(url).toContain('res.cloudinary.com'); + expect(url).toContain('/image/'); }); - it('successfully created an url for a resource with provided cloud_name', () => { - const cloudName = 'amazingCloudName'; - const resource = createUrl(publicId, {cloud_name: cloudName}); + it('reads the provider from metadata when the top-level field is absent', () => { + const url = createUrl({ publicId: 'space/img/x.png', metadata: { provider: 'cloudflare' } }); + expect(url).toContain('media.contentchef.io'); + }); - expect(resource).toContain(cloudName); + it('lets the top-level provider win over metadata', () => { + const url = createUrl({ + publicId: 'space/img/x.png', + provider: 'cloudflare', + metadata: { provider: 'cloudinary' }, + }); + expect(url).toContain('media.contentchef.io'); }); }); -describe('imageUrl should', () => { - const publicId = 'test-public-id'; - it('successfully created an url for a resource with https as protocol', () => { - const resource = imageUrl(publicId); +describe('createUrl (cloudinary)', () => { + it('builds a secure url with https as protocol', () => { + const resource = createUrl(cloudinaryMedia()); const groups = resource.match(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)((\/\w+)*\/)([\w\-.]+[^#?\s]+)(.*)?(#[\w-]+)?$/); - const protocol = groups[2]; - expect(protocol).toEqual('https'); + expect(groups[2]).toEqual('https'); }); - it('successfully created an url for a resource with provided cloud_name', () => { - const cloudName = 'amazingCloudName'; - const resource = imageUrl(publicId, {cloud_name: cloudName}); - expect(resource).toContain(cloudName); + it('honours a provided cloud_name', () => { + expect(createUrl(cloudinaryMedia(), { cloud_name: 'amazingCloudName' })).toContain('amazingCloudName'); }); - it('have /image/ in generated url', () => { - const resource = imageUrl(publicId); - expect(resource).toContain('/image/'); - }) + it('infers image/video/raw from metadata', () => { + expect(createUrl(cloudinaryMedia('id', 'image'))).toContain('/image/'); + expect(createUrl(cloudinaryMedia('id', 'video'))).toContain('/video/'); + expect(createUrl(cloudinaryMedia('id', 'raw'))).toContain('/raw/'); + }); + + it('defaults to image when metadata has no resource type', () => { + expect(createUrl({ publicId: 'id' })).toContain('/image/'); + }); + + it('lets the explicit resourceType argument override metadata', () => { + expect(createUrl(cloudinaryMedia('id', 'image'), {}, ResourceType.video)).toContain('/video/'); + }); }); -describe('videoUrl should', () => { - const publicId = 'test-public-id'; - it('successfully created an url for a resource with https as protocol', () => { - const resource = videoUrl(publicId); - const groups = resource.match(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)((\/\w+)*\/)([\w\-.]+[^#?\s]+)(.*)?(#[\w-]+)?$/); - const protocol = groups[2]; - expect(protocol).toEqual('https'); +describe('createUrl (cloudflare)', () => { + it('builds a /cdn-cgi/image/ url for images off the default base', () => { + const url = createUrl(cloudflareMedia('space/img/logo.png'), { + resize: { width: 100, height: 200, type: 'fill' }, + }); + expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/cdn-cgi/image/width=100,height=200,fit=cover/space/img/logo.png`); + }); + + it('returns a plain delivery url for images when nothing maps', () => { + expect(createUrl(cloudflareMedia('space/img/logo.png'))).toBe( + `${DEFAULT_CLOUDFLARE_BASE_URL}/space/img/logo.png`, + ); }); - it('successfully created an url for a resource with provided cloud_name', () => { - const cloudName = 'amazingCloudName'; - const resource = videoUrl(publicId, {cloud_name: cloudName}); - expect(resource).toContain(cloudName); + it('serves video as plain delivery (transformations ignored)', () => { + const url = createUrl(cloudflareMedia('space/video/clip.mp4', 'video'), { resize: { width: 100 } }); + expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/space/video/clip.mp4`); + expect(url).not.toContain('cdn-cgi'); }); - it('have /image/ in generated url', () => { - const resource = videoUrl(publicId); - expect(resource).toContain('/video/'); - }) + it('serves raw files as plain delivery', () => { + expect(createUrl(cloudflareMedia('space/raw/doc.pdf', 'raw'))).toBe( + `${DEFAULT_CLOUDFLARE_BASE_URL}/space/raw/doc.pdf`, + ); + }); + + it('honours a baseUrl override and strips redundant slashes', () => { + const url = createUrl(cloudflareMedia('/space/img/logo.png'), { + baseUrl: 'https://cdn.example.com/', + resize: { width: 50 }, + }); + expect(url).toBe('https://cdn.example.com/cdn-cgi/image/width=50/space/img/logo.png'); + }); + + it('does not leak baseUrl or cloud_name into cloudflare params', () => { + const url = createUrl(cloudflareMedia('a/b.png'), { + baseUrl: 'https://cdn.example.com', + cloud_name: 'x', + resize: { width: 50 }, + }); + expect(url).toBe('https://cdn.example.com/cdn-cgi/image/width=50/a/b.png'); + }); }); -describe('rawFileUrl should', () => { - const publicId = 'test-public-id'; - it('successfully created an url for a resource with https as protocol', () => { - const resource = rawFileUrl(publicId); - const groups = resource.match(/^((http[s]?|ftp):\/)?\/?([^:/\s]+)((\/\w+)*\/)([\w\-.]+[^#?\s]+)(.*)?(#[\w-]+)?$/); - const protocol = groups[2]; - expect(protocol).toEqual('https'); +describe('per-type helpers force the resource type', () => { + it('imageUrl / videoUrl / rawFileUrl override the inferred type', () => { + expect(imageUrl(cloudinaryMedia('id', 'video'))).toContain('/image/'); + expect(videoUrl(cloudinaryMedia('id', 'image'))).toContain('/video/'); + expect(rawFileUrl(cloudinaryMedia('id', 'image'))).toContain('/raw/'); + }); + + it('imageUrl maps a cloudflare image', () => { + expect(imageUrl(cloudflareMedia('a/b.png'), { resize: { width: 10 } })).toBe( + `${DEFAULT_CLOUDFLARE_BASE_URL}/cdn-cgi/image/width=10/a/b.png`, + ); }); - it('successfully created an url for a resource with provided cloud_name', () => { - const cloudName = 'amazingCloudName'; - const resource = rawFileUrl(publicId, {cloud_name: cloudName}); - expect(resource).toContain(cloudName); + it('videoUrl serves a cloudflare video as plain delivery', () => { + expect(videoUrl(cloudflareMedia('a/clip.mp4'))).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/a/clip.mp4`); }); - it('have /image/ in generated url', () => { - const resource = rawFileUrl(publicId); - expect(resource).toContain('/raw/'); - }) + it('rawFileUrl serves a cloudflare raw file as plain delivery', () => { + expect(rawFileUrl(cloudflareMedia('a/doc.pdf'))).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/a/doc.pdf`); + }); +}); + +describe('toCloudflareOptions mapping', () => { + it('maps resize dimensions and fit', () => { + expect(toCloudflareOptions({ resize: { width: 10, height: 20, type: 'fit' } })) + .toEqual(['width=10', 'height=20', 'fit=contain']); + }); + + it('maps compass gravity to sides and corners', () => { + expect(toCloudflareOptions({ gravity: 'north' as any })).toEqual(['gravity=top']); + expect(toCloudflareOptions({ gravity: 'south_east' as any })).toEqual(['gravity=1x1']); + expect(toCloudflareOptions({ gravity: 'auto' as any })).toEqual(['gravity=auto']); + }); + + it('maps format, falling back to fetchFormat, and normalises jpg', () => { + expect(toCloudflareOptions({ format: 'auto' })).toEqual(['format=auto']); + expect(toCloudflareOptions({ fetchFormat: 'jpg' })).toEqual(['format=jpeg']); + }); + + it('passes numeric quality through and drops auto quality', () => { + expect(toCloudflareOptions({ quality: 75 })).toEqual(['quality=75']); + expect(toCloudflareOptions({ quality: 'auto' })).toEqual([]); + }); + + it('only allows cloudflare-supported rotations', () => { + expect(toCloudflareOptions({ rotate: 90 })).toEqual(['rotate=90']); + expect(toCloudflareOptions({ rotate: 45 })).toEqual([]); + }); + + it('escapes the hash in a hex background', () => { + expect(toCloudflareOptions({ background: '#ff0000' })).toEqual(['background=%23ff0000']); + }); + + it('maps common effects with approximate scaling', () => { + expect(toCloudflareOptions({ effect: { name: 'brightness', value: 50 } })).toEqual(['brightness=1.5']); + expect(toCloudflareOptions({ effect: { name: 'grayscale' } })).toEqual(['saturation=0']); + expect(toCloudflareOptions({ effect: { name: 'sepia', value: 80 } })).toEqual([]); + }); +}); + +describe('buildCloudflareUrl', () => { + it('joins multiple options with commas', () => { + const url = buildCloudflareUrl('a/b.png', { resize: { width: 100 }, format: 'auto', quality: 80 }); + expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/cdn-cgi/image/width=100,format=auto,quality=80/a/b.png`); + }); + + it('returns a plain delivery url when no options map to params', () => { + expect(buildCloudflareUrl('space/raw/doc.pdf')).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/space/raw/doc.pdf`); + }); }); diff --git a/packages/contentchef-media/src/__tests__/mediaUrl.test.ts b/packages/contentchef-media/src/__tests__/mediaUrl.test.ts deleted file mode 100644 index 896da12..0000000 --- a/packages/contentchef-media/src/__tests__/mediaUrl.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - IMedia, - mediaImageUrl, - mediaRawFileUrl, - mediaVideoUrl, - MediaProvider, -} from '..'; -import { - buildCloudflareImageUrl, - DEFAULT_CLOUDFLARE_BASE_URL, - toCloudflareOptions, -} from '../cloudflare'; - -const cloudinaryMedia = (publicId = 'test-public-id'): IMedia => ({ - publicId, - provider: MediaProvider.cloudinary, - metadata: { provider: MediaProvider.cloudinary }, -}); - -const cloudflareMedia = (publicId = 'space/img/logo.png'): IMedia => ({ - publicId, - provider: MediaProvider.cloudflare, - metadata: { provider: MediaProvider.cloudflare }, -}); - -describe('provider detection', () => { - it('treats media without a provider as cloudinary (legacy)', () => { - const url = mediaImageUrl({ publicId: 'legacy-id' }); - expect(url).toContain('res.cloudinary.com'); - expect(url).toContain('/image/'); - }); - - it('reads the provider from metadata when the top-level field is absent', () => { - const url = mediaImageUrl({ publicId: 'space/img/x.png', metadata: { provider: MediaProvider.cloudflare } }); - expect(url).toContain('media.contentchef.io'); - }); - - it('lets the top-level provider win over metadata', () => { - const url = mediaImageUrl({ - publicId: 'space/img/x.png', - provider: MediaProvider.cloudflare, - metadata: { provider: MediaProvider.cloudinary }, - }); - expect(url).toContain('media.contentchef.io'); - }); -}); - -describe('mediaImageUrl (cloudinary)', () => { - it('builds a secure cloudinary image url', () => { - const url = mediaImageUrl(cloudinaryMedia(), { cloud_name: 'myCloud', resize: { width: 100 } }); - expect(url.startsWith('https://')).toBe(true); - expect(url).toContain('myCloud'); - expect(url).toContain('/image/'); - }); -}); - -describe('mediaImageUrl (cloudflare)', () => { - it('builds a /cdn-cgi/image/ url off the default base', () => { - const url = mediaImageUrl(cloudflareMedia('space/img/logo.png'), { - resize: { width: 100, height: 200, type: 'fill' }, - }); - expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/cdn-cgi/image/width=100,height=200,fit=cover/space/img/logo.png`); - }); - - it('returns a plain delivery url when no options map to cloudflare params', () => { - const url = mediaImageUrl(cloudflareMedia('space/img/logo.png')); - expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/space/img/logo.png`); - }); - - it('honours a baseUrl override and strips redundant slashes', () => { - const url = mediaImageUrl(cloudflareMedia('/space/img/logo.png'), { - baseUrl: 'https://cdn.example.com/', - resize: { width: 50 }, - }); - expect(url).toBe('https://cdn.example.com/cdn-cgi/image/width=50/space/img/logo.png'); - }); - - it('does not leak baseUrl into cloudinary transformations', () => { - const url = mediaImageUrl(cloudinaryMedia(), { baseUrl: 'https://cdn.example.com', resize: { width: 50 } }); - expect(url).not.toContain('baseUrl'); - expect(url).not.toContain('cdn.example.com'); - }); -}); - -describe('mediaVideoUrl', () => { - it('serves cloudflare video directly from the delivery host', () => { - const url = mediaVideoUrl(cloudflareMedia('space/video/clip.mp4')); - expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/space/video/clip.mp4`); - }); - - it('keeps the cloudinary video pipeline', () => { - const url = mediaVideoUrl(cloudinaryMedia()); - expect(url).toContain('/video/'); - }); -}); - -describe('mediaRawFileUrl', () => { - it('serves cloudflare raw files directly', () => { - const url = mediaRawFileUrl(cloudflareMedia('space/raw/doc.pdf')); - expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/space/raw/doc.pdf`); - }); - - it('keeps the cloudinary raw pipeline', () => { - const url = mediaRawFileUrl(cloudinaryMedia()); - expect(url).toContain('/raw/'); - }); -}); - -describe('toCloudflareOptions mapping', () => { - it('maps resize dimensions and fit', () => { - expect(toCloudflareOptions({ resize: { width: 10, height: 20, type: 'fit' } })) - .toEqual(['width=10', 'height=20', 'fit=contain']); - }); - - it('maps compass gravity to sides and corners', () => { - expect(toCloudflareOptions({ gravity: 'north' as any })).toEqual(['gravity=top']); - expect(toCloudflareOptions({ gravity: 'south_east' as any })).toEqual(['gravity=1x1']); - expect(toCloudflareOptions({ gravity: 'auto' as any })).toEqual(['gravity=auto']); - }); - - it('maps format, falling back to fetchFormat, and normalises jpg', () => { - expect(toCloudflareOptions({ format: 'auto' })).toEqual(['format=auto']); - expect(toCloudflareOptions({ fetchFormat: 'jpg' })).toEqual(['format=jpeg']); - }); - - it('passes numeric quality through and drops auto quality', () => { - expect(toCloudflareOptions({ quality: 75 })).toEqual(['quality=75']); - expect(toCloudflareOptions({ quality: 'auto' })).toEqual([]); - }); - - it('only allows cloudflare-supported rotations', () => { - expect(toCloudflareOptions({ rotate: 90 })).toEqual(['rotate=90']); - expect(toCloudflareOptions({ rotate: 45 })).toEqual([]); - }); - - it('escapes the hash in a hex background', () => { - expect(toCloudflareOptions({ background: '#ff0000' })).toEqual(['background=%23ff0000']); - }); - - it('maps common effects with approximate scaling', () => { - expect(toCloudflareOptions({ effect: { name: 'brightness', value: 50 } })).toEqual(['brightness=1.5']); - expect(toCloudflareOptions({ effect: { name: 'grayscale' } })).toEqual(['saturation=0']); - expect(toCloudflareOptions({ effect: { name: 'sepia', value: 80 } })).toEqual([]); - }); -}); - -describe('buildCloudflareImageUrl', () => { - it('joins multiple options with commas', () => { - const url = buildCloudflareImageUrl('a/b.png', { resize: { width: 100 }, format: 'auto', quality: 80 }); - expect(url).toBe(`${DEFAULT_CLOUDFLARE_BASE_URL}/cdn-cgi/image/width=100,format=auto,quality=80/a/b.png`); - }); -}); diff --git a/packages/contentchef-media/src/cloudflare.ts b/packages/contentchef-media/src/cloudflare.ts index 414617e..6e719c1 100644 --- a/packages/contentchef-media/src/cloudflare.ts +++ b/packages/contentchef-media/src/cloudflare.ts @@ -164,7 +164,7 @@ function normalizeSource(publicId: string): string { return String(publicId).replace(/^\/+/, ''); } -export function buildCloudflareImageUrl(publicId: string, options: TransformerOption = {}, baseUrl?: string): string { +export function buildCloudflareUrl(publicId: string, options: TransformerOption = {}, baseUrl?: string): string { const base = normalizeBase(baseUrl); const source = normalizeSource(publicId); const params = toCloudflareOptions(options); @@ -173,7 +173,3 @@ export function buildCloudflareImageUrl(publicId: string, options: TransformerOp } return `${base}/cdn-cgi/image/${params.join(',')}/${source}`; } - -export function buildCloudflareDeliveryUrl(publicId: string, baseUrl?: string): string { - return `${normalizeBase(baseUrl)}/${normalizeSource(publicId)}`; -} diff --git a/packages/contentchef-media/src/index.ts b/packages/contentchef-media/src/index.ts index 20a7b84..13b7a30 100644 --- a/packages/contentchef-media/src/index.ts +++ b/packages/contentchef-media/src/index.ts @@ -1,9 +1,6 @@ import { TransformerOption, TransformerVideoOption } from '@cld-apis/types'; -import buildUrl from 'cloudinary-build-url'; -import { - buildCloudflareDeliveryUrl, - buildCloudflareImageUrl, -} from './cloudflare'; +import cloudinaryBuildUrl from 'cloudinary-build-url'; +import { buildCloudflareUrl } from './cloudflare'; import { IMedia, MediaProvider, resolveProvider } from './types'; export type IMediaOptions = (TransformerOption | TransformerVideoOption) & { @@ -13,6 +10,7 @@ export type IImageOptions = TransformerOption & { cloud_name?: string }; export type IVideoOptions = TransformerVideoOption & { cloud_name?: string }; export type BaseUrlAware = { baseUrl?: string }; +export type IMediaUrlOptions = IMediaOptions & BaseUrlAware; export type IMediaImageOptions = IImageOptions & BaseUrlAware; export type IMediaVideoOptions = IVideoOptions & BaseUrlAware; export type IMediaFileOptions = IMediaOptions & BaseUrlAware; @@ -25,73 +23,91 @@ export enum ResourceType { const defaultCloudName = 'contentchef'; +function inferResourceType(media: IMedia): ResourceType { + const resourceType = media.metadata && media.metadata.resourceType; + if (resourceType === ResourceType.video) { + return ResourceType.video; + } + if (resourceType === ResourceType.raw) { + return ResourceType.raw; + } + return ResourceType.image; +} + +/** + * Builds a delivery URL for a media object taken from a published content payload. + * + * The provider and the resource type are detected from the media object itself + * (`metadata.resourceType`, defaulting to image); pass `resourceType` to force it. + * + * @param media - Media reference, e.g. `{ publicId, provider, metadata }`. + * @param options - Transformation options; `baseUrl` overrides the default delivery host. + * @param resourceType - Overrides the resource type inferred from `metadata`. + * @returns The media delivery URL. + * + * @example + * createUrl(media); + * createUrl(media, { resize: { width: 200 } }); + * createUrl(media, {}, ResourceType.video); + */ export function createUrl( - publicId: string, - options: IMediaOptions = { cloud_name: defaultCloudName }, - resourceType: ResourceType = ResourceType.image, + media: IMedia, + options: IMediaUrlOptions = {}, + resourceType?: ResourceType, ): string { - const { cloud_name, ...transformations } = options; - return buildUrl(publicId, { + const type = resourceType || inferResourceType(media); + const { cloud_name, baseUrl, ...transformations } = options; + if (resolveProvider(media) === MediaProvider.cloudflare) { + const cloudflareOptions = + type === ResourceType.image + ? (transformations as TransformerOption) + : undefined; + return buildCloudflareUrl(media.publicId, cloudflareOptions, baseUrl); + } + return cloudinaryBuildUrl(media.publicId, { cloud: { cloudName: cloud_name || defaultCloudName, - resourceType, + resourceType: type, secure: true, }, transformations, }); } -export function imageUrl(publicId: string, options?: IImageOptions) { - return createUrl(publicId, options, ResourceType.image); -} - -export function videoUrl(publicId: string, options?: IVideoOptions) { - return createUrl(publicId, options, ResourceType.video); -} - -export function rawFileUrl(publicId: string, options?: IMediaOptions) { - return createUrl(publicId, options, ResourceType.raw); -} - -export function mediaImageUrl( - media: IMedia, - options: IMediaImageOptions = {}, -): string { - const { baseUrl, ...transformations } = options; - if (resolveProvider(media) === MediaProvider.cloudflare) { - return buildCloudflareImageUrl(media.publicId, transformations, baseUrl); - } - return createUrl(media.publicId, transformations, ResourceType.image); +/** + * Builds a delivery URL for the given media, forcing the image resource type. + * + * @param media - Media reference, e.g. `{ publicId, provider, metadata }`. + * @param options - Transformation options; `baseUrl` overrides the default delivery host. + * @returns The image delivery URL. + */ +export function imageUrl(media: IMedia, options?: IMediaImageOptions) { + return createUrl(media, options, ResourceType.image); } -export function mediaVideoUrl( - media: IMedia, - options: IMediaVideoOptions = {}, -): string { - const { baseUrl, ...transformations } = options; - if (resolveProvider(media) === MediaProvider.cloudflare) { - return buildCloudflareDeliveryUrl(media.publicId, baseUrl); - } - return createUrl(media.publicId, transformations, ResourceType.video); +/** + * Builds a delivery URL for the given media, forcing the video resource type. + * + * @param media - Media reference, e.g. `{ publicId, provider, metadata }`. + * @param options - Transformation options; `baseUrl` overrides the default delivery host. + * @returns The video delivery URL. + */ +export function videoUrl(media: IMedia, options?: IMediaVideoOptions) { + return createUrl(media, options, ResourceType.video); } -export function mediaRawFileUrl( - media: IMedia, - options: IMediaFileOptions = {}, -): string { - const { baseUrl, ...transformations } = options; - if (resolveProvider(media) === MediaProvider.cloudflare) { - return buildCloudflareDeliveryUrl(media.publicId, baseUrl); - } - return createUrl(media.publicId, transformations, ResourceType.raw); +/** + * Builds a delivery URL for the given media (pdf, zip, etc.), forcing the raw resource type. + * + * @param media - Media reference, e.g. `{ publicId, provider, metadata }`. + * @param options - Transformation options; `baseUrl` overrides the default delivery host. + * @returns The raw-file delivery URL. + */ +export function rawFileUrl(media: IMedia, options?: IMediaFileOptions) { + return createUrl(media, options, ResourceType.raw); } -export { - IMedia, - IMediaMetadata, - MediaProvider, - resolveProvider, -} from './types'; +export { IMedia, IMediaMetadata } from './types'; export { AudioCodec, From 38c15aef0bd4b7f9e53ea138e4457f31db5becfd Mon Sep 17 00:00:00 2001 From: PierpaoloIannone Date: Fri, 3 Jul 2026 11:06:53 +0200 Subject: [PATCH 5/5] publish @contentchef/contentchef-media@9.0.0-beta.1 --- packages/contentchef-media/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contentchef-media/package.json b/packages/contentchef-media/package.json index 51a767f..ca17951 100644 --- a/packages/contentchef-media/package.json +++ b/packages/contentchef-media/package.json @@ -1,6 +1,6 @@ { "name": "@contentchef/contentchef-media", - "version": "9.0.0-alpha.2", + "version": "9.0.0-beta.1", "description": "Package for helping managing media with ContentChef", "author": "ContentChef", "maintainers": [