├── .eslintrc.js ├── .github └── workflows │ ├── e2e.js.yml │ ├── e2ebasepath.js.yml │ └── test.js.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .swcrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── environment.d.ts ├── example ├── .eslintrc.json ├── .gitignore ├── README.md ├── app │ ├── appdir │ │ ├── page.module.css │ │ └── page.tsx │ ├── globals.css │ └── layout.tsx ├── assets │ └── chris-zhang-Jq8-3Bmh1pQ-unsplash_static_asset.jpg ├── environment.d.ts ├── next-env.d.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── pages │ ├── _app.js │ ├── fixedImage.js │ ├── forwardRef.js │ ├── gifs.js │ ├── index.js │ ├── nested │ │ ├── page.js │ │ └── page_fixed.js │ ├── nestedSlug │ │ └── [slug].js │ ├── remote.js │ ├── smallImage.js │ ├── subfolder.js │ ├── transparent.js │ └── typescript.tsx ├── public │ ├── animated.png │ ├── chris-zhang-Jq8-3Bmh1pQ-unsplash_static.jpg │ ├── favicon.ico │ ├── images │ │ ├── 402107790_STATIC_NOISE_GIF.gif │ │ ├── 402107790_STATIC_NOISE_WEBP.webp │ │ ├── chris-zhang-Jq8-3Bmh1pQ-unsplash.jpg │ │ ├── chris-zhang-Jq8-3Bmh1pQ-unsplash_small.jpg │ │ ├── next-image-export-optimizer-hashes.json │ │ ├── subfolder │ │ │ ├── ollie-barker-jones-K52HVSPVvKI-unsplash.jpg │ │ │ └── subfolder2 │ │ │ │ └── ollie-barker-jones-K52HVSPVvKI-unsplash.jpg │ │ └── transparentImage.png │ └── vercel.svg ├── remoteOptimizedImages.js ├── src │ ├── ExportedImage.tsx │ └── legacy │ │ └── ExportedImage.tsx ├── styles │ ├── Home.module.css │ └── globals.css ├── test │ └── e2e │ │ ├── fixedImageSizeTest.spec.mjs │ │ ├── getImageById.js │ │ ├── imageSizeTest.spec.mjs │ │ └── unoptimizedTest.spec.mjs ├── testServer.js └── tsconfig.json ├── package-lock.json ├── package.json ├── playwright-basePath.config.js ├── playwright.config.js ├── src ├── optimizeImages.ts └── utils │ ├── ImageObject.ts │ ├── defineProgressBar.ts │ ├── downloadImagesInBatches.ts │ ├── ensureDirectoryExists.ts │ ├── getAllFilesAsObject.ts │ ├── getHash.ts │ ├── getRemoteImageURLs.ts │ └── urlToFilename.ts ├── test ├── __snapshots__ │ └── optimizeImages.test.js.snap └── optimizeImages.test.js ├── tsconfig.json └── tsconfig.optimizeImages.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ["eslint:recommended", "plugin:react/recommended"], 8 | parserOptions: { 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | ecmaVersion: 13, 13 | sourceType: "module", 14 | }, 15 | //ignore the dist folder 16 | ignorePatterns: ["dist/"], 17 | plugins: ["react"], 18 | rules: { "react/react-in-jsx-scope": "off" }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/workflows/e2e.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Test e2e 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm ci 29 | - run: cd example/ && npm ci && cd .. 30 | - run: npm run build && npm run test:e2e 31 | -------------------------------------------------------------------------------- /.github/workflows/e2ebasepath.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Test e2e basePath 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm ci 29 | - run: cd example/ && npm ci && cd .. 30 | - run: npm run build && npm run test:e2e:basePath 31 | -------------------------------------------------------------------------------- /.github/workflows/test.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Test image generation 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm ci 29 | - run: cd example/ && npm ci && cd .. 30 | - run: npm run build && npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | example/public/images/subfolder/nextImageExportOptimizer 5 | example/public/images/subfolder/subfolder2/nextImageExportOptimizer 6 | example/public/images/nextImageExportOptimizer 7 | example/public/nextImageExportOptimizer 8 | example/remoteImagesForOptimization 9 | example/.vscode/settings.json 10 | test-results/.last-run.json 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | example/.next 2 | example/out 3 | example/public 4 | *.css 5 | .vscode -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "decorators": false, 7 | "dynamicImport": false 8 | }, 9 | "target": "es5", 10 | "loose": false, 11 | "minify": { 12 | "compress": false, 13 | "mangle": false 14 | } 15 | }, 16 | "module": { 17 | "type": "commonjs" 18 | }, 19 | "minify": false 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "gifs", 4 | "networkidle", 5 | "unoptimized" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Niels Grafen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next-Image-Export-Optimizer 2 | 3 | [![npm](https://img.shields.io/npm/v/next-image-export-optimizer)](https://www.npmjs.com/package/next-image-export-optimizer) 4 | 5 | Use [Next.js advanced **\** component](https://nextjs.org/docs/basic-features/image-optimization) with the static export functionality. Optimizes all static images in an additional step after the Next.js static export. 6 | 7 | - Reduces the image size and page load times drastically through responsive images 8 | - Fast image transformation using [sharp.js](https://www.npmjs.com/package/sharp) (also used by the Next.js server in production) 9 | - Conversion of JPEG and PNG files to the modern WEBP format by default 10 | - Serve the exported React bundle only via a CDN. No server required 11 | - Automatic generation of tiny, blurry placeholder images 12 | - Minimal configuration necessary 13 | - Supports TypeScript 14 | - Supports remote images which will be downloaded and optimized 15 | - Supports animated images (accepted formats: GIF and WEBP) 16 | - Note that only one global value can be used for the image quality setting. The default value is 75. 17 | 18 | ## Placement of the images: 19 | 20 | **For images using a path string:** (e.g. src="/profile.png") 21 | 22 | Place the images in a folder inside the public folder like _public/images_ 23 | 24 | **For images using a static import:** (e.g. src={profileImage}) 25 | 26 | You can place the images anywhere in your project. The images will be optimized and copied to the export folder. 27 | 28 | **For remote images:** (e.g. src="https://example.com/image.jpg") 29 | 30 | Please refer to the section on remote images. 31 | 32 | ## Installation 33 | 34 | ``` 35 | npm install next-image-export-optimizer 36 | 37 | # Or 38 | yarn add next-image-export-optimizer 39 | pnpm install next-image-export-optimizer 40 | ``` 41 | 42 | ## Configuration 43 | 44 | ## Basic configuration 45 | 46 | Add the following to your `next.config.js`. You can also use `next.config.ts` for TypeScript projects.: 47 | 48 | ```javascript 49 | // next.config.js 50 | module.exports = { 51 | output: "export", 52 | images: { 53 | loader: "custom", 54 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 55 | deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], 56 | }, 57 | transpilePackages: ["next-image-export-optimizer"], 58 | env: { 59 | nextImageExportOptimizer_imageFolderPath: "public/images", 60 | nextImageExportOptimizer_exportFolderPath: "out", 61 | nextImageExportOptimizer_quality: "75", 62 | nextImageExportOptimizer_storePicturesInWEBP: "true", 63 | nextImageExportOptimizer_exportFolderName: "nextImageExportOptimizer", 64 | nextImageExportOptimizer_generateAndUseBlurImages: "true", 65 | nextImageExportOptimizer_remoteImageCacheTTL: "0", 66 | }, 67 | }; 68 | ``` 69 | 70 | Update the build command in `package.json` 71 | 72 | ```diff 73 | { 74 | - "build": "next build", 75 | + "build": "next build && next-image-export-optimizer" 76 | } 77 | ``` 78 | 79 | Replace the **\** component with the **\** component: 80 | 81 | Example: 82 | 83 | ```javascript 84 | // Old 85 | import Image from "next/image"; 86 | 87 | Large Image; 93 | 94 | // Replace with either of the following: 95 | 96 | // With static import (Recommended) 97 | import ExportedImage from "next-image-export-optimizer"; 98 | import testPictureStatic from "PATH_TO_IMAGE/test_static.jpg"; 99 | 100 | ; 101 | 102 | // With dynamic import 103 | import ExportedImage from "next-image-export-optimizer"; 104 | 105 | ; 111 | ``` 112 | 113 | ## Advanced configuration 114 | 115 | ### Remote images 116 | 117 | For remote images, you have to specify the src as a string starting with either http or https in the ExportedImage component. 118 | 119 | ```javascript 120 | import ExportedImage from "next-image-export-optimizer"; 121 | 122 | ; 123 | ``` 124 | 125 | In order for the image optimization at build time to work correctly, you have to specify all remote image urls in a file called **remoteOptimizedImages.js** in the root directory of your project (where the `next.config.js` is stored as well). The file should export an array of strings containing the urls of the remote images. Returning a promise of such array is also supported. 126 | 127 | Example: 128 | 129 | ```javascript 130 | // remoteOptimizedImages.js 131 | module.exports = [ 132 | "https://example.com/image1.jpg", 133 | "https://example.com/image2.jpg", 134 | "https://example.com/image3.jpg", 135 | // ... 136 | ]; 137 | ``` 138 | 139 | ```javascript 140 | // Or with a promise 141 | module.exports = new Promise((resolve) => 142 | resolve([ 143 | "https://example.com/image1.jpg", 144 | "https://example.com/image2.jpg", 145 | "https://example.com/image3.jpg", 146 | // ... 147 | ]) 148 | ); 149 | 150 | // Or with an async API call 151 | module.exports = fetch("https://example.com/api/images").catch((error) => { 152 | console.error(error); 153 | return []; // return an empty array in case of error 154 | }); 155 | ``` 156 | 157 | At build time, the images will be either downloaded or read from the cache. The image urls will be replaced with the optimized image urls in the Exported Image component. 158 | 159 | You can specify the time to live of the cache in seconds by setting the `nextImageExportOptimizer_remoteImageCacheTTL` environment variable in your `next.config.js` file. The default value is 0 seconds (as the image might have changed). 160 | 161 | Set it to: 162 | 163 | - 60 for 1 minute 164 | - 3600 for 1 hour 165 | - 86400 for 1 day 166 | - 604800 for 1 week 167 | - 2592000 for 1 month 168 | - 31536000 for 1 year 169 | 170 | If you want to hide the remote image urls from the user, you can use the [overrideSrc](https://nextjs.org/docs/pages/api-reference/components/image#overridesrc) prop of the ExportedImage component. This will replace the src attribute of the image tag with the value of the overrideSrc prop. 171 | 172 | Beware that the Image component cannot fall back to the original image URL if the optimized images are not yet generated when you use the overrideSrc prop. This will result in a broken image link. 173 | 174 | You can customize the filename for remote optimized images by adding the following to your `next.config.js`: 175 | 176 | ```javascript 177 | module.exports = { 178 | env: { 179 | // ... other env variables 180 | nextImageExportOptimizer_remoteImagesFilename: "remoteOptimizedImages.cjs", 181 | }, 182 | // ... other config options 183 | }; 184 | ``` 185 | 186 | ### Custom next.config.js path 187 | 188 | If your Next.js project is not at the root directory where you are running the commands, for example when you are using a monorepo, you can specify the location of the `next.config.js` as an argument to the script: 189 | 190 | ```json 191 | "export": "next build && next-image-export-optimizer --nextConfigPath path/to/my/next.config.js" 192 | ``` 193 | 194 | ### Custom export folder path 195 | 196 | Specify the output folder path either via environment variable: 197 | 198 | ```javascript 199 | // next.config.js 200 | { "env": { 201 | "nextImageExportOptimizer_exportFolderPath": "path/to/my/export/folder" 202 | }} 203 | ``` 204 | 205 | Or by passing the argument to the script: 206 | 207 | ```json 208 | "export": "next build && next-image-export-optimizer --exportFolderPath path/to/my/export/folder" 209 | ``` 210 | 211 | ### Base path 212 | 213 | If you want to deploy your app to a subfolder of your domain, you can set the basePath in the `next.config.js` file: 214 | 215 | ```javascript 216 | module.exports = { 217 | basePath: "/subfolder", 218 | }; 219 | ``` 220 | 221 | The ExportedImage component has a basePath prop which you can use to pass the basePath to the component. 222 | 223 | ```javascript 224 | import ExportedImage from "next-image-export-optimizer"; 225 | import testPictureStatic from "PATH_TO_IMAGE/test_static.jpg"; 226 | 227 | ; 232 | ``` 233 | 234 | ### Placeholder images 235 | 236 | If you do not want the automatic generation of tiny, blurry placeholder images, set the `nextImageExportOptimizer_generateAndUseBlurImages` environment variable to `false` and set the `placeholder` prop from the **\** component to `empty`. 237 | 238 | ### Custom export folder name 239 | 240 | If you want to rename the export folder name, set the `nextImageExportOptimizer_exportFolderPath` environment variable to the desired folder name. The default is `nextImageExportOptimizer`. 241 | 242 | ### Image format 243 | 244 | By default, the images are stored in the WEBP format. 245 | 246 | If you do not want to use the WEBP format, set the `nextImageExportOptimizer_storePicturesInWEBP` environment variable to `false`. 247 | 248 | ## Good to know 249 | 250 | - The **\** component is a wrapper around the **\** component of Next.js. It uses the custom loader feature to generate a [srcset](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images) for different resolutions of the original image. The browser can then load the correct size based on the current viewport size. 251 | 252 | - The image transformation operation is optimized as it uses hashes to determine whether an image has already been optimized or not. This way, the images are only optimized once and not every time the build command is run. 253 | 254 | - The **\** component falls back to the original image if the optimized images are not yet generated in the development mode. In the exported, static React app, the responsive images are available as srcset and dynamically loaded by the browser. 255 | 256 | - The static import method is recommended as it informs the client about the original image size. When widths larger than the original image width are requested, the next largest image size in the deviceSizes array (specified in the `next.config.js`) will be used for the generation of the srcset attribute. 257 | When you specify the images as a path string, this library will create duplicates of the original image for each image size in the deviceSizes array that is larger than the original image size. 258 | 259 | - You can output the original, unoptimized images using the `unoptimized` prop. 260 | Example: 261 | 262 | ```javascript 263 | import ExportedImage from "next-image-export-optimizer"; 264 | 265 | ; 270 | ``` 271 | 272 | - You can still use the legacy image component `next/legacy/image`: 273 | 274 | ```javascript 275 | import ExportedImage from "next-image-export-optimizer/legacy/ExportedImage"; 276 | 277 | import testPictureStatic from "PATH_TO_IMAGE/test_static.jpg"; 278 | 279 | ; 280 | ``` 281 | 282 | - Animated images: 283 | You can use .gif and animated .webp images. Next-image-export-optimizer will automatically optimize the animated images and generate the srcset for the different resolutions. 284 | 285 | If you set the variable nextImageExportOptimizer_storePicturesInWEBP to true, the animated images will be converted to .webp format which can reduce the file size significantly. 286 | Note that animated png images are not supported by this package. 287 | 288 | ## Live example 289 | 290 | You can see a live example of the use of this library at [reactapp.dev/next-image-export-optimizer](https://reactapp.dev/next-image-export-optimizer) 291 | 292 | > **Warning** 293 | > Version 1.0.0 is a breaking change. It follows the changes introduced in Next 13.0.0 which replaces the `next/image` component with `next/future/image`. If you are using Next 12 or below, please use version _0.17.1_. 294 | -------------------------------------------------------------------------------- /environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | storePicturesInWEBP: string | undefined; 5 | generateAndUseBlurImages: string | undefined; 6 | nextImageExportOptimizer_storePicturesInWEBP: string | undefined; 7 | nextImageExportOptimizer_generateAndUseBlurImages: string | undefined; 8 | nextImageExportOptimizer_exportFolderName: string | undefined; 9 | nextImageExportOptimizer_quality: string | undefined; 10 | nextImageExportOptimizer_remoteImageCacheTTL: string | undefined; 11 | __NEXT_IMAGE_OPTS: { deviceSizes: string[]; imageSizes: string[] }; 12 | } 13 | } 14 | } 15 | 16 | // If this file has no import/export statements (i.e. is a script) 17 | // convert it into a module by adding an empty export statement. 18 | export {}; 19 | -------------------------------------------------------------------------------- /example/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | public/images/nextImageExportOptimizer 37 | remoteImagesForOptimization 38 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project to test the next-image-export-optimizer module. 2 | -------------------------------------------------------------------------------- /example/app/appdir/page.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | padding: 6rem; 7 | min-height: 100vh; 8 | } 9 | 10 | .description { 11 | display: inherit; 12 | justify-content: inherit; 13 | align-items: inherit; 14 | font-size: 0.85rem; 15 | max-width: var(--max-width); 16 | width: 100%; 17 | z-index: 2; 18 | font-family: var(--font-mono); 19 | } 20 | 21 | .description a { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | gap: 0.5rem; 26 | } 27 | 28 | .description p { 29 | position: relative; 30 | margin: 0; 31 | padding: 1rem; 32 | background-color: rgba(var(--callout-rgb), 0.5); 33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3); 34 | border-radius: var(--border-radius); 35 | } 36 | 37 | .code { 38 | font-weight: 700; 39 | font-family: var(--font-mono); 40 | } 41 | 42 | .grid { 43 | display: grid; 44 | grid-template-columns: repeat(4, minmax(25%, auto)); 45 | width: var(--max-width); 46 | max-width: 100%; 47 | } 48 | 49 | .card { 50 | padding: 1rem 1.2rem; 51 | border-radius: var(--border-radius); 52 | background: rgba(var(--card-rgb), 0); 53 | border: 1px solid rgba(var(--card-border-rgb), 0); 54 | transition: background 200ms, border 200ms; 55 | } 56 | 57 | .card span { 58 | display: inline-block; 59 | transition: transform 200ms; 60 | } 61 | 62 | .card h2 { 63 | font-weight: 600; 64 | margin-bottom: 0.7rem; 65 | } 66 | 67 | .card p { 68 | margin: 0; 69 | opacity: 0.6; 70 | font-size: 0.9rem; 71 | line-height: 1.5; 72 | max-width: 30ch; 73 | } 74 | 75 | .center { 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | position: relative; 80 | padding: 4rem 0; 81 | } 82 | 83 | .center::before { 84 | background: var(--secondary-glow); 85 | border-radius: 50%; 86 | width: 480px; 87 | height: 360px; 88 | margin-left: -400px; 89 | } 90 | 91 | .center::after { 92 | background: var(--primary-glow); 93 | width: 240px; 94 | height: 180px; 95 | z-index: -1; 96 | } 97 | 98 | .center::before, 99 | .center::after { 100 | content: ''; 101 | left: 50%; 102 | position: absolute; 103 | filter: blur(45px); 104 | transform: translateZ(0); 105 | } 106 | 107 | .logo { 108 | position: relative; 109 | } 110 | /* Enable hover only on non-touch devices */ 111 | @media (hover: hover) and (pointer: fine) { 112 | .card:hover { 113 | background: rgba(var(--card-rgb), 0.1); 114 | border: 1px solid rgba(var(--card-border-rgb), 0.15); 115 | } 116 | 117 | .card:hover span { 118 | transform: translateX(4px); 119 | } 120 | } 121 | 122 | @media (prefers-reduced-motion) { 123 | .card:hover span { 124 | transform: none; 125 | } 126 | } 127 | 128 | /* Mobile */ 129 | @media (max-width: 700px) { 130 | .content { 131 | padding: 4rem; 132 | } 133 | 134 | .grid { 135 | grid-template-columns: 1fr; 136 | margin-bottom: 120px; 137 | max-width: 320px; 138 | text-align: center; 139 | } 140 | 141 | .card { 142 | padding: 1rem 2.5rem; 143 | } 144 | 145 | .card h2 { 146 | margin-bottom: 0.5rem; 147 | } 148 | 149 | .center { 150 | padding: 8rem 0 6rem; 151 | } 152 | 153 | .center::before { 154 | transform: none; 155 | height: 300px; 156 | } 157 | 158 | .description { 159 | font-size: 0.8rem; 160 | } 161 | 162 | .description a { 163 | padding: 1rem; 164 | } 165 | 166 | .description p, 167 | .description div { 168 | display: flex; 169 | justify-content: center; 170 | position: fixed; 171 | width: 100%; 172 | } 173 | 174 | .description p { 175 | align-items: center; 176 | inset: 0 0 auto; 177 | padding: 2rem 1rem 1.4rem; 178 | border-radius: 0; 179 | border: none; 180 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); 181 | background: linear-gradient( 182 | to bottom, 183 | rgba(var(--background-start-rgb), 1), 184 | rgba(var(--callout-rgb), 0.5) 185 | ); 186 | background-clip: padding-box; 187 | backdrop-filter: blur(24px); 188 | } 189 | 190 | .description div { 191 | align-items: flex-end; 192 | pointer-events: none; 193 | inset: auto 0 0; 194 | padding: 2rem; 195 | height: 200px; 196 | background: linear-gradient( 197 | to bottom, 198 | transparent 0%, 199 | rgb(var(--background-end-rgb)) 40% 200 | ); 201 | z-index: 1; 202 | } 203 | } 204 | 205 | /* Tablet and Smaller Desktop */ 206 | @media (min-width: 701px) and (max-width: 1120px) { 207 | .grid { 208 | grid-template-columns: repeat(2, 50%); 209 | } 210 | } 211 | 212 | @media (prefers-color-scheme: dark) { 213 | .vercelLogo { 214 | filter: invert(1); 215 | } 216 | 217 | .logo { 218 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); 219 | } 220 | } 221 | 222 | @keyframes rotate { 223 | from { 224 | transform: rotate(360deg); 225 | } 226 | to { 227 | transform: rotate(0deg); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /example/app/appdir/page.tsx: -------------------------------------------------------------------------------- 1 | import styles from "../../styles/Home.module.css"; 2 | import ExportedImage from "../../src/ExportedImage"; 3 | import ExportedImageLegacy from "../../src/legacy/ExportedImage"; 4 | import testPictureStatic from "../../public/chris-zhang-Jq8-3Bmh1pQ-unsplash_static.jpg"; 5 | 6 | export default function Home() { 7 | // get the basePath set in next.config.js 8 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 9 | return ( 10 |
11 |

