From b743cbf6ae941b90e7f51dc3720e7c93c497f649 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Mon, 2 Nov 2020 18:43:20 +0300 Subject: [PATCH 1/5] Add uploadcare image loader --- docs/basic-features/image-optimization.md | 29 ++++++++- errors/invalid-images-config.md | 2 +- .../image-component-uploadcare/.gitignore | 34 ++++++++++ examples/image-component-uploadcare/README.md | 23 +++++++ .../image-component-uploadcare/next.config.js | 6 ++ .../image-component-uploadcare/package.json | 15 +++++ .../image-component-uploadcare/pages/index.js | 60 +++++++++++++++++ .../styles.module.css | 34 ++++++++++ packages/next/client/image.tsx | 65 ++++++++++++++++++- 9 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 examples/image-component-uploadcare/.gitignore create mode 100644 examples/image-component-uploadcare/README.md create mode 100644 examples/image-component-uploadcare/next.config.js create mode 100644 examples/image-component-uploadcare/package.json create mode 100644 examples/image-component-uploadcare/pages/index.js create mode 100644 examples/image-component-uploadcare/styles.module.css diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index f8ac2de708903..c3fb7bc580510 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -1,5 +1,5 @@ --- -description: Next.js supports built-in image optimization, as well as third party loaders for Imgix, Cloudinary, and more! Learn more here. +description: Next.js supports built-in image optimization, as well as third party loaders for Imgix, Cloudinary, Uploadcare, and more! Learn more here. --- # Image Component and Image Optimization @@ -132,6 +132,33 @@ The following Image Optimization cloud providers are supported: - [Imgix](https://www.imgix.com): `loader: 'imgix'` - [Cloudinary](https://cloudinary.com): `loader: 'cloudinary'` - [Akamai](https://www.akamai.com): `loader: 'akamai'` +- [Uploadcare](https://uploadcare.com): `loader: 'uploadcare'`. See more info [here](#uploadcare-loader). + +### Uploadcare loader + +Before all, you need to create an Uploadcare [account](https://uploadcare.com/accounts/signup/). + +Then, to enable Uploadcare loader you need to configure your `next.config.js` in the following way: + +```js +module.exports = { + images: { + loader: 'uploadcare', + path: 'https://YOUR_PUBLIC_KEY.ucr.io', // optional + }, +} +``` + +`path` is the Uploadcare [Media Proxy](https://uploadcare.com/docs/delivery/media_proxy/) endpoint. It is optional and not needed unless you are going to optimize images hosted on external sites. + +Uploadcare loader supports following types of image sources: + +- Images hosted on Uploadcare CDN (like `https://ucarecdn.com/:uuid/`). +- Images hosted on external sites. They are automatically proxied through Media Proxy. `path` is required. + +Relative images, statically served by Next.js, are not supported. + +By default, loader uses `smart` quality. You can override it using `quality` property for Image component. Integer quality values are mapped onto Uploadcare ones. All available quality transformations described [here](https://uploadcare.com/docs/transformations/compression/#operation-quality). ## Caching diff --git a/errors/invalid-images-config.md b/errors/invalid-images-config.md index 19846e447bb70..9457abc697893 100644 --- a/errors/invalid-images-config.md +++ b/errors/invalid-images-config.md @@ -18,7 +18,7 @@ module.exports = { // limit of 50 domains values domains: [], path: '/_next/image', - // loader can be 'default', 'imgix', 'cloudinary', or 'akamai' + // loader can be 'default', 'imgix', 'cloudinary', 'akamai', or 'uploadcare' loader: 'default', }, } diff --git a/examples/image-component-uploadcare/.gitignore b/examples/image-component-uploadcare/.gitignore new file mode 100644 index 0000000000000..1437c53f70bc2 --- /dev/null +++ b/examples/image-component-uploadcare/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/examples/image-component-uploadcare/README.md b/examples/image-component-uploadcare/README.md new file mode 100644 index 0000000000000..1bda25d983cad --- /dev/null +++ b/examples/image-component-uploadcare/README.md @@ -0,0 +1,23 @@ +# Uploadcare loader for Image Component Example + +This example shows how to use Uploadcare loader with [Image Component in Next.js](https://nextjs.org/docs/api-reference/next/image). + +The index page ([`pages/index.js`](pages/index.js)) has a couple images, one Uploadcare CDN hosted image and one external image. In [`next.config.js`](next.config.js), the `path` property is used to set Media Proxy endpoint. Run or deploy the app to see how it works! + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/vercel/next.js/tree/canary/examples/image-component) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npx create-next-app --example image-component-uploadcare image-app +# or +yarn create next-app --example image-component-uploadcare image-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/examples/image-component-uploadcare/next.config.js b/examples/image-component-uploadcare/next.config.js new file mode 100644 index 0000000000000..769f76f862ddb --- /dev/null +++ b/examples/image-component-uploadcare/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + images: { + loader: 'uploadcare', + path: 'https://cd813a35e9a71d2f5125.ucr.io', // optional + }, +} diff --git a/examples/image-component-uploadcare/package.json b/examples/image-component-uploadcare/package.json new file mode 100644 index 0000000000000..5a7227e3b5e8c --- /dev/null +++ b/examples/image-component-uploadcare/package.json @@ -0,0 +1,15 @@ +{ + "name": "image-component-uploadcare", + "version": "1.0.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "latest", + "react": "^16.13.1", + "react-dom": "^16.13.1" + }, + "license": "MIT" +} diff --git a/examples/image-component-uploadcare/pages/index.js b/examples/image-component-uploadcare/pages/index.js new file mode 100644 index 0000000000000..d06547b0e4c0b --- /dev/null +++ b/examples/image-component-uploadcare/pages/index.js @@ -0,0 +1,60 @@ +import styles from '../styles.module.css' +import Image from 'next/image' + +const Code = (p) => + +const Index = () => ( +
+
+

