diff --git a/.changeset/autorender-cdn-provider.md b/.changeset/autorender-cdn-provider.md new file mode 100644 index 000000000..8734d7c0f --- /dev/null +++ b/.changeset/autorender-cdn-provider.md @@ -0,0 +1,5 @@ +--- +'@responsive-image/cdn': minor +--- + +Add Autorender image CDN provider diff --git a/apps/docs/.vitepress/config.ts b/apps/docs/.vitepress/config.ts index d730c925b..94f74d53a 100644 --- a/apps/docs/.vitepress/config.ts +++ b/apps/docs/.vitepress/config.ts @@ -114,6 +114,7 @@ export default defineConfig({ link: '/', base: '/cdn', items: [ + { text: 'Autorender', link: '/autorender' }, { text: 'Cloudinary', link: '/cloudinary' }, { text: 'Fastly', link: '/fastly' }, { text: 'Imgix', link: '/imgix' }, diff --git a/apps/docs/src/cdn/autorender.md b/apps/docs/src/cdn/autorender.md new file mode 100644 index 000000000..5a5a7959a --- /dev/null +++ b/apps/docs/src/cdn/autorender.md @@ -0,0 +1,170 @@ +--- +outline: [2, 3] +--- + +# Autorender + +The image processing capabilities of the [Autorender](https://autorender.io) image CDN are supported by a helper function provided to you by this library. + +## Setup + +Make sure you have the `@responsive-image/cdn` package installed: + +::: code-group + +```bash [npm] +npm install @responsive-image/cdn +``` + +```bash [yarn] +yarn add @responsive-image/cdn +``` + +```bash [pnpm] +pnpm add @responsive-image/cdn +``` + +::: + +You need to specify your Autorender `domain` and `workspace` in your configuration, which you can set up in your application (e.g. `app.js`). The workspace ID is the public routing identifier in your delivery URLs and is not a secret: + +```js +import { setConfig } from '@responsive-image/core'; + +setConfig('cdn', { + autorender: { + domain: 'assets.autorender.io', + workspace: 'LOKVTtKVGb', + }, +}); +``` + +## Usage + +> [!IMPORTANT] +> Please make sure you have read the section on [remote images](../usage/remote-images.md) first. + +Use the autorender provider function passing the source path of the image inside your workspace, and pass the return value to the [image component](../usage/component.md): + +::: code-group + +```gjs [Ember .gjs] +import { ResponsiveImage } from '@responsive-image/ember'; +import { autorender } from '@responsive-image/cdn'; + + +``` + +```hbs [Ember .hbs] + +``` + +```ts [Lit] +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { autorender } from '@responsive-image/cdn'; +import '@responsive-image/wc'; + +@customElement('my-app') +export class MyApp extends LitElement { + render() { + return html``; + } +} +``` + +```tsx [React] +import { ResponsiveImage } from '@responsive-image/react'; +import { autorender } from '@responsive-image/cdn'; + +export default function MyApp() { + return ; +} +``` + +```tsx [Solid] +import { ResponsiveImage } from '@responsive-image/solid'; +import { autorender } from '@responsive-image/cdn'; + +export default function MyApp() { + return ; +} +``` + +```svelte [Svelte] + + + +``` + +```vue [Vue] + + + +``` + +::: + +### Aspect Ratio + +For the image component to be able to render `width` and `height` attributes to prevent layout shifts after loading has completed, it needs to know the aspect ratio of the source image. Unlike [local images](../usage/local-images.md) it cannot know this upfront for remote images, that's why it is recommended to supply the `aspectRatio` parameter if possible: + +```ts [Lit] +autorender('products/chair.jpg', { + aspectRatio: 1.5, +}); +``` + +### Quality + +Use the `quality` parameter to pass a custom quality setting (`1`–`100`) instead of Autorender's per-format default: + +```ts [Lit] +autorender('products/chair.jpg', { + quality: 50, +}); +``` + +### Image formats + +By default the component lets Autorender select the format from the request `Accept` header. + +If you want a `picture` tag with one or more specific formats as `source` tags you can specify them using the `formats` argument. Autorender supports `avif`, `webp`, `jpeg`, `png`, `gif`, and `tiff`: + +```ts [Lit] +autorender('products/chair.jpg', { + formats: ['avif', 'webp'], +}); +``` + +### Remote images + +Besides source paths inside your workspace, you can pass a full `http(s)` URL. Autorender fetches the remote image, then optimizes and delivers it through the CDN — useful when your originals live elsewhere and aren't uploaded to the workspace: + +```ts [Lit] +autorender('https://images.example.com/products/chair.jpg', { + formats: ['avif', 'webp'], +}); +``` + +### Custom transforms + +Besides the resizing and format tokens the library adds implicitly, you can append any additional [Autorender transform tokens](https://autorender.io/docs/transformations/introduction) verbatim by passing a `transforms` array: + +```ts [Lit] +autorender('products/chair.jpg', { + transforms: ['e_sharpen', 'r_16'], +}); +``` diff --git a/apps/docs/src/cdn/index.md b/apps/docs/src/cdn/index.md index 2daa8a2e8..de026062e 100644 --- a/apps/docs/src/cdn/index.md +++ b/apps/docs/src/cdn/index.md @@ -6,6 +6,7 @@ With image CDNs the image processing is offloaded to the Cloud. This allows for The following image CDNs are supported by the `@responsive-image/cdn` package out of the box: +- [Autorender](./autorender.md) - [Cloudinary](./cloudinary.md) - [Fastly](./fastly.md) - [Imgix](./imgix.md) diff --git a/packages/cdn/src/autorender.ts b/packages/cdn/src/autorender.ts new file mode 100644 index 000000000..a0db48d53 --- /dev/null +++ b/packages/cdn/src/autorender.ts @@ -0,0 +1,101 @@ +import { assert, getConfig } from '@responsive-image/core'; + +import type { Config, CoreOptions } from './types'; +import type { ImageData, ImageUrlForType } from '@responsive-image/core'; + +export interface AutorenderConfig { + /** + * Delivery domain that serves your transformed images, + * e.g. `assets.autorender.io`. + */ + domain: string; + /** + * Public workspace ID that routes the request, e.g. `LOKVTtKVGb`. + * It is part of the delivery URL and is not a secret. + */ + workspace: string; +} + +export interface AutorenderOptions extends CoreOptions { + /** + * Extra Autorender transform tokens applied verbatim, e.g. + * `['e_sharpen', 'r_16']`. See the transformation reference for the + * full token vocabulary. + */ + transforms?: string[]; +} + +const ABSOLUTE_URL_RE = /^https?:\/\//i; + +function normalizeSrc(src: string): string { + return src[0] === '/' ? src.slice(1) : src; +} + +/** + * Escape the structural characters of a remote fetch URL so it survives as a + * single transform token: `%` (round-trip safety), `,` (token delimiter), + * `?` and `#` (which would otherwise terminate the path). Slashes and colons + * stay literal; the delivery backend percent-decodes path segments. + */ +function escapeFetchPayload(url: string): string { + return url + .replace(/%/g, '%25') + .replace(/,/g, '%2C') + .replace(/\?/g, '%3F') + .replace(/#/g, '%23'); +} + +export function autorender( + image: string, + options: AutorenderOptions = {}, +): ImageData { + const config = getConfig('cdn')?.autorender; + const domain = config?.domain; + const workspace = config?.workspace; + assert( + 'domain must be set for the autorender provider!', + typeof domain === 'string', + ); + assert( + 'workspace must be set for the autorender provider!', + typeof workspace === 'string', + ); + + const isRemote = ABSOLUTE_URL_RE.test(image.trim()); + const src = isRemote ? image.trim() : normalizeSrc(image); + + const imageData: ImageData = { + imageTypes: options.formats ?? 'auto', + imageUrlFor(width: number, type: ImageUrlForType = 'jpeg'): string { + const tokens = [`w_${width}`]; + + // Autorender accepts `f_jpeg` directly, so the format name maps 1:1. + // `auto` emits `f_auto`, letting Autorender negotiate from the + // Accept header instead of pinning a single format. + tokens.push(`f_${type}`); + + if (options.quality) { + tokens.push(`q_${options.quality}`); + } + + if (options.transforms) { + tokens.push(...options.transforms); + } + + // Remote sources become a `fetch_` transform token (not a path + // segment); the workspace-relative path otherwise trails the tokens. + if (isRemote) { + tokens.push(`fetch_${escapeFetchPayload(src)}`); + return `https://${domain}/${workspace}/${tokens.join(',')}`; + } + + return `https://${domain}/${workspace}/${tokens.join(',')}/${src}`; + }, + }; + + if (options.aspectRatio) { + imageData.aspectRatio = options.aspectRatio; + } + + return imageData; +} diff --git a/packages/cdn/src/index.ts b/packages/cdn/src/index.ts index 61e3a6e48..4134bc7fa 100644 --- a/packages/cdn/src/index.ts +++ b/packages/cdn/src/index.ts @@ -1,3 +1,4 @@ +export * from './autorender.ts'; export * from './cloudinary.ts'; export * from './fastly.ts'; export * from './imgix.ts'; diff --git a/packages/cdn/src/types.ts b/packages/cdn/src/types.ts index 5c63c32c9..7e02a73f4 100644 --- a/packages/cdn/src/types.ts +++ b/packages/cdn/src/types.ts @@ -1,3 +1,4 @@ +import type { AutorenderConfig } from './autorender'; import type { CloudinaryConfig } from './cloudinary'; import type { FastlyConfig } from './fastly'; import type { ImgixConfig } from './imgix'; @@ -5,6 +6,7 @@ import type { NetlifyConfig } from './netlify'; import type { ImageTypeAuto, ImageType } from '@responsive-image/core'; export interface Config { + autorender?: AutorenderConfig; imgix?: ImgixConfig; fastly?: FastlyConfig; cloudinary?: CloudinaryConfig; diff --git a/packages/cdn/tests/autorender.test.ts b/packages/cdn/tests/autorender.test.ts new file mode 100644 index 000000000..d68ed66f3 --- /dev/null +++ b/packages/cdn/tests/autorender.test.ts @@ -0,0 +1,108 @@ +import { setConfig } from '@responsive-image/core'; +import { beforeAll, describe, expect, test } from 'vitest'; + +import { autorender } from '../src'; + +import type { Config } from '../src'; + +describe('autorender', function () { + beforeAll(() => { + setConfig('cdn', { + autorender: { domain: 'assets.autorender.io', workspace: 'LOKVTtKVGb' }, + }); + }); + + test('it lets the CDN choose image type by default', function () { + const result = autorender('products/chair.jpg'); + + expect(result?.imageTypes).toEqual('auto'); + }); + + test('it supports custom image types', function () { + const result = autorender('products/chair.jpg', { + formats: ['jpeg', 'webp'], + }); + + expect(result?.imageTypes).toEqual(['jpeg', 'webp']); + }); + + test('it returns correct image URLs', function () { + const result = autorender('products/chair.jpg'); + + expect(result.imageUrlFor(100, 'jpeg')).toBe( + 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_jpeg/products/chair.jpg', + ); + + expect(result.imageUrlFor(1000, 'webp')).toBe( + 'https://assets.autorender.io/LOKVTtKVGb/w_1000,f_webp/products/chair.jpg', + ); + }); + + test('it normalizes a leading slash in the source path', function () { + const result = autorender('/products/chair.jpg'); + + expect(result.imageUrlFor(100, 'jpeg')).toBe( + 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_jpeg/products/chair.jpg', + ); + }); + + test('it emits f_auto for the auto type', function () { + const result = autorender('products/chair.jpg', { formats: 'auto' }); + + expect(result.imageUrlFor(100, 'auto')).toBe( + 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_auto/products/chair.jpg', + ); + }); + + test('it supports custom quality setting', function () { + const result = autorender('products/chair.jpg', { quality: 50 }); + + expect(result.imageUrlFor(100, 'jpeg')).toBe( + 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_jpeg,q_50/products/chair.jpg', + ); + }); + + test('it appends custom transform tokens', function () { + const result = autorender('products/chair.jpg', { + transforms: ['e_sharpen', 'r_16'], + }); + + expect(result.imageUrlFor(100, 'jpeg')).toBe( + 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_jpeg,e_sharpen,r_16/products/chair.jpg', + ); + }); + + test('it supports custom aspectRatio', function () { + const result = autorender('products/chair.jpg', { aspectRatio: 2 }); + + expect(result.aspectRatio).toBe(2); + }); + + test('it fetches a remote URL as a fetch_ transform token', function () { + const result = autorender('https://images.example.com/chair.jpg'); + + expect(result.imageUrlFor(100, 'webp')).toBe( + 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_webp,fetch_https://images.example.com/chair.jpg', + ); + }); + + test('it escapes structural characters in a remote fetch URL', function () { + const result = autorender( + 'https://images.example.com/chair.jpg?v=2&size=xl', + ); + + expect(result.imageUrlFor(100, 'webp')).toBe( + 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_webp,fetch_https://images.example.com/chair.jpg%3Fv=2&size=xl', + ); + }); + + test('it appends custom transforms before the fetch token', function () { + const result = autorender('https://images.example.com/chair.jpg', { + transforms: ['e_sharpen'], + }); + + expect(result.imageUrlFor(100, 'webp')).toBe( + 'https://assets.autorender.io/LOKVTtKVGb/w_100,f_webp,e_sharpen,fetch_https://images.example.com/chair.jpg', + ); + }); +});