diff --git a/docs/content/3.providers/truocloud.md b/docs/content/3.providers/truocloud.md new file mode 100644 index 000000000..49c0ed8ab --- /dev/null +++ b/docs/content/3.providers/truocloud.md @@ -0,0 +1,81 @@ +--- +title: TruoCloud +description: Nuxt Image has first class integration with TruoCloud. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/image/blob/main/src/runtime/providers/truocloud.ts + size: xs +--- + +Integration between [TruoCloud](https://docs.truo.cloud/images) and the image module. + +To use this provider, set `baseURL` to the delivery endpoint shown in your +console under **Images → Endpoint**. It ends in your tenant's public id, which +is not a secret: it appears in every image URL on your site. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + image: { + truocloud: { + baseURL: 'https://img.truo.cloud/i/' + } + } +}) +``` + +## TruoCloud `fit` Values + +TruoCloud supports all the [standard values for the `fit` property](/usage/nuxt-img#fit) of Nuxt image and Nuxt picture, and also accepts the imgix and +ImageKit vocabularies (`crop`, `clip`, `pad`, `scale`…), which it maps itself. + +One difference worth knowing if you are migrating: `fit=fill` **stretches** the +image, following sharp's semantics rather than imgix's letterbox. Use +`fit=contain` for padding. + +## TruoCloud Modifiers + +Beside the [standard modifiers](/usage/nuxt-img#modifiers), you can pass any +TruoCloud parameter through the `modifiers` prop — gravity, crops, blur, +filters and the rest. The full list is in the [TruoCloud image +documentation](https://docs.truo.cloud/images). + +## Choosing an output format + +`format: 'auto'` picks avif or webp from the browser's `Accept` header and +answers `Vary: Accept`. That is correct HTTP, and it is also the fragile part: +`Accept` has very high cardinality, and some CDNs ignore `Vary` on images +unless you turn it on explicitly. + +If your images sit behind a third-party CDN you did not configure, pin +`format: 'webp'` instead. A slightly larger file that is always the right one +beats an avif served to a browser that cannot decode it. + +```vue + +``` + +That returns a 300 x 500 image, cropped towards the most interesting region of +the picture rather than its centre, in the best format the browser accepts. + +## Sizing the ladder + +TruoCloud caches a transformation on its second identical request, so every +extra width in a responsive ladder costs two transformations before it starts +being served from cache. Five breakpoints cover the real range; the default +`screens` are worth trimming if you are on a free tier. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + image: { + screens: { xs: 640, sm: 828, md: 1200, lg: 1600, xl: 2048 } + } +}) +``` diff --git a/docs/content/index.md b/docs/content/index.md index 58263c007..906173a4f 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -162,6 +162,7 @@ orientation: horizontal - shopify - storyblok - strapi + - truocloud - twicpics - unsplash - uploadcare diff --git a/playground/app/providers.ts b/playground/app/providers.ts index d80670711..d42e38433 100644 --- a/playground/app/providers.ts +++ b/playground/app/providers.ts @@ -13,6 +13,24 @@ interface Provider { } export const providers: Provider[] = [ + { + name: 'truocloud', + samples: [ + { + src: '/wikipedia/commons/3/3f/Fronalpstock_big.jpg', + width: 400, + height: 300, + fit: 'cover', + }, + { + src: '/wikipedia/commons/3/3f/Fronalpstock_big.jpg', + width: 400, + height: 300, + format: 'auto', + }, + ], + }, + // null provider (for non-node environments) { name: 'none', diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 56ea54439..ed6643791 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -89,6 +89,9 @@ export default defineNuxtConfig({ imgix: { baseURL: 'https://assets.imgix.net', }, + truocloud: { + baseURL: 'https://img.truo.cloud/i/demo', + }, imgproxy: { baseURL: 'http://localhost:8080', key: 'ee3b0e07dfc9ec20d5d9588a558753547a8a88c48291ae96171330daf4ce2800', diff --git a/src/provider.ts b/src/provider.ts index f35031054..742d7598c 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -51,6 +51,7 @@ export const BuiltInProviders = [ 'strapi', 'strapi5', 'supabase', + 'truocloud', 'twicpics', 'umbraco', 'unsplash', diff --git a/src/runtime/providers/truocloud.ts b/src/runtime/providers/truocloud.ts new file mode 100644 index 000000000..a8afe7ff7 --- /dev/null +++ b/src/runtime/providers/truocloud.ts @@ -0,0 +1,173 @@ +import { joinURL } from 'ufo' +import { createOperationsGenerator } from '../utils/index' +import { defineProvider } from '../utils/provider' + +interface TruoCloudOptions { + /** The delivery endpoint, `https://img.truo.cloud/i/`. */ + baseURL?: string +} + +/** + * Standard modifier name to the service's wire name. + * + * Declared separately so it can be reversed below: a source that is already a + * TruoCloud URL carries wire names (`w`), while modifiers arrive with standard + * names (`width`). Merging them without translating one side emits both, and + * `?w=200&w=800` means whichever the service reads first. + */ +const keyMap = { + width: 'w', + height: 'h', + format: 'f', + quality: 'q', + fit: 'fit', + dpr: 'dpr', + background: 'bg', + rotate: 'ro', + blur: 'blur', + sharpen: 'sharp', + brightness: 'bri', + contrast: 'con', + saturation: 'sat', + gamma: 'gam', + gravity: 'a', + crop: 'crop', + trim: 'trim', + mask: 'mask', + filter: 'filt', + withoutEnlargement: 'we', + lossless: 'll', + progressive: 'il', + frames: 'n', +} as const + +const wireToStandard: Record = Object.fromEntries( + Object.entries(keyMap).map(([standard, wire]) => [wire, standard]), +) + +export const operationsGenerator = createOperationsGenerator({ + keyMap, + valueMap: { + // The service answers `jpg`, and silently ignores a format it does not + // know: an unmapped `jpeg` would return the source format with a 200. + format: { + jpeg: 'jpg', + jpg: 'jpg', + png: 'png', + webp: 'webp', + avif: 'avif', + gif: 'gif', + tiff: 'tiff', + // Negotiates from the `Accept` header, answered with `Vary: Accept`. + auto: 'auto', + }, + }, +}) + +/** + * Percent-encodes each path segment per RFC 3986. + * + * Not `encodeURI`, and not nothing: this path ends up inside a query parameter + * upstream, where a raw `+` means a space and the file is not found. The strict + * form also matches `rawurlencode`, which the CMS-side builders of this + * contract use, so the same file produces the same URL everywhere. + */ +function encodePath(path: string): string { + return path + .replace(/^\/+/, '') + .split('/') + .map(segment => + encodeURIComponent(segment).replace( + /[!'()*]/g, + c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ), + ) + .join('/') +} + +/** + * Sorts parameters by name and restores literal commas. + * + * Every builder of this contract sorts them, and two orderings of one request + * are two CDN cache entries for the same image. The comma matters separately: + * the transformation engine does not decode `%2C`, so an escaped + * `crop=60,30,0,0` is ignored and the image comes back uncropped, with a 200. + */ +function canonicalise(query: string): string { + if (!query) { + return '' + } + return query + .split('&') + .sort((a, b) => (a.split('=')[0]! < b.split('=')[0]! ? -1 : 1)) + .join('&') + .replace(/%2C/g, ',') +} + +/** + * Splits a source that is already a TruoCloud URL. + * + * Without this, a `src` that already points at the CDN is wrapped again into + * `/i//https%3A//img.truo.cloud/i//…` — a URL that works, costs twice + * and is unreadable in a bug report. It is the normal state of a partially + * migrated site. + */ +function unwrap(src: string, baseURL: string) { + const prefix = baseURL.replace(/\/+$/, '') + if (!src.toLowerCase().startsWith(`${prefix.toLowerCase()}/`)) { + return null + } + const rest = src.slice(prefix.length + 1) + const q = rest.indexOf('?') + if (q === -1) { + return { path: rest, carried: {} as Record } + } + + const carried: Record = {} + for (const pair of rest.slice(q + 1).split('&')) { + const [k, v = ''] = pair.split('=') + // A signature covers one exact path and query and cannot be re-derived + // here, so carrying it over would produce a URL that 403s. + if (!k || k === 's' || k === 'exp') { + continue + } + const name = decodeURIComponent(k) + carried[wireToStandard[name] ?? name] = decodeURIComponent(v) + } + return { path: rest.slice(0, q), carried } +} + +/** + * Booleans travel as `1`, and `false` drops the parameter. + * + * `createOperationsGenerator` stringifies `true` as `'true'`, which the service + * accepts — but the other builders of this contract emit `1`, and two spellings + * of one request are two cache entries and two different signatures. + */ +function normaliseModifiers(modifiers: Record = {}) { + const out: Record = {} + for (const [key, value] of Object.entries(modifiers)) { + if (value === false || value === null || value === undefined || value === '') { + continue + } + out[key] = value === true ? 1 : value + } + return out +} + +export default defineProvider({ + getImage: (src, { modifiers, baseURL = 'https://img.truo.cloud' }) => { + const existing = unwrap(src, baseURL) + // The explicit call wins over what was glued to the URL: the caller asking + // now knows more than the markup did. + const merged = { ...(existing?.carried ?? {}), ...normaliseModifiers(modifiers) } + // An already-encoded path is reused verbatim; encoding it again would turn + // `%20` into `%2520`. + const path = existing ? existing.path : encodePath(src) + const query = canonicalise(operationsGenerator(merged)) + + return { + url: joinURL(baseURL, path) + (query ? `?${query}` : ''), + } + }, +}) diff --git a/test/e2e/__snapshots__/truocloud.json5 b/test/e2e/__snapshots__/truocloud.json5 new file mode 100644 index 000000000..a5a599099 --- /dev/null +++ b/test/e2e/__snapshots__/truocloud.json5 @@ -0,0 +1,10 @@ +{ + "requests": [ + "https://img.truo.cloud/i/demo/wikipedia/commons/3/3f/Fronalpstock_big.jpg?f=auto&h=300&w=400", + "https://img.truo.cloud/i/demo/wikipedia/commons/3/3f/Fronalpstock_big.jpg?fit=cover&h=300&w=400", + ], + "sources": [ + "https://img.truo.cloud/i/demo/wikipedia/commons/3/3f/Fronalpstock_big.jpg?fit=cover&h=300&w=400", + "https://img.truo.cloud/i/demo/wikipedia/commons/3/3f/Fronalpstock_big.jpg?f=auto&h=300&w=400", + ], +} \ No newline at end of file diff --git a/test/nuxt/providers.test.ts b/test/nuxt/providers.test.ts index 91f4e2cbf..5814d52dc 100644 --- a/test/nuxt/providers.test.ts +++ b/test/nuxt/providers.test.ts @@ -37,6 +37,7 @@ import storyblok from '../../dist/runtime/providers/storyblok' import strapi from '../../dist/runtime/providers/strapi' import strapi5 from '../../dist/runtime/providers/strapi5' import supabase from '../../dist/runtime/providers/supabase' +import truocloud from '../../dist/runtime/providers/truocloud' import edgeonePages from '../../dist/runtime/providers/edgeonePages' import twicpics from '../../dist/runtime/providers/twicpics' import umbraco from '../../dist/runtime/providers/umbraco' @@ -88,6 +89,27 @@ describe('Providers', () => { url: '/_ipx/_/images/test.png', }) }) + it('truocloud', () => { + const providerOptions = { + baseURL: 'https://img.truo.cloud/i/demo', + } + for (const image of images) { + const [src, modifiers] = image.args + const generated = truocloud().getImage(src, { modifiers, ...providerOptions }, getEmptyContext()) + expect(generated).toMatchObject(image.truocloud) + } + }) + + it('truocloud does not wrap a source that is already one of its own URLs', () => { + // Nuxt re-runs the provider over whatever `src` it is given, and a + // partially migrated site hands it URLs that already point at the CDN. + // Wrapping twice produces a URL that works and costs twice. + const providerOptions = { baseURL: 'https://img.truo.cloud/i/demo' } + const once = truocloud().getImage('/test.png', { modifiers: { width: 200 }, ...providerOptions }, getEmptyContext()) + const twice = truocloud().getImage(once.url, { modifiers: { width: 400 }, ...providerOptions }, getEmptyContext()) + expect(twice).toMatchObject({ url: 'https://img.truo.cloud/i/demo/test.png?w=400' }) + }) + it('aliyun', () => { const providerOptions = { baseURL: '/', diff --git a/test/providers.ts b/test/providers.ts index 1f9673d30..dcfc5eccf 100644 --- a/test/providers.ts +++ b/test/providers.ts @@ -30,6 +30,7 @@ export const images = [ cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png' }, storyblok: { url: 'https://a.storyblok.com/test.png' }, supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png' }, + truocloud: { url: 'https://img.truo.cloud/i/demo/test.png' }, edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png' }, vercel: { url: '/_vercel/image?url=%2Ftest.png&w=1536&q=100' }, wagtail: { url: '329944/original|format-webp|webpquality-70' }, @@ -77,6 +78,7 @@ export const images = [ cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?width=200' }, storyblok: { url: 'https://a.storyblok.com/test.png/m/200x0' }, supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?width=200' }, + truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?w=200' }, edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/200x' }, vercel: { url: '/_vercel/image?url=%2Ftest.png&w=640&q=100' }, wagtail: { url: '329944/width-200|format-webp|webpquality-70' }, @@ -123,6 +125,7 @@ export const images = [ cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?height=200' }, storyblok: { url: 'https://a.storyblok.com/test.png/m/0x200' }, supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?height=200' }, + truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?h=200' }, edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/x200' }, vercel: { url: '/_vercel/image?url=%2Ftest.png&w=1536&q=100' }, wagtail: { url: '329944/height-200|format-webp|webpquality-70' }, @@ -169,6 +172,7 @@ export const images = [ cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?width=200&height=200' }, storyblok: { url: 'https://a.storyblok.com/test.png/m/200x200' }, supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?width=200&height=200' }, + truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?h=200&w=200' }, edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/200x200' }, vercel: { url: '/_vercel/image?url=%2Ftest.png&w=640&q=100' }, wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' }, @@ -215,6 +219,7 @@ export const images = [ cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?width=200&height=200&func=fit' }, storyblok: { url: 'https://a.storyblok.com/test.png/m/fit-contain/200x200' }, supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?width=200&height=200&resize=contain' }, + truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?fit=contain&h=200&w=200' }, edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/200x200' }, vercel: { url: '/_vercel/image?url=%2Ftest.png&w=640&q=100' }, wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' }, @@ -261,6 +266,7 @@ export const images = [ cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?width=200&height=200&func=fit&force_format=jpeg' }, storyblok: { url: 'https://a.storyblok.com/test.png/m/fit-contain/200x200/filters:format(jpeg)' }, supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?width=200&height=200&resize=contain&format=jpeg' }, + truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?f=jpg&fit=contain&h=200&w=200' }, edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/200x200/format/jpg' }, vercel: { url: '/_vercel/image?url=%2Ftest.png&w=640&q=100' }, wagtail: { url: '329944/fill-200x200-c0|format-jpeg|jpegquality-70' },