Uploadcare loader for Image Component

+

+ The following is an example of a reference to an image from the{' '} + Uploadcare CDN at ucarecdn.com +

+

+ It will be served directly from ucarecdn.com, without + proxying through Media Proxy. +

+ Vercel logo +
+

+ The following is an example of a reference to an external image at{' '} + assets.vercel.com. +

+

It will be proxied through Media Proxy.

+ Next.js logo +
+

SVGs and GIFs will be used without transformations

+ Next.js logo + Vercel logo +
+ Checkout the documentation for{' '} + + Image Optimization + {' '} + to learn more. +
+
+) + +export default Index diff --git a/examples/image-component-uploadcare/styles.module.css b/examples/image-component-uploadcare/styles.module.css new file mode 100644 index 0000000000000..27c150c04f393 --- /dev/null +++ b/examples/image-component-uploadcare/styles.module.css @@ -0,0 +1,34 @@ +.container { + padding: 4rem 1rem; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; +} + +.container p { + margin: 1.5rem 0; +} + +.card { + max-width: 50rem; + box-shadow: -10px 10px 80px rgba(0, 0, 0, 0.12); + border: 1px solid #eee; + border-radius: 8px; + padding: 2rem; + margin: 0 auto; +} + +.inlineCode { + color: #be00ff; + font-size: 16px; + white-space: pre-wrap; +} + +.inlineCode::before, +.inlineCode::after { + content: '`'; +} + +.hr { + border: 0; + border-top: 1px solid #eaeaea; + margin: 1.5rem 0; +} diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 56a236ac37fca..d9e8ef5be666e 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -8,10 +8,11 @@ const loaders = new Map string>([ ['imgix', imgixLoader], ['cloudinary', cloudinaryLoader], ['akamai', akamaiLoader], + ['uploadcare', uploadcareLoader], ['default', defaultLoader], ]) -type LoaderKey = 'imgix' | 'cloudinary' | 'akamai' | 'default' +type LoaderKey = 'imgix' | 'cloudinary' | 'akamai' | 'uploadcare' | 'default' const VALID_LAYOUT_VALUES = [ 'fill', @@ -549,6 +550,68 @@ function cloudinaryLoader({ root, src, width, quality }: LoaderProps): string { return `${root}${paramsString}${normalizeSrc(src)}` } +function uploadcareLoader({ root, src, width, quality }: LoaderProps): string { + const isOnCdn = /^https?:\/\/ucarecdn\.com/.test(src) + + if (process.env.NODE_ENV !== 'production') { + if (!isOnCdn && src.startsWith('/')) { + throw new Error( + `Failed to parse "${src}" in "next/image", Uploadcare loader doesn't support relative images` + ) + } + + if (!isOnCdn && !/^https?:\/\/.+\.ucr\.io\/?$/.test(root)) { + throw new Error( + `Failed to parse "${root}" in "next/image", Uploadcare loader expects proxy endpoint like "https://YOUR_PUBLIC_KEY.ucr.io".` + ) + } + } + + const filename = src.substring(1 + src.lastIndexOf('/')) + const extension = filename + .toLowerCase() + .split('?')[0] + .split('#')[0] + .split('.')[1] + + if (['svg', 'gif'].includes(extension)) { + return isOnCdn ? src : `${root.replace(/\/$/, '')}${src}` + } + + /** + * Output image dimensions is limited to 3000px + * It can be increased by explicitly setting /format/jpeg/ + */ + const maxResizeWidth = Math.min(Math.max(width, 0), 3000) + // Demo: https://ucarecdn.com/a6f8abc8-f92e-460a-b7a1-c5cd70a18cdb/-/format/auto/-/resize/300x/vercel.png + const params = ['format/auto', `resize/${maxResizeWidth}x`] + + if (quality) { + /** + * Uploadcare doesn't support integer-based quality modificators, + * so we need to map them onto uploadcare's equivalents + */ + const names = ['lightest', 'lighter', 'normal', 'better', 'best'] + const intervals = [0, 38, 70, 80, 87, 100] + const nameIdx = intervals.findIndex((min, idx) => { + const max = intervals[idx + 1] + return min <= quality && quality <= max + }) + params.push(`quality/${names[nameIdx]}`) + } else { + params.push('quality/smart') + } + + const paramsString = '/-/' + params.join('/-/') + '/' + + if (isOnCdn) { + const withoutFilename = src.slice(0, src.lastIndexOf('/')) + return `${withoutFilename}${paramsString}${filename}` + } + + return `${root.replace(/\/$/, '')}${paramsString}${src}` +} + function defaultLoader({ root, src, width, quality }: LoaderProps): string { if (process.env.NODE_ENV !== 'production') { const missingValues = [] From cf1cf0a7004159cbc03bbd819b6e095bf952d0e8 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 3 Nov 2020 15:07:49 +0300 Subject: [PATCH 2/5] Prevent image upscaling --- packages/next/client/image.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 235c5e8ed89dd..38c6238721a02 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -589,7 +589,7 @@ function uploadcareLoader({ root, src, width, quality }: LoaderProps): string { */ const maxResizeWidth = Math.min(Math.max(width, 0), 3000) // Demo: https://ucarecdn.com/a6f8abc8-f92e-460a-b7a1-c5cd70a18cdb/-/format/auto/-/resize/300x/vercel.png - const params = ['format/auto', `resize/${maxResizeWidth}x`] + const params = ['format/auto', 'stretch/off', `resize/${maxResizeWidth}x`] if (quality) { /** From 5a41cb2f04747490ee81c10ddf4a6b8ccc84cedc Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 10 Nov 2020 10:21:19 +0300 Subject: [PATCH 3/5] Add note about custom cnames at readme --- docs/basic-features/image-optimization.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 1132a508dc420..0764654bfa0cc 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -115,6 +115,8 @@ Uploadcare loader supports following types of image sources: Relative images, statically served by Next.js, are not supported. +Custom CNAMEs also are not supported. + By default, loader uses `smart` quality. You can override it using `quality` property for Image component. Integer quality values are mapped onto Uploadcare ones. All available quality transformations described [here](https://uploadcare.com/docs/transformations/compression/#operation-quality). ## Caching From ae6beb3d28d2cc10cd29db3ba8fe5a769911d3eb Mon Sep 17 00:00:00 2001 From: nd0ut Date: Mon, 30 Nov 2020 12:47:12 +0300 Subject: [PATCH 4/5] Fix lint --- packages/next/next-server/server/image-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/next-server/server/image-config.ts b/packages/next/next-server/server/image-config.ts index 973f81b87b978..e5e22de0c8796 100644 --- a/packages/next/next-server/server/image-config.ts +++ b/packages/next/next-server/server/image-config.ts @@ -3,7 +3,7 @@ export const VALID_LOADERS = [ 'imgix', 'cloudinary', 'akamai', - 'uploadcare' + 'uploadcare', ] as const export type LoaderValue = typeof VALID_LOADERS[number] From 08d098a4bf90623a71dc2f657b17b6b3a53649dd Mon Sep 17 00:00:00 2001 From: nd0ut Date: Mon, 30 Nov 2020 12:47:37 +0300 Subject: [PATCH 5/5] Fix test --- test/integration/image-optimizer/test/index.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 2b095c2d02258..576efce4f577e 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -568,7 +568,7 @@ describe('Image Optimizer', () => { await nextConfig.restore() expect(stderr).toContain( - 'Specified images.loader should be one of (default, imgix, cloudinary, akamai), received invalid value (notreal)' + 'Specified images.loader should be one of (default, imgix, cloudinary, akamai, uploadcare), received invalid value (notreal)' ) }) })