Next-Image-Export-Optimizer

12 |

Optimized example - Legacy

13 |
21 | 30 |
31 |

Optimized example (static import) - Legacy

32 |
39 | 47 |
48 |

Optimized example

49 |
54 | 62 |
63 |

Optimized example (fill)

64 |
72 | 80 |
81 |

Optimized example (fill & static import)

82 |
90 | 98 |
99 |

Optimized example (static import)

100 |
107 | 116 |
117 |

Unoptimized example - Legacy

118 |
126 | 136 |
137 |

Unoptimized example - Legacy static import

138 |
146 | 156 |
157 |

Unoptimized example

158 |
166 | 176 |
177 |

Unoptimized example static import

178 |
186 | 196 |
197 | {/* */} 204 |
205 | ); 206 | } 207 | -------------------------------------------------------------------------------- /example/app/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /example/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { Inter } from "next/font/google"; 3 | import React from "react"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /example/assets/chris-zhang-Jq8-3Bmh1pQ-unsplash_static_asset.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/assets/chris-zhang-Jq8-3Bmh1pQ-unsplash_static_asset.jpg -------------------------------------------------------------------------------- /example/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | storePicturesInWEBP: string | undefined; 5 | generateAndUseBlurImages: string | undefined; 6 | nextImageExportOptimizer_storePicturesInWEBP: string | undefined; 7 | nextImageExportOptimizer_generateAndUseBlurImages: string | undefined; 8 | nextImageExportOptimizer_exportFolderName: string | undefined; 9 | nextImageExportOptimizer_quality: string | undefined; 10 | __NEXT_IMAGE_OPTS: { deviceSizes: string[]; imageSizes: string[] }; 11 | } 12 | } 13 | } 14 | 15 | // If this file has no import/export statements (i.e. is a script) 16 | // convert it into a module by adding an empty export statement. 17 | export {}; 18 | -------------------------------------------------------------------------------- /example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. 7 | -------------------------------------------------------------------------------- /example/next.config.ts: -------------------------------------------------------------------------------- 1 | 2 | import type { NextConfig } from 'next' 3 | 4 | const nextConfig: NextConfig = { 5 | images: { 6 | loader: "custom", 7 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 8 | deviceSizes: [640, 750, 777, 828, 1080, 1200, 1920, 2048, 3840], 9 | }, 10 | output: "export", 11 | transpilePackages: ["next-image-export-optimizer"], 12 | env: { 13 | nextImageExportOptimizer_imageFolderPath: "public/images", 14 | nextImageExportOptimizer_exportFolderPath: "out", 15 | nextImageExportOptimizer_exportFolderName: "nextImageExportOptimizer", 16 | nextImageExportOptimizer_quality: "75", 17 | nextImageExportOptimizer_storePicturesInWEBP: "true", 18 | nextImageExportOptimizer_generateAndUseBlurImages: "true", 19 | }, 20 | }; 21 | export default nextConfig 22 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "export": "next build && cd .. && npm run build && cd example && node ../dist/optimizeImages.js", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "^15.0.3", 13 | "next-image-export-optimizer": "^1.17.0-beta.3", 14 | "react": "19.0.0-rc-66855b96-20241106", 15 | "react-dom": "19.0.0-rc-66855b96-20241106" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^22.9.0", 19 | "@types/react": "npm:types-react@rc", 20 | "eslint-config-next": "^15.0.3", 21 | "express": "^4.18.2" 22 | }, 23 | "overrides": { 24 | "@types/react": "npm:types-react@rc", 25 | "@types/react-dom": "npm:types-react-dom@rc" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /example/pages/_app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import "../styles/globals.css"; 3 | 4 | function MyApp({ Component, pageProps }) { 5 | return ; 6 | } 7 | 8 | export default MyApp; 9 | -------------------------------------------------------------------------------- /example/pages/fixedImage.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import ExportedImageLegacy from "../src/legacy/ExportedImage"; 3 | import ExportedImage from "../src/ExportedImage"; 4 | // import ExportedImage from "next-image-export-optimizer"; 5 | 6 | import styles from "../styles/Home.module.css"; 7 | import testPictureStatic from "../assets/chris-zhang-Jq8-3Bmh1pQ-unsplash_static_asset.jpg"; 8 | 9 | export default function Home() { 10 | // get the basePath set in next.config.js 11 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 12 | return ( 13 |
14 | 15 | Next-Image-Export-Optimizer 16 | 20 | 21 | 22 | 23 |
24 |

Next-Image-Export-Optimizer

25 |

Fixed size test page

