diff --git a/.gitignore b/.gitignore index a23125d21..5c0b9ddff 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ sw.* .idea .vercel .output + +# Local Netlify folder +.netlify diff --git a/docs/content/3.providers/netlify.md b/docs/content/3.providers/netlify.md index 121707208..bbfe07419 100644 --- a/docs/content/3.providers/netlify.md +++ b/docs/content/3.providers/netlify.md @@ -8,21 +8,72 @@ links: size: xs --- +When deploying your Nuxt applications to [Netlify's composable platform](https://docs.netlify.com/platform/overview/), the image module uses [Netlify Image CDN](https://docs.netlify.com/image-cdn/overview/) to optimize and transform images on demand without impacting build times. Netlify Image CDN also handles content negotiation to use the most efficient image format for the requesting client. + +This provider is automatically enabled in Netlify deployments, and also when running locally using [the Netlify CLI](https://docs.netlify.com/cli/local-development/). + +You can also manually enable this provider. To do so, set the provider to `netlify` or add the following to your Nuxt configuration: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + image: { + provider: 'netlify', + } +}) +``` + +## Local development + +To test image transformations locally, use [Netlify Dev](https://docs.netlify.com/cli/local-development/). This feature of the Netlify CLI runs a local development server that mimics the Netlify production environment, including Netlify Image CDN. + +## Remote images + +To transform a source image hosted on another domain, you must first configure allowed domains in your `netlify.toml` file. + +```toml [netlify.toml] +[images] + remote_images = ["https://my-images.com/.*", "https://animals.more-images.com/[bcr]at/.*"] +``` + +The `remote_images` property accepts an array of regex. If your images are in specific subdomains or directories, you can use regex to allow just those subdomains or directories. + +## Modifiers + +Beyond the [standard properties](https://image.nuxt.com/usage/nuxt-img), you can use the [Netlify Image CDN `position` parameter](https://docs.netlify.com/image-cdn/overview/#position) as a modifier for Nuxt Image. + +```vue + +``` + +## Deprecated Netlify Large Media option + ::callout{color="amber" icon="i-ph-warning-duotone"} -Netlify’s Large Media service is [deprecated](https://answers.netlify.com/t/large-media-feature-deprecated-but-not-removed/100804). If this feature is already enabled, Large Media will continue to work on these sites as usual. New Large Media configuration is not recommended. +Netlify’s Large Media service is [deprecated](https://answers.netlify.com/t/large-media-feature-deprecated-but-not-removed/100804). If this feature is already enabled for your site on Netlify and you have already set `provider: 'netlify'` in your Nuxt configuration, then this will be detected at build time and Large Media continues to work on your site as usual. You can also explicitly enable it by setting `provider: 'netlifyLargeMedia'`. However, new Large Media configuration is not recommended. :: -Netlify offers dynamic image transformation for all JPEG, PNG, and GIF files you have set to be tracked with [Netlify Large Media](https://docs.netlify.com/large-media/overview/). +### Migrate to Netlify Image CDN -::callout -Before setting `provider: 'netlify'`, make sure you have followed the steps to enable [Netlify Large Media](https://docs.netlify.com/large-media/overview/). -:: +To migrate from the deprecated Netlify Large Media option to the more robust Netlify Image CDN option, change `provider: 'netlify'` to `provider: 'netlifyImageCdn'`. This will enable the Netlify Image CDN service, even if large media is enabled on your site. -## Modifiers -In addition to `height` and `width`, the Netlify provider supports the following modifiers: +### Use deprecated Netlify Large Media option + +If you're not ready to migrate to the more robust Netlify Image CDN option, Netlify continues to support dynamic image transformation for all JPEG, PNG, and GIF files you have set to be tracked with [Netlify Large Media](https://docs.netlify.com/large-media/overview/). + +#### Large Media Modifiers + +In addition to `height` and `width`, the deprecated Netlify Large Media provider supports the following modifiers: -### `fit` +##### `fit` * **Default**: `contain` * **Valid options**: `contain` (equivalent to `nf_resize=fit`) and `fill` (equivalent to `nf_resize=smartcrop`) diff --git a/docs/pages/index.vue b/docs/pages/index.vue index c41eba44d..469c61274 100644 --- a/docs/pages/index.vue +++ b/docs/pages/index.vue @@ -16,7 +16,7 @@ useSeoMeta({ const source = ref('npm i @nuxt/image') const { copy, copied } = useClipboard({ source }) -const providers = ['caisy', 'bunny', 'cloudflare', 'cloudimage', 'cloudinary', 'directus', 'edgio', 'fastly', 'glide', 'gumlet', 'hygraph', 'imageengine', 'imagekit', 'imgix', 'ipx', 'netlify', 'prepr', 'prismic', 'sanity', 'storyblok', 'strapi', 'twicpics', 'unsplash', 'uploadcare', 'vercel', 'weserv'] +const providers = ['caisy', 'bunny', 'cloudflare', 'cloudimage', 'cloudinary', 'directus', 'edgio', 'fastly', 'glide', 'gumlet', 'hygraph', 'imageengine', 'imagekit', 'imgix', 'ipx', 'netlify', 'netlifyImageCdn', 'netlifyLargeMedia', 'prepr', 'prismic', 'sanity', 'storyblok', 'strapi', 'twicpics', 'unsplash', 'uploadcare', 'vercel', 'weserv'] // Disabling because svg to png does not work now with SSG // Related issue: https://github.com/unjs/ipx/issues/160 // const img = useImage() diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 0cb4268d6..fc6f3a995 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -74,7 +74,10 @@ export default defineNuxtConfig({ imagekit: { baseURL: 'https://ik.imagekit.io/demo' }, - netlify: { + netlifyImageCdn: { + baseURL: 'https://netlify-photo-gallery.netlify.app/.netlify/images' + }, + netlifyLargeMedia: { baseURL: 'https://netlify-photo-gallery.netlify.app' }, layer0: { diff --git a/playground/providers.ts b/playground/providers.ts index 23ecd3594..22d26bba3 100644 --- a/playground/providers.ts +++ b/playground/providers.ts @@ -333,9 +333,24 @@ export const providers: Provider[] = [ } ] }, - // Netlify { - name: 'netlify', + name: 'netlifyImageCdn', + samples: [ + { + src: '/images/apple.jpg', + width: 100, + height: 100, + fit: 'cover' + }, + { + src: '/images/apple.jpg', + width: 400, + height: 300 + } + ] + }, + { + name: 'netlifyLargeMedia', samples: [ { src: '/images/apple.jpg', diff --git a/src/provider.ts b/src/provider.ts index 7eab191bf..74ac4f65d 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -32,6 +32,8 @@ const BuiltInProviders = [ 'ipxStatic', 'layer0', 'netlify', + 'netlifyLargeMedia', + 'netlifyImageCdn', 'prepr', 'none', 'prismic', @@ -118,6 +120,10 @@ export async function resolveProvider (_nuxt: any, key: string, input: InputProv input.provider = input.name } + if (input.provider in normalizableProviders) { + input.provider = normalizableProviders[input.provider]!() + } + const resolver = createResolver(import.meta.url) input.provider = BuiltInProviders.includes(input.provider as ImageProviderName) ? await resolver.resolve('./runtime/providers/' + input.provider) @@ -136,7 +142,14 @@ export async function resolveProvider (_nuxt: any, key: string, input: InputProv const autodetectableProviders: Partial> = { vercel: 'vercel', - aws_amplify: 'awsAmplify' + aws_amplify: 'awsAmplify', + netlify: 'netlify' +} + +const normalizableProviders: Partial ImageProviderName>> = { + netlify: () => { + return process.env.NETLIFY_LFS_ORIGIN_URL ? 'netlifyLargeMedia' : 'netlifyImageCdn' + } } export function detectProvider (userInput: string = '') { diff --git a/src/runtime/providers/netlifyImageCdn.ts b/src/runtime/providers/netlifyImageCdn.ts new file mode 100644 index 000000000..3b8426aaa --- /dev/null +++ b/src/runtime/providers/netlifyImageCdn.ts @@ -0,0 +1,53 @@ +import { encodeQueryItem } from 'ufo' +import type { ProviderGetImage } from '../../types' +import { createOperationsGenerator } from '#image' + +// https://docs.netlify.com/image-cdn/overview/ +export const operationsGenerator = createOperationsGenerator({ + keyMap: { + width: 'w', + height: 'h', + format: 'fm', + quality: 'q', + position: 'position', + fit: 'fit' + }, + valueMap: { + fit: { + fill: 'fill', + cover: 'cover', + contain: 'contain' + }, + format: { + avif: 'avif', + gif: 'gif', + jpg: 'jpg', + png: 'png', + webp: 'webp' + }, + position: { + top: 'top', + right: 'right', + bottom: 'bottom', + left: 'left', + center: 'center' + } + }, + joinWith: '&', + formatter: (key, value) => encodeQueryItem(key, value) +}) + +export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL } = {}) => { + const mods: Record = { ...modifiers } + mods.url = src + if (modifiers.width) { + mods.width = modifiers.width.toString() + } + if (modifiers.height) { + mods.height = modifiers.height.toString() + } + const operations = operationsGenerator(mods) + return { + url: `${baseURL || '/.netlify/images'}?${operations}` + } +} diff --git a/src/runtime/providers/netlify.ts b/src/runtime/providers/netlifyLargeMedia.ts similarity index 93% rename from src/runtime/providers/netlify.ts rename to src/runtime/providers/netlifyLargeMedia.ts index 36b84c13b..412ab731c 100644 --- a/src/runtime/providers/netlify.ts +++ b/src/runtime/providers/netlifyLargeMedia.ts @@ -1,4 +1,4 @@ -import { joinURL } from 'ufo' +import { encodeQueryItem, joinURL } from 'ufo' import type { ProviderGetImage } from '../../types' import { createOperationsGenerator } from '#image' @@ -15,7 +15,7 @@ export const operationsGenerator = createOperationsGenerator({ } }, joinWith: '&', - formatter: (key, value) => `${key}=${value}` + formatter: (key, value) => encodeQueryItem(key, value) }) const isDev = process.env.NODE_ENV === 'development' diff --git a/test/e2e/__snapshots__/no-ssr.test.ts.snap b/test/e2e/__snapshots__/no-ssr.test.ts.snap index 333d811ff..28cd43881 100644 --- a/test/e2e/__snapshots__/no-ssr.test.ts.snap +++ b/test/e2e/__snapshots__/no-ssr.test.ts.snap @@ -286,14 +286,28 @@ exports[`browser (ssr: false) > layer0 should render images 2`] = ` ] `; -exports[`browser (ssr: false) > netlify should render images 1`] = ` +exports[`browser (ssr: false) > netlifyImageCdn should render images 1`] = ` +[ + "https://netlify-photo-gallery.netlify.app/.netlify/images?w=100&h=100&fit=cover&url=%2Fimages%2Fapple.jpg", + "https://netlify-photo-gallery.netlify.app/.netlify/images?w=400&h=300&url=%2Fimages%2Fapple.jpg", +] +`; + +exports[`browser (ssr: false) > netlifyImageCdn should render images 2`] = ` +[ + "https://netlify-photo-gallery.netlify.app/.netlify/images?w=100&h=100&fit=cover&url=%2Fimages%2Fapple.jpg", + "https://netlify-photo-gallery.netlify.app/.netlify/images?w=400&h=300&url=%2Fimages%2Fapple.jpg", +] +`; + +exports[`browser (ssr: false) > netlifyLargeMedia should render images 1`] = ` [ "https://netlify-photo-gallery.netlify.app/images/apple.jpg?w=101&nf_resize=fit", "https://netlify-photo-gallery.netlify.app/images/apple.jpg?w=200&h=200&nf_resize=smartcrop", ] `; -exports[`browser (ssr: false) > netlify should render images 2`] = ` +exports[`browser (ssr: false) > netlifyLargeMedia should render images 2`] = ` [ "https://netlify-photo-gallery.netlify.app/images/apple.jpg?w=101&nf_resize=fit", "https://netlify-photo-gallery.netlify.app/images/apple.jpg?w=200&h=200&nf_resize=smartcrop", diff --git a/test/e2e/__snapshots__/ssr.test.ts.snap b/test/e2e/__snapshots__/ssr.test.ts.snap index 03d4e1e1f..d23cd1944 100644 --- a/test/e2e/__snapshots__/ssr.test.ts.snap +++ b/test/e2e/__snapshots__/ssr.test.ts.snap @@ -286,14 +286,28 @@ exports[`browser (ssr: true) > layer0 should render images 2`] = ` ] `; -exports[`browser (ssr: true) > netlify should render images 1`] = ` +exports[`browser (ssr: true) > netlifyImageCdn should render images 1`] = ` +[ + "https://netlify-photo-gallery.netlify.app/.netlify/images?w=100&h=100&fit=cover&url=%2Fimages%2Fapple.jpg", + "https://netlify-photo-gallery.netlify.app/.netlify/images?w=400&h=300&url=%2Fimages%2Fapple.jpg", +] +`; + +exports[`browser (ssr: true) > netlifyImageCdn should render images 2`] = ` +[ + "https://netlify-photo-gallery.netlify.app/.netlify/images?w=400&h=300&url=%2Fimages%2Fapple.jpg", + "https://netlify-photo-gallery.netlify.app/.netlify/images?w=100&h=100&fit=cover&url=%2Fimages%2Fapple.jpg", +] +`; + +exports[`browser (ssr: true) > netlifyLargeMedia should render images 1`] = ` [ "https://netlify-photo-gallery.netlify.app/images/apple.jpg?w=101&nf_resize=fit", "https://netlify-photo-gallery.netlify.app/images/apple.jpg?w=200&h=200&nf_resize=smartcrop", ] `; -exports[`browser (ssr: true) > netlify should render images 2`] = ` +exports[`browser (ssr: true) > netlifyLargeMedia should render images 2`] = ` [ "https://netlify-photo-gallery.netlify.app/images/apple.jpg?w=101&nf_resize=fit", "https://netlify-photo-gallery.netlify.app/images/apple.jpg?w=200&h=200&nf_resize=smartcrop", diff --git a/test/providers.ts b/test/providers.ts index 44f63d708..acb5eb2a1 100644 --- a/test/providers.ts +++ b/test/providers.ts @@ -15,7 +15,8 @@ export const images = [ imageengine: { url: '/test.png' }, unsplash: { url: '/test.png' }, imagekit: { url: '/test.png' }, - netlify: { url: '/test.png' }, + netlifyImageCdn: { url: '/.netlify/images?url=%2Ftest.png' }, + netlifyLargeMedia: { url: '/test.png' }, prepr: { url: 'https://projectName.stream.prepr.io/image-test-300x450-png' }, prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=100&h=100' }, sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?auto=format' }, @@ -49,7 +50,8 @@ export const images = [ imageengine: { url: '/test.png?imgeng=/w_200' }, unsplash: { url: '/test.png?w=200' }, imagekit: { url: '/test.png?tr=w-200' }, - netlify: { url: '/test.png?w=200&nf_resize=fit' }, + netlifyImageCdn: { url: '/.netlify/images?w=200&url=%2Ftest.png' }, + netlifyLargeMedia: { url: '/test.png?w=200&nf_resize=fit' }, prepr: { url: 'https://projectName.stream.prepr.io/w_200/image-test-300x450-png' }, prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=100' }, sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&auto=format' }, @@ -83,7 +85,8 @@ export const images = [ imageengine: { url: '/test.png?imgeng=/h_200' }, unsplash: { url: '/test.png?h=200' }, imagekit: { url: '/test.png?tr=h-200' }, - netlify: { url: '/test.png?h=200&nf_resize=fit' }, + netlifyImageCdn: { url: '/.netlify/images?h=200&url=%2Ftest.png' }, + netlifyLargeMedia: { url: '/test.png?h=200&nf_resize=fit' }, prepr: { url: 'https://projectName.stream.prepr.io/h_200/image-test-300x450-png' }, prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=100&h=200' }, sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?h=200&auto=format' }, @@ -117,7 +120,8 @@ export const images = [ imageengine: { url: '/test.png?imgeng=/w_200/h_200' }, unsplash: { url: '/test.png?w=200&h=200' }, imagekit: { url: '/test.png?tr=w-200,h-200' }, - netlify: { url: '/test.png?w=200&h=200&nf_resize=fit' }, + netlifyImageCdn: { url: '/.netlify/images?w=200&h=200&url=%2Ftest.png' }, + netlifyLargeMedia: { url: '/test.png?w=200&h=200&nf_resize=fit' }, prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=200' }, prepr: { url: 'https://projectName.stream.prepr.io/w_200,h_200/image-test-300x450-png' }, sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&h=200&auto=format' }, @@ -151,7 +155,8 @@ export const images = [ imageengine: { url: '/test.png?imgeng=/w_200/h_200/m_letterbox' }, unsplash: { url: '/test.png?w=200&h=200&fit=fill' }, imagekit: { url: '/test.png?tr=w-200,h-200,cm-pad_resize' }, - netlify: { url: '/test.png?w=200&h=200&nf_resize=fit' }, + netlifyImageCdn: { url: '/.netlify/images?w=200&h=200&fit=contain&url=%2Ftest.png' }, + netlifyLargeMedia: { url: '/test.png?w=200&h=200&nf_resize=fit' }, prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=200&fit=fill' }, prepr: { url: 'https://projectName.stream.prepr.io/w_200,h_200,fit_contain/image-test-300x450-png' }, sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&h=200&fit=fill&auto=format&bg=ffffff' }, @@ -185,7 +190,8 @@ export const images = [ imageengine: { url: '/test.png?imgeng=/w_200/h_200/m_letterbox/f_jpg' }, unsplash: { url: '/test.png?w=200&h=200&fit=fill&fm=jpeg' }, imagekit: { url: '/test.png?tr=w-200,h-200,cm-pad_resize,f-jpeg' }, - netlify: { url: '/test.png?w=200&h=200&nf_resize=fit' }, + netlifyImageCdn: { url: '/.netlify/images?w=200&h=200&fit=contain&fm=jpeg&url=%2Ftest.png' }, + netlifyLargeMedia: { url: '/test.png?w=200&h=200&nf_resize=fit' }, prismic: { url: '/test.png?auto=compress,format&rect=0,0,200,200&w=200&h=200&fit=fill&fm=jpeg' }, sanity: { url: 'https://cdn.sanity.io/images/projectid/production/test-300x450.png?w=200&h=200&fit=fill&fm=jpg&bg=ffffff' }, prepr: { url: 'https://projectName.stream.prepr.io/w_200,h_200,fit_contain,format_jpg/image-test-300x450-png' }, diff --git a/test/unit/providers.test.ts b/test/unit/providers.test.ts index c924dac2b..598a821ab 100644 --- a/test/unit/providers.test.ts +++ b/test/unit/providers.test.ts @@ -21,7 +21,8 @@ import * as gumlet from '#image/providers/gumlet' import * as imageengine from '#image/providers/imageengine' import * as unsplash from '#image/providers/unsplash' import * as imagekit from '#image/providers/imagekit' -import * as netlify from '#image/providers/netlify' +import * as netlifyImageCdn from '#image/providers/netlifyImageCdn' +import * as netlifyLargeMedia from '#image/providers/netlifyLargeMedia' import * as prismic from '#image/providers/prismic' import * as sanity from '#image/providers/sanity' import * as contentful from '#image/providers/contentful' @@ -288,15 +289,27 @@ describe('Providers', () => { } }) - it('netlify', () => { + it('netlifyImageCdn', () => { const providerOptions = { baseURL: '' } for (const image of images) { const [src, modifiers] = image.args - const generated = netlify.getImage(src, { modifiers: { ...modifiers }, ...providerOptions }, emptyContext) - expect(generated).toMatchObject(image.netlify) + const generated = netlifyImageCdn.getImage(src, { modifiers: { ...modifiers }, ...providerOptions }, emptyContext) + expect(generated).toMatchObject(image.netlifyImageCdn) + } + }) + + it('netlifyLargeMedia', () => { + const providerOptions = { + baseURL: '' + } + + for (const image of images) { + const [src, modifiers] = image.args + const generated = netlifyLargeMedia.getImage(src, { modifiers: { ...modifiers }, ...providerOptions }, emptyContext) + expect(generated).toMatchObject(image.netlifyLargeMedia) } })