Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 46 additions & 17 deletions packages/contentchef-media/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +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';
// A media field taken straight from a published content payload
const media = content.payload.hero; // { publicId, provider, metadata }

const mediaUrl = createUrl(mediaPublicId);
// Provider and resource type (image / video / raw) are detected from the media object
const url = createUrl(media);

const image = imageUrl(mediaPublicId);
// Pass Cloudinary transformation options in the second argument
const resized = createUrl(media, { resize: { width: 200, height: 100, type: 'fill' } });

const video = videoUrl(mediaPublicId);
// Override the detected resource type with the optional third argument
const asVideo = createUrl(media, {}, ResourceType.video);
```

Three per-type helpers wrap `createUrl` when you want to force the resource type explicitly:

* `imageUrl(media, options?)`
* `videoUrl(media, options?)`
* `rawFileUrl(media, options?)`

> **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 })`.

### How urls are built

const rawFile = rawFileUrl(mediaPublicId);
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.

// 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);
**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/<options>/<publicId>`.
For example the resized call above yields:

```
https://media.contentchef.io/cdn-cgi/image/width=200,height=100,fit=cover/<publicId>
```

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. Video and raw files are served as plain delivery urls
(`https://media.contentchef.io/<publicId>`) 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.
2 changes: 1 addition & 1 deletion packages/contentchef-media/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentchef/contentchef-media",
"version": "8.0.0",
"version": "9.0.0-beta.1",
"description": "Package for helping managing media with ContentChef",
"author": "ContentChef",
"maintainers": [
Expand Down
225 changes: 169 additions & 56 deletions packages/contentchef-media/src/__tests__/createUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
});
});
Loading
Loading