26 |
34 | {[16, 32, 48, 64, 96, 128, 256, 384].map((size) => ( 35 |
36 | 47 | 57 | 67 |
68 | ))} 69 |
70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /example/pages/forwardRef.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import ExportedImage from "../src/ExportedImage"; 3 | 4 | import styles from "../styles/Home.module.css"; 5 | import { useEffect, useRef, useState } from "react"; 6 | 7 | export default function Home() { 8 | // example of using a ref to get the image dimensions to test the forwardRef functionality 9 | const imageRef = useRef(null); 10 | const [imageDimensions, setImageDimensions] = useState({ 11 | width: 0, 12 | height: 0, 13 | }); 14 | useEffect(() => { 15 | if (imageRef.current) { 16 | setImageDimensions({ 17 | width: imageRef.current.clientWidth, 18 | height: imageRef.current.clientHeight, 19 | }); 20 | } 21 | }, [imageRef]); 22 | // get the basePath set in next.config.js 23 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 24 | 25 | return ( 26 |
27 | 28 | Next-Image-Export-Optimizer 29 | 33 | 34 | 35 | 36 |
37 |

Next-Image-Export-Optimizer

38 |

Optimized example

39 |
46 | 55 |
56 |
65 |

Width:

66 | {imageDimensions.width} 67 |

Height:

68 | {imageDimensions.height} 69 |
70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /example/pages/gifs.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import ExportedImage from "../src/ExportedImage"; 3 | 4 | import styles from "../styles/Home.module.css"; 5 | import animatedImage from "../public/animated.png"; 6 | 7 | export default function Home() { 8 | // get the basePath set in next.config.js 9 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 10 | return ( 11 |
12 | 13 | Next-Image-Export-Optimizer 14 | 18 | 19 | 20 | 21 |
22 |

Next-Image-Export-Optimizer

23 |

Format: .gif

24 | 25 |
33 | 41 |
42 |

Format: .webp

43 | 44 |
52 | 60 |
61 |

Format: .png

62 | 63 |
71 | 78 |
79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /example/pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import ExportedImageLegacy from "../src/legacy/ExportedImage"; 3 | // import ExportedImageLegacy from "next-image-export-optimizer/legacy/ExportedImage"; 4 | // import ExportedImage from "next-image-export-optimizer"; 5 | import ExportedImage from "../src/ExportedImage"; 6 | 7 | import styles from "../styles/Home.module.css"; 8 | import testPictureStatic from "../public/chris-zhang-Jq8-3Bmh1pQ-unsplash_static.jpg"; 9 | 10 | export default function Home() { 11 | // get the basePath set in next.config.js 12 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 13 | return ( 14 |
15 | 16 | Next-Image-Export-Optimizer 17 | 21 | 22 | 23 | 24 |
25 |

Next-Image-Export-Optimizer

26 |

Optimized example - Legacy

27 |
35 | 44 |
45 |

Optimized example (static import) - Legacy

46 |
53 | 61 |
62 |

Optimized example

63 |
68 | 76 |
77 |

Optimized example (fill)

78 |
86 | 95 |
96 |

Optimized example (fill & static import)

97 |
105 | 113 |
114 |

Optimized example (static import)

115 |
122 | 132 |
133 |

Unoptimized example - Legacy

134 |
142 | 152 |
153 |

Unoptimized example - Legacy static import

154 |
162 | 172 |
173 |

Unoptimized example

174 |
182 | 192 |
193 |

Unoptimized example static import

194 |
202 | 212 |
213 | {/* */} 220 |
221 |
222 | ); 223 | } 224 | -------------------------------------------------------------------------------- /example/pages/nested/page.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ExportedImage from "../../src/ExportedImage"; 3 | import ExportedImageLegacy from "../../src/legacy/ExportedImage"; 4 | import testPictureStatic from "../../public/chris-zhang-Jq8-3Bmh1pQ-unsplash_static.jpg"; 5 | 6 | function Page() { 7 | // get the basePath set in next.config.js 8 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 9 | return ( 10 |
11 |

Nested page test

12 |

Optimized example (static import) - Legacy

13 | 14 |
21 | 28 |
29 |

Optimized example with fixed size (static import) - Legacy

30 | 31 |
38 | 47 |
48 |

Optimized example - Legacy

49 |
57 | 65 |
66 |

Optimized example

67 |
75 | 84 |
85 |

Optimized example (static import)

86 | 87 |
94 | 103 |
104 |
105 | ); 106 | } 107 | 108 | export default Page; 109 | -------------------------------------------------------------------------------- /example/pages/nested/page_fixed.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ExportedImage from "../../src/ExportedImage"; 3 | import ExportedImageLegacy from "../../src/legacy/ExportedImage"; 4 | import testPictureStatic from "../../public/chris-zhang-Jq8-3Bmh1pQ-unsplash_static.jpg"; 5 | 6 | function Page() { 7 | // get the basePath set in next.config.js 8 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 9 | return ( 10 |
11 |

Nested page test

12 | 13 |

Optimized example with fixed size (static import) - Legacy

14 | 15 |
22 | 31 |
32 |

Optimized example with fixed size (static import)

33 | 34 |
41 | 50 |
51 |
52 | ); 53 | } 54 | 55 | export default Page; 56 | -------------------------------------------------------------------------------- /example/pages/nestedSlug/[slug].js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ExportedImage from "../../src/ExportedImage"; 3 | import ExportedImageLegacy from "../../src/legacy/ExportedImage"; 4 | import testPictureStatic from "../../public/chris-zhang-Jq8-3Bmh1pQ-unsplash_static.jpg"; 5 | 6 | function Slug() { 7 | // get the basePath set in next.config.js 8 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 9 | return ( 10 |
11 |

Nested slug page test

12 |

Optimized example (static import) - Legacy

13 | 14 |
21 | 28 |
29 |

Optimized example (fill)

30 |
38 | 46 |
47 |

Optimized example (fill & static import)

48 |
56 | 64 |
65 |
66 | ); 67 | } 68 | 69 | export async function getStaticPaths() { 70 | return { 71 | paths: [{ params: { slug: "page" } }], 72 | fallback: false, // can also be true or 'blocking' 73 | }; 74 | } 75 | 76 | // `getStaticPaths` requires using `getStaticProps` 77 | export async function getStaticProps() { 78 | return { 79 | // Passed to the page component as props 80 | props: { post: {} }, 81 | }; 82 | } 83 | 84 | export default Slug; 85 | -------------------------------------------------------------------------------- /example/pages/remote.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import ExportedImage from "../src/ExportedImage"; 3 | 4 | import styles from "../styles/Home.module.css"; 5 | 6 | export default function Home() { 7 | // get the basePath set in next.config.js 8 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 9 | return ( 10 |
11 | 12 | Next-Image-Export-Optimizer 13 | 17 | 18 | 19 | 20 |
21 |

Next-Image-Export-Optimizer

22 |

Optimized example - Remote

23 |
31 | 40 |
41 |
49 | 59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /example/pages/smallImage.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import ExportedImageLegacy from "../src/legacy/ExportedImage"; 3 | import ExportedImage from "../src/ExportedImage"; 4 | 5 | import smallImage from "../public/images/chris-zhang-Jq8-3Bmh1pQ-unsplash_small.jpg"; 6 | 7 | import styles from "../styles/Home.module.css"; 8 | 9 | export default function Home() { 10 | // get the basePath set in next.config.js 11 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 12 | return ( 13 |
14 | 15 | Next-Image-Export-Optimizer 16 | 20 | 21 | 22 | 23 |
24 |

Next-Image-Export-Optimizer

25 |

Optimized example - Legacy

26 |
34 | 43 |
44 |

Optimized example

45 | 46 |
53 | 62 |
63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /example/pages/subfolder.js: -------------------------------------------------------------------------------- 1 | import ExportedImageLegacy from "../src/legacy/ExportedImage"; 2 | import Head from "next/head"; 3 | import React from "react"; 4 | import styles from "../styles/Home.module.css"; 5 | 6 | function Subfolder() { 7 | // get the basePath set in next.config.js 8 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 9 | return ( 10 |
11 | 12 | Next-Image-Export-Optimizer 13 | 17 | 18 | 19 | 20 |
21 |

Subfolder test

22 |
30 | 39 |
40 |
48 | 57 |
58 |
59 |
60 | ); 61 | } 62 | 63 | export default Subfolder; 64 | -------------------------------------------------------------------------------- /example/pages/transparent.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import ExportedImage from "../src/ExportedImage"; 3 | 4 | import styles from "../styles/Home.module.css"; 5 | 6 | export default function Home() { 7 | // get the basePath set in next.config.js 8 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 9 | return ( 10 |
11 | 12 | Next-Image-Export-Optimizer 13 | 17 | 18 | 19 | 20 |
21 |

Next-Image-Export-Optimizer

22 |

Optimized example

23 |
30 | 38 |
39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /example/pages/typescript.tsx: -------------------------------------------------------------------------------- 1 | import ExportedImage from "next-image-export-optimizer"; 2 | import ExportedImageLegacy from "next-image-export-optimizer/legacy/ExportedImage"; 3 | import Head from "next/head"; 4 | import ExportedImage_Local from "../src/ExportedImage"; 5 | import ExportedImageLegacy_Local from "../src/legacy/ExportedImage"; 6 | 7 | import Image from "next/image"; 8 | import testPictureStatic from "../public/chris-zhang-Jq8-3Bmh1pQ-unsplash_static.jpg"; 9 | 10 | export default function Home() { 11 | // get the basePath set in next.config.js 12 | const basePath = process.env.__NEXT_ROUTER_BASEPATH || ""; 13 | return ( 14 |
15 | 16 | Next-Image-Export-Optimizer 17 | 21 | 22 | 23 | 24 |
25 |

Next-Image-Export-Optimizer

26 |
34 | 43 |
44 |
52 | 63 |
64 |
72 | 83 |
84 | 85 |
92 | 99 |
100 |
107 | 114 |
115 |
121 | 128 | 135 | { 138 | return src; 139 | }} 140 | width={400} 141 | height={400} 142 | alt="SVG" 143 | /> 144 |
145 |
146 |
147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /example/public/animated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/animated.png -------------------------------------------------------------------------------- /example/public/chris-zhang-Jq8-3Bmh1pQ-unsplash_static.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/chris-zhang-Jq8-3Bmh1pQ-unsplash_static.jpg -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/images/402107790_STATIC_NOISE_GIF.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/images/402107790_STATIC_NOISE_GIF.gif -------------------------------------------------------------------------------- /example/public/images/402107790_STATIC_NOISE_WEBP.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/images/402107790_STATIC_NOISE_WEBP.webp -------------------------------------------------------------------------------- /example/public/images/chris-zhang-Jq8-3Bmh1pQ-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/images/chris-zhang-Jq8-3Bmh1pQ-unsplash.jpg -------------------------------------------------------------------------------- /example/public/images/chris-zhang-Jq8-3Bmh1pQ-unsplash_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/images/chris-zhang-Jq8-3Bmh1pQ-unsplash_small.jpg -------------------------------------------------------------------------------- /example/public/images/next-image-export-optimizer-hashes.json: -------------------------------------------------------------------------------- 1 | { 2 | "/402107790_STATIC_NOISE_GIF.gif": "QJxjefkpS5FMjtaQKuEdNTEpqunW3A+dIKHFl2ImZR0=", 3 | "/402107790_STATIC_NOISE_WEBP.webp": "uZRLAraNeZHDwCPNJdTdyVlkcPdWrLp-I4AW5MSYBbw=", 4 | "/chris-zhang-Jq8-3Bmh1pQ-unsplash.jpg": "HzEIrObHWZKrBNj0BsxsjyHTujdcALWCgDC-sH+nRaM=", 5 | "/chris-zhang-Jq8-3Bmh1pQ-unsplash_small.jpg": "uaQKauUmJn2erNY1qBjQNi-Ky+yipqW7BJebV8DCYHI=", 6 | "subfolder/ollie-barker-jones-K52HVSPVvKI-unsplash.jpg": "eCN-BKBwsstx+QGPEOXBodpbU1DUWgpf1DhnuXeoG8w=", 7 | "subfolder/subfolder2/ollie-barker-jones-K52HVSPVvKI-unsplash.jpg": "AWW05lFZl-Qt-8gAeuDu3bnMm9m6lyTUH80yXpit0Og=", 8 | "/transparentImage.png": "XH3+oYb10y7DOx5iqAbzi64ChyebfrxLgJNJolxjpPw=", 9 | "/animated.c00e0188.png": "1u18UQP7SYClRgh+v8TnzU82uY96MSKW3bxNat8HYOo=", 10 | "/chris-zhang-Jq8-3Bmh1pQ-unsplash_small.0fa13b23.jpg": "w8j9FhKoGEyo52uc8zEMt7XCeMUZsGCQjEjnWzkams4=", 11 | "/chris-zhang-Jq8-3Bmh1pQ-unsplash_static.921260e0.jpg": "F4KuoW3LZSTHxrqqDmnFlIcTPSHwtJTKuB2djCCjEnw=", 12 | "/chris-zhang-Jq8-3Bmh1pQ-unsplash_static_asset.921260e0.jpg": "TKroa8LFPMSjLnRq67yFY71qpejsNlpVOV7TkeASSGA=", 13 | "/5206242668571649.WEBP": "NBLWVfuacpA+Xj8Z3PMTT8Q8UuFo9Dn2KHG6uMAGcJw=", 14 | "/6725071117443837.WEBP": "w5l7YQrMfohCxKHUQ+Z6ml2LJN36eH4gzJ0y6YWuYBU=" 15 | } -------------------------------------------------------------------------------- /example/public/images/subfolder/ollie-barker-jones-K52HVSPVvKI-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/images/subfolder/ollie-barker-jones-K52HVSPVvKI-unsplash.jpg -------------------------------------------------------------------------------- /example/public/images/subfolder/subfolder2/ollie-barker-jones-K52HVSPVvKI-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/images/subfolder/subfolder2/ollie-barker-jones-K52HVSPVvKI-unsplash.jpg -------------------------------------------------------------------------------- /example/public/images/transparentImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Niels-IO/next-image-export-optimizer/32dec4ab1aba126450b45b5e4b3622c8731fea19/example/public/images/transparentImage.png -------------------------------------------------------------------------------- /example/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /example/remoteOptimizedImages.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | "https://reactapp.dev/images/nextImageExportOptimizer/christopher-gower-m_HRfLhgABo-unsplash-opt-2048.WEBP", 3 | "https://reactapp.dev/images/nextImageExportOptimizer/christopher-gower-m_HRfLhgABo-unsplash-opt-2048.WEBP?ref=next-image-export-optimizer", 4 | // 'https://example.com/image1.jpg', 5 | // 'https://example.com/image2.jpg', 6 | // 'https://example.com/image3.jpg', 7 | // ... 8 | ]; 9 | -------------------------------------------------------------------------------- /example/src/ExportedImage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image, { ImageProps, StaticImageData } from "next/image"; 4 | import React, { forwardRef, useCallback, useMemo, useState } from "react"; 5 | 6 | const splitFilePath = ({ filePath }: { filePath: string }) => { 7 | const filenameWithExtension = 8 | filePath.split("\\").pop()?.split("/").pop() || ""; 9 | const filePathWithoutFilename = filePath.split(filenameWithExtension).shift(); 10 | const fileExtension = filePath.split(".").pop(); 11 | const filenameWithoutExtension = 12 | filenameWithExtension.substring( 13 | 0, 14 | filenameWithExtension.lastIndexOf(".") 15 | ) || filenameWithExtension; 16 | return { 17 | path: filePathWithoutFilename, 18 | filename: filenameWithoutExtension, 19 | extension: fileExtension || "", 20 | }; 21 | }; 22 | 23 | const generateImageURL = ( 24 | src: string, 25 | width: number, 26 | basePath: string | undefined, 27 | isRemoteImage: boolean = false 28 | ) => { 29 | const { filename, path, extension } = splitFilePath({ filePath: src }); 30 | const useWebp = 31 | process.env.nextImageExportOptimizer_storePicturesInWEBP != undefined 32 | ? process.env.nextImageExportOptimizer_storePicturesInWEBP == "true" 33 | : true; 34 | 35 | if ( 36 | !["JPG", "JPEG", "WEBP", "PNG", "AVIF", "GIF"].includes( 37 | extension.toUpperCase() 38 | ) 39 | ) { 40 | // The images has an unsupported extension 41 | // We will return the src 42 | return src; 43 | } 44 | // If the images are stored as WEBP by the package, then we should change 45 | // the extension to WEBP to load them correctly 46 | let processedExtension = extension; 47 | 48 | if ( 49 | useWebp && 50 | ["JPG", "JPEG", "PNG", "GIF"].includes(extension.toUpperCase()) 51 | ) { 52 | processedExtension = "WEBP"; 53 | } 54 | 55 | let correctedPath = path; 56 | const lastChar = correctedPath?.substr(-1); // Selects the last character 57 | if (lastChar != "/") { 58 | // If the last character is not a slash 59 | correctedPath = correctedPath + "/"; // Append a slash to it. 60 | } 61 | 62 | const isStaticImage = src.includes("_next/static/media"); 63 | 64 | if (basePath) { 65 | if ( 66 | basePath.endsWith("/") && 67 | correctedPath && 68 | correctedPath.startsWith("/") 69 | ) { 70 | correctedPath = basePath + correctedPath.slice(1); 71 | } else if ( 72 | !basePath.endsWith("/") && 73 | correctedPath && 74 | !correctedPath.startsWith("/") 75 | ) { 76 | correctedPath = basePath + "/" + correctedPath; 77 | } else { 78 | correctedPath = basePath + correctedPath; 79 | } 80 | } 81 | 82 | const exportFolderName = 83 | process.env.nextImageExportOptimizer_exportFolderName || 84 | "nextImageExportOptimizer"; 85 | const basePathPrefixForStaticImages = basePath ? basePath + "/" : ""; 86 | 87 | let generatedImageURL = `${ 88 | isStaticImage ? basePathPrefixForStaticImages : correctedPath 89 | }${exportFolderName}/${filename}-opt-${width}.${processedExtension.toUpperCase()}`; 90 | 91 | // if the generatedImageURL is not starting with a slash, then we add one as long as it is not a remote image 92 | if (!isRemoteImage && generatedImageURL.charAt(0) !== "/") { 93 | generatedImageURL = "/" + generatedImageURL; 94 | } 95 | 96 | return generatedImageURL; 97 | }; 98 | 99 | // Credits to https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js 100 | // This is a hash function that is used to generate a hash from the image URL 101 | const hashAlgorithm = (str: string, seed = 0) => { 102 | let h1 = 0xdeadbeef ^ seed, 103 | h2 = 0x41c6ce57 ^ seed; 104 | for (let i = 0, ch; i < str.length; i++) { 105 | ch = str.charCodeAt(i); 106 | h1 = Math.imul(h1 ^ ch, 2654435761); 107 | h2 = Math.imul(h2 ^ ch, 1597334677); 108 | } 109 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); 110 | h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); 111 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); 112 | h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); 113 | 114 | return 4294967296 * (2097151 & h2) + (h1 >>> 0); 115 | }; 116 | 117 | function urlToFilename(url: string) { 118 | try { 119 | const parsedUrl = new URL(url); 120 | const extension = parsedUrl.pathname.split(".").pop(); 121 | if (extension) { 122 | return hashAlgorithm(url).toString().concat(".", extension); 123 | } 124 | } catch (error) { 125 | console.error("Error parsing URL", url, error); 126 | } 127 | return hashAlgorithm(url).toString(); 128 | } 129 | 130 | const imageURLForRemoteImage = ({ 131 | src, 132 | width, 133 | basePath, 134 | }: { 135 | src: string; 136 | width: number; 137 | basePath: string | undefined; 138 | }) => { 139 | const encodedSrc = urlToFilename(src); 140 | 141 | return generateImageURL(encodedSrc, width, basePath, true); 142 | }; 143 | 144 | const optimizedLoader = ({ 145 | src, 146 | width, 147 | basePath, 148 | }: { 149 | src: string | StaticImageData; 150 | width: number; 151 | basePath: string | undefined; 152 | }) => { 153 | const isStaticImage = typeof src === "object"; 154 | const _src = isStaticImage ? src.src : src; 155 | const originalImageWidth = (isStaticImage && src.width) || undefined; 156 | 157 | // if it is a static image, we can use the width of the original image to generate a reduced srcset that returns 158 | // the same image url for widths that are larger than the original image 159 | if (isStaticImage && originalImageWidth && width > originalImageWidth) { 160 | const deviceSizes = ( 161 | process.env.__NEXT_IMAGE_OPTS?.deviceSizes || [ 162 | 640, 750, 828, 1080, 1200, 1920, 2048, 3840, 163 | ] 164 | ).map(Number); 165 | const imageSizes = ( 166 | process.env.__NEXT_IMAGE_OPTS?.imageSizes || [ 167 | 16, 32, 48, 64, 96, 128, 256, 384, 168 | ] 169 | ).map(Number); 170 | let allSizes: number[] = [...deviceSizes, ...imageSizes]; 171 | allSizes = allSizes.filter((v, i, a) => a.indexOf(v) === i); 172 | allSizes.sort((a, b) => a - b); 173 | 174 | // only use the width if it is smaller or equal to the next size in the allSizes array 175 | let nextLargestSize = null; 176 | for (let i = 0; i < allSizes.length; i++) { 177 | if ( 178 | Number(allSizes[i]) >= originalImageWidth && 179 | (nextLargestSize === null || Number(allSizes[i]) < nextLargestSize) 180 | ) { 181 | nextLargestSize = Number(allSizes[i]); 182 | } 183 | } 184 | 185 | if (nextLargestSize !== null) { 186 | return generateImageURL(_src, nextLargestSize, basePath); 187 | } 188 | } 189 | 190 | // Check if the image is a remote image (starts with http or https) 191 | if (_src.startsWith("http")) { 192 | return imageURLForRemoteImage({ src: _src, width, basePath }); 193 | } 194 | 195 | return generateImageURL(_src, width, basePath); 196 | }; 197 | 198 | const fallbackLoader = ({ src }: { src: string | StaticImageData }) => { 199 | let _src = typeof src === "object" ? src.src : src; 200 | 201 | const isRemoteImage = _src.startsWith("http"); 202 | 203 | // if the _src does not start with a slash, then we add one as long as it is not a remote image 204 | if (!isRemoteImage && _src.charAt(0) !== "/") { 205 | _src = "/" + _src; 206 | } 207 | return _src; 208 | }; 209 | 210 | export interface ExportedImageProps 211 | extends Omit { 212 | src: string | StaticImageData; 213 | basePath?: string; 214 | } 215 | 216 | const ExportedImage = forwardRef( 217 | ( 218 | { 219 | src, 220 | priority = false, 221 | loading, 222 | className, 223 | width, 224 | height, 225 | onLoad, 226 | unoptimized, 227 | placeholder = "blur", 228 | basePath = "", 229 | alt = "", 230 | blurDataURL, 231 | style, 232 | onError, 233 | overrideSrc, 234 | ...rest 235 | }, 236 | ref 237 | ) => { 238 | const [imageError, setImageError] = useState(false); 239 | const automaticallyCalculatedBlurDataURL = useMemo(() => { 240 | if (blurDataURL) { 241 | // use the user provided blurDataURL if present 242 | return blurDataURL; 243 | } 244 | // check if the src is specified as a local file -> then it is an object 245 | const isStaticImage = typeof src === "object"; 246 | let _src = isStaticImage ? src.src : src; 247 | 248 | if (unoptimized === true) { 249 | // return the src image when unoptimized 250 | return _src; 251 | } 252 | // Check if the image is a remote image (starts with http or https) 253 | if (_src.startsWith("http")) { 254 | return imageURLForRemoteImage({ src: _src, width: 10, basePath }); 255 | } 256 | 257 | // otherwise use the generated image of 10px width as a blurDataURL 258 | return generateImageURL(_src, 10, basePath); 259 | }, [blurDataURL, src, unoptimized, basePath]); 260 | 261 | // check if the src is a SVG image -> then we should not use the blurDataURL and use unoptimized 262 | const isSVG = 263 | typeof src === "object" ? src.src.endsWith(".svg") : src.endsWith(".svg"); 264 | 265 | const [blurComplete, setBlurComplete] = useState(false); 266 | 267 | // Currently, we have to handle the blurDataURL ourselves as the new Image component 268 | // is expecting a base64 encoded string, but the generated blurDataURL is a normal URL 269 | const blurStyle = 270 | placeholder === "blur" && 271 | !isSVG && 272 | automaticallyCalculatedBlurDataURL && 273 | automaticallyCalculatedBlurDataURL.startsWith("/") && 274 | !blurComplete 275 | ? { 276 | backgroundSize: style?.objectFit || "cover", 277 | backgroundPosition: style?.objectPosition || "50% 50%", 278 | backgroundRepeat: "no-repeat", 279 | backgroundImage: `url("${automaticallyCalculatedBlurDataURL}")`, 280 | } 281 | : undefined; 282 | const isStaticImage = typeof src === "object"; 283 | 284 | let _src = isStaticImage ? src.src : src; 285 | if (basePath && !isStaticImage && _src.startsWith("/")) { 286 | _src = basePath + _src; 287 | } 288 | if (basePath && !isStaticImage && !_src.startsWith("/")) { 289 | _src = basePath + "/" + _src; 290 | } 291 | 292 | // Memoize the loader function 293 | const imageLoader = useMemo(() => { 294 | return imageError || unoptimized === true 295 | ? () => fallbackLoader({ src: overrideSrc || src }) 296 | : (e: { width: number }) => 297 | optimizedLoader({ src, width: e.width, basePath }); 298 | }, [imageError, unoptimized, overrideSrc, src, basePath]); 299 | 300 | const handleError = useCallback( 301 | (error: any) => { 302 | setImageError(true); 303 | setBlurComplete(true); 304 | // execute the onError function if provided 305 | onError && onError(error); 306 | }, 307 | [onError] 308 | ); 309 | 310 | const handleLoad = useCallback( 311 | (e: any) => { 312 | // for some configurations, the onError handler is not called on an error occurrence 313 | // so we need to check if the image is loaded correctly 314 | const target = e.target as HTMLImageElement; 315 | if (target.naturalWidth === 0) { 316 | // Broken image, fall back to unoptimized (meaning the original image src) 317 | setImageError(true); 318 | } 319 | setBlurComplete(true); 320 | 321 | // execute the onLoad callback if present 322 | onLoad && onLoad(e); 323 | }, 324 | [onLoad] 325 | ); 326 | 327 | return ( 328 | {alt} 354 | ); 355 | } 356 | ); 357 | ExportedImage.displayName = "ExportedImage"; 358 | export default ExportedImage; 359 | -------------------------------------------------------------------------------- /example/src/legacy/ExportedImage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useMemo, useState } from "react"; 4 | import Image, { ImageProps, StaticImageData } from "next/legacy/image"; 5 | 6 | const splitFilePath = ({ filePath }: { filePath: string }) => { 7 | const filenameWithExtension = 8 | filePath.split("\\").pop()?.split("/").pop() || ""; 9 | const filePathWithoutFilename = filePath.split(filenameWithExtension).shift(); 10 | const fileExtension = filePath.split(".").pop(); 11 | const filenameWithoutExtension = 12 | filenameWithExtension.substring( 13 | 0, 14 | filenameWithExtension.lastIndexOf(".") 15 | ) || filenameWithExtension; 16 | return { 17 | path: filePathWithoutFilename, 18 | filename: filenameWithoutExtension, 19 | extension: fileExtension || "", 20 | }; 21 | }; 22 | 23 | const generateImageURL = ( 24 | src: string, 25 | width: number, 26 | basePath: string | undefined, 27 | isRemoteImage: boolean = false 28 | ) => { 29 | const { filename, path, extension } = splitFilePath({ filePath: src }); 30 | const useWebp = 31 | process.env.nextImageExportOptimizer_storePicturesInWEBP != undefined 32 | ? process.env.nextImageExportOptimizer_storePicturesInWEBP == "true" 33 | : true; 34 | if ( 35 | !["JPG", "JPEG", "WEBP", "PNG", "AVIF", "GIF"].includes( 36 | extension.toUpperCase() 37 | ) 38 | ) { 39 | // The images has an unsupported extension 40 | // We will return the src 41 | return src; 42 | } 43 | // If the images are stored as WEBP by the package, then we should change 44 | // the extension to WEBP to load them correctly 45 | let processedExtension = extension; 46 | 47 | if ( 48 | useWebp && 49 | ["JPG", "JPEG", "PNG", "GIF"].includes(extension.toUpperCase()) 50 | ) { 51 | processedExtension = "WEBP"; 52 | } 53 | 54 | let correctedPath = path; 55 | const lastChar = correctedPath?.substr(-1); // Selects the last character 56 | if (lastChar != "/") { 57 | // If the last character is not a slash 58 | correctedPath = correctedPath + "/"; // Append a slash to it. 59 | } 60 | 61 | const isStaticImage = src.includes("_next/static/media"); 62 | 63 | if (!isStaticImage && basePath) { 64 | correctedPath = basePath + "/" + correctedPath; 65 | } 66 | 67 | const exportFolderName = 68 | process.env.nextImageExportOptimizer_exportFolderName || 69 | "nextImageExportOptimizer"; 70 | const basePathPrefixForStaticImages = basePath ? basePath + "/" : ""; 71 | 72 | let generatedImageURL = `${ 73 | isStaticImage ? basePathPrefixForStaticImages : correctedPath 74 | }${exportFolderName}/${filename}-opt-${width}.${processedExtension.toUpperCase()}`; 75 | 76 | // if the generatedImageURL is not starting with a slash, then we add one as long as it is not a remote image 77 | if (!isRemoteImage && generatedImageURL.charAt(0) !== "/") { 78 | generatedImageURL = "/" + generatedImageURL; 79 | } 80 | 81 | return generatedImageURL; 82 | }; 83 | 84 | // Credits to https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js 85 | // This is a hash function that is used to generate a hash from the image URL 86 | const hashAlgorithm = (str: string, seed = 0) => { 87 | let h1 = 0xdeadbeef ^ seed, 88 | h2 = 0x41c6ce57 ^ seed; 89 | for (let i = 0, ch; i < str.length; i++) { 90 | ch = str.charCodeAt(i); 91 | h1 = Math.imul(h1 ^ ch, 2654435761); 92 | h2 = Math.imul(h2 ^ ch, 1597334677); 93 | } 94 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); 95 | h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); 96 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); 97 | h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); 98 | 99 | return 4294967296 * (2097151 & h2) + (h1 >>> 0); 100 | }; 101 | 102 | function urlToFilename(url: string) { 103 | try { 104 | const parsedUrl = new URL(url); 105 | const extension = parsedUrl.pathname.split(".").pop(); 106 | if (extension) { 107 | return hashAlgorithm(url).toString().concat(".", extension); 108 | } 109 | } catch (error) { 110 | console.error("Error parsing URL", url, error); 111 | } 112 | return hashAlgorithm(url).toString(); 113 | } 114 | 115 | const imageURLForRemoteImage = ({ 116 | src, 117 | width, 118 | basePath, 119 | }: { 120 | src: string; 121 | width: number; 122 | basePath: string | undefined; 123 | }) => { 124 | const encodedSrc = urlToFilename(src); 125 | 126 | return generateImageURL(encodedSrc, width, basePath, true); 127 | }; 128 | 129 | const optimizedLoader = ({ 130 | src, 131 | width, 132 | basePath, 133 | }: { 134 | src: string | StaticImageData; 135 | width: number; 136 | basePath: string | undefined; 137 | }) => { 138 | const isStaticImage = typeof src === "object"; 139 | const _src = isStaticImage ? src.src : src; 140 | const originalImageWidth = (isStaticImage && src.width) || undefined; 141 | 142 | // if it is a static image, we can use the width of the original image to generate a reduced srcset that returns 143 | // the same image url for widths that are larger than the original image 144 | if (isStaticImage && originalImageWidth && width > originalImageWidth) { 145 | const deviceSizes = process.env.__NEXT_IMAGE_OPTS?.deviceSizes || [ 146 | 640, 750, 828, 1080, 1200, 1920, 2048, 3840, 147 | ]; 148 | const imageSizes = process.env.__NEXT_IMAGE_OPTS?.imageSizes || [ 149 | 16, 32, 48, 64, 96, 128, 256, 384, 150 | ]; 151 | const allSizes = [...deviceSizes, ...imageSizes]; 152 | 153 | // only use the width if it is smaller or equal to the next size in the allSizes array 154 | let nextLargestSize = null; 155 | for (let i = 0; i < allSizes.length; i++) { 156 | if ( 157 | Number(allSizes[i]) >= originalImageWidth && 158 | (nextLargestSize === null || Number(allSizes[i]) < nextLargestSize) 159 | ) { 160 | nextLargestSize = Number(allSizes[i]); 161 | } 162 | } 163 | 164 | if (nextLargestSize !== null) { 165 | return generateImageURL(_src, nextLargestSize, basePath); 166 | } 167 | } 168 | 169 | // Check if the image is a remote image (starts with http or https) 170 | if (_src.startsWith("http")) { 171 | return imageURLForRemoteImage({ src: _src, width, basePath }); 172 | } 173 | 174 | return generateImageURL(_src, width, basePath); 175 | }; 176 | 177 | const fallbackLoader = ({ 178 | src, 179 | basePath, 180 | }: { 181 | src: string | StaticImageData; 182 | basePath: string | undefined; 183 | }) => { 184 | let _src = typeof src === "object" ? src.src : src; 185 | const isRemoteImage = _src.startsWith("http"); 186 | 187 | // if the _src does not start with a slash, then we add one as long as it is not a remote image 188 | if (!isRemoteImage && _src.charAt(0) !== "/") { 189 | _src = "/" + _src; 190 | } 191 | 192 | if (basePath) { 193 | _src = basePath + _src; 194 | } 195 | return _src; 196 | }; 197 | 198 | export interface ExportedImageProps 199 | extends Omit { 200 | src: string | StaticImageData; 201 | basePath?: string; 202 | } 203 | 204 | function ExportedImage({ 205 | src, 206 | priority = false, 207 | loading, 208 | lazyRoot = null, 209 | lazyBoundary = "200px", 210 | className, 211 | width, 212 | height, 213 | objectFit, 214 | objectPosition, 215 | layout, 216 | onLoadingComplete, 217 | unoptimized, 218 | alt = "", 219 | placeholder = "blur", 220 | basePath = "", 221 | blurDataURL, 222 | onError, 223 | ...rest 224 | }: ExportedImageProps) { 225 | const [imageError, setImageError] = useState(false); 226 | 227 | const automaticallyCalculatedBlurDataURL = useMemo(() => { 228 | if (blurDataURL) { 229 | // use the user provided blurDataURL if present 230 | return blurDataURL; 231 | } 232 | // check if the src is specified as a local file -> then it is an object 233 | const isStaticImage = typeof src === "object"; 234 | let _src = isStaticImage ? src.src : src; 235 | if (unoptimized === true) { 236 | // return the src image when unoptimized 237 | if (!isStaticImage) { 238 | if (basePath && _src.startsWith("/")) { 239 | _src = basePath + _src; 240 | } 241 | if (basePath && !_src.startsWith("/")) { 242 | _src = basePath + "/" + _src; 243 | } 244 | } 245 | 246 | return _src; 247 | } 248 | // Check if the image is a remote image (starts with http or https) 249 | if (_src.startsWith("http")) { 250 | return imageURLForRemoteImage({ src: _src, width: 10, basePath }); 251 | } 252 | 253 | // otherwise use the generated image of 10px width as a blurDataURL 254 | return generateImageURL(_src, 10, basePath); 255 | }, [blurDataURL, src, unoptimized, basePath]); 256 | const isStaticImage = typeof src === "object"; 257 | let _src = isStaticImage ? src.src : src; 258 | if (!isStaticImage) { 259 | if (basePath && _src.startsWith("/")) { 260 | _src = basePath + _src; 261 | } 262 | if (basePath && !_src.startsWith("/")) { 263 | _src = basePath + "/" + _src; 264 | } 265 | } 266 | 267 | return ( 268 | {alt} fallbackLoader({ src, basePath }) 294 | : (e) => optimizedLoader({ src, width: e.width, basePath }) 295 | } 296 | blurDataURL={automaticallyCalculatedBlurDataURL} 297 | onError={(error) => { 298 | setImageError(true); 299 | // execute the onError function if provided 300 | onError && onError(error); 301 | }} 302 | onLoadingComplete={(result) => { 303 | // for some configurations, the onError handler is not called on an error occurrence 304 | // so we need to check if the image is loaded correctly 305 | if (result.naturalWidth === 0) { 306 | // Broken image, fall back to unoptimized (meaning the original image src) 307 | setImageError(true); 308 | } 309 | // execute the onLoadingComplete callback if present 310 | onLoadingComplete && onLoadingComplete(result); 311 | }} 312 | src={_src} 313 | /> 314 | ); 315 | } 316 | 317 | export default ExportedImage; 318 | -------------------------------------------------------------------------------- /example/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | padding-bottom: 2rem; 47 | } 48 | 49 | .title, 50 | .description { 51 | text-align: center; 52 | } 53 | 54 | .description { 55 | margin: 4rem 0; 56 | line-height: 1.5; 57 | font-size: 1.5rem; 58 | } 59 | 60 | .code { 61 | background: #fafafa; 62 | border-radius: 5px; 63 | padding: 0.75rem; 64 | font-size: 1.1rem; 65 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 66 | Bitstream Vera Sans Mono, Courier New, monospace; 67 | } 68 | 69 | .grid { 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | flex-wrap: wrap; 74 | max-width: 800px; 75 | } 76 | 77 | .card { 78 | margin: 1rem; 79 | padding: 1.5rem; 80 | text-align: left; 81 | color: inherit; 82 | text-decoration: none; 83 | border: 1px solid #eaeaea; 84 | border-radius: 10px; 85 | transition: color 0.15s ease, border-color 0.15s ease; 86 | max-width: 300px; 87 | } 88 | 89 | .card:hover, 90 | .card:focus, 91 | .card:active { 92 | color: #0070f3; 93 | border-color: #0070f3; 94 | } 95 | 96 | .card h2 { 97 | margin: 0 0 1rem 0; 98 | font-size: 1.5rem; 99 | } 100 | 101 | .card p { 102 | margin: 0; 103 | font-size: 1.25rem; 104 | line-height: 1.5; 105 | } 106 | 107 | .logo { 108 | height: 1em; 109 | margin-left: 0.5rem; 110 | } 111 | 112 | @media (max-width: 600px) { 113 | .grid { 114 | width: 100%; 115 | flex-direction: column; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /example/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /example/test/e2e/fixedImageSizeTest.spec.mjs: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import getImageById from "./getImageById.js"; 3 | 4 | // get the environment variable flag for the test 5 | const testBasePath = process.env.BASEPATH === "true"; 6 | const basePath = testBasePath ? "/subsite" : ""; 7 | const imagesWebP = 8 | process.env.IMAGESWEBP === "true" || process.env.IMAGESWEBP === undefined; 9 | 10 | const widths = [16, 32, 48, 64, 96, 128, 256, 384]; 11 | const correctSrc = { 12 | 16: [ 13 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-16.${ 14 | imagesWebP ? "WEBP" : "JPG" 15 | }`, 16 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-32.${ 17 | imagesWebP ? "WEBP" : "JPG" 18 | }`, 19 | ], 20 | 32: [ 21 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-32.${ 22 | imagesWebP ? "WEBP" : "JPG" 23 | }`, 24 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-64.${ 25 | imagesWebP ? "WEBP" : "JPG" 26 | }`, 27 | ], 28 | 48: [ 29 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-48.${ 30 | imagesWebP ? "WEBP" : "JPG" 31 | }`, 32 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-96.${ 33 | imagesWebP ? "WEBP" : "JPG" 34 | }`, 35 | ], 36 | 64: [ 37 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-64.${ 38 | imagesWebP ? "WEBP" : "JPG" 39 | }`, 40 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-128.${ 41 | imagesWebP ? "WEBP" : "JPG" 42 | }`, 43 | ], 44 | 96: [ 45 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-96.${ 46 | imagesWebP ? "WEBP" : "JPG" 47 | }`, 48 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-256.${ 49 | imagesWebP ? "WEBP" : "JPG" 50 | }`, 51 | ], 52 | 128: [ 53 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-128.${ 54 | imagesWebP ? "WEBP" : "JPG" 55 | }`, 56 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-256.${ 57 | imagesWebP ? "WEBP" : "JPG" 58 | }`, 59 | ], 60 | 256: [ 61 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-256.${ 62 | imagesWebP ? "WEBP" : "JPG" 63 | }`, 64 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-512.${ 65 | imagesWebP ? "WEBP" : "JPG" 66 | }`, 67 | ], 68 | 384: [ 69 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-384.${ 70 | imagesWebP ? "WEBP" : "JPG" 71 | }`, 72 | `http://localhost:8080${basePath}/images/nextImageExportOptimizer/chris-zhang-Jq8-3Bmh1pQ-unsplash-opt-768.${ 73 | imagesWebP ? "WEBP" : "JPG" 74 | }`, 75 | ], 76 | }; 77 | 78 | for (let index = 0; index < widths.length; index++) { 79 | const width = widths[index]; 80 | 81 | test.describe(`Test fixed width: ${width}`, () => { 82 | test.use({ 83 | viewport: { width: 1024, height: 1024 }, 84 | deviceScaleFactor: 1, 85 | }); 86 | test("should check the image size", async ({ page }) => { 87 | await page.goto(`${basePath}/fixedImage`, { 88 | waitUntil: "networkidle", 89 | }); 90 | 91 | await page.click("text=Next-Image-Export-Optimizer"); 92 | 93 | const img = await page.locator(`#test_image_${width}`); 94 | await img.click(); 95 | const testWidth = width; 96 | 97 | const image = await getImageById(page, `test_image_${testWidth}`); 98 | 99 | expect( 100 | correctSrc[width.toString()].includes(image.currentSrc) 101 | ).toBeTruthy(); 102 | const image_future = await getImageById( 103 | page, 104 | `test_image_${testWidth}_future` 105 | ); 106 | 107 | expect( 108 | correctSrc[width.toString()].includes(image_future.currentSrc) 109 | ).toBeTruthy(); 110 | 111 | // check the number of images on the page 112 | const images = await page.$$("img"); 113 | expect(images.length).toBe(24); 114 | }); 115 | }); 116 | } 117 | -------------------------------------------------------------------------------- /example/test/e2e/getImageById.js: -------------------------------------------------------------------------------- 1 | module.exports = async function getImageById(page, imageId) { 2 | return await page.evaluate((imageId) => { 3 | let img = document.getElementById(imageId); 4 | return { 5 | src: img.src, 6 | currentSrc: img.currentSrc, 7 | naturalWidth: img.naturalWidth, 8 | width: img.width, 9 | srcset: img.srcset, 10 | }; 11 | }, imageId); 12 | }; 13 | -------------------------------------------------------------------------------- /example/test/e2e/unoptimizedTest.spec.mjs: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import getImageById from "./getImageById.js"; 3 | 4 | // get the environment variable flag for the test 5 | const testBasePath = process.env.BASEPATH === "true"; 6 | const basePath = testBasePath ? "/subsite" : ""; 7 | 8 | test.describe(`Test unoptimized image prop`, () => { 9 | test.use({ 10 | viewport: { width: 1200, height: 1200 * 3 }, 11 | deviceScaleFactor: 1, 12 | }); 13 | test("should check the image size", async ({ page }) => { 14 | await page.goto(`${basePath}/`, { 15 | waitUntil: "networkidle", 16 | }); 17 | 18 | await page.click("text=Next-Image-Export-Optimizer"); 19 | 20 | const img = await page.locator("#test_image_unoptimized"); 21 | await img.click(); 22 | 23 | const image = await getImageById(page, "test_image_unoptimized"); 24 | 25 | expect(image.currentSrc).toBe( 26 | `http://localhost:8080${basePath}/images/chris-zhang-Jq8-3Bmh1pQ-unsplash.jpg` 27 | ); 28 | const img_legacy = await page.locator("#test_image_unoptimized_legacy"); 29 | await img_legacy.click(); 30 | 31 | const image_legacy = await getImageById( 32 | page, 33 | "test_image_unoptimized_legacy" 34 | ); 35 | 36 | expect(image_legacy.currentSrc).toBe( 37 | `http://localhost:8080${basePath}/images/chris-zhang-Jq8-3Bmh1pQ-unsplash.jpg` 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /example/testServer.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const path = require("path"); 3 | const app = express(); 4 | const port = 8080; 5 | 6 | // get the environment variable flag for the test 7 | const testBasePath = process.env.BASEPATH === "true"; 8 | const basePath = testBasePath ? "/subsite" : ""; 9 | 10 | const outPath = path.join(__dirname, "out"); 11 | 12 | app.use( 13 | basePath, 14 | express.static(outPath, { 15 | extensions: ["html", "htm"], 16 | }) 17 | ); 18 | 19 | app.listen(port, () => { 20 | if (testBasePath) 21 | console.log(`Server running at http://localhost:${port}/subsite`); 22 | else console.log(`Server running at http://localhost:${port}`); 23 | }); 24 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-image-export-optimizer", 3 | "version": "1.19.0", 4 | "description": "Optimizes all static images for Next.js static HTML export functionality", 5 | "main": "dist/ExportedImage.js", 6 | "types": "dist/ExportedImage.d.ts", 7 | "bin": "dist/optimizeImages.js", 8 | "engines": { 9 | "node": ">=16.0.0" 10 | }, 11 | "exports": { 12 | ".": { 13 | "types": "./dist/ExportedImage.d.ts", 14 | "require": "./dist/ExportedImage.js", 15 | "import": "./dist/ExportedImage.js" 16 | }, 17 | "./legacy/ExportedImage": { 18 | "types": "./dist/legacy/ExportedImage.d.ts", 19 | "require": "./dist/legacy/ExportedImage.js", 20 | "import": "./dist/legacy/ExportedImage.js" 21 | } 22 | }, 23 | "typesVersions": { 24 | "*": { 25 | "legacy/ExportedImage": [ 26 | "./dist/legacy/ExportedImage.d.ts" 27 | ] 28 | } 29 | }, 30 | "files": [ 31 | "dist/ExportedImage.js", 32 | "dist/ExportedImage.d.ts", 33 | "dist/legacy/ExportedImage.js", 34 | "dist/legacy/ExportedImage.d.ts", 35 | "dist/optimizeImages.js", 36 | "dist/utils" 37 | ], 38 | "scripts": { 39 | "build": "rimraf dist && tsc && tsc --project tsconfig.optimizeImages.json", 40 | "test": "jest", 41 | "test:e2e:basePath": "npx playwright install && BASEPATH=true IMAGESWEBP=false playwright test --config playwright-basePath.config.js", 42 | "test:e2e": "npx playwright install && playwright test", 43 | "exportExample": "cd example && npm run export && cd ..", 44 | "prepublishOnly": "npm run build && npm test && npm run test:e2e:basePath && npm run test:e2e", 45 | "fetchTags": "git fetch --tags -f" 46 | }, 47 | "repository": { 48 | "type": "git", 49 | "url": "https://github.com/Niels-IO/next-image-export-optimizer" 50 | }, 51 | "keywords": [ 52 | "next.js", 53 | "next", 54 | "static", 55 | "export", 56 | "image", 57 | "optimization", 58 | "webp", 59 | "sharp" 60 | ], 61 | "author": "Niels Grafen", 62 | "license": "MIT", 63 | "dependencies": { 64 | "sharp": "^0.33.1", 65 | "typescript": "^5.2.2" 66 | }, 67 | "peerDependencies": { 68 | "next": "^14.2.18 || ^15.0.3", 69 | "react": "^18.2.0 || ^19.0.0-0" 70 | }, 71 | "devDependencies": { 72 | "@next/eslint-plugin-next": "^15.0.3", 73 | "@playwright/test": "^1.39.0", 74 | "@types/react": "npm:types-react@rc", 75 | "eslint": "^8.57.0", 76 | "eslint-plugin-react": "^7.33.2", 77 | "jest": "^29.7.0", 78 | "rimraf": "^6.0.1", 79 | "ts-node": "^10.9.1" 80 | }, 81 | "overrides": { 82 | "@types/react": "npm:types-react@rc", 83 | "@types/react-dom": "npm:types-react-dom@rc" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /playwright-basePath.config.js: -------------------------------------------------------------------------------- 1 | const { devices } = require("@playwright/test"); 2 | const fs = require("fs"); 3 | 4 | const newConfigBasePath = ` 5 | import type { NextConfig } from 'next' 6 | 7 | const nextConfig: NextConfig = { 8 | images: { 9 | loader: "custom", 10 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 11 | deviceSizes: [640, 750, 777, 828, 1080, 1200, 1920, 2048, 3840], 12 | }, 13 | basePath: "/subsite", 14 | output: "export", 15 | transpilePackages: ["next-image-export-optimizer"], 16 | env: { 17 | nextImageExportOptimizer_imageFolderPath: "public/images", 18 | nextImageExportOptimizer_exportFolderPath: "out", 19 | nextImageExportOptimizer_exportFolderName: "nextImageExportOptimizer", 20 | nextImageExportOptimizer_quality: "75", 21 | nextImageExportOptimizer_storePicturesInWEBP: "false", 22 | nextImageExportOptimizer_generateAndUseBlurImages: "true", 23 | }, 24 | }; 25 | export default nextConfig 26 | `; 27 | // write config file for the to be tested configuration variables to the folder 28 | fs.writeFileSync("example/next.config.ts", newConfigBasePath); 29 | const config = { 30 | use: { 31 | baseURL: "http://localhost:8080/", 32 | }, 33 | testDir: "example/test/e2e", 34 | projects: [ 35 | { 36 | name: "chromium", 37 | use: { ...devices["Desktop Chrome"] }, 38 | }, 39 | { 40 | name: "firefox", 41 | use: { ...devices["Desktop Firefox"] }, 42 | }, 43 | ], 44 | webServer: { 45 | command: 46 | "cd example && npm run export && BASEPATH=true IMAGESWEBP=false node testServer.js", 47 | port: 8080, 48 | timeout: 120 * 1000, 49 | reuseExistingServer: false, 50 | stdout: "pipe", 51 | }, 52 | }; 53 | module.exports = config; 54 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | const { devices } = require("@playwright/test"); 2 | const fs = require("fs"); 3 | 4 | const newConfigBasePath = ` 5 | import type { NextConfig } from 'next' 6 | 7 | const nextConfig: NextConfig = { 8 | images: { 9 | loader: "custom", 10 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 11 | deviceSizes: [640, 750, 777, 828, 1080, 1200, 1920, 2048, 3840], 12 | }, 13 | output: "export", 14 | transpilePackages: ["next-image-export-optimizer"], 15 | env: { 16 | nextImageExportOptimizer_imageFolderPath: "public/images", 17 | nextImageExportOptimizer_exportFolderPath: "out", 18 | nextImageExportOptimizer_exportFolderName: "nextImageExportOptimizer", 19 | nextImageExportOptimizer_quality: "75", 20 | nextImageExportOptimizer_storePicturesInWEBP: "true", 21 | nextImageExportOptimizer_generateAndUseBlurImages: "true", 22 | }, 23 | }; 24 | export default nextConfig 25 | `; 26 | // write config file for the to be tested configuration variables to the folder 27 | fs.writeFileSync("example/next.config.ts", newConfigBasePath); 28 | const config = { 29 | use: { 30 | baseURL: "http://localhost:8080/", 31 | }, 32 | testDir: "example/test/e2e", 33 | projects: [ 34 | { 35 | name: "chromium", 36 | use: { ...devices["Desktop Chrome"] }, 37 | }, 38 | { 39 | name: "firefox", 40 | use: { ...devices["Desktop Firefox"] }, 41 | }, 42 | ], 43 | webServer: { 44 | command: "cd example && npm run export && cd out/ && npx serve -p 8080", 45 | port: 8080, 46 | timeout: 120 * 1000, 47 | reuseExistingServer: false, 48 | }, 49 | }; 50 | module.exports = config; 51 | -------------------------------------------------------------------------------- /src/optimizeImages.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { ImageObject } from "./utils/ImageObject"; 4 | 5 | const defineProgressBar = require("./utils/defineProgressBar"); 6 | const ensureDirectoryExists = require("./utils/ensureDirectoryExists"); 7 | const getAllFilesAsObject = require("./utils/getAllFilesAsObject"); 8 | const getHash = require("./utils/getHash"); 9 | import { getRemoteImageURLs } from "./utils/getRemoteImageURLs"; 10 | import { downloadImagesInBatches } from "./utils/downloadImagesInBatches"; 11 | 12 | const urlToFilename = require("./utils/urlToFilename"); 13 | 14 | const fs = require("fs"); 15 | const sharp = require("sharp"); 16 | const path = require("path"); 17 | 18 | const loadConfig = require("next/dist/server/config").default; 19 | 20 | // Check if the --name and --age arguments are present 21 | const nextConfigPathIndex = process.argv.indexOf("--nextConfigPath"); 22 | const exportFolderPathIndex = process.argv.indexOf("--exportFolderPath"); 23 | 24 | // Check if there is only one argument without a name present -> this is the case if the user does not provide the path to the next.config.[js/ts] file 25 | if (process.argv.length === 3) { 26 | // Colorize the output to red 27 | // Colorize the output to red 28 | console.error("\x1b[31m"); 29 | console.error( 30 | "next-image-export-optimizer: Breaking change: Please provide the path to the next.config.[js/ts] file as an argument with the name --nextConfigPath." 31 | ); 32 | // Reset the color 33 | console.error("\x1b[0m"); 34 | process.exit(1); 35 | } 36 | 37 | // Set the nextConfigPath and exportFolderPath variables to the corresponding arguments, or to undefined if the arguments are not present 38 | let nextConfigPath = 39 | nextConfigPathIndex !== -1 40 | ? process.argv[nextConfigPathIndex + 1] 41 | : undefined; 42 | let exportFolderPathCommandLine = 43 | exportFolderPathIndex !== -1 44 | ? process.argv[exportFolderPathIndex + 1] 45 | : undefined; 46 | 47 | if (nextConfigPath) { 48 | nextConfigPath = path.isAbsolute(nextConfigPath) 49 | ? nextConfigPath 50 | : path.join(process.cwd(), nextConfigPath); 51 | } else { 52 | // Check for next.config.js, next.config.ts, and next.config.mjs 53 | const jsConfigPath = path.join(process.cwd(), "next.config.js"); 54 | const tsConfigPath = path.join(process.cwd(), "next.config.ts"); 55 | const mjsConfigPath = path.join(process.cwd(), "next.config.mjs"); 56 | 57 | if (fs.existsSync(jsConfigPath)) { 58 | nextConfigPath = jsConfigPath; 59 | } else if (fs.existsSync(tsConfigPath)) { 60 | nextConfigPath = tsConfigPath; 61 | } else if (fs.existsSync(mjsConfigPath)) { 62 | nextConfigPath = mjsConfigPath; 63 | } else { 64 | console.error("\x1b[31m"); 65 | console.error( 66 | "next-image-export-optimizer: Could not find next.config.js, next.config.ts, or next.config.mjs. Please provide the path to the configuration file." 67 | ); 68 | console.error("\x1b[0m"); 69 | process.exit(1); 70 | } 71 | } 72 | const nextConfigFolder = path.dirname(nextConfigPath); 73 | 74 | const folderNameForRemoteImages = `remoteImagesForOptimization`; 75 | const folderPathForRemoteImages = path.join( 76 | nextConfigFolder, 77 | folderNameForRemoteImages 78 | ); 79 | 80 | if (exportFolderPathCommandLine) { 81 | exportFolderPathCommandLine = path.isAbsolute(exportFolderPathCommandLine) 82 | ? exportFolderPathCommandLine 83 | : path.join(process.cwd(), exportFolderPathCommandLine); 84 | } 85 | 86 | const nextImageExportOptimizer = async function () { 87 | console.log( 88 | "---- next-image-export-optimizer: Begin with optimization... ---- " 89 | ); 90 | 91 | // Default values 92 | let imageFolderPath = "public/images"; 93 | let staticImageFolderPath = ".next/static/media"; 94 | let exportFolderPath = "out"; 95 | let deviceSizes = [640, 750, 828, 1080, 1200, 1920, 2048, 3840]; 96 | let imageSizes = [16, 32, 48, 64, 96, 128, 256, 384]; 97 | let quality = 75; 98 | let storePicturesInWEBP = true; 99 | let blurSize: number[] = []; 100 | let remoteImageCacheTTL = 0; 101 | let exportFolderName = "nextImageExportOptimizer"; 102 | let remoteImageFileName = "remoteOptimizedImages.js"; 103 | 104 | let remoteImageFilenames: { 105 | basePath: string; 106 | file: any; 107 | dirPathWithoutBasePath: string; 108 | fullPath: string; 109 | }[] = []; 110 | let remoteImageURLs: string[] = []; 111 | 112 | try { 113 | // Read in the configuration parameters 114 | const nextjsConfig = await loadConfig("phase-export", nextConfigFolder); 115 | 116 | // Check if nextjsConfig is an object or is undefined 117 | if (typeof nextjsConfig !== "object" || nextjsConfig === null) { 118 | throw new Error("next.config.[js/ts] is not an object"); 119 | } 120 | const legacyPath = nextjsConfig.images?.nextImageExportOptimizer; 121 | const newPath = nextjsConfig.env; 122 | 123 | if (legacyPath?.remoteImagesFilename !== undefined) { 124 | remoteImageFileName = legacyPath.remoteImagesFilename; 125 | } else if ( 126 | newPath?.nextImageExportOptimizer_remoteImagesFilename !== undefined 127 | ) { 128 | remoteImageFileName = 129 | newPath.nextImageExportOptimizer_remoteImagesFilename; 130 | } 131 | 132 | if (legacyPath?.imageFolderPath !== undefined) { 133 | imageFolderPath = legacyPath.imageFolderPath; 134 | } else if ( 135 | newPath?.nextImageExportOptimizer_imageFolderPath !== undefined 136 | ) { 137 | imageFolderPath = newPath.nextImageExportOptimizer_imageFolderPath; 138 | // if the imageFolderPath starts with a slash, remove it 139 | if (imageFolderPath.startsWith("/")) { 140 | imageFolderPath = imageFolderPath.slice(1); 141 | } 142 | } 143 | if (legacyPath?.exportFolderPath !== undefined) { 144 | exportFolderPath = legacyPath.exportFolderPath; 145 | } else if ( 146 | newPath?.nextImageExportOptimizer_exportFolderPath !== undefined 147 | ) { 148 | exportFolderPath = newPath.nextImageExportOptimizer_exportFolderPath; 149 | } 150 | if (nextjsConfig.images?.deviceSizes !== undefined) { 151 | deviceSizes = nextjsConfig.images.deviceSizes; 152 | } 153 | if (nextjsConfig.images?.imageSizes !== undefined) { 154 | imageSizes = nextjsConfig.images.imageSizes; 155 | } 156 | 157 | if (legacyPath?.quality !== undefined) { 158 | quality = Number(legacyPath.quality); 159 | } else if (newPath?.nextImageExportOptimizer_quality !== undefined) { 160 | quality = Number(newPath.nextImageExportOptimizer_quality); 161 | } 162 | if (nextjsConfig.env?.storePicturesInWEBP !== undefined) { 163 | storePicturesInWEBP = 164 | nextjsConfig.env.storePicturesInWEBP.toLowerCase() == "true"; 165 | } else if ( 166 | newPath?.nextImageExportOptimizer_storePicturesInWEBP !== undefined 167 | ) { 168 | storePicturesInWEBP = 169 | newPath.nextImageExportOptimizer_storePicturesInWEBP.toLowerCase() == 170 | "true"; 171 | } 172 | if (nextjsConfig.env?.generateAndUseBlurImages?.toLowerCase() == "true") { 173 | blurSize = [10]; 174 | } else if ( 175 | newPath?.nextImageExportOptimizer_generateAndUseBlurImages == "true" 176 | ) { 177 | blurSize = [10]; 178 | } 179 | if (newPath.nextImageExportOptimizer_exportFolderName !== undefined) { 180 | exportFolderName = newPath.nextImageExportOptimizer_exportFolderName; 181 | } 182 | if (newPath.nextImageExportOptimizer_remoteImageCacheTTL !== undefined) { 183 | remoteImageCacheTTL = Number( 184 | newPath.nextImageExportOptimizer_remoteImageCacheTTL 185 | ); 186 | } 187 | 188 | // Give the user a warning if the transpilePackages: ["next-image-export-optimizer"], is not set in the next.config.[js/ts] 189 | if ( 190 | nextjsConfig.transpilePackages === undefined || // transpilePackages is not set 191 | (nextjsConfig.transpilePackages !== undefined && 192 | !nextjsConfig.transpilePackages.includes("next-image-export-optimizer")) // transpilePackages is set but does not include next-image-export-optimizer 193 | ) { 194 | console.warn( 195 | "\x1b[41m", 196 | `Changed in 1.2.0: You have not set transpilePackages: ["next-image-export-optimizer"] in your next.config.[js/ts]. This may cause problems with next-image-export-optimizer. Please add this line to your next.config.[js/ts].`, 197 | "\x1b[0m" 198 | ); 199 | } 200 | } catch (e) { 201 | // Configuration file not found 202 | console.log( 203 | "Could not find a next.config.js or next.config.ts file. Use of default values" 204 | ); 205 | } finally { 206 | const result = await getRemoteImageURLs( 207 | remoteImageFileName, 208 | nextConfigFolder, 209 | folderPathForRemoteImages 210 | ); 211 | remoteImageFilenames = result.remoteImageFilenames; 212 | remoteImageURLs = result.remoteImageURLs; 213 | } 214 | 215 | // if the user has specified a path for the export folder via the command line, use this path 216 | exportFolderPath = exportFolderPathCommandLine || exportFolderPath; 217 | 218 | // Give the user a warning, if the public directory of Next.js is not found as the user 219 | // may have run the command in a wrong directory 220 | if (!fs.existsSync(path.join(nextConfigFolder, "public"))) { 221 | console.warn( 222 | "\x1b[41m", 223 | `Could not find a public folder in this directory. Make sure you run the command in the main directory of your project.`, 224 | "\x1b[0m" 225 | ); 226 | } 227 | 228 | // Create the folder for the remote images if it does not exists 229 | if (remoteImageURLs.length > 0) { 230 | try { 231 | if (!fs.existsSync(folderNameForRemoteImages)) { 232 | fs.mkdirSync(folderNameForRemoteImages); 233 | console.log( 234 | `Create remote image output folder: ${folderNameForRemoteImages}` 235 | ); 236 | } 237 | } catch (err) { 238 | console.error(err); 239 | } 240 | } 241 | 242 | // Download the remote images specified in the remoteOptimizedImages.js file 243 | if (remoteImageURLs.length > 0) 244 | console.log( 245 | `Found ${remoteImageURLs.length} remote image${ 246 | remoteImageURLs.length > 1 ? "s" : "" 247 | }...` 248 | ); 249 | 250 | // we clear all images in the remote image folder that are not in the remoteImageURLs array 251 | const allFilesInRemoteImageFolder: string[] = fs.existsSync( 252 | folderNameForRemoteImages 253 | ) 254 | ? fs.readdirSync(folderNameForRemoteImages) 255 | : []; 256 | const encodedRemoteImageURLs = remoteImageURLs.map((url: string) => 257 | urlToFilename(url) 258 | ); 259 | 260 | function removeLastUpdated(str: string) { 261 | const suffix = ".lastUpdated"; 262 | if (str.endsWith(suffix)) { 263 | return str.slice(0, -suffix.length); 264 | } 265 | return str; 266 | } 267 | 268 | for (const filename of allFilesInRemoteImageFolder) { 269 | if ( 270 | encodedRemoteImageURLs.includes(filename) || 271 | encodedRemoteImageURLs.includes(removeLastUpdated(filename)) 272 | ) { 273 | // the filename is in the remoteImageURLs array or the filename without the .lastUpdated suffix 274 | // so we do not delete it 275 | continue; 276 | } 277 | 278 | fs.unlinkSync(path.join(folderNameForRemoteImages, filename)); 279 | 280 | console.log( 281 | `Deleted ${filename} from remote image folder as it is not retrieved from ${remoteImageFileName}.` 282 | ); 283 | } 284 | 285 | await downloadImagesInBatches( 286 | remoteImageURLs, 287 | remoteImageFilenames, 288 | folderPathForRemoteImages, 289 | Math.min(remoteImageURLs.length, 20), 290 | remoteImageCacheTTL 291 | ); 292 | 293 | // Create or read the JSON containing the hashes of the images in the image directory 294 | let imageHashes: { 295 | [key: string]: string; 296 | } = {}; 297 | const hashFilePath = `${imageFolderPath}/next-image-export-optimizer-hashes.json`; 298 | try { 299 | let rawData = fs.readFileSync(hashFilePath); 300 | imageHashes = JSON.parse(rawData); 301 | } catch (e) { 302 | // No image hashes yet 303 | } 304 | 305 | // check if the image folder is a subdirectory of the public folder 306 | // if not, the images in the image folder can only be static images and are taken from the static image folder (staticImageFolderPath) 307 | // so we do not add them to the images that need to be optimized 308 | 309 | const isImageFolderSubdirectoryOfPublicFolder = 310 | imageFolderPath.includes("public"); 311 | 312 | // Generate a warning if the image folder is not a subdirectory of the public folder 313 | if (!isImageFolderSubdirectoryOfPublicFolder) { 314 | console.warn( 315 | "\x1b[41mWarning: The image folder is not a subdirectory of the public folder. The images in the image folder are not optimized.\x1b[0m" 316 | ); 317 | } 318 | 319 | const allFilesInImageFolderAndSubdirectories = 320 | isImageFolderSubdirectoryOfPublicFolder 321 | ? getAllFilesAsObject(imageFolderPath, imageFolderPath, exportFolderName) 322 | : []; 323 | const allFilesInStaticImageFolder = getAllFilesAsObject( 324 | staticImageFolderPath, 325 | staticImageFolderPath, 326 | exportFolderName 327 | ); 328 | // append the static image folder to the image array 329 | allFilesInImageFolderAndSubdirectories.push(...allFilesInStaticImageFolder); 330 | 331 | // append the remote images to the image array 332 | if (remoteImageURLs.length > 0) { 333 | // get all files in the remote image folder again, as we added extensions to the filenames 334 | // if they were not present in the URLs in remoteOptimizedImages.js 335 | 336 | const allFilesInRemoteImageFolder = fs.readdirSync( 337 | folderNameForRemoteImages 338 | ); 339 | 340 | const remoteImageFiles = allFilesInRemoteImageFolder.map( 341 | (filename: string) => { 342 | const filenameFull = path.join(folderPathForRemoteImages, filename); 343 | 344 | return { 345 | basePath: folderPathForRemoteImages, 346 | file: filename, 347 | dirPathWithoutBasePath: "", 348 | fullPath: filenameFull, 349 | }; 350 | } 351 | ); 352 | 353 | // append the remote images to the image array 354 | allFilesInImageFolderAndSubdirectories.push(...remoteImageFiles); 355 | } 356 | 357 | const allImagesInImageFolder = allFilesInImageFolderAndSubdirectories.filter( 358 | (fileObject: ImageObject) => { 359 | if (fileObject === undefined) return false; 360 | if (fileObject.file === undefined) return false; 361 | // check if the file has a supported extension 362 | const filenameSplit = fileObject.file.split("."); 363 | if (filenameSplit.length === 1) return false; 364 | const extension = filenameSplit.pop()!.toUpperCase(); 365 | // Only include file with image extensions 366 | return ["JPG", "JPEG", "WEBP", "PNG", "AVIF", "GIF"].includes(extension); 367 | } 368 | ); 369 | console.log( 370 | `Found ${ 371 | allImagesInImageFolder.length - remoteImageURLs.length 372 | } supported images in ${imageFolderPath}, static folder and subdirectories and ${ 373 | remoteImageURLs.length 374 | } remote image${remoteImageURLs.length > 1 ? "s" : ""}.` 375 | ); 376 | 377 | let widths = [...blurSize, ...imageSizes, ...deviceSizes]; 378 | 379 | // sort the widths in ascending order to make sure the logic works for limiting the number of images 380 | widths.sort((a, b) => a - b); 381 | 382 | // remove duplicate widths from the array 383 | widths = widths.filter((item, index) => widths.indexOf(item) === index); 384 | 385 | const progressBar = defineProgressBar(); 386 | if (allImagesInImageFolder.length > 0) { 387 | console.log(`Using sizes: ${widths.toString()}`); 388 | console.log( 389 | `Start optimization of ${allImagesInImageFolder.length} images with ${ 390 | widths.length 391 | } sizes resulting in ${ 392 | allImagesInImageFolder.length * widths.length 393 | } optimized images...` 394 | ); 395 | progressBar.start(allImagesInImageFolder.length * widths.length, 0, { 396 | sizeOfGeneratedImages: 0, 397 | }); 398 | } 399 | let sizeOfGeneratedImages = 0; 400 | const allGeneratedImages: string[] = []; 401 | 402 | const updatedImageHashes: { 403 | [key: string]: string; 404 | } = {}; 405 | 406 | // Loop through all images 407 | for (let index = 0; index < allImagesInImageFolder.length; index++) { 408 | // try catch to catch errors in the loop and let the user know which image caused the error 409 | try { 410 | const file = allImagesInImageFolder[index].file; 411 | let fileDirectory = allImagesInImageFolder[index].dirPathWithoutBasePath; 412 | let basePath = allImagesInImageFolder[index].basePath; 413 | 414 | let extension = file.split(".").pop()!.toUpperCase(); 415 | const imageBuffer = fs.readFileSync( 416 | path.join(basePath, fileDirectory, file) 417 | ); 418 | const imageHash = getHash([ 419 | imageBuffer, 420 | ...widths, 421 | quality, 422 | fileDirectory, 423 | file, 424 | ]); 425 | const keyForImageHashes = `${fileDirectory}/${file}`; 426 | 427 | let hashContentChanged = false; 428 | if (imageHashes[keyForImageHashes] !== imageHash) { 429 | hashContentChanged = true; 430 | } 431 | // Store image hash in temporary object 432 | updatedImageHashes[keyForImageHashes] = imageHash; 433 | 434 | let optimizedOriginalWidthImagePath; 435 | let optimizedOriginalWidthImageSizeInMegabytes; 436 | 437 | // Loop through all widths 438 | for (let indexWidth = 0; indexWidth < widths.length; indexWidth++) { 439 | const width = widths[indexWidth]; 440 | 441 | const filename = path.parse(file).name; 442 | if (storePicturesInWEBP) { 443 | extension = "WEBP"; 444 | } 445 | 446 | const isStaticImage = basePath === staticImageFolderPath; 447 | // for a static image, we copy the image to public/nextImageExportOptimizer or public/${exportFolderName} 448 | // and not the staticImageFolderPath 449 | // as the static image folder is deleted before each build 450 | const basePathToStoreOptimizedImages = 451 | isStaticImage || 452 | basePath === path.join(nextConfigFolder, folderNameForRemoteImages) 453 | ? "public" 454 | : basePath; 455 | const optimizedFileNameAndPath = path.join( 456 | basePathToStoreOptimizedImages, 457 | fileDirectory, 458 | exportFolderName, 459 | `${filename}-opt-${width}.${extension.toUpperCase()}` 460 | ); 461 | 462 | // Check if file is already in hash and specific size and quality is present in the 463 | // opt file directory 464 | if ( 465 | !hashContentChanged && 466 | keyForImageHashes in imageHashes && 467 | fs.existsSync(optimizedFileNameAndPath) 468 | ) { 469 | const stats = fs.statSync(optimizedFileNameAndPath); 470 | const fileSizeInBytes = stats.size; 471 | const fileSizeInMegabytes = fileSizeInBytes / (1024 * 1024); 472 | sizeOfGeneratedImages += fileSizeInMegabytes; 473 | progressBar.increment({ 474 | sizeOfGeneratedImages: sizeOfGeneratedImages.toFixed(1), 475 | }); 476 | allGeneratedImages.push(optimizedFileNameAndPath); 477 | 478 | continue; 479 | } 480 | 481 | const transformer = sharp(imageBuffer, { 482 | animated: true, 483 | limitInputPixels: false, // disable pixel limit 484 | }); 485 | 486 | transformer.rotate(); 487 | 488 | const { width: metaWidth } = await transformer.metadata(); 489 | 490 | // For a static image, we can skip the image optimization and the copying 491 | // of the image for images with a width greater than the original image width 492 | // we will stop the loop at the first image with a width greater than the original image width 493 | let nextLargestSize = -1; 494 | for (let i = 0; i < widths.length; i++) { 495 | if ( 496 | Number(widths[i]) >= metaWidth && 497 | (nextLargestSize === -1 || Number(widths[i]) < nextLargestSize) 498 | ) { 499 | nextLargestSize = Number(widths[i]); 500 | } 501 | } 502 | 503 | if ( 504 | isStaticImage && 505 | nextLargestSize !== -1 && 506 | width > nextLargestSize 507 | ) { 508 | progressBar.increment({ 509 | sizeOfGeneratedImages: sizeOfGeneratedImages.toFixed(1), 510 | }); 511 | continue; 512 | } 513 | 514 | // If the original image's width is X, the optimized images are 515 | // identical for all widths >= X. Once we have generated the first of 516 | // these identical images, we can simply copy that file instead of redoing 517 | // the optimization. 518 | if ( 519 | optimizedOriginalWidthImagePath && 520 | optimizedOriginalWidthImageSizeInMegabytes 521 | ) { 522 | fs.copyFileSync( 523 | optimizedOriginalWidthImagePath, 524 | optimizedFileNameAndPath 525 | ); 526 | 527 | sizeOfGeneratedImages += optimizedOriginalWidthImageSizeInMegabytes; 528 | progressBar.increment({ 529 | sizeOfGeneratedImages: sizeOfGeneratedImages.toFixed(1), 530 | }); 531 | allGeneratedImages.push(optimizedFileNameAndPath); 532 | 533 | continue; 534 | } 535 | 536 | const resize = metaWidth && metaWidth > width; 537 | if (resize) { 538 | transformer.resize(width); 539 | } 540 | 541 | if (extension === "AVIF") { 542 | if (transformer.avif) { 543 | const avifQuality = quality - 15; 544 | transformer.avif({ 545 | quality: Math.max(avifQuality, 0), 546 | chromaSubsampling: "4:2:0", // same as webp 547 | }); 548 | } else { 549 | transformer.webp({ quality }); 550 | } 551 | } else if (extension === "WEBP" || storePicturesInWEBP) { 552 | transformer.webp({ quality }); 553 | } else if (extension === "PNG") { 554 | transformer.png({ quality }); 555 | } else if (extension === "JPEG" || extension === "JPG") { 556 | transformer.jpeg({ quality }); 557 | } else if (extension === "GIF") { 558 | transformer.gif({ quality }); 559 | } 560 | 561 | // Write the optimized image to the file system 562 | ensureDirectoryExists(optimizedFileNameAndPath); 563 | const info = await transformer.toFile(optimizedFileNameAndPath); 564 | const fileSizeInBytes = info.size; 565 | const fileSizeInMegabytes = fileSizeInBytes / (1024 * 1024); 566 | sizeOfGeneratedImages += fileSizeInMegabytes; 567 | progressBar.increment({ 568 | sizeOfGeneratedImages: sizeOfGeneratedImages.toFixed(1), 569 | }); 570 | allGeneratedImages.push(optimizedFileNameAndPath); 571 | 572 | if (!resize) { 573 | optimizedOriginalWidthImagePath = optimizedFileNameAndPath; 574 | optimizedOriginalWidthImageSizeInMegabytes = fileSizeInMegabytes; 575 | } 576 | } 577 | } catch (error) { 578 | console.log( 579 | ` 580 | Error while optimizing image ${allImagesInImageFolder[index].file} 581 | ${error} 582 | ` 583 | ); 584 | // throw the error so that the process stops 585 | throw error; 586 | } 587 | } 588 | let data = JSON.stringify(updatedImageHashes, null, 4); 589 | ensureDirectoryExists(hashFilePath); 590 | fs.writeFileSync(hashFilePath, data); 591 | 592 | // Copy the optimized images to the build folder 593 | 594 | console.log("\nCopy optimized images to build folder..."); 595 | for (let index = 0; index < allGeneratedImages.length; index++) { 596 | const filePath = allGeneratedImages[index]; 597 | const fileInBuildFolder = path.join( 598 | exportFolderPath, 599 | (() => { 600 | const parts = filePath.split("public"); 601 | if (parts.length > 1) { 602 | return parts.slice(1).join("public"); 603 | } else { 604 | // Handle case where 'public' is not found 605 | return filePath; 606 | } 607 | })() 608 | ); 609 | 610 | // Create the folder for the optimized images in the build directory if it does not exists 611 | ensureDirectoryExists(fileInBuildFolder); 612 | fs.copyFileSync(filePath, fileInBuildFolder); 613 | } 614 | 615 | function findSubfolders( 616 | rootPath: string, 617 | folderName: string, 618 | results: string[] = [] 619 | ) { 620 | const items = fs.readdirSync(rootPath); 621 | for (const item of items) { 622 | const itemPath = path.join(rootPath, item); 623 | const stat = fs.statSync(itemPath); 624 | if (stat.isDirectory()) { 625 | if (item === folderName) { 626 | results.push(itemPath); 627 | } 628 | findSubfolders(itemPath, folderName, results); 629 | } 630 | } 631 | return results; 632 | } 633 | 634 | const optimizedImagesFolders = findSubfolders( 635 | imageFolderPath, 636 | exportFolderName 637 | ); 638 | optimizedImagesFolders.push(`public/${exportFolderName}`); 639 | 640 | function findImageFiles( 641 | folderPath: string, 642 | extensions: string[], 643 | results: string[] = [] 644 | ) { 645 | // check if the folder exists 646 | if (!fs.existsSync(folderPath)) { 647 | return results; 648 | } 649 | const items = fs.readdirSync(folderPath); 650 | for (const item of items) { 651 | const itemPath = path.join(folderPath, item); 652 | const stat = fs.statSync(itemPath); 653 | if (stat.isDirectory()) { 654 | findImageFiles(itemPath, extensions, results); 655 | } else { 656 | const ext = path.extname(item).toUpperCase(); 657 | if (extensions.includes(ext)) { 658 | results.push(itemPath); 659 | } 660 | } 661 | } 662 | return results; 663 | } 664 | 665 | const imageExtensions = [".PNG", ".GIF", ".JPG", ".JPEG", ".AVIF", ".WEBP"]; 666 | 667 | const imagePaths: string[] = []; 668 | for (const subfolderPath of optimizedImagesFolders) { 669 | const paths = findImageFiles(subfolderPath, imageExtensions); 670 | imagePaths.push(...paths); 671 | } 672 | 673 | // find the optimized images that are no longer used in the project 674 | const unusedImages: string[] = []; 675 | for (const imagePath of imagePaths) { 676 | const isUsed = allGeneratedImages.includes(imagePath); 677 | if (!isUsed) { 678 | unusedImages.push(imagePath); 679 | } 680 | } 681 | // delete the unused images 682 | for (const imagePath of unusedImages) { 683 | if (fs.existsSync(imagePath)) { 684 | fs.unlinkSync(imagePath); 685 | } 686 | } 687 | if (unusedImages.length > 0) 688 | console.log( 689 | `Deleted ${unusedImages.length} unused image${ 690 | unusedImages.length > 1 ? "s" : "" 691 | } from the optimized images folders.` 692 | ); 693 | progressBar.stop(); 694 | 695 | console.log("---- next-image-export-optimizer: Done ---- "); 696 | process.exit(0); 697 | }; 698 | 699 | if (require.main === module) { 700 | nextImageExportOptimizer(); 701 | } 702 | module.exports = nextImageExportOptimizer; 703 | -------------------------------------------------------------------------------- /src/utils/ImageObject.ts: -------------------------------------------------------------------------------- 1 | export type ImageObject = { 2 | basePath: string; 3 | dirPathWithoutBasePath: string; 4 | file: string; 5 | fullPath?: string; 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/defineProgressBar.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | module.exports = function defineProgressBar() { 4 | let startTime: number; 5 | let total: number; 6 | let current: number = 0; 7 | let sizeOfGeneratedImages: number = 0; 8 | 9 | const updateProgress = () => { 10 | const percentage = Math.floor((current / total) * 100); 11 | const barLength = 50; 12 | const filledLength = Math.floor((percentage * barLength) / 100); 13 | const bar = "=".repeat(filledLength) + "-".repeat(barLength - filledLength); 14 | const eta = Math.round( 15 | (((Date.now() - startTime) / current) * (total - current)) / 1000 16 | ); 17 | 18 | // Use process.stdout.write for more consistent cross-platform behavior 19 | process.stdout.write( 20 | `\r${bar} ${percentage}% | ETA: ${eta}s | ${current}/${total} | Total size: ${sizeOfGeneratedImages.toFixed( 21 | 1 22 | )} MB` 23 | ); 24 | }; 25 | 26 | return { 27 | start: (totalValue: number, startValue: number, payload: any) => { 28 | total = totalValue; 29 | current = startValue; 30 | startTime = Date.now(); 31 | sizeOfGeneratedImages = payload.sizeOfGeneratedImages; 32 | updateProgress(); 33 | }, 34 | increment: (payload: any) => { 35 | current++; 36 | sizeOfGeneratedImages = parseFloat(payload.sizeOfGeneratedImages); 37 | updateProgress(); 38 | }, 39 | stop: () => { 40 | const endTime = Date.now(); 41 | const elapsedTime = endTime - startTime; 42 | process.stdout.write( 43 | `\nFinished optimization in: ${msToTime(elapsedTime)}\n` 44 | ); 45 | }, 46 | }; 47 | }; 48 | 49 | function msToTime(ms: number): string { 50 | let seconds = (ms / 1000).toFixed(1); 51 | let minutes = (ms / (1000 * 60)).toFixed(1); 52 | let hours = (ms / (1000 * 60 * 60)).toFixed(1); 53 | let days = (ms / (1000 * 60 * 60 * 24)).toFixed(1); 54 | if (parseFloat(seconds) < 60) return seconds + " seconds"; 55 | else if (parseFloat(minutes) < 60) return minutes + " minutes"; 56 | else if (parseFloat(hours) < 24) return hours + " hours"; 57 | else return days + " days"; 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/downloadImagesInBatches.ts: -------------------------------------------------------------------------------- 1 | import { ImageObject } from "./ImageObject"; 2 | import path from "path"; 3 | import fs from "fs"; 4 | const http = require("http"); 5 | const https = require("https"); 6 | const urlModule = require("url"); // Import url module to parse the url 7 | 8 | async function downloadImage(url: string, filename: string, folder: string) { 9 | return new Promise((resolve, reject) => { 10 | // Choose the right http library: 11 | const httpLib = urlModule.parse(url).protocol === "http:" ? http : https; 12 | 13 | const request = httpLib.get(url, function (response: any) { 14 | if (response.statusCode !== 200) { 15 | console.error( 16 | `Error: Unable to download ${url} (status code: ${response.statusCode}).` 17 | ); 18 | reject(new Error(`Status code: ${response.statusCode}`)); 19 | return; 20 | } 21 | // check if the file is a valid image by checking the content type 22 | if ( 23 | !response.headers["content-type"].startsWith("image/") && 24 | !response.headers["content-type"].startsWith("application/octet-stream") 25 | ) { 26 | console.error( 27 | `Error: Unable to download ${url} (invalid content type: ${response.headers["content-type"]}).` 28 | ); 29 | reject( 30 | new Error(`Invalid content type: ${response.headers["content-type"]}`) 31 | ); 32 | return; 33 | } 34 | 35 | // Extract image format from response headers 36 | const contentType = response.headers["content-type"]; 37 | let imageFormat = contentType.split("/").pop(); 38 | 39 | // Further split on semicolon (;) if exists 40 | if (imageFormat.includes(";")) { 41 | imageFormat = imageFormat.split(";")[0]; 42 | } 43 | 44 | // Further split on plus (+) if exists, e.g. image/svg+xml 45 | if (imageFormat.includes("+")) { 46 | imageFormat = imageFormat.split("+")[0]; 47 | } 48 | 49 | // Check for jpeg and change it to jpg if necessary 50 | if (imageFormat === "jpeg") { 51 | imageFormat = "jpg"; 52 | } 53 | 54 | // Check if filename already has an extension that matches the image format 55 | const regex = new RegExp(`.${imageFormat}$`, "i"); 56 | const hasMatchingExtension = regex.test(filename); 57 | 58 | // Add appropriate extension to filename based on image format 59 | const formattedFilename = hasMatchingExtension 60 | ? filename 61 | : `${filename}.${imageFormat}`; 62 | 63 | fs.access(folder, fs.constants.W_OK, function (err: any) { 64 | if (err) { 65 | console.error( 66 | `Error: Unable to write to ${folder} (${err.message}).` 67 | ); 68 | reject(err); 69 | return; 70 | } 71 | // on close, check the file size and reject if it's 0 otherwise resolve 72 | response 73 | .pipe(fs.createWriteStream(formattedFilename)) 74 | .on("error", function (err: any) { 75 | console.error( 76 | `Error: Unable to save ${formattedFilename} (${err.message}).` 77 | ); 78 | reject(err); 79 | }) 80 | .on("close", function () { 81 | fs.stat(formattedFilename, function (err: any, stats: any) { 82 | if (err) { 83 | console.error( 84 | `Error: Unable to get the size of ${formattedFilename} (${err.message}).` 85 | ); 86 | reject(err); 87 | return; 88 | } 89 | 90 | if (stats.size === 0) { 91 | console.error( 92 | `Error: Unable to save ${formattedFilename} (empty file).` 93 | ); 94 | reject(new Error("Empty file")); 95 | return; 96 | } 97 | // to cache the image locally, we store a file with the same name as the image, but with a .lastUpdated extension and the timestamp 98 | storeLastUpdated({ 99 | basePath: folder, 100 | file: formattedFilename, 101 | dirPathWithoutBasePath: "", 102 | fullPath: formattedFilename, 103 | }); 104 | 105 | resolve(); 106 | }); 107 | }); 108 | }); 109 | }); 110 | request.on("error", (err: Error) => { 111 | console.error(`Error: Unable to download ${url}.`, err); 112 | reject(err); 113 | }); 114 | }); 115 | } 116 | 117 | export async function downloadImagesInBatches( 118 | imagesURLs: string[], 119 | imageFileNames: ImageObject[], 120 | folder: string, 121 | batchSize: number, 122 | remoteImageCacheTTL: number 123 | ) { 124 | let downloadedImages = 0; 125 | let cachedImages = 0; 126 | const batches = Math.ceil(imagesURLs.length / batchSize); // determine the number of batches 127 | for (let i = 0; i < batches; i++) { 128 | const start = i * batchSize; // calculate the start index of the batch 129 | const end = Math.min(imagesURLs.length, start + batchSize); // calculate the end index of the batch 130 | const batchURLs = imagesURLs.slice(start, end); // slice the URLs for the current batch 131 | const batchFileNames = imageFileNames.slice(start, end); // slice the file names for the current batch 132 | for (let j = 0; j < batchFileNames.length; j++) { 133 | if (batchFileNames[j].fullPath === undefined) { 134 | console.error( 135 | `Error: Unable to download ${batchURLs[i]} (fullPath is undefined).` 136 | ); 137 | return; 138 | } 139 | } 140 | 141 | const promises = batchURLs.map((url, index) => { 142 | const file = batchFileNames[index]; 143 | if (file.fullPath === undefined) { 144 | console.error( 145 | `Error: Unable to download ${url} (fullPath is undefined).` 146 | ); 147 | return Promise.resolve(); 148 | } 149 | 150 | // check if the image was downloaded before and if it's still valid 151 | // if it's valid, skip downloading it again 152 | // if there is no .lastUpdated file, download the image 153 | // if there is a .lastUpdated file, check if it's older than the image 154 | // if it's older, download the image 155 | 156 | const lastUpdatedFilename = `${file.file}.lastUpdated`; 157 | const lastUpdatedPath = path.join(file.basePath, lastUpdatedFilename); 158 | 159 | let skipDownload = false; 160 | if (fs.existsSync(lastUpdatedPath) && fs.existsSync(file.fullPath)) { 161 | const lastUpdated = fs.readFileSync(lastUpdatedPath, "utf8"); 162 | const lastUpdatedTimestamp = parseInt(lastUpdated); 163 | const now = Date.now(); 164 | const timeDifferenceInSeconds = (now - lastUpdatedTimestamp) / 1000; 165 | 166 | if (timeDifferenceInSeconds < remoteImageCacheTTL) { 167 | // console.log( 168 | // `Skipping download of ${file.file} because it was downloaded ${timeDifferenceInSeconds} seconds ago.` 169 | // ); 170 | skipDownload = true; 171 | cachedImages++; 172 | } 173 | } else { 174 | // console.log("No .lastUpdated file found"); 175 | } 176 | 177 | if (skipDownload) { 178 | return Promise.resolve(); 179 | } 180 | downloadedImages++; 181 | return downloadImage(url, file.fullPath as string, folder); 182 | }); // create an array of promises for downloading images in the batch 183 | 184 | try { 185 | await Promise.all(promises); // download images in parallel for the current batch 186 | downloadedImages > 0 && 187 | console.log( 188 | `Downloaded ${downloadedImages} remote image${ 189 | downloadedImages > 1 ? "s" : "" 190 | }.` 191 | ); 192 | cachedImages > 0 && 193 | console.log( 194 | `Used ${cachedImages} cached remote image${ 195 | cachedImages > 1 ? "s" : "" 196 | }.` 197 | ); 198 | } catch (err: any) { 199 | console.error( 200 | `Error: Unable to download remote images (${err.message}).` 201 | ); 202 | throw err; 203 | } 204 | } 205 | } 206 | 207 | const storeLastUpdated = (file: ImageObject) => { 208 | // to cache the image locally, we need to know last time it was downloaded 209 | // so we can check if it's still valid 210 | // store a file with the same name as the image, but with a .lastUpdated extension and the timestamp 211 | const lastUpdated = Date.now(); 212 | 213 | const lastUpdatedFilename = `${file.file}.lastUpdated`; 214 | try { 215 | fs.writeFileSync(lastUpdatedFilename, lastUpdated.toString()); 216 | } catch (error) { 217 | console.error( 218 | `Error writing the cache file for ${lastUpdatedFilename}: `, 219 | error 220 | ); 221 | throw error; 222 | } 223 | }; 224 | -------------------------------------------------------------------------------- /src/utils/ensureDirectoryExists.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | 5 | function ensureDirectoryExists(filePath: string) { 6 | const dirName = path.dirname(filePath); 7 | if (fs.existsSync(dirName)) { 8 | return true; 9 | } 10 | ensureDirectoryExists(dirName); 11 | fs.mkdirSync(dirName); 12 | } 13 | module.exports = ensureDirectoryExists; 14 | -------------------------------------------------------------------------------- /src/utils/getAllFilesAsObject.ts: -------------------------------------------------------------------------------- 1 | import { ImageObject } from "./ImageObject"; 2 | const fs = require("fs"); 3 | module.exports = function getAllFilesAsObject( 4 | basePath: string, 5 | dirPath: string, 6 | exportFolderName: string, 7 | arrayOfFiles: ImageObject[] = [] 8 | ) { 9 | // check if the path is existing 10 | if (fs.existsSync(dirPath)) { 11 | let files = fs.readdirSync(dirPath); 12 | 13 | files.forEach(function (file: string) { 14 | if ( 15 | fs.statSync(dirPath + "/" + file).isDirectory() && 16 | file !== exportFolderName && 17 | file !== "nextImageExportOptimizer" // default export folder name 18 | ) { 19 | arrayOfFiles = getAllFilesAsObject( 20 | basePath, 21 | dirPath + "/" + file, 22 | exportFolderName, 23 | arrayOfFiles 24 | ); 25 | } else { 26 | const dirPathWithoutBasePath = dirPath 27 | .replace(basePath, "") // remove the basePath for later path composition 28 | .replace(/^(\/)/, ""); // remove the first trailing slash if there is one at the first position 29 | arrayOfFiles.push({ basePath, dirPathWithoutBasePath, file }); 30 | } 31 | }); 32 | } 33 | 34 | return arrayOfFiles; 35 | }; 36 | -------------------------------------------------------------------------------- /src/utils/getHash.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | const { createHash } = require("crypto"); 4 | 5 | module.exports = function getHash(items: string[]) { 6 | const hash = createHash("sha256"); 7 | for (let item of items) { 8 | if (typeof item === "number") hash.update(String(item)); 9 | else { 10 | hash.update(item); 11 | } 12 | } 13 | // See https://en.wikipedia.org/wiki/Base64#Filenames 14 | return hash.digest("base64").replace(/\//g, "-"); 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/getRemoteImageURLs.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | 4 | const urlToFilename = require("./urlToFilename"); 5 | 6 | export async function getRemoteImageURLs( 7 | remoteImageFileName: string, 8 | nextConfigFolder: string, 9 | folderPathForRemoteImages: string 10 | ) { 11 | let remoteImageURLs: string[] = []; 12 | const remoteImagesFilePath = path.join(nextConfigFolder, remoteImageFileName); 13 | 14 | if (fs.existsSync(remoteImagesFilePath)) { 15 | const remoteOptimizedImages = await require(remoteImagesFilePath); 16 | 17 | remoteImageURLs = remoteOptimizedImages; 18 | } 19 | // Create the filenames for the remote images 20 | const remoteImageFilenames = remoteImageURLs.map((url: string) => { 21 | const encodedURL = urlToFilename(url); 22 | 23 | const filename = path.join(folderPathForRemoteImages, encodedURL); 24 | 25 | return { 26 | basePath: folderPathForRemoteImages, 27 | file: encodedURL, 28 | dirPathWithoutBasePath: "", 29 | fullPath: filename, 30 | }; 31 | }); 32 | 33 | return { remoteImageFilenames, remoteImageURLs }; 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/urlToFilename.ts: -------------------------------------------------------------------------------- 1 | module.exports = function urlToFilename(url: string) { 2 | try { 3 | const parsedUrl = new URL(url); 4 | const extension = parsedUrl.pathname.split(".").pop(); 5 | if (extension) { 6 | return hashAlgorithm(url).toString().concat(".", extension); 7 | } 8 | return hashAlgorithm(url).toString(); 9 | } catch (error) { 10 | console.error("Error parsing URL", url, error); 11 | } 12 | }; 13 | 14 | // Credits to https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js 15 | // This is a hash function that is used to generate a hash from the image URL 16 | const hashAlgorithm = (str: string, seed = 0) => { 17 | let h1 = 0xdeadbeef ^ seed, 18 | h2 = 0x41c6ce57 ^ seed; 19 | for (let i = 0, ch; i < str.length; i++) { 20 | ch = str.charCodeAt(i); 21 | h1 = Math.imul(h1 ^ ch, 2654435761); 22 | h2 = Math.imul(h2 ^ ch, 1597334677); 23 | } 24 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); 25 | h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); 26 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); 27 | h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); 28 | 29 | return 4294967296 * (2097151 & h2) + (h1 >>> 0); 30 | }; 31 | -------------------------------------------------------------------------------- /test/optimizeImages.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const assert = require("assert"); 3 | const fs = require("fs"); 4 | const execSync = require("child_process").execSync; 5 | const sharp = require("sharp"); 6 | 7 | const deleteFolder = (folderName) => { 8 | if (fs.existsSync(folderName)) { 9 | fs.rmSync(folderName, { 10 | recursive: true, 11 | force: false, 12 | }); 13 | } 14 | assert(!fs.existsSync(folderName)); 15 | }; 16 | 17 | const filterForImages = (file) => { 18 | let extension = file.split(".").pop().toUpperCase(); 19 | // Stop if the file is not an image 20 | return ["JPG", "JPEG", "WEBP", "PNG", "GIF", "AVIF"].includes(extension); 21 | }; 22 | const getFiles = (dirPath) => 23 | fs.existsSync(dirPath) ? fs.readdirSync(dirPath) : []; 24 | 25 | const legacyConfig = `module.exports = { 26 | images: { 27 | loader: "custom", 28 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 29 | deviceSizes: [640, 750, 777, 828, 1080, 1200, 1920, 2048, 3840], 30 | }, 31 | output: "export", 32 | transpilePackages: ["next-image-export-optimizer"], 33 | env: { 34 | storePicturesInWEBP: "false", 35 | generateAndUseBlurImages: "true", 36 | }, 37 | }; 38 | `; 39 | const newConfig = `module.exports = { 40 | images: { 41 | loader: "custom", 42 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 43 | deviceSizes: [640, 750, 777, 828, 1080, 1200, 1920, 2048, 3840], 44 | }, 45 | output: "export", 46 | transpilePackages: ["next-image-export-optimizer"], 47 | env: { 48 | nextImageExportOptimizer_imageFolderPath: "public/images", 49 | nextImageExportOptimizer_exportFolderPath: "out", 50 | nextImageExportOptimizer_exportFolderName: "nextImageExportOptimizer", 51 | nextImageExportOptimizer_quality: "75", 52 | nextImageExportOptimizer_storePicturesInWEBP: "true", 53 | nextImageExportOptimizer_generateAndUseBlurImages: "true", 54 | nextImageExportOptimizer_remoteImageCacheTTL: "0", 55 | }, 56 | }; 57 | `; 58 | const newConfigJpeg = `module.exports = { 59 | images: { 60 | loader: "custom", 61 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 62 | deviceSizes: [640, 750, 777, 828, 1080, 1200, 1920, 2048, 3840], 63 | }, 64 | output: "export", 65 | transpilePackages: ["next-image-export-optimizer"], 66 | env: { 67 | nextImageExportOptimizer_imageFolderPath: "public/images", 68 | nextImageExportOptimizer_exportFolderPath: "out", 69 | nextImageExportOptimizer_quality: "75", 70 | nextImageExportOptimizer_storePicturesInWEBP: "false", 71 | nextImageExportOptimizer_generateAndUseBlurImages: "true", 72 | nextImageExportOptimizer_remoteImageCacheTTL: "0", 73 | }, 74 | }; 75 | `; 76 | const newConfigExportFolderName = `module.exports = { 77 | images: { 78 | loader: "custom", 79 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 80 | deviceSizes: [640, 750, 777, 828, 1080, 1200, 1920, 2048, 3840], 81 | }, 82 | output: "export", 83 | transpilePackages: ["next-image-export-optimizer"], 84 | env: { 85 | nextImageExportOptimizer_imageFolderPath: "public/images", 86 | nextImageExportOptimizer_exportFolderPath: "out", 87 | nextImageExportOptimizer_quality: "75", 88 | nextImageExportOptimizer_storePicturesInWEBP: "false", 89 | nextImageExportOptimizer_generateAndUseBlurImages: "true", 90 | nextImageExportOptimizer_exportFolderName: "nextImageExportOptimizer2", 91 | }, 92 | }; 93 | `; 94 | 95 | const newConfigBasePath = `module.exports = { 96 | basePath: "/subsite", 97 | images: { 98 | loader: "custom", 99 | imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], 100 | deviceSizes: [640, 750, 777, 828, 1080, 1200, 1920, 2048, 3840], 101 | }, 102 | transpilePackages: ["next-image-export-optimizer"], 103 | env: { 104 | nextImageExportOptimizer_imageFolderPath: "public/images", 105 | nextImageExportOptimizer_exportFolderPath: "out", 106 | nextImageExportOptimizer_exportFolderName: "nextImageExportOptimizer", 107 | nextImageExportOptimizer_quality: "75", 108 | nextImageExportOptimizer_storePicturesInWEBP: "true", 109 | nextImageExportOptimizer_generateAndUseBlurImages: "true", 110 | nextImageExportOptimizer_remoteImageCacheTTL: "0", 111 | }, 112 | }; 113 | `; 114 | 115 | async function testConfig(config) { 116 | deleteFolder("example/public/images/nextImageExportOptimizer"); 117 | deleteFolder("example/public/nextImageExportOptimizer"); 118 | deleteFolder("example/public/images/subfolder/nextImageExportOptimizer"); 119 | deleteFolder( 120 | "example/public/images/subfolder/subfolder2/nextImageExportOptimizer" 121 | ); 122 | deleteFolder("example/public/images/nextImageExportOptimizer2"); 123 | deleteFolder("example/public/nextImageExportOptimizer2"); 124 | deleteFolder("example/public/images/subfolder/nextImageExportOptimizer2"); 125 | deleteFolder( 126 | "example/public/images/subfolder/subfolder2/nextImageExportOptimizer2" 127 | ); 128 | // write config file for the to be tested configuration variables to the folder 129 | fs.writeFileSync("example/next.config.ts", config); 130 | 131 | deleteFolder("example/out/images/nextImageExportOptimizer"); 132 | deleteFolder("example/out/nextImageExportOptimizer"); 133 | deleteFolder("example/out/images/subfolder/nextImageExportOptimizer"); 134 | deleteFolder( 135 | "example/out/images/subfolder/subfolder2/nextImageExportOptimizer" 136 | ); 137 | deleteFolder("example/out/images/nextImageExportOptimizer2"); 138 | deleteFolder("example/out/nextImageExportOptimizer2"); 139 | deleteFolder("example/out/images/subfolder/nextImageExportOptimizer2"); 140 | deleteFolder( 141 | "example/out/images/subfolder/subfolder2/nextImageExportOptimizer2" 142 | ); 143 | 144 | execSync( 145 | "npm run build && cd example/ && npm run export && node ../dist/optimizeImages.js" 146 | ); 147 | 148 | const allFilesInImageFolder = getFiles( 149 | "example/public/images/nextImageExportOptimizer" 150 | ); 151 | const allImagesInImageFolder = allFilesInImageFolder.filter(filterForImages); 152 | const allFilesInStaticImageFolder = getFiles( 153 | "example/public/nextImageExportOptimizer" 154 | ); 155 | const allImagesInStaticImageFolder = 156 | allFilesInStaticImageFolder.filter(filterForImages); 157 | 158 | const allFilesInImageSubFolder = getFiles( 159 | "example/public/images/subfolder/nextImageExportOptimizer" 160 | ); 161 | const allImagesInImageSubFolder = 162 | allFilesInImageSubFolder.filter(filterForImages); 163 | 164 | const allFilesInImageBuildFolder = getFiles( 165 | "example/out/images/nextImageExportOptimizer" 166 | ); 167 | const allFilesInStaticImageBuildFolder = getFiles( 168 | "example/out/nextImageExportOptimizer" 169 | ); 170 | 171 | const allFilesInImageBuildSubFolder = getFiles( 172 | "example/out/images/subfolder/nextImageExportOptimizer" 173 | ); 174 | 175 | // For custom export folder name 176 | const allFilesInImageFolderCustomExportFolder = getFiles( 177 | "example/public/images/nextImageExportOptimizer2" 178 | ); 179 | 180 | const allImagesInImageFolderCustomExportFolder = 181 | allFilesInImageFolderCustomExportFolder.filter(filterForImages); 182 | const allFilesInStaticImageFolderCustomExportFolder = getFiles( 183 | "example/public/nextImageExportOptimizer2" 184 | ); 185 | 186 | const allImagesInStaticImageFolderCustomExportFolder = 187 | allFilesInStaticImageFolderCustomExportFolder.filter(filterForImages); 188 | 189 | const allFilesInImageSubFolderCustomExportFolder = getFiles( 190 | "example/public/images/subfolder/nextImageExportOptimizer2" 191 | ); 192 | 193 | const allImagesInImageSubFolderCustomExportFolder = 194 | allFilesInImageSubFolderCustomExportFolder.filter(filterForImages); 195 | 196 | const allFilesInImageBuildFolderCustomExportFolder = getFiles( 197 | "example/out/images/nextImageExportOptimizer2" 198 | ); 199 | 200 | const allFilesInStaticImageBuildFolderCustomExportFolder = getFiles( 201 | "example/out/nextImageExportOptimizer2" 202 | ); 203 | 204 | const allFilesInImageBuildSubFolderCustomExportFolder = getFiles( 205 | "example/out/images/subfolder/nextImageExportOptimizer2" 206 | ); 207 | 208 | if ( 209 | config === newConfig || 210 | config === legacyConfig || 211 | config === newConfigBasePath 212 | ) { 213 | expect(allImagesInImageFolder).toMatchSnapshot(); 214 | expect(allImagesInStaticImageFolder).toMatchSnapshot(); 215 | 216 | expect(allImagesInImageSubFolder).toMatchSnapshot(); 217 | expect(allFilesInImageBuildFolder).toMatchSnapshot(); 218 | expect(allFilesInStaticImageFolder).toMatchSnapshot(); 219 | expect(allFilesInImageBuildSubFolder).toMatchSnapshot(); 220 | } else if (config === newConfigExportFolderName) { 221 | expect(allImagesInImageFolderCustomExportFolder).toMatchSnapshot(); 222 | expect(allImagesInStaticImageFolderCustomExportFolder).toMatchSnapshot(); 223 | expect(allImagesInImageSubFolderCustomExportFolder).toMatchSnapshot(); 224 | expect(allFilesInImageBuildFolderCustomExportFolder).toMatchSnapshot(); 225 | expect( 226 | allFilesInStaticImageBuildFolderCustomExportFolder 227 | ).toMatchSnapshot(); 228 | expect(allFilesInImageBuildSubFolderCustomExportFolder).toMatchSnapshot(); 229 | } else { 230 | expect(allImagesInImageFolder).toMatchSnapshot(); 231 | expect(allImagesInStaticImageFolder).toMatchSnapshot(); 232 | expect(allImagesInImageSubFolder).toMatchSnapshot(); 233 | expect(allFilesInImageBuildFolder).toMatchSnapshot(); 234 | expect(allFilesInStaticImageBuildFolder).toMatchSnapshot(); 235 | expect(allFilesInImageBuildSubFolder).toMatchSnapshot(); 236 | } 237 | 238 | const imageFolders = [ 239 | { 240 | basePath: "example/public/images/nextImageExportOptimizer", 241 | imageFileArray: allImagesInImageFolder, 242 | }, 243 | { 244 | basePath: "example/public/images/subfolder/nextImageExportOptimizer", 245 | imageFileArray: allFilesInImageBuildSubFolder, 246 | }, 247 | { 248 | basePath: "example/public/nextImageExportOptimizer", 249 | imageFileArray: allImagesInStaticImageFolder, 250 | }, 251 | { 252 | basePath: "example/public/images/nextImageExportOptimizer2", 253 | imageFileArray: allImagesInImageFolderCustomExportFolder, 254 | }, 255 | { 256 | basePath: "example/public/images/subfolder/nextImageExportOptimizer2", 257 | imageFileArray: allFilesInImageBuildSubFolderCustomExportFolder, 258 | }, 259 | { 260 | basePath: "example/public/nextImageExportOptimizer2", 261 | imageFileArray: allImagesInStaticImageFolderCustomExportFolder, 262 | }, 263 | ]; 264 | for (let index = 0; index < imageFolders.length; index++) { 265 | const imageFolderBasePath = imageFolders[index].basePath; 266 | const imageFileArray = imageFolders[index].imageFileArray; 267 | 268 | const imageFileStats = []; 269 | for (let index = 0; index < imageFileArray.length; index++) { 270 | const imageFile = imageFileArray[index]; 271 | const image = await sharp(`${imageFolderBasePath}/${imageFile}`); 272 | const metadata = await image.metadata(); 273 | const statsToBeChecked = [ 274 | metadata.format, 275 | metadata.width, 276 | metadata.height, 277 | ]; 278 | imageFileStats.push(statsToBeChecked); 279 | } 280 | if ( 281 | config === newConfig || 282 | config === legacyConfig || 283 | config === newConfigBasePath 284 | ) { 285 | if (index == 0 || index == 2) { 286 | expect(imageFileStats).toMatchSnapshot(); 287 | } else if (index === 1) { 288 | expect(imageFileStats).toMatchSnapshot(); 289 | } 290 | } 291 | if (config === newConfigJpeg) { 292 | if (index == 0 || index == 2) { 293 | expect(imageFileStats).toMatchSnapshot(); 294 | } else if (index === 1) { 295 | expect(imageFileStats).toMatchSnapshot(); 296 | } 297 | } 298 | } 299 | } 300 | 301 | jest.setTimeout(180000); 302 | test("legacyConfig", async () => { 303 | console.log("Running legacyConfig test..."); 304 | await testConfig(legacyConfig); 305 | console.log("legacyConfig test finished."); 306 | }); 307 | 308 | test("newConfigJpeg", async () => { 309 | console.log("Running newConfigJpeg test..."); 310 | await testConfig(newConfigJpeg); 311 | console.log("newConfigJpeg test finished."); 312 | }); 313 | 314 | test("newConfigExportFolderName", async () => { 315 | console.log("Running newConfigExportFolderName test..."); 316 | await testConfig(newConfigExportFolderName); 317 | console.log("newConfigExportFolderName test finished."); 318 | }); 319 | 320 | test("newConfigBasePath", async () => { 321 | console.log("Running newConfigBasePath test..."); 322 | await testConfig(newConfigBasePath); 323 | console.log("newConfigBasePath test finished."); 324 | }); 325 | 326 | test("newConfig", async () => { 327 | console.log("Running newConfig test..."); 328 | await testConfig(newConfig); 329 | console.log("newConfig test finished."); 330 | }); 331 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "react" /* Specify what JSX code is generated. */, 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "ES2022" /* Specify what module code is generated. */, 28 | // "rootDir": "./src" /* Specify the root folder within your source files. */, 29 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | "emitDeclarationOnly": false /* Only output d.ts files and not JavaScript files. */, 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./dist/index.js" /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */, 50 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./dist" /* Specify the output directory for generated declaration files. */, 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "exclude": [ 102 | "node_modules", 103 | "example", 104 | "dist", 105 | "src/optimizeImages.ts", 106 | "src/utils" 107 | ], 108 | "files": [ 109 | "example/src/ExportedImage.tsx", 110 | "example/src/legacy/ExportedImage.tsx", 111 | "example/environment.d.ts" 112 | ] 113 | } 114 | -------------------------------------------------------------------------------- /tsconfig.optimizeImages.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "react" /* Specify what JSX code is generated. */, 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "CommonJS" /* Specify what module code is generated. */, 28 | // "rootDir": "./src" /* Specify the root folder within your source files. */, 29 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | "emitDeclarationOnly": false /* Only output d.ts files and not JavaScript files. */, 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./dist/index.js" /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */, 50 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./dist" /* Specify the output directory for generated declaration files. */, 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "exclude": [ 102 | "node_modules", 103 | "example", 104 | "dist", 105 | "src/legacy", 106 | "src/ExportedImage.tsx" 107 | ] 108 | } 109 | --------------------------------------------------------------------------------