From 7102050ec74482467a65d077601177e8cac5876c Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 21 Oct 2022 15:31:53 -0400 Subject: [PATCH] Add support for `images.loaderFile` config (#41585) This PR adds a new configure property, `images.loaderFile` that allow you to define a path to a file with an exported image loader function. This is useful when migrating from `next/legacy/image` to `next/image` because it lets you configure the loader for every instance of `next/image` once, similar to the legacy "built-in loaders". --- docs/api-reference/next/image.md | 25 +++ docs/basic-features/image-optimization.md | 8 +- errors/invalid-images-config.md | 2 + packages/next/build/webpack-config.ts | 6 + packages/next/client/image.tsx | 99 +++--------- packages/next/server/config-schema.ts | 4 + packages/next/server/config.ts | 32 +++- packages/next/shared/lib/image-config.ts | 8 +- packages/next/shared/lib/image-loader.ts | 65 ++++++++ .../export-image-loader-legacy/next.config.js | 2 + .../export-image-loader-legacy/pages/index.js | 10 ++ .../test/index.test.js | 142 ++++++++++++++++++ .../export-image-loader/dummy-loader.js | 3 + .../export-image-loader/test/index.test.js | 61 ++++++++ .../image-optimizer/test/index.test.ts | 50 ++++++ .../loader-config/dummy-loader.js | 3 + .../loader-config/next.config.js | 6 + .../loader-config/pages/index.js | 35 +++++ .../loader-config/public/logo.png | Bin 0 -> 1545 bytes .../loader-config/test/index.test.ts | 76 ++++++++++ 20 files changed, 549 insertions(+), 88 deletions(-) create mode 100644 packages/next/shared/lib/image-loader.ts create mode 100644 test/integration/export-image-loader-legacy/next.config.js create mode 100644 test/integration/export-image-loader-legacy/pages/index.js create mode 100644 test/integration/export-image-loader-legacy/test/index.test.js create mode 100644 test/integration/export-image-loader/dummy-loader.js create mode 100644 test/integration/next-image-new/loader-config/dummy-loader.js create mode 100644 test/integration/next-image-new/loader-config/next.config.js create mode 100644 test/integration/next-image-new/loader-config/pages/index.js create mode 100644 test/integration/next-image-new/loader-config/public/logo.png create mode 100644 test/integration/next-image-new/loader-config/test/index.test.ts diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index f81736ac2fc52..508e3e358896f 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -111,6 +111,8 @@ const MyImage = (props) => { } ``` +Alternatively, you can use the [loaderFile](#loader-configuration) configuration in next.config.js to configure every instance of `next/image` in your application, without passing a prop. + ### fill A boolean that causes the image to fill the parent element instead of setting [`width`](#width) and [`height`](#height). @@ -343,6 +345,29 @@ module.exports = { } ``` +### Loader Configuration + +If you want to use a cloud provider to optimize images instead of using the Next.js built-in Image Optimization API, you can configure the `loaderFile` in your `next.config.js` like the following: + +```js +module.exports = { + images: { + loader: 'custom', + loaderFile: './my/image/loader.js', + }, +} +``` + +This must point to a file relative to the root of your Next.js application. The file must export a default function that returns a string, for example: + +```js +export default function myImageLoader({ src, width, quality }) { + return `https://example.com/${src}?w=${width}&q=${quality || 75}` +} +``` + +Alternatively, you can use the [`loader` prop](#loader) to configure each instance of `next/image`. + ## Advanced The following configuration is for advanced use cases and is usually not necessary. If you choose to configure the properties below, you will override any changes to the Next.js defaults in future updates. diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 355d8c26260ba..8f28cd14b1d23 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -99,13 +99,13 @@ To protect your application from malicious users, you must define a list of remo ### Loaders -Note that in the [example earlier](#remote-images), a partial URL (`"/me.png"`) is provided for a remote image. This is possible because of the `next/image` [loader](/docs/api-reference/next/image.md#loader) architecture. +Note that in the [example earlier](#remote-images), a partial URL (`"/me.png"`) is provided for a remote image. This is possible because of the loader architecture. A loader is a function that generates the URLs for your image. It modifies the provided `src`, and generates multiple URLs to request the image at different sizes. These multiple URLs are used in the automatic [srcset](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset) generation, so that visitors to your site will be served an image that is the right size for their viewport. -The default loader for Next.js applications uses the built-in Image Optimization API, which optimizes images from anywhere on the web, and then serves them directly from the Next.js web server. If you would like to serve your images directly from a CDN or image server, you can use one of the [built-in loaders](/docs/api-reference/next/image.md#built-in-loaders) or write your own with a few lines of JavaScript. +The default loader for Next.js applications uses the built-in Image Optimization API, which optimizes images from anywhere on the web, and then serves them directly from the Next.js web server. If you would like to serve your images directly from a CDN or image server, you can write your own loader function with a few lines of JavaScript. -Loaders can be defined per-image, or at the application level. +You can define a loader per-image with the [`loader` prop](/docs/api-reference/next/image.md#loader), or at the application level with the [`loaderFile` configuration](https://nextjs.org/docs/api-reference/next/image#loader-configuration). ### Priority @@ -151,7 +151,7 @@ Because `next/image` is designed to guarantee good performance results, it canno > > If you are accessing images from a source without knowledge of the images' sizes, there are several things you can do: > -> **Use `fill``** +> **Use `fill`** > > The [`fill`](/docs/api-reference/next/image#fill) prop allows your image to be sized by its parent element. Consider using CSS to give the image's parent element space on the page along [`sizes`](/docs/api-reference/next/image#sizes) prop to match any media query break points. You can also use [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) with `fill`, `contain`, or `cover`, and [`object-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) to define how the image should occupy that space. > diff --git a/errors/invalid-images-config.md b/errors/invalid-images-config.md index 553c79facc747..badaa4f3e1a88 100644 --- a/errors/invalid-images-config.md +++ b/errors/invalid-images-config.md @@ -21,6 +21,8 @@ module.exports = { path: '/_next/image', // loader can be 'default', 'imgix', 'cloudinary', 'akamai', or 'custom' loader: 'default', + // file with `export default function loader({src, width, quality})` + loaderFile: '', // disable static imports for image files disableStaticImages: false, // minimumCacheTTL is in seconds, must be integer 0 or more diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index d74fea3d98337..b3a309077bb0a 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -866,6 +866,12 @@ export default async function getBaseWebpackConfig( } : undefined), + ...(config.images.loaderFile + ? { + 'next/dist/shared/lib/image-loader': config.images.loaderFile, + } + : undefined), + next: NEXT_PROJECT_ROOT, ...(hasServerComponents diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 9e5ba10ecebbd..da4910cadd5a5 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -16,6 +16,8 @@ import { } from '../shared/lib/image-config' import { ImageConfigContext } from '../shared/lib/image-config-context' import { warnOnce } from '../shared/lib/utils' +// @ts-ignore - This is replaced by webpack alias +import defaultLoader from 'next/dist/shared/lib/image-loader' const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete const allImgs = new Map< @@ -468,70 +470,6 @@ const ImageElement = ({ ) } -function defaultLoader({ - config, - src, - width, - quality, -}: ImageLoaderPropsWithConfig): string { - if (process.env.NODE_ENV !== 'production') { - const missingValues = [] - - // these should always be provided but make sure they are - if (!src) missingValues.push('src') - if (!width) missingValues.push('width') - - if (missingValues.length > 0) { - throw new Error( - `Next Image Optimization requires ${missingValues.join( - ', ' - )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( - { src, width, quality } - )}` - ) - } - - if (src.startsWith('//')) { - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` - ) - } - - if (!src.startsWith('/') && (config.domains || config.remotePatterns)) { - let parsedSrc: URL - try { - parsedSrc = new URL(src) - } catch (err) { - console.error(err) - throw new Error( - `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` - ) - } - - if (process.env.NODE_ENV !== 'test') { - // We use dynamic require because this should only error in development - const { hasMatch } = require('../shared/lib/match-remote-pattern') - if (!hasMatch(config.domains, config.remotePatterns, parsedSrc)) { - throw new Error( - `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + - `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` - ) - } - } - } - } - - if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { - // Special case to make svg serve as-is to avoid proxying - // through the built-in Image Optimization API. - return src - } - - return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${ - quality || 75 - }` -} - export default function Image({ src, sizes, @@ -559,20 +497,29 @@ export default function Image({ }, [configContext]) let rest: Partial = all + let loader: ImageLoaderWithConfig = rest.loader || defaultLoader - let loader: ImageLoaderWithConfig = defaultLoader - if ('loader' in rest) { - if (rest.loader) { - const customImageLoader = rest.loader - loader = (obj) => { - const { config: _, ...opts } = obj - // The config object is internal only so we must - // not pass it to the user-defined loader() - return customImageLoader(opts) - } + // Remove property so it's not spread on element + delete rest.loader + + if ('__next_img_default' in loader) { + // This special value indicates that the user + // didn't define a "loader" prop or config. + if (config.loader === 'custom') { + throw new Error( + `Image with src "${src}" is missing "loader" prop.` + + `\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader` + ) + } + } else { + // The user defined a "loader" prop or config. + // Since the config object is internal only, we + // must not pass it to the user-defined "loader". + const customImageLoader = loader as ImageLoader + loader = (obj) => { + const { config: _, ...opts } = obj + return customImageLoader(opts) } - // Remove property so it's not spread on - delete rest.loader } let staticSrc = '' diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index 29d08ee22729b..eeb8b94d6b01d 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -585,6 +585,10 @@ const configSchema = { enum: VALID_LOADERS as any, type: 'string', }, + loaderFile: { + minLength: 1, + type: 'string', + }, minimumCacheTTL: { type: 'number', }, diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 88fb42dd095b6..83ee128ac16fd 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -1,4 +1,5 @@ -import { basename, extname, relative, isAbsolute, resolve } from 'path' +import { existsSync } from 'fs' +import { basename, extname, join, relative, isAbsolute, resolve } from 'path' import { pathToFileURL } from 'url' import { Agent as HttpAgent } from 'http' import { Agent as HttpsAgent } from 'https' @@ -76,7 +77,7 @@ export function setHttpClientAndAgentOptions(options: NextConfig) { ;(global as any).__NEXT_HTTPS_AGENT = new HttpsAgent(options.httpAgentOptions) } -function assignDefaults(userConfig: { [key: string]: any }) { +function assignDefaults(dir: string, userConfig: { [key: string]: any }) { const configFileName = userConfig.configFileName if (typeof userConfig.exportTrailingSlash !== 'undefined') { console.warn( @@ -379,7 +380,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { images.path === imageConfigDefault.path ) { throw new Error( - `Specified images.loader property (${images.loader}) also requires images.path property to be assigned to a URL prefix.\nSee more info here: https://nextjs.org/docs/api-reference/next/image#loader-configuration` + `Specified images.loader property (${images.loader}) also requires images.path property to be assigned to a URL prefix.\nSee more info here: https://nextjs.org/docs/api-reference/next/legacy/image#loader-configuration` ) } @@ -398,6 +399,22 @@ function assignDefaults(userConfig: { [key: string]: any }) { images.path = `${result.basePath}${images.path}` } + if (images.loaderFile) { + if (images.loader !== 'default' && images.loader !== 'custom') { + throw new Error( + `Specified images.loader property (${images.loader}) cannot be used with images.loaderFile property. Please set images.loader to "custom".` + ) + } + const absolutePath = join(dir, images.loaderFile) + if (!existsSync(absolutePath)) { + throw new Error( + `Specified images.loaderFile does not exist at "${absolutePath}".` + ) + } + images.loader = 'custom' + images.loaderFile = absolutePath + } + if ( images.minimumCacheTTL && (!Number.isInteger(images.minimumCacheTTL) || images.minimumCacheTTL < 0) @@ -739,7 +756,7 @@ export default async function loadConfig( let configFileName = 'next.config.js' if (customConfig) { - return assignDefaults({ + return assignDefaults(dir, { configOrigin: 'server', configFileName, ...customConfig, @@ -818,7 +835,7 @@ export default async function loadConfig( : canonicalBase) || '' } - return assignDefaults({ + return assignDefaults(dir, { configOrigin: relative(dir, path), configFile: path, configFileName, @@ -846,7 +863,10 @@ export default async function loadConfig( // always call assignDefaults to ensure settings like // reactRoot can be updated correctly even with no next.config.js - const completeConfig = assignDefaults(defaultConfig) as NextConfigComplete + const completeConfig = assignDefaults( + dir, + defaultConfig + ) as NextConfigComplete completeConfig.configFileName = configFileName setHttpClientAndAgentOptions(completeConfig) return completeConfig diff --git a/packages/next/shared/lib/image-config.ts b/packages/next/shared/lib/image-config.ts index cfa172dc0b589..d72a4caacaab7 100644 --- a/packages/next/shared/lib/image-config.ts +++ b/packages/next/shared/lib/image-config.ts @@ -49,12 +49,15 @@ export type ImageConfigComplete = { /** @see [Image sizing documentation](https://nextjs.org/docs/basic-features/image-optimization#image-sizing) */ imageSizes: number[] - /** @see [Image loaders configuration](https://nextjs.org/docs/basic-features/image-optimization#loaders) */ + /** @see [Image loaders configuration](https://nextjs.org/docs/api-reference/next/legacy/image#loader) */ loader: LoaderValue - /** @see [Image loader configuration](https://nextjs.org/docs/api-reference/next/image#loader-configuration) */ + /** @see [Image loader configuration](https://nextjs.org/docs/api-reference/next/legacy/image#loader-configuration) */ path: string + /** @see [Image loader configuration](https://nextjs.org/docs/api-reference/next/image#loader-configuration) */ + loaderFile: string + /** * @see [Image domains configuration](https://nextjs.org/docs/api-reference/next/image#domains) */ @@ -89,6 +92,7 @@ export const imageConfigDefault: ImageConfigComplete = { imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], path: '/_next/image', loader: 'default', + loaderFile: '', domains: [], disableStaticImages: false, minimumCacheTTL: 60, diff --git a/packages/next/shared/lib/image-loader.ts b/packages/next/shared/lib/image-loader.ts new file mode 100644 index 0000000000000..ef9d5b6cba90c --- /dev/null +++ b/packages/next/shared/lib/image-loader.ts @@ -0,0 +1,65 @@ +// TODO: change "any" to actual type +function defaultLoader({ config, src, width, quality }: any): string { + if (process.env.NODE_ENV !== 'production') { + const missingValues = [] + + // these should always be provided but make sure they are + if (!src) missingValues.push('src') + if (!width) missingValues.push('width') + + if (missingValues.length > 0) { + throw new Error( + `Next Image Optimization requires ${missingValues.join( + ', ' + )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( + { src, width, quality } + )}` + ) + } + + if (src.startsWith('//')) { + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)` + ) + } + + if (!src.startsWith('/') && (config.domains || config.remotePatterns)) { + let parsedSrc: URL + try { + parsedSrc = new URL(src) + } catch (err) { + console.error(err) + throw new Error( + `Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)` + ) + } + + if (process.env.NODE_ENV !== 'test') { + // We use dynamic require because this should only error in development + const { hasMatch } = require('./match-remote-pattern') + if (!hasMatch(config.domains, config.remotePatterns, parsedSrc)) { + throw new Error( + `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + + `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` + ) + } + } + } + } + + if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) { + // Special case to make svg serve as-is to avoid proxying + // through the built-in Image Optimization API. + return src + } + + return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${ + quality || 75 + }` +} + +// We use this to determine if the import is the default loader +// or a custom loader defined by the user in next.config.js +defaultLoader.__next_img_default = true + +export default defaultLoader diff --git a/test/integration/export-image-loader-legacy/next.config.js b/test/integration/export-image-loader-legacy/next.config.js new file mode 100644 index 0000000000000..6b05babba9373 --- /dev/null +++ b/test/integration/export-image-loader-legacy/next.config.js @@ -0,0 +1,2 @@ +// prettier-ignore +module.exports = { /* replaceme */ } diff --git a/test/integration/export-image-loader-legacy/pages/index.js b/test/integration/export-image-loader-legacy/pages/index.js new file mode 100644 index 0000000000000..78287bf05e936 --- /dev/null +++ b/test/integration/export-image-loader-legacy/pages/index.js @@ -0,0 +1,10 @@ +import Image from 'next/legacy/image' + +const loader = undefined + +export default () => ( +
+

Should succeed during export

+ icon +
+) diff --git a/test/integration/export-image-loader-legacy/test/index.test.js b/test/integration/export-image-loader-legacy/test/index.test.js new file mode 100644 index 0000000000000..a15d38215ec7f --- /dev/null +++ b/test/integration/export-image-loader-legacy/test/index.test.js @@ -0,0 +1,142 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import cheerio from 'cheerio' +import { nextBuild, nextExport, File } from 'next-test-utils' + +const appDir = join(__dirname, '../') +const outdir = join(appDir, 'out') +const nextConfig = new File(join(appDir, 'next.config.js')) +const pagesIndexJs = new File(join(appDir, 'pages', 'index.js')) + +describe('Export with cloudinary loader next/legacy/image component', () => { + beforeAll(async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + loader: 'cloudinary', + path: 'https://example.com/', + }, + }) + ) + }) + it('should build successfully', async () => { + await fs.remove(join(appDir, '.next')) + const { code } = await nextBuild(appDir) + if (code !== 0) throw new Error(`build failed with status ${code}`) + }) + + it('should export successfully', async () => { + const { code } = await nextExport(appDir, { outdir }) + if (code !== 0) throw new Error(`export failed with status ${code}`) + }) + + it('should contain img element in html output', async () => { + const html = await fs.readFile(join(outdir, 'index.html')) + const $ = cheerio.load(html) + expect($('img[alt="icon"]').attr('alt')).toBe('icon') + }) + + afterAll(async () => { + await nextConfig.restore() + }) +}) + +describe('Export with custom loader next/legacy/image component', () => { + beforeAll(async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + loader: 'custom', + }, + }) + ) + await pagesIndexJs.replace( + 'loader = undefined', + 'loader = ({src}) => "/custom" + src' + ) + }) + it('should build successfully', async () => { + await fs.remove(join(appDir, '.next')) + const { code } = await nextBuild(appDir) + if (code !== 0) throw new Error(`build failed with status ${code}`) + }) + + it('should export successfully', async () => { + const { code } = await nextExport(appDir, { outdir }) + if (code !== 0) throw new Error(`export failed with status ${code}`) + }) + + it('should contain img element with same src in html output', async () => { + const html = await fs.readFile(join(outdir, 'index.html')) + const $ = cheerio.load(html) + expect($('img[src="/custom/o.png"]')).toBeDefined() + }) + + afterAll(async () => { + await nextConfig.restore() + await pagesIndexJs.restore() + }) +}) + +describe('Export with custom loader config but no loader prop on next/legacy/image', () => { + beforeAll(async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + loader: 'custom', + }, + }) + ) + }) + it('should fail build', async () => { + await fs.remove(join(appDir, '.next')) + const { code, stderr } = await nextBuild(appDir, [], { stderr: true }) + expect(code).toBe(1) + expect(stderr).toContain( + 'Error: Image with src "/i.png" is missing "loader" prop' + ) + }) + + afterAll(async () => { + await nextConfig.restore() + await pagesIndexJs.restore() + }) +}) + +describe('Export with unoptimized next/legacy/image component', () => { + beforeAll(async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + unoptimized: true, + }, + }) + ) + }) + it('should build successfully', async () => { + await fs.remove(join(appDir, '.next')) + const { code } = await nextBuild(appDir) + if (code !== 0) throw new Error(`build failed with status ${code}`) + }) + + it('should export successfully', async () => { + const { code } = await nextExport(appDir, { outdir }) + if (code !== 0) throw new Error(`export failed with status ${code}`) + }) + + it('should contain img element with same src in html output', async () => { + const html = await fs.readFile(join(outdir, 'index.html')) + const $ = cheerio.load(html) + expect($('img[src="/o.png"]')).toBeDefined() + }) + + afterAll(async () => { + await nextConfig.restore() + }) +}) diff --git a/test/integration/export-image-loader/dummy-loader.js b/test/integration/export-image-loader/dummy-loader.js new file mode 100644 index 0000000000000..63c101aa95528 --- /dev/null +++ b/test/integration/export-image-loader/dummy-loader.js @@ -0,0 +1,3 @@ +export default function dummyLoader({ src, width, quality }) { + return `${src}#w:${width},q:${quality || 50}` +} diff --git a/test/integration/export-image-loader/test/index.test.js b/test/integration/export-image-loader/test/index.test.js index 4f221aff2ffae..be3db688fa968 100644 --- a/test/integration/export-image-loader/test/index.test.js +++ b/test/integration/export-image-loader/test/index.test.js @@ -82,6 +82,67 @@ describe('Export with custom loader next/image component', () => { }) }) +describe('Export with custom loader config but no loader prop on next/image', () => { + beforeAll(async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + loader: 'custom', + }, + }) + ) + }) + it('should fail build', async () => { + await fs.remove(join(appDir, '.next')) + const { code, stderr } = await nextBuild(appDir, [], { stderr: true }) + expect(code).toBe(1) + expect(stderr).toContain( + 'Error: Image with src "/i.png" is missing "loader" prop' + ) + }) + + afterAll(async () => { + await nextConfig.restore() + await pagesIndexJs.restore() + }) +}) + +describe('Export with loaderFile config next/image component', () => { + beforeAll(async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + loader: 'custom', + loaderFile: './dummy-loader.js', + }, + }) + ) + }) + it('should build successfully', async () => { + await fs.remove(join(appDir, '.next')) + const { code } = await nextBuild(appDir) + if (code !== 0) throw new Error(`build failed with status ${code}`) + }) + + it('should export successfully', async () => { + const { code } = await nextExport(appDir, { outdir }) + if (code !== 0) throw new Error(`export failed with status ${code}`) + }) + + it('should contain img element with same src in html output', async () => { + const html = await fs.readFile(join(outdir, 'index.html')) + const $ = cheerio.load(html) + expect($('img[src="/i.png#w:32,q:50"]')).toBeDefined() + }) + + afterAll(async () => { + await nextConfig.restore() + await pagesIndexJs.restore() + }) +}) + describe('Export with unoptimized next/image component', () => { beforeAll(async () => { await nextConfig.replace( diff --git a/test/integration/image-optimizer/test/index.test.ts b/test/integration/image-optimizer/test/index.test.ts index 4182101099c4b..cc31e3e7433bd 100644 --- a/test/integration/image-optimizer/test/index.test.ts +++ b/test/integration/image-optimizer/test/index.test.ts @@ -274,6 +274,56 @@ describe('Image Optimizer', () => { ) }) + it('should error when images.loader and images.loaderFile are both assigned', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + loader: 'imgix', + path: 'https://example.com', + loaderFile: './dummy.js', + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain( + `Specified images.loader property (imgix) cannot be used with images.loaderFile property. Please set images.loader to "custom".` + ) + }) + + it('should error when images.loaderFile does not exist', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + loaderFile: './fakefile.js', + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain(`Specified images.loaderFile does not exist at`) + }) + it('should error when images.dangerouslyAllowSVG is not a boolean', async () => { await nextConfig.replace( '{ /* replaceme */ }', diff --git a/test/integration/next-image-new/loader-config/dummy-loader.js b/test/integration/next-image-new/loader-config/dummy-loader.js new file mode 100644 index 0000000000000..63c101aa95528 --- /dev/null +++ b/test/integration/next-image-new/loader-config/dummy-loader.js @@ -0,0 +1,3 @@ +export default function dummyLoader({ src, width, quality }) { + return `${src}#w:${width},q:${quality || 50}` +} diff --git a/test/integration/next-image-new/loader-config/next.config.js b/test/integration/next-image-new/loader-config/next.config.js new file mode 100644 index 0000000000000..55555d3c3cdfc --- /dev/null +++ b/test/integration/next-image-new/loader-config/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + images: { + loader: 'custom', + loaderFile: './dummy-loader.js', + }, +} diff --git a/test/integration/next-image-new/loader-config/pages/index.js b/test/integration/next-image-new/loader-config/pages/index.js new file mode 100644 index 0000000000000..e246516278ae4 --- /dev/null +++ b/test/integration/next-image-new/loader-config/pages/index.js @@ -0,0 +1,35 @@ +import React from 'react' +import Image from 'next/image' + +function loader({ src, width, quality }) { + return `${src}?wid=${width}&qual=${quality || 35}` +} + +const Page = () => { + return ( +
+

Loader Config

+ img1 +

Scroll down...

+
+

Loader Prop

+ img2 +
+ ) +} + +export default Page diff --git a/test/integration/next-image-new/loader-config/public/logo.png b/test/integration/next-image-new/loader-config/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e14fafc5cf3bc63b70914ad20467f40f7fecd572 GIT binary patch literal 1545 zcmbVM{Xf$Q9A6%~&O#i9S`$MamWNGTo{nv7c{rq_?QHVUW~E6OQi@{ZJcmZ|?7l@Q zzFgO>LgSLHn=t)6BZh%t7EPMfk1SL z1Y86JvZ4In(b7~asTB_EYLbzJ#fBxtCqp2a7u(A{gEak&&i%OE5K&=dA02(f0EgVb zDQO?EwAgXhbPx#1STW3~N_6+*i-&gO&5gIVD)qtd)=yh(VkE{hpxOq=E?Uo-)5z*x z!Au!iA$YiLAm+*0qggP>?VsKD-2i&HQxQ3+OqX*8S}wK5H8(1QM_f{Jya%lp;-fFQ z-RxdA9ea)1aI;`EXvn#9J~1_}n?bl%WsA3~x1yF~ZJY?F%5TY1f>Os{GDi>X>C?IS zC87Oo3ZX}KJ*U`mZ%63leZQDa&ij+|L2Ig&kv$8+G!kJ)!A>IpI0!SpvZ=R*dmxwE z_A02!zif^Xi?D&?&%f0Tzbc>bI(#PkQsao89{0s~R(I*hM>py`YIH=n8s(l<+!VhFb)fj#H;uE`npo7 zY;0_#QmGRY6Algzb}0{05Qr9vi1UjyHCq}CIyy~&Xo)lk4660;XBm=IbzH;Vwux!6 z@U`%Q<6`U_r^#vHXzMH%_g}z&^bvih;Naksl&3F)p7Kn#$+goa*xhsUD|t?H%CawT z>JQ8!^fPzDF6c8waZPU1$^P~{X*y_EN`KC=6nc}~iEX#>ud*u)-GT=qZK~K!#eMKri|K2@v zeX7|gqiZ-a27vkY(m>jlb*A45J^WhNqUd5svx=i!WlyGoDxyIkDCJw8 zl1RKs=y0j+xtSIh@AZ-SU-~z%d7|iJXK0I}nj!QZ_;_V0t%N>WpH)B+RT91Kkuhzx zSp{CL@O&X!puOb5enarY#IKV0$GfaZ<5QCF#q6Ih66Bl1Pk?cT!sCl5^YK4KUf8=r z`aO#WUfA<6@Z|tBgFYm!h8b-eKV4c&$3bTW&<9YGGZ&`xG#9~EHI4;**~o$2bOc^F z)xqxjhTZjF)wtZ04Ns<6mIBW?61;SKUp&Ix#QrYF;SY_@rCeH2X2*tJ$*pAIHb zh#ej+0ZbcVCs7JzV7TsL6Jyyhc?vBAKW|d~E=#`(Epz?bhZI(;xeQ`sbe2CXvFp-!)9gAPmnDWWTsf>26XSP@ zv&2i`WrNZNf%ZoawxTiv7?Jj|6+NW@o>r`=449DMidcqyfhe1CUhQqXbvCSyC1#>! z&TQ9Zpp%MX zY5qJSn%bSF+=@PAVhp9?wWsW-al19&OZPE literal 0 HcmV?d00001 diff --git a/test/integration/next-image-new/loader-config/test/index.test.ts b/test/integration/next-image-new/loader-config/test/index.test.ts new file mode 100644 index 0000000000000..fecd5d4f8bb22 --- /dev/null +++ b/test/integration/next-image-new/loader-config/test/index.test.ts @@ -0,0 +1,76 @@ +/* eslint-env jest */ + +import { + findPort, + killApp, + launchApp, + nextBuild, + nextStart, +} from 'next-test-utils' +import webdriver from 'next-webdriver' +import { join } from 'path' + +const appDir = join(__dirname, '../') + +let appPort +let app +let browser + +function runTests() { + it('should add "src" to img1 based on the loader config', async () => { + expect(await browser.elementById('img1').getAttribute('src')).toBe( + '/logo.png#w:828,q:50' + ) + }) + + it('should add "srcset" to img1 based on the loader config', async () => { + expect(await browser.elementById('img1').getAttribute('srcset')).toBe( + '/logo.png#w:640,q:50 1x, /logo.png#w:828,q:50 2x' + ) + }) + + it('should add "src" to img2 based on the loader prop', async () => { + expect(await browser.elementById('img2').getAttribute('src')).toBe( + '/logo.png?wid=640&qual=35' + ) + }) + + it('should add "srcset" to img2 based on the loader prop', async () => { + expect(await browser.elementById('img2').getAttribute('srcset')).toBe( + '/logo.png?wid=256&qual=35 1x, /logo.png?wid=640&qual=35 2x' + ) + }) +} + +describe('Image Loader Config', () => { + describe('dev mode', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + browser = await webdriver(appPort, '/') + }) + afterAll(() => { + killApp(app) + if (browser) { + browser.close() + } + }) + runTests() + }) + + describe('server mode', () => { + beforeAll(async () => { + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + browser = await webdriver(appPort, '/') + }) + afterAll(() => { + killApp(app) + if (browser) { + browser.close() + } + }) + runTests() + }) +})