diff --git a/docs/content/3.providers/flyimg.md b/docs/content/3.providers/flyimg.md new file mode 100644 index 000000000..d0432e6b5 --- /dev/null +++ b/docs/content/3.providers/flyimg.md @@ -0,0 +1,94 @@ +````mdc +--- +title: Flyimg +description: Nuxt Image has first class integration with Flyimg. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/image/blob/main/src/runtime/providers/flyimg.ts + size: xs +--- + +Integration between [Flyimg](https://flyimg.io) and the image module. + +[Flyimg](https://github.com/flyimg/flyimg) is a self-hosted, open-source image processing server built on ImageMagick. It accepts images from a source URL and applies transformations on the fly, caching results for subsequent requests. A managed SaaS version is also available at [flyimg.io](https://flyimg.io). + +## Setup + +### Self‑hosted + +`baseURL` points to your **Flyimg server**. If you use relative image paths (e.g. ``), also set `sourceURL` to your **website's public URL** so that Flyimg can fetch the source image — it requires an absolute URL. Absolute `src` values (e.g. from a CDN) are passed through as-is and `sourceURL` is ignored for those. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + image: { + flyimg: { + // URL of your Flyimg server + baseURL: 'https://flyimg.example.com', + // Public URL of your website — only needed for relative image paths + sourceURL: 'https://www.example.com', + } + } +}) +``` + +::callout{type="warning"} +**nginx users:** nginx's default `merge_slashes on` setting collapses consecutive slashes in URLs, which corrupts the embedded `https://` in Flyimg request paths (e.g. `https://flyimg.example.com/upload/-/https://www.example.com/photo.jpg`). Add `merge_slashes off;` to the `server` block of your nginx config when reverse-proxying requests to Flyimg. +:: + +### Flyimg SaaS + +After [creating an instance](https://flyimg.io/documentation) you receive a unique subdomain (e.g. `img-abc123.flyimg.io`): + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + image: { + flyimg: { + // Unique subdomain provided by Flyimg SaaS + baseURL: 'https://img-abc123.flyimg.io', + // Public URL of your website — only needed for relative image paths + sourceURL: 'https://www.example.com', + } + } +}) +``` + +## Options + +| Option | Default | Description | +|---|---|---| +| `baseURL` | — | **Required.** URL of your Flyimg server or SaaS instance. | +| `sourceURL` | — | Public base URL of your website. Only used for relative image paths — prefixed to make them absolute before passing to Flyimg. Absolute `src` values are unaffected. | +| `processType` | `upload` | Flyimg process type: `upload` (serve the image) or `path` (return the path as text). | + +## Modifiers + +In addition to the standard `width`, `height`, `quality`, `format`, and `fit` modifiers, the Flyimg provider exposes the full range of [Flyimg URL options](https://docs.flyimg.io/url-options/). + +### `fit` + +| Nuxt Image value | Flyimg behaviour | +|---|---| +| `contain` / `inside` | Scale to fit within the target rectangle preserving aspect ratio (Flyimg default) | +| `cover` | Crop to fill the rectangle (`c_1`) | +| `fill` | Stretch to fill without preserving aspect ratio (`par_0`) | +| `outside` | ⚠️ Unsupported — ignored (dev-time warning emitted) | + +### Additional Flyimg modifiers + +```vue + +``` + +Refer to the [Flyimg URL options documentation](https://docs.flyimg.io/url-options/) for the full list of supported parameters. +```` diff --git a/playground/app/providers.ts b/playground/app/providers.ts index 6417d39fb..d3e595088 100644 --- a/playground/app/providers.ts +++ b/playground/app/providers.ts @@ -366,6 +366,31 @@ export const providers: Provider[] = [ }, ], }, + // Flyimg + { + name: 'flyimg', + samples: [ + { + src: 'https://picsum.photos/seed/nuxtimage/800/600', + width: 500, + height: 375, + quality: 80, + format: 'webp', + }, + { + src: 'https://picsum.photos/seed/nuxtimage/800/600', + width: 300, + height: 200, + fit: 'cover', + quality: 75, + }, + { + src: 'https://picsum.photos/seed/nuxtimage/800/600', + width: 200, + fit: 'contain', + }, + ], + }, // ImageKit { name: 'imagekit', diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 9e9db4485..c83909a01 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -72,6 +72,10 @@ export default defineNuxtConfig({ filerobot: { baseURL: 'https://fesrinkeb.filerobot.com/', }, + flyimg: { + baseURL: 'https://demo.flyimg.io', + sourceURL: 'https://picsum.photos', + }, github: {}, glide: { baseURL: 'https://glide.herokuapp.com/1.0/', diff --git a/src/provider.ts b/src/provider.ts index 1ef7bfcff..817c721c6 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -25,6 +25,7 @@ export const BuiltInProviders = [ 'directus', 'fastly', 'filerobot', + 'flyimg', 'github', 'glide', 'gumlet', diff --git a/src/runtime/providers/flyimg.ts b/src/runtime/providers/flyimg.ts new file mode 100644 index 000000000..ecd143c7c --- /dev/null +++ b/src/runtime/providers/flyimg.ts @@ -0,0 +1,229 @@ +// https://docs.flyimg.io/url-options/ + +import { joinURL, hasProtocol } from 'ufo' +import { createOperationsGenerator } from '../utils/index' +import { defineProvider } from '../utils/provider' + +/** + * Flyimg URL format: + * https://flyimg.example.com/{processType}/{image_options}/{path_to_image} + * + * Example: + * https://demo.flyimg.io/upload/w_300,h_200,q_85/https://example.com/image.jpg + */ +const operationsGenerator = createOperationsGenerator({ + keyMap: { + // Core dimensions + width: 'w', + height: 'h', + quality: 'q', + format: 'o', + rotate: 'r', + + // Cropping + crop: 'c', + gravity: 'g', + + // WebP + webpLossless: 'webpl', + webpMethod: 'webpm', + + // JPEG XL + jxlEffort: 'jxlef', + jxlDecodingSpeed: 'jxlds', + + // Cache + refresh: 'rf', + version: 'v', + + // Text / Watermark + text: 't', + textColor: 'tc', + textSize: 'ts', + textBackground: 'tbg', + + // Image Processing + background: 'bg', + strip: 'st', + autoOrient: 'ao', + resize: 'rz', + mozjpeg: 'moz', + unsharp: 'unsh', + sharpen: 'sh', + blur: 'blr', + filter: 'f', + scale: 'sc', + samplingFactor: 'sf', + preserveAspectRatio: 'par', + preserveNaturalSize: 'pns', + + // Advanced + faceCrop: 'fc', + faceCropPosition: 'fcp', + faceBlur: 'fb', + smartCrop: 'smc', + colorspace: 'clsp', + monochrome: 'mnchr', + + // PDF + pdfPage: 'pdfp', + density: 'dnst', + + // Video + videoTime: 'tm', + + // Extract + extract: 'e', + extractTopX: 'p1x', + extractTopY: 'p1y', + extractBottomX: 'p2x', + extractBottomY: 'p2y', + + // Other + extent: 'ett', + gifFrame: 'gf', + }, + valueMap: { + // Booleans become 0 / 1 + crop: Number, + webpLossless: Number, + refresh: Number, + autoOrient: Number, + resize: Number, + scale: Number, + faceCrop: Number, + faceBlur: Number, + smartCrop: Number, + monochrome: Number, + extract: Number, + // Inverted-defaults (strip/mozjpeg/par/pns default ON on the server; + // we only emit them when explicitly set to false — see getImage pre-processing) + strip: Number, + mozjpeg: Number, + preserveAspectRatio: Number, + preserveNaturalSize: Number, + // Encode colours so # does not break the URL path segment + background: (value: string) => value.startsWith('#') ? value.replace('#', '%23') : value, + textColor: (value: string) => value.startsWith('#') ? value.replace('#', '%23') : value, + textBackground: (value: string) => value.startsWith('#') ? value.replace('#', '%23') : value, + // Encode text watermarks + text: (value: string) => encodeURIComponent(value), + }, + joinWith: ',', + formatter: (key, value) => `${key}_${value}`, +}) + +interface FlyimgOptions { + /** + * Base URL of the Flyimg server. + * + * For the official Flyimg SaaS each instance gets a unique subdomain: + * `https://img-abc123.flyimg.io` + * + * For self-hosted instances use the URL of your deployment, + * e.g. `https://images.example.com`. + */ + baseURL: string + + /** + * Public base URL of your website. + * + * Only applied to **relative** image paths (e.g. `/images/photo.jpg`) — + * the value is prepended to produce an absolute URL that Flyimg can fetch. + * Absolute `src` values (e.g. from a CDN) are passed through unchanged and + * this option has no effect for those. + * + * Example: `https://www.example.com` + */ + sourceURL?: string + + /** + * Flyimg process type. + * + * - `upload` (default): fetch, transform, cache and serve the image. + * - `path`: same as upload but returns the path to the cached image as a + * plain-text response body instead of serving the image directly. + * + * @default 'upload' + */ + processType?: 'upload' | 'path' +} + +export default defineProvider({ + getImage: (src, options) => { + const { + modifiers: rawModifiers = {}, + baseURL, + sourceURL, + processType = 'upload', + } = options + + if (import.meta.dev && !baseURL) { + console.warn('[nuxt] [image] [flyimg] `baseURL` is required. Set it in your nuxt.config under `image.flyimg.baseURL`.') + } + + // --- fit → Flyimg flags ------------------------------------------------ + const { + fit, + strip, + mozjpeg, + preserveAspectRatio, + preserveNaturalSize, + ...rest + } = rawModifiers as Record + + const modifiers: Partial> = { ...rest } as Partial> + + switch (fit) { + case 'cover': + // Crop to fill the target rectangle (Flyimg: c_1) + if (!modifiers.crop) modifiers.crop = true + break + case 'fill': + // Stretch to fill — disable aspect-ratio preservation (Flyimg: par_0) + if (preserveAspectRatio !== false) modifiers.preserveAspectRatio = false + break + case 'contain': + case 'inside': + // Flyimg default behaviour when width + height are given — no extra flags needed + break + case 'outside': + if (import.meta.dev) { + console.warn('[nuxt] [image] [flyimg] fit="outside" is not supported by Flyimg and will be ignored.') + } + break + } + + // --- Inverted-defaults -------------------------------------------------- + // strip / mozjpeg / preserveAspectRatio / preserveNaturalSize default to + // 1 (enabled) in Flyimg, so we only need to emit them when explicitly + // disabled. Treat boolean false, numeric 0, and string '0' as opt-out. + const isDisabled = (v: unknown) => v === false || v === 0 || v === '0' + if (strip != null && isDisabled(strip)) modifiers.strip = false + if (mozjpeg != null && isDisabled(mozjpeg)) modifiers.mozjpeg = false + if (preserveAspectRatio != null && isDisabled(preserveAspectRatio)) modifiers.preserveAspectRatio = false + if (preserveNaturalSize != null && isDisabled(preserveNaturalSize)) modifiers.preserveNaturalSize = false + + // --- Resolve image URL ------------------------------------------------- + // Flyimg needs an absolute source URL. If src is relative and sourceURL is + // configured, make it absolute. + if (import.meta.dev && !hasProtocol(src) && !sourceURL) { + console.warn('[nuxt] [image] [flyimg] `src` is a relative path but `sourceURL` is not configured. Flyimg requires an absolute source URL. Set `image.flyimg.sourceURL` in your nuxt.config.') + } + const imageUrl = !hasProtocol(src) && sourceURL + ? joinURL(sourceURL, src) + : src + + // --- Build Flyimg URL -------------------------------------------------- + const operations = operationsGenerator(modifiers as Partial>) + const imageOptions = operations || '-' + + // Construct the path manually so that an absolute imageUrl is treated as a + // literal path segment rather than a new base by ufo's joinURL. + // Note: if reverse-proxied via nginx, set `merge_slashes off` to prevent + // nginx from collapsing the `https://` embedded in this path. See docs. + return { + url: joinURL(baseURL || '/', `${processType}/${imageOptions}/${imageUrl}`), + } + }, +}) diff --git a/test/e2e/__snapshots__/flyimg.json5 b/test/e2e/__snapshots__/flyimg.json5 new file mode 100644 index 000000000..02594e4e8 --- /dev/null +++ b/test/e2e/__snapshots__/flyimg.json5 @@ -0,0 +1,12 @@ +{ + "requests": [ + "https://demo.flyimg.io/upload/w_200/https://picsum.photos/seed/nuxtimage/800/600", + "https://demo.flyimg.io/upload/w_300,h_200,q_75,c_1/https://picsum.photos/seed/nuxtimage/800/600", + "https://demo.flyimg.io/upload/w_500,h_375,o_webp,q_80/https://picsum.photos/seed/nuxtimage/800/600", + ], + "sources": [ + "https://demo.flyimg.io/upload/w_500,h_375,o_webp,q_80/https://picsum.photos/seed/nuxtimage/800/600", + "https://demo.flyimg.io/upload/w_300,h_200,q_75,c_1/https://picsum.photos/seed/nuxtimage/800/600", + "https://demo.flyimg.io/upload/w_200/https://picsum.photos/seed/nuxtimage/800/600", + ], +} \ No newline at end of file diff --git a/test/nuxt/providers.test.ts b/test/nuxt/providers.test.ts index a867866a3..d4bd550b9 100644 --- a/test/nuxt/providers.test.ts +++ b/test/nuxt/providers.test.ts @@ -37,6 +37,7 @@ import wagtail from '../../dist/runtime/providers/wagtail' import uploadcare from '../../dist/runtime/providers/uploadcare' import sirv from '../../dist/runtime/providers/sirv' import hygraph from '../../dist/runtime/providers/hygraph' +import flyimg from '../../dist/runtime/providers/flyimg' const emptyContext = { options: { @@ -718,6 +719,27 @@ describe('Providers', () => { } }) + it('flyimg', () => { + const providerOptions = { + baseURL: 'https://demo.flyimg.io', + sourceURL: 'https://my-website.com', + } + + for (const image of images) { + const [src, modifiers] = image.args + const generated = flyimg().getImage(src, { modifiers, ...providerOptions }, emptyContext) + expect(generated).toMatchObject(image.flyimg) + } + + // fit: 'cover' → c_1 flag + expect(flyimg().getImage('/test.png', { modifiers: { width: 200, height: 200, fit: 'cover' }, ...providerOptions }, emptyContext)) + .toMatchObject({ url: 'https://demo.flyimg.io/upload/w_200,h_200,c_1/https://my-website.com/test.png' }) + + // fit: 'fill' → par_0 flag + expect(flyimg().getImage('/test.png', { modifiers: { width: 200, height: 200, fit: 'fill' }, ...providerOptions }, emptyContext)) + .toMatchObject({ url: 'https://demo.flyimg.io/upload/w_200,h_200,par_0/https://my-website.com/test.png' }) + }) + it('weserv', () => { const providerOptions = { baseURL: 'https://my-website.com/', diff --git a/test/providers.ts b/test/providers.ts index 75ec56e7c..545add86b 100644 --- a/test/providers.ts +++ b/test/providers.ts @@ -33,6 +33,7 @@ export const images = [ directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4' }, uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/' }, weserv: { url: 'https://wsrv.nl/?filename=test.png&we&url=https%3A%2F%2Fmy-website.com%2Ftest.png' }, + flyimg: { url: 'https://demo.flyimg.io/upload/-/https://my-website.com/test.png' }, sirv: { url: 'https://demo.sirv.com/test.png' }, hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/auto_image/cltsrex89477t08unlckqx9ue' }, caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg' }, @@ -74,6 +75,7 @@ export const images = [ directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200' }, uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x/' }, weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&url=https%3A%2F%2Fmy-website.com%2Ftest.png' }, + flyimg: { url: 'https://demo.flyimg.io/upload/w_200/https://my-website.com/test.png' }, sirv: { url: 'https://demo.sirv.com/test.png?w=200' }, hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200/auto_image/cltsrex89477t08unlckqx9ue' }, caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200' }, @@ -114,6 +116,7 @@ export const images = [ directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?height=200' }, uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/x200/' }, weserv: { url: 'https://wsrv.nl/?filename=test.png&we&h=200&url=https%3A%2F%2Fmy-website.com%2Ftest.png' }, + flyimg: { url: 'https://demo.flyimg.io/upload/h_200/https://my-website.com/test.png' }, sirv: { url: 'https://demo.sirv.com/test.png?h=200' }, hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=height:200/auto_image/cltsrex89477t08unlckqx9ue' }, caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?h=200' }, @@ -154,6 +157,7 @@ export const images = [ directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200' }, uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/' }, weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&url=https%3A%2F%2Fmy-website.com%2Ftest.png' }, + flyimg: { url: 'https://demo.flyimg.io/upload/w_200,h_200/https://my-website.com/test.png' }, sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200' }, hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200,height:200/auto_image/cltsrex89477t08unlckqx9ue' }, caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200&h=200' }, @@ -194,6 +198,7 @@ export const images = [ directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain' }, uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/-/stretch/off/' }, weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&fit=contain&url=https%3A%2F%2Fmy-website.com%2Ftest.png' }, + flyimg: { url: 'https://demo.flyimg.io/upload/w_200,h_200/https://my-website.com/test.png' }, sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200&scale.option=fit' }, hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200,height:200,fit:max/auto_image/cltsrex89477t08unlckqx9ue' }, caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200&h=200' }, @@ -234,6 +239,7 @@ export const images = [ directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain&format=jpg' }, uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/format/jpeg/-/resize/200x200/-/stretch/off/' }, weserv: { url: 'https://wsrv.nl/?filename=test.png&we&w=200&h=200&fit=contain&output=jpg&url=https%3A%2F%2Fmy-website.com%2Ftest.png' }, + flyimg: { url: 'https://demo.flyimg.io/upload/w_200,h_200,o_jpeg/https://my-website.com/test.png' }, sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200&scale.option=fit&format=jpg' }, hygraph: { url: 'https://eu-central-1-shared-euc1-02.graphassets.com/cltsj3mii0pvd07vwb5cyh1ig/resize=width:200,height:200,fit:max/output=format:jpeg/cltsrex89477t08unlckqx9ue' }, caisy: { url: 'https://assets.caisy.io/assets/b76210be-a043-4989-98df-ecaf6c6e68d8/056c27e2-81f5-4cd3-b728-cef181dfe7dc/d83ea6f0-f90a-462c-aebd-b8bc615fdce0pexelsmiguelapadrinan1591056.jpg?w=200&h=200' },