├── .babelrc ├── .eslintrc.json ├── .github ├── bundlewatch.json ├── renovate.json └── workflows │ ├── stats.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── images.d.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── cache.ts ├── convert │ ├── index.ts │ └── webp.ts ├── index.ts ├── lqip │ ├── blur.ts │ └── colors.ts ├── optimize │ ├── gif.ts │ ├── index.ts │ ├── jpeg.ts │ ├── png.ts │ ├── svg.ts │ └── webp.ts ├── options.ts ├── parseQuery.ts ├── processImage.ts ├── processLoaders.ts └── util.ts ├── tsconfig.json └── types ├── get-rgba-palette.d.ts └── url-loader.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "node": 10 6 | } 7 | }], 8 | "@babel/typescript" 9 | ], 10 | "ignore": [ 11 | "**/*.d.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": [ 4 | "@typescript-eslint" 5 | ], 6 | "extends": [ 7 | "airbnb-base", 8 | "plugin:prettier/recommended", 9 | "prettier/@typescript-eslint", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "ignorePatterns": [ 14 | "**/node_modules/**", 15 | "**/lib/**", 16 | "**/*.d.ts" 17 | ], 18 | "settings": { 19 | "import/resolver": { 20 | "node": { 21 | "extensions": [".js", ".ts"] 22 | } 23 | } 24 | }, 25 | "rules": { 26 | "import/extensions": ["error", { 27 | "d.ts": "always" 28 | }], 29 | "import/no-extraneous-dependencies": "off", 30 | "import/prefer-default-export": "off", 31 | "@typescript-eslint/no-use-before-define": "off" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/bundlewatch.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | { 4 | "path": "./lib/**/*.*", 5 | "compression": "none" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "rangeStrategy": "replace", 6 | "vulnerabilityAlerts": { 7 | "enabled": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/stats.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ master ] 4 | pull_request: 5 | types: [ opened, synchronize ] 6 | 7 | name: Stats 8 | 9 | jobs: 10 | stats: 11 | name: Generate Stats 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Install dependencies 16 | run: npm ci 17 | - name: Build 18 | run: npm run build 19 | - uses: cyrilwanner/bundlewatch-comment-action@v1 20 | with: 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | bundlewatch-github-token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [10.x, 12.x, 14.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: Install dependencies 25 | run: npm ci 26 | - name: Build 27 | run: npm run build 28 | - name: Lint 29 | run: npm run lint 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # os 2 | .DS_Store 3 | 4 | # editor 5 | .vscode 6 | .idea 7 | 8 | # node 9 | node_modules 10 | npm-debug.log* 11 | 12 | # project 13 | lib 14 | tmp 15 | .cache 16 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Cyril Wanner 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 | # optimized-images-loader [![npm version](https://badgen.net/npm/v/optimized-images-loader)](https://www.npmjs.com/package/optimized-images-loader) [![license](https://badgen.net/github/license/cyrilwanner/optimized-images-loader)](https://github.com/cyrilwanner/optimized-images-loader/blob/master/LICENSE) [![downloads](https://badgen.net/npm/dt/optimized-images-loader)](https://www.npmjs.com/package/optimized-images-loader) 2 | 3 | Features: 4 | - **Optimize** images using WebAssembly (runs in every environment) 5 | - **Image manipulation** provided by various query params (resize, converting, low quality placeholders, ...) 6 | - **Build cache for images** for faster builds 7 | - **Convert to WebP** automatically during a webpack build 8 | - **Inline** small images automatically 9 | - ... 10 | 11 | ## Table of contents 12 | 13 | - [Installation](#installation) 14 | - [Options](#options) 15 | - [Usage](#usage) 16 | - [License](#license) 17 | 18 | ## Installation 19 | 20 | ``` 21 | npm install optimized-images-loader 22 | ``` 23 | 24 | Add the loader to your webpack configuration: 25 | 26 | ```javascript 27 | module.exports = { 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(png|jpe?g|gif|svg|webp)$/i, 32 | use: [ 33 | { 34 | loader: 'optimized-images-loader', 35 | options: { 36 | // see below for available options 37 | }, 38 | }, 39 | ], 40 | }, 41 | ], 42 | }, 43 | }; 44 | ``` 45 | 46 | ## Options 47 | 48 | | Option | Default | Type | Description | 49 | | :--- | :------: | :--: | :---------- | 50 | | limit | `8192` | `number` | Images smaller than this number (in bytes) will get inlined with a data-uri. | 51 | | optimize | `true` | `boolean` | If this plugin should not optimized images, set this to `false`. You can still resize images, convert them to WebP and use other features in that case. | 52 | | cacheFolder | `'node_modules/optimized-images-loader/.cache'` | `string` | Images will be cached in this folder to avoid long build times. | 53 | | includeStrategy | `string` | `'string'` | When using the [?include](#include) query param, it returns a string by default. By setting this value to `'react'`, it returns a React component instead (requires manually installing the additional `@svgr/core` package). | 54 | | name | `'[name]-[contenthash].[ext]'` | `string` | File name of the images after they got processed. Additionally to the [default placeholders](https://github.com/webpack-contrib/file-loader#placeholders), `[width]` and `[height]` are also available. | 55 | | outputPath | | `string` | Images will be saved in this directory instead of the default webpack outputPath. | 56 | | publicPath | | `string` | The public path that should be used for image URLs instead of the default webpack publicPath. | 57 | | mozjpeg | | `MozjpegOptions` | Specifies the options used to optimize jpeg images. All available options can be seen [here](https://www.npmjs.com/package/@wasm-codecs/mozjpeg#encodeoptions-encodeoptions). | 58 | | oxipng | | `OxipngOptions` | Specifies the options used to optimize png images. All available options can be seen [here](https://www.npmjs.com/package/@wasm-codecs/oxipng#encodeoptions-encodeoptions). | 59 | | webp | | `WebpOptions` | Specifies the options used to optimize webp images. All available options can be seen [here](https://sharp.pixelplumbing.com/api-output#webp). | 60 | | svgo | | `SvgoOptions` | Specifies the options used to optimize svg images. All available options can be seen [here](https://github.com/svg/svgo#what-it-can-do). | 61 | 62 | ## Usage 63 | 64 | You can now simply import images in your projects the same way as you would import source code. 65 | 66 | ```javascript 67 | import Header from './images/header.jpg'; 68 | 69 | export default () => ( 70 |
71 | 72 |
73 | ); 74 | ``` 75 | 76 | This loader also provides a variety of query params to provide you even more options: 77 | 78 | * [`?include`](#include): Include the raw file directly (useful for SVG icons) 79 | * [`?webp`](#webp): Convert an image to WebP on the fly 80 | * [`?inline`](#inline): Force inlining an image (data-uri) 81 | * [`?url`](#url): Force an URL for a small image (instead of data-uri) 82 | * [`?original`](#original): Use the original image and do not optimize it 83 | * [`?lqip`](#lqip): Generate a low quality image placeholder 84 | * [`?colors`](#colors): Extract the dominant colors of an image 85 | * [`?width`](#width): Resize an image to the given width 86 | * [`?height`](#height): Resize an image to the given height 87 | * [`?trace`](#trace): Use traced outlines as loading placeholder *(currently not supported)* 88 | * [`?sprite`](#sprite): Use SVG sprites *(currently not supported)* 89 | 90 | #### ?include 91 | 92 | The image will now directly be included in your HTML without a data-uri or a reference to your file. 93 | 94 | By default, it will be included as a normal `string`. If you are in a React project and wish to transform it into a React component, set the [`includeStrategy`](#options) to `'react'` and run `npm install @svgr/core`. 95 | 96 | #### ?webp 97 | 98 | If this `?webp` query parameter is specified, `optimized-images-loader` automatically converts the image to the new WebP format. 99 | 100 | For browsers that don't yet support WebP, you may want to also provide a fallback using the `` tag or use the [`Img`](#img) component which does this out of the box: 101 | 102 | #### ?inline 103 | 104 | You can specify a [limit for inlining](#inlineimagelimit) images which will include it as a data-uri directly in your content instead of referencing a file if the file size is below that limit. 105 | 106 | You usually don't want to specify a too high limit but there may be cases where you still want to inline larger images. 107 | 108 | In this case, you don't have to set the global limit to a higher value but you can add an exception for a single image using the `?inline` query options. 109 | 110 | #### ?url 111 | 112 | When you have an image smaller than your defined [limit for inlining](#inlineimagelimit), it normally gets inlined automatically. 113 | If you don't want a specific small file to get inlined, you can use the `?url` query param to always get back an image URL, regardless of the inline limit. 114 | 115 | #### ?original 116 | 117 | The image won't get optimized and used as it is. 118 | It makes sense to use this query param if you know an image already got optimized (e.g. during export) so it doesn't get optimized again a second time. 119 | 120 | #### ?lqip 121 | 122 | When using this resource query, a very small (about 10x10 pixel) image gets created. 123 | You can then display this image as a placeholder until the real (big) image has loaded. 124 | 125 | #### ?colors 126 | 127 | This resource query returns you an **array with hex values** of the dominant colors of an image. 128 | You can also use this as a placeholder until the real image has loaded (e.g. as a background) like the *Google Picture Search* does. 129 | 130 | The number of colors returned can vary and depends on how many different colors your image has. 131 | 132 | ```javascript 133 | import React from 'react'; 134 | 135 | export default () => ( 136 |
...
137 | ); 138 | 139 | /** 140 | * require('./images/my-image.jpg?colors') 141 | * 142 | * returns for example 143 | * 144 | * ['#0e648d', '#5f94b5', '#a7bbcb', '#223240', '#a4c3dc', '#1b6c9c'] 145 | */ 146 | ``` 147 | 148 | #### ?trace 149 | 150 | > Currently not supported 151 | 152 | With the `?trace` resource query, you can generate [SVG image outlines](https://twitter.com/mikaelainalem/status/918213244954861569) which can be used as a placeholder while loading the original image. 153 | 154 | #### ?width 155 | 156 | Resizes the source image to the given width. If a height is additionally specified, it ensures the image covers both sizes and crops the remaining parts. If no height is specified, it will be automatically calculated to preserve the image aspect ratio. 157 | 158 | ```javascript 159 | import React from 'react'; 160 | import Image from './images/my-image.jpg?width=800'; 161 | import Thumbnail from './images/my-image.jpg?width=300&height=300'; 162 | 163 | export default () => ( 164 |
165 | 166 | 167 |
168 | ); 169 | ``` 170 | 171 | #### ?height 172 | 173 | Resizes the source image to the given height. If a width is additionally specified, it ensures the image covers both sizes and crops the remaining parts. If no width is specified, it will be automatically calculated to preserve the image aspect ratio. 174 | 175 | ```javascript 176 | import React from 'react'; 177 | import Image from './images/my-image.jpg?height=800'; 178 | import Thumbnail from './images/my-image.jpg?width=300&height=300'; 179 | 180 | export default () => ( 181 |
182 | 183 | 184 |
185 | ); 186 | ``` 187 | 188 | #### ?sprite 189 | 190 | > Currently not supported 191 | 192 | TODO: needs general documentation 193 | 194 | ## License 195 | 196 | Licensed under the [MIT](https://github.com/cyrilwanner/optimized-images-loader/blob/master/LICENSE) license. 197 | 198 | © Copyright Cyril Wanner 199 | -------------------------------------------------------------------------------- /images.d.ts: -------------------------------------------------------------------------------- 1 | type ImgSrc = { 2 | src: string; 3 | width: number; 4 | height: number; 5 | format: string; 6 | toString(): string; 7 | } 8 | 9 | type ColorsSrc = { 10 | src: string[]; 11 | width: number; 12 | height: number; 13 | format: string; 14 | toString(): string; 15 | } 16 | 17 | declare module '*.png' { 18 | const value: ImgSrc; 19 | export = value; 20 | } 21 | 22 | declare module '*.png?colors' { 23 | const value: ColorsSrc; 24 | export = value; 25 | } 26 | 27 | declare module '*.jpg' { 28 | const value: ImgSrc; 29 | export = value; 30 | } 31 | 32 | declare module '*.jpg?colors' { 33 | const value: ColorsSrc; 34 | export = value; 35 | } 36 | 37 | declare module '*.jpeg' { 38 | const value: ImgSrc; 39 | export = value; 40 | } 41 | 42 | declare module '*.jpeg?colors' { 43 | const value: ColorsSrc; 44 | export = value; 45 | } 46 | 47 | declare module '*.webp' { 48 | const value: ImgSrc; 49 | export = value; 50 | } 51 | 52 | declare module '*.webp?colors' { 53 | const value: ColorsSrc; 54 | export = value; 55 | } 56 | 57 | declare module '*.svg' { 58 | const value: ImgSrc; 59 | export = value; 60 | } 61 | 62 | declare module '*.svg?colors' { 63 | const value: ColorsSrc; 64 | export = value; 65 | } 66 | 67 | declare module '*.gif' { 68 | const value: ImgSrc; 69 | export = value; 70 | } 71 | 72 | declare module '*.gif?colors' { 73 | const value: ColorsSrc; 74 | export = value; 75 | } 76 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'], 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "optimized-images-loader", 3 | "version": "0.4.0", 4 | "description": "Automatically optimize images in webpack projects.", 5 | "keywords": [ 6 | "webpack", 7 | "loader", 8 | "images", 9 | "optimize" 10 | ], 11 | "author": "Cyril Wanner ", 12 | "homepage": "https://github.com/cyrilwanner/optimized-images-loader#readme", 13 | "license": "MIT", 14 | "scripts": { 15 | "build": "npm run build:js && npm run build:types", 16 | "build:js": "babel src --out-dir lib --delete-dir-on-start --extensions \".ts\"", 17 | "build:js:watch": "npm run build:js -- --watch --verbose", 18 | "build:types": "tsc --emitDeclarationOnly", 19 | "lint": "tsc --noEmit && eslint . --ext .ts --ext .js", 20 | "lint:fix": "npm run lint -- --fix", 21 | "test": "jest", 22 | "test:watch": "npm test -- --watch" 23 | }, 24 | "dependencies": { 25 | "@wasm-codecs/gifsicle": "^1.0.0", 26 | "@wasm-codecs/mozjpeg": "^1.0.1", 27 | "@wasm-codecs/oxipng": "^1.0.1", 28 | "file-loader": "^6.0.0", 29 | "get-rgba-palette": "^2.0.1", 30 | "loader-utils": "^2.0.0", 31 | "schema-utils": "^2.6.6", 32 | "sharp": "^0.26.0", 33 | "svgo": "^1.3.2", 34 | "url-loader": "^4.1.0" 35 | }, 36 | "devDependencies": { 37 | "@babel/cli": "^7.8.4", 38 | "@babel/core": "^7.9.6", 39 | "@babel/preset-env": "^7.9.6", 40 | "@babel/preset-typescript": "^7.9.0", 41 | "@types/jest": "^26.0.0", 42 | "@types/loader-utils": "^2.0.0", 43 | "@types/sharp": "^0.25.0", 44 | "@types/svgo": "^1.3.3", 45 | "@typescript-eslint/eslint-plugin": "^3.8.0", 46 | "@typescript-eslint/parser": "^3.8.0", 47 | "eslint": "^7.0.0", 48 | "eslint-config-airbnb-base": "^14.1.0", 49 | "eslint-config-prettier": "^6.11.0", 50 | "eslint-plugin-import": "^2.20.2", 51 | "eslint-plugin-prettier": "^3.1.3", 52 | "jest": "^26.0.1", 53 | "prettier": "^2.0.5", 54 | "typescript": "^3.9.3", 55 | "webpack": "^4.43.0" 56 | }, 57 | "main": "lib/index.js", 58 | "types": "images.d.ts", 59 | "directories": { 60 | "lib": "lib", 61 | "test": "__tests__" 62 | }, 63 | "files": [ 64 | "lib", 65 | "images.d.ts" 66 | ], 67 | "repository": { 68 | "type": "git", 69 | "url": "git+https://github.com/cyrilwanner/optimized-images-loader.git" 70 | }, 71 | "bugs": { 72 | "url": "https://github.com/cyrilwanner/optimized-images-loader/issues" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import os from 'os'; 3 | import { promises as fs, constants } from 'fs'; 4 | import querystring from 'querystring'; 5 | import { getHashDigest } from 'loader-utils'; 6 | import { ImageOptions } from './parseQuery'; 7 | import { LoaderOptions } from './options'; 8 | import { getLoaderVersion } from './util'; 9 | 10 | /** 11 | * Checks if the given cache folder is valid and writable 12 | * 13 | * @async 14 | * @param {string} cacheFolder Cache folder 15 | * @returns {boolean} Whether the cache folder is valid 16 | */ 17 | const isValidCacheFolder = async (cacheFolder: string): Promise => { 18 | // try accessing the parent folder 19 | try { 20 | await fs.access(path.dirname(cacheFolder)); 21 | } catch { 22 | return false; 23 | } 24 | 25 | // check if the folder already exists 26 | try { 27 | await fs.access(cacheFolder, constants.W_OK); 28 | return true; 29 | } catch { 30 | // otherwise try to create the cache folder 31 | try { 32 | await fs.mkdir(cacheFolder); 33 | return true; 34 | } catch (e) { 35 | return e.code === 'EEXIST'; 36 | } 37 | } 38 | }; 39 | 40 | /** 41 | * Determines the correct cache folder to use 42 | * 43 | * @async 44 | * @param {LoaderOptions} loaderOptions Optimized images loader options 45 | * @returns {string} Cache folder path 46 | */ 47 | const getCacheFolder = async (loaderOptions: LoaderOptions): Promise => { 48 | let cacheFolder = loaderOptions.cacheFolder || path.resolve(__dirname, '..', '.cache'); 49 | 50 | if (await isValidCacheFolder(cacheFolder)) { 51 | return cacheFolder; 52 | } 53 | 54 | if (!loaderOptions.cacheFolder) { 55 | cacheFolder = path.resolve(os.tmpdir(), 'optimized-images-loader'); 56 | 57 | if (await isValidCacheFolder(cacheFolder)) { 58 | return cacheFolder; 59 | } 60 | } 61 | 62 | throw new Error(`Cache folder ${cacheFolder} is not writable or parent folder does not exist`); 63 | }; 64 | 65 | /** 66 | * Calculates a hash for the given image and query string 67 | * 68 | * @param {Buffer} source Source image 69 | * @param {ImageOptions} imageOptions Image options 70 | * @returns {string} Hash 71 | */ 72 | export const getHash = (source: Buffer, imageOptions: ImageOptions): string => { 73 | const query = querystring.stringify(imageOptions as any); // eslint-disable-line 74 | 75 | return `${(getHashDigest as (input: Buffer) => string)(source)}-${(getHashDigest as (input: Buffer) => string)( 76 | Buffer.from(query), 77 | )}`; 78 | }; 79 | 80 | /** 81 | * Retrieves an optimized image from cache if it exists 82 | * 83 | * @async 84 | * @param {string} hash Cache hash 85 | * @param {LoaderOptions} loaderOptions Optimized images loader options 86 | * @returns {{ data: Buffer | string | string[]; info: { width?: number; height?: number; format?: string }, imageOptions: ImageOptions } | null} Cached image or null if not present 87 | */ 88 | export const getCache = async ( 89 | hash: string, 90 | loaderOptions: LoaderOptions, 91 | ): Promise<{ 92 | data: Buffer | string | string[]; 93 | info: { width?: number; height?: number; format?: string }; 94 | imageOptions: ImageOptions; 95 | } | null> => { 96 | const cacheFolder = await getCacheFolder(loaderOptions); 97 | 98 | try { 99 | const options = JSON.parse((await fs.readFile(path.resolve(cacheFolder, `${hash}.json`))).toString()); 100 | 101 | // make sure the cache file was created for the current version 102 | if (options.version !== (await getLoaderVersion())) { 103 | return null; 104 | } 105 | 106 | const data = await fs.readFile(path.resolve(cacheFolder, hash)); 107 | 108 | if (options.isBuffer) { 109 | return { data, info: options.info, imageOptions: options.imageOptions }; 110 | } 111 | 112 | return { data: JSON.parse(data.toString()), info: options.info, imageOptions: options.imageOptions }; 113 | } catch { 114 | return null; 115 | } 116 | }; 117 | 118 | /** 119 | * Writes an optimized image into the cache 120 | * 121 | * @async 122 | * @param {string} hash Cache hash 123 | * @param {Buffer | string | string[]} result Optimized image 124 | * @param {{ width?: number; height?: number; format?: string }} info Image information 125 | * @param {ImageOptions} imageOptions Image options 126 | * @param {LoaderOptions} loaderOptions Optimized images loader options 127 | */ 128 | export const setCache = async ( 129 | hash: string, 130 | result: Buffer | string | string[], 131 | { width, height, format }: { width?: number; height?: number; format?: string }, 132 | imageOptions: ImageOptions, 133 | loaderOptions: LoaderOptions, 134 | ): Promise => { 135 | const cacheFolder = await getCacheFolder(loaderOptions); 136 | 137 | if (Buffer.isBuffer(result)) { 138 | await fs.writeFile(path.resolve(cacheFolder, hash), result); 139 | } else { 140 | await fs.writeFile(path.resolve(cacheFolder, hash), JSON.stringify(result)); 141 | } 142 | 143 | await fs.writeFile( 144 | path.resolve(cacheFolder, `${hash}.json`), 145 | JSON.stringify({ 146 | imageOptions, 147 | info: { width, height, format }, 148 | isBuffer: Buffer.isBuffer(result), 149 | version: await getLoaderVersion(), 150 | }), 151 | ); 152 | }; 153 | -------------------------------------------------------------------------------- /src/convert/index.ts: -------------------------------------------------------------------------------- 1 | import { Sharp } from 'sharp'; 2 | import convertToWebp from './webp'; 3 | import { LoaderOptions } from '../options'; 4 | 5 | const converters = { 6 | webp: { 7 | handler: convertToWebp, 8 | optionsKey: 'webp', 9 | }, 10 | } as Record Promise; optionsKey: string }>; 11 | 12 | /** 13 | * Convert an input image into the given format if a convert exists for that format 14 | * 15 | * @async 16 | * @param {Sharp} image Sharp wrapped input image 17 | * @param {string} targetFormat Target image format 18 | * @param {LoaderOptions} loaderOptions Optimized images loader options 19 | * @returns {Buffer} Converted image 20 | */ 21 | const convertImage = async (image: Sharp, targetFormat: string, loaderOptions: LoaderOptions): Promise => { 22 | if (converters[targetFormat]) { 23 | return converters[targetFormat].handler( 24 | image, 25 | (loaderOptions as Record)[converters[targetFormat].optionsKey], 26 | ); 27 | } 28 | 29 | return image.toBuffer(); 30 | }; 31 | 32 | export default convertImage; 33 | -------------------------------------------------------------------------------- /src/convert/webp.ts: -------------------------------------------------------------------------------- 1 | import { Sharp, WebpOptions } from 'sharp'; 2 | 3 | /** 4 | * Convert an image to webp using sharp 5 | * 6 | * @async 7 | * @param {Sharp} image Sharp wrapped input image 8 | * @param {WebpOptions} [options] Webp options 9 | * @returns {Buffer} Converted image 10 | */ 11 | const convertToWebp = async (image: Sharp, options?: WebpOptions): Promise => { 12 | // convert the image to webp using sharp 13 | return image.webp(options).toBuffer(); 14 | }; 15 | 16 | export default convertToWebp; 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { loader } from 'webpack'; 2 | import { getOptions } from 'loader-utils'; 3 | import processImage from './processImage'; 4 | import parseQuery, { ImageOptions } from './parseQuery'; 5 | import { LoaderOptions } from './options'; 6 | import processLoaders from './processLoaders'; 7 | import { getCache, setCache, getHash } from './cache'; 8 | 9 | /** 10 | * Optimized images loader 11 | * Called by webpack 12 | * 13 | * @param {Buffer} source Image to optimize 14 | * @returns {null} Calls the webpack callback once finished 15 | */ 16 | export default function optimizedImagesLoader(this: loader.LoaderContext, source: Buffer): null { 17 | const callback = this.async() as loader.loaderCallback; 18 | 19 | (async () => { 20 | const loaderOptions = getOptions(this) as LoaderOptions; 21 | 22 | // parse image options 23 | const imageOptions = parseQuery(this.resourceQuery, loaderOptions); 24 | 25 | let result: { data: Buffer | string | string[]; info: { width?: number; height?: number; format?: string } }; 26 | 27 | // try retrieving the image from cache 28 | const cacheHash = getHash(source, imageOptions); 29 | const cached = await getCache(cacheHash, loaderOptions); 30 | 31 | if (cached) { 32 | result = cached; 33 | 34 | // update image options from cache 35 | if (cached.imageOptions) { 36 | (Object.keys(cached.imageOptions) as Array).forEach((option: keyof ImageOptions) => { 37 | (imageOptions[option] as unknown) = cached.imageOptions[option]; 38 | }); 39 | } 40 | } else { 41 | // process image 42 | result = await processImage(source, imageOptions, loaderOptions); 43 | 44 | // cache processed image 45 | setCache(cacheHash, result.data, result.info, imageOptions, loaderOptions); 46 | } 47 | 48 | // process further loaders 49 | const output = processLoaders(this, result.data, result.info, imageOptions, loaderOptions); 50 | 51 | callback(null, output); 52 | })(); 53 | 54 | return null; 55 | } 56 | 57 | export const raw = true; 58 | -------------------------------------------------------------------------------- /src/lqip/blur.ts: -------------------------------------------------------------------------------- 1 | import { ImageOptions } from '../parseQuery'; 2 | 3 | /* eslint-disable no-param-reassign */ 4 | 5 | const calculateBlurOptions = (imageInfo: { width?: number; height?: number }, imageOptions: ImageOptions): void => { 6 | if (!imageInfo.width || !imageInfo.height) { 7 | imageOptions.width = 10; 8 | imageOptions.height = 10; 9 | } else if (imageInfo.width > imageInfo.height) { 10 | imageOptions.width = 10; 11 | imageOptions.height = Math.round((10 / imageInfo.width) * imageInfo.height); 12 | } else { 13 | imageOptions.height = 10; 14 | imageOptions.width = Math.round((10 / imageInfo.height) * imageInfo.width); 15 | } 16 | }; 17 | 18 | export default calculateBlurOptions; 19 | -------------------------------------------------------------------------------- /src/lqip/colors.ts: -------------------------------------------------------------------------------- 1 | import { Sharp } from 'sharp'; 2 | import palette from 'get-rgba-palette'; 3 | 4 | /** 5 | * Converts rgb values into a hex string 6 | * 7 | * @param {number[]} rgb RGB values 8 | * @returns {string} HEX values 9 | */ 10 | export const convertRgbToHex = (rgb: number[]): string => { 11 | const hex = rgb.map((value) => value.toString(16)); 12 | 13 | for (let i = 0; i < hex.length; i += 1) { 14 | if (hex[i].length === 1) { 15 | hex[i] = `0${hex[i]}`; 16 | } 17 | } 18 | 19 | return `#${hex.join('')}`; 20 | }; 21 | 22 | /** 23 | * Extract the dominant colors of an image 24 | * 25 | * @async 26 | * @param {Sharp} image Sharp wrapped input image 27 | * @returns {string[]} 28 | */ 29 | const getDominantColors = async (image: Sharp): Promise => { 30 | // get raw rgba pixel data 31 | const rawData = await image.ensureAlpha().raw().toBuffer(); 32 | 33 | // get dominant colors 34 | const rgbColors = palette(rawData, 5); 35 | 36 | // convert rgb to hex 37 | const hexColors = rgbColors.map(convertRgbToHex); 38 | 39 | return hexColors; 40 | }; 41 | 42 | export default getDominantColors; 43 | -------------------------------------------------------------------------------- /src/optimize/gif.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp'; 2 | import encode from '@wasm-codecs/gifsicle'; 3 | import { LoaderOptions } from '../options'; 4 | import { ImageOptions } from '../parseQuery'; 5 | 6 | /** 7 | * Optimize a gif image using gifsicle 8 | * 9 | * @async 10 | * @param {Buffer} image Input image 11 | * @param {ImageOptions} imageOptions Image options 12 | * @param {LoaderOptions['gifsicle']} [options] Gifsicle options 13 | * @returns {Buffer} Optimized image 14 | */ 15 | const optimizeGif = async ( 16 | image: Buffer, 17 | imageOptions: ImageOptions, 18 | options?: LoaderOptions['gifsicle'], 19 | ): Promise => { 20 | const encodeOptions = options || {}; 21 | 22 | if (imageOptions.resize) { 23 | if (imageOptions.width) { 24 | encodeOptions.width = imageOptions.width; 25 | } 26 | 27 | if (imageOptions.height) { 28 | encodeOptions.height = imageOptions.height; 29 | } 30 | } 31 | 32 | // optimize the image using gifsicle 33 | const encodedImage = await encode(image, encodeOptions); 34 | 35 | // fill missing resize values in case the image was resized 36 | if (imageOptions.resize) { 37 | const imageData = await sharp(encodedImage).metadata(); 38 | imageOptions.width = imageData.width; // eslint-disable-line no-param-reassign 39 | imageOptions.height = imageData.height; // eslint-disable-line no-param-reassign 40 | } 41 | 42 | return encodedImage; 43 | }; 44 | 45 | export default optimizeGif; 46 | -------------------------------------------------------------------------------- /src/optimize/index.ts: -------------------------------------------------------------------------------- 1 | import { Sharp } from 'sharp'; 2 | import optimizeJpeg from './jpeg'; 3 | import { LoaderOptions } from '../options'; 4 | import optimizePng from './png'; 5 | import optimizeWebp from './webp'; 6 | import optimizeSvg from './svg'; 7 | import optimizeGif from './gif'; 8 | import { ImageOptions } from '../parseQuery'; 9 | 10 | const sharpBasedOptimizers = { 11 | jpeg: { 12 | handler: optimizeJpeg, 13 | optionsKey: 'mozjpeg', 14 | }, 15 | png: { 16 | handler: optimizePng, 17 | optionsKey: 'oxipng', 18 | }, 19 | webp: { 20 | handler: optimizeWebp, 21 | optionsKey: 'webp', 22 | }, 23 | } as Record< 24 | string, 25 | { handler: (image: Sharp, imageOptions: ImageOptions, options?: unknown) => Promise; optionsKey: string } 26 | >; 27 | 28 | const rawBufferBasedOptimizers = { 29 | svg: { 30 | handler: optimizeSvg, 31 | optionsKey: 'svgo', 32 | }, 33 | gif: { 34 | handler: optimizeGif, 35 | optionsKey: 'gifsicle', 36 | }, 37 | } as Record< 38 | string, 39 | { handler: (image: Buffer, imageOptions: ImageOptions, options?: unknown) => Promise; optionsKey: string } 40 | >; 41 | 42 | /** 43 | * Optimize the given input image if an optimizer exists for the image format 44 | * 45 | * @async 46 | * @param {Sharp} image Sharp wrapped input image 47 | * @param {Buffer} rawImage Raw input image 48 | * @param {string} format Format of the input image 49 | * @param {LoaderOptions} loaderOptions Optimized images loader options 50 | * @returns {Buffer} Optimized image 51 | */ 52 | const optimizeImage = async ( 53 | image: Sharp, 54 | rawImage: Buffer, 55 | format: string, 56 | imageOptions: ImageOptions, 57 | loaderOptions: LoaderOptions, 58 | ): Promise => { 59 | if (sharpBasedOptimizers[format]) { 60 | return sharpBasedOptimizers[format].handler( 61 | image, 62 | imageOptions, 63 | (loaderOptions as Record)[sharpBasedOptimizers[format].optionsKey], 64 | ); 65 | } 66 | 67 | if (rawBufferBasedOptimizers[format]) { 68 | return rawBufferBasedOptimizers[format].handler( 69 | rawImage, 70 | imageOptions, 71 | (loaderOptions as Record)[rawBufferBasedOptimizers[format].optionsKey], 72 | ); 73 | } 74 | 75 | return image.toBuffer(); 76 | }; 77 | 78 | export default optimizeImage; 79 | -------------------------------------------------------------------------------- /src/optimize/jpeg.ts: -------------------------------------------------------------------------------- 1 | import { Sharp } from 'sharp'; 2 | import encode from '@wasm-codecs/mozjpeg'; 3 | import { LoaderOptions } from '../options'; 4 | import { ImageOptions } from '../parseQuery'; 5 | 6 | /** 7 | * Optimize a jpeg image using @wasm-codecs/mozjpeg 8 | * 9 | * @async 10 | * @param {Sharp} image Sharp wrapped input image 11 | * @param {ImageOptions} imageOptions Image options 12 | * @param {LoaderOptions['mozjpeg']} [options] Mozjpeg options 13 | * @returns {Buffer} Optimized image 14 | */ 15 | const optimizeJpeg = async ( 16 | image: Sharp, 17 | imageOptions: ImageOptions, 18 | options?: LoaderOptions['mozjpeg'], 19 | ): Promise => { 20 | // convert to raw image data 21 | const { 22 | data, 23 | info: { width, height, channels }, 24 | } = await image.raw().toBuffer({ resolveWithObject: true }); 25 | 26 | // encode the image using @wasm-codecs/mozjpeg 27 | return encode(data, { width, height, channels }, options); 28 | }; 29 | 30 | export default optimizeJpeg; 31 | -------------------------------------------------------------------------------- /src/optimize/png.ts: -------------------------------------------------------------------------------- 1 | import { Sharp } from 'sharp'; 2 | import encode from '@wasm-codecs/oxipng'; 3 | import { LoaderOptions } from '../options'; 4 | import { ImageOptions } from '../parseQuery'; 5 | 6 | /** 7 | * Optimize a png image using @wasm-codecs/oxipng 8 | * 9 | * @async 10 | * @param {Sharp} image Sharp wrapped input image 11 | * @param {ImageOptions} imageOptions Image options 12 | * @param {LoaderOptions['oxipng']} [options] Oxipng options 13 | * @returns {Buffer} Optimized image 14 | */ 15 | const optimizePng = async ( 16 | image: Sharp, 17 | imageOptions: ImageOptions, 18 | options?: LoaderOptions['oxipng'], 19 | ): Promise => { 20 | // encode the image using @wasm-codecs/oxipng 21 | return encode(await image.toBuffer(), options); 22 | }; 23 | 24 | export default optimizePng; 25 | -------------------------------------------------------------------------------- /src/optimize/svg.ts: -------------------------------------------------------------------------------- 1 | import SVGO from 'svgo'; 2 | import { LoaderOptions } from '../options'; 3 | import { ImageOptions } from '../parseQuery'; 4 | 5 | /** 6 | * Optimize a svg image using svgo 7 | * 8 | * @async 9 | * @param {Buffer} image Input image 10 | * @param {ImageOptions} imageOptions Image options 11 | * @param {LoaderOptions['svgo']} [options] Svgo options 12 | * @returns {Buffer} Optimized image 13 | */ 14 | const optimizeSvg = async ( 15 | image: Buffer, 16 | imageOptions: ImageOptions, 17 | options?: LoaderOptions['svgo'], 18 | ): Promise => { 19 | // optimize the image using svgo 20 | const svgo = new SVGO(options); 21 | const { data } = await svgo.optimize(image.toString()); 22 | 23 | return Buffer.from(data); 24 | }; 25 | 26 | export default optimizeSvg; 27 | -------------------------------------------------------------------------------- /src/optimize/webp.ts: -------------------------------------------------------------------------------- 1 | import { Sharp } from 'sharp'; 2 | import { LoaderOptions } from '../options'; 3 | import { ImageOptions } from '../parseQuery'; 4 | 5 | /** 6 | * Optimize a webp image using sharp 7 | * 8 | * @async 9 | * @param {Sharp} image Sharp wrapped input image 10 | * @param {ImageOptions} imageOptions Image options 11 | * @param {LoaderOptions['webp']} [options] Webp options 12 | * @returns {Buffer} Optimized image 13 | */ 14 | const optimizeWebp = async ( 15 | image: Sharp, 16 | imageOptions: ImageOptions, 17 | options?: LoaderOptions['webp'], 18 | ): Promise => { 19 | // encode the image using sharp 20 | return image.webp(options).toBuffer(); 21 | }; 22 | 23 | export default optimizeWebp; 24 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { EncodeOptions as MozjpegOptions } from '@wasm-codecs/mozjpeg/lib/types'; 2 | import { EncodeOptions as OxipngOptions } from '@wasm-codecs/oxipng/lib/types'; 3 | import { EncodeOptions as GifsicleOptions } from '@wasm-codecs/gifsicle/lib/types'; 4 | import { WebpOptions } from 'sharp'; 5 | 6 | export interface LoaderOptions { 7 | optimize?: boolean; 8 | cacheFolder?: string; 9 | includeStrategy?: 'string' | 'react'; 10 | mozjpeg?: MozjpegOptions; 11 | oxipng?: OxipngOptions; 12 | webp?: WebpOptions; 13 | gifsicle?: GifsicleOptions; 14 | svgo?: Record; 15 | svgr?: Record; 16 | } 17 | 18 | export interface OptionObject { 19 | [key: string]: any; // eslint-disable-line 20 | } 21 | 22 | // default options for file- & url-loader 23 | export const defaultFurtherLoaderOptions = { 24 | name: '[name]-[contenthash].[ext]', 25 | limit: 8192, 26 | }; 27 | -------------------------------------------------------------------------------- /src/parseQuery.ts: -------------------------------------------------------------------------------- 1 | import querystring from 'querystring'; 2 | import { LoaderOptions } from './options'; 3 | 4 | export interface ImageOptions { 5 | optimize: boolean; 6 | resize: boolean; 7 | width?: number; 8 | height?: number; 9 | convert?: 'webp'; 10 | forceInline?: boolean; 11 | forceUrl?: boolean; 12 | processLoaders?: boolean; 13 | component?: 'react'; 14 | lqip?: 'blur' | 'colors'; 15 | } 16 | 17 | /** 18 | * Parses a query string into image options 19 | * 20 | * @param {string} rawQuery Resource query 21 | * @param {LoaderOptions} loaderOptions Optimized images loader options 22 | * @returns {ImageOptions} Image options 23 | */ 24 | const parseQuery = (rawQuery: string, loaderOptions: LoaderOptions): ImageOptions => { 25 | const query = querystring.parse(rawQuery.substr(0, 1) === '?' ? rawQuery.substr(1) : rawQuery); 26 | const options: ImageOptions = { 27 | optimize: loaderOptions.optimize !== false, 28 | resize: false, 29 | }; 30 | 31 | // disable optimization 32 | if (typeof query.original !== 'undefined') { 33 | options.optimize = false; 34 | } 35 | 36 | // force inline 37 | if (typeof query.inline !== 'undefined') { 38 | options.forceInline = true; 39 | } 40 | 41 | // force url 42 | if (typeof query.url !== 'undefined') { 43 | options.forceUrl = true; 44 | } 45 | 46 | // include raw image (used for svg) 47 | if (typeof query.include !== 'undefined') { 48 | options.processLoaders = false; 49 | 50 | if (loaderOptions.includeStrategy === 'react') { 51 | options.component = 'react'; 52 | } 53 | } 54 | 55 | // resize image 56 | if (typeof query.width === 'string') { 57 | options.width = parseInt(query.width, 10); 58 | options.resize = true; 59 | } 60 | if (typeof query.height === 'string') { 61 | options.height = parseInt(query.height, 10); 62 | options.resize = true; 63 | } 64 | 65 | // convert image to webp 66 | if (typeof query.webp !== 'undefined') { 67 | options.convert = 'webp'; 68 | } 69 | 70 | // return low quality image placeholder 71 | if (typeof query.lqip !== 'undefined') { 72 | options.resize = true; 73 | options.optimize = false; 74 | options.lqip = 'blur'; 75 | } 76 | 77 | // return dominant colors instead of image 78 | if (typeof query.colors !== 'undefined' || typeof query['lqip-colors'] !== 'undefined') { 79 | options.processLoaders = false; 80 | options.lqip = 'colors'; 81 | } 82 | 83 | return options; 84 | }; 85 | 86 | export default parseQuery; 87 | -------------------------------------------------------------------------------- /src/processImage.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp'; 2 | import { ImageOptions } from './parseQuery'; 3 | import { LoaderOptions } from './options'; 4 | import optimizeImage from './optimize'; 5 | import convertImage from './convert'; 6 | import getDominantColors from './lqip/colors'; 7 | import calculateBlurOptions from './lqip/blur'; 8 | 9 | /** 10 | * Processes an image by performing all steps specified in the image options 11 | * 12 | * @async 13 | * @param {Buffer} inputImage Input image 14 | * @param {{ format?: string }} imageInfo Input image metadata 15 | * @param {ImageOptions} imageOptions Target image options 16 | * @param {LoaderOptions} loaderOptions Optimized images loader options 17 | * @returns {{ data: Buffer | string; info: { width?: number; height?: number; format?: string } }} Processed image 18 | */ 19 | const processImage = async ( 20 | inputImage: Buffer, 21 | imageOptions: ImageOptions, 22 | loaderOptions: LoaderOptions, 23 | ): Promise<{ data: Buffer | string | string[]; info: { width?: number; height?: number; format?: string } }> => { 24 | // load image 25 | let image = sharp(inputImage); 26 | const imageMetadata = await image.metadata(); 27 | 28 | // rotate image if necessary 29 | if (imageMetadata.format !== 'svg') { 30 | image = image.rotate(); 31 | } 32 | 33 | // calculate blur options if lqip is requested 34 | if (imageOptions.lqip === 'blur') { 35 | calculateBlurOptions(imageMetadata, imageOptions); 36 | } 37 | 38 | // resize image 39 | if (imageOptions.resize && imageMetadata.format !== 'gif') { 40 | image = image.resize(imageOptions.width, imageOptions.height); 41 | 42 | // fill missing resize values 43 | if (typeof imageOptions.width !== 'number' || typeof imageOptions.height !== 'number') { 44 | const { info } = await image.toBuffer({ resolveWithObject: true }); 45 | 46 | if (typeof imageOptions.width !== 'number') { 47 | imageOptions.width = info.width; // eslint-disable-line no-param-reassign 48 | } 49 | 50 | if (typeof imageOptions.height !== 'number') { 51 | imageOptions.height = info.height; // eslint-disable-line no-param-reassign 52 | } 53 | } 54 | } 55 | 56 | // get lqip colors 57 | if (imageOptions.lqip === 'colors') { 58 | return { data: await getDominantColors(image), info: imageMetadata }; 59 | } 60 | 61 | // convert image 62 | if (imageOptions.convert) { 63 | return { data: await convertImage(image, imageOptions.convert, loaderOptions), info: imageMetadata }; 64 | } 65 | 66 | // optimize image 67 | if (imageMetadata.format && (imageOptions.optimize || (imageMetadata.format === 'gif' && imageOptions.resize))) { 68 | return { 69 | data: await optimizeImage(image, inputImage, imageMetadata.format, imageOptions, loaderOptions), 70 | info: imageMetadata, 71 | }; 72 | } 73 | 74 | // for svg, return input image if it was not optimized 75 | if (imageMetadata.format === 'svg') { 76 | return { data: inputImage, info: imageMetadata }; 77 | } 78 | 79 | // make sure original sizes are served 80 | if (!imageOptions.resize) { 81 | return { data: inputImage, info: imageMetadata }; 82 | } 83 | 84 | return { data: await image.toBuffer(), info: imageMetadata }; 85 | }; 86 | 87 | export default processImage; 88 | -------------------------------------------------------------------------------- /src/processLoaders.ts: -------------------------------------------------------------------------------- 1 | import urlLoader from 'url-loader'; 2 | import { loader } from 'webpack'; 3 | import { ImageOptions } from './parseQuery'; 4 | import { defaultFurtherLoaderOptions, OptionObject } from './options'; 5 | 6 | /** 7 | * Enrich previous loader result with new information 8 | * 9 | * @param {string | string[]} result Previous loader result 10 | * @param {{ width?: number; height?: number; format?: string }} originalImageInfo Metadata of original image 11 | * @param {ImageOptions} imageOptions Image options 12 | * @returns {string} Enriched result 13 | */ 14 | const enrichResult = ( 15 | result: string | string[], 16 | originalImageInfo: { width?: number; height?: number; format?: string }, 17 | imageOptions: ImageOptions, 18 | ): string => { 19 | const width = imageOptions.resize ? imageOptions.width : originalImageInfo.width; 20 | const height = imageOptions.resize ? imageOptions.height : originalImageInfo.height; 21 | const format = imageOptions.convert ? imageOptions.convert : originalImageInfo.format; 22 | 23 | // an array means it was not processed by the url-/file-loader and the result should still be an array 24 | // instead of a string. so in this case, append the additional export information to the array prototype 25 | if (Array.isArray(result)) { 26 | return `var res=${JSON.stringify(result)};res.width=${width};res.height=${height};res.format=${JSON.stringify( 27 | format || '', 28 | )};module.exports = res;`; 29 | } 30 | 31 | if (result.indexOf('module.exports') < 0) { 32 | throw new Error('Unexpected input'); 33 | } 34 | 35 | const output = result.replace(/((module\.exports\s*=)\s*)([^\s].*[^;])(;$|$)/g, 'var src = $3;'); 36 | 37 | return `${output}module.exports={src:src,width:${width},height:${height},format:${JSON.stringify( 38 | format || '', 39 | )},toString:function(){return src;}};`; 40 | }; 41 | 42 | /** 43 | * Replace additional placeholders in the file name 44 | * 45 | * @param {string} name File name pattern 46 | * @param {{ width?: number; height?: number; format?: string }} originalImageInfo Metadata of original image 47 | * @param {ImageOptions} imageOptions Image options 48 | * @returns {string} Replaced file name 49 | */ 50 | const replaceName = ( 51 | name: string, 52 | originalImageInfo: { width?: number; height?: number; format?: string }, 53 | imageOptions: ImageOptions, 54 | ): string => { 55 | return name 56 | .replace(/\[width\]/g, `${imageOptions.width || originalImageInfo.width}`) 57 | .replace(/\[height\]/g, `${imageOptions.height || originalImageInfo.height}`); 58 | }; 59 | 60 | /** 61 | * Convert the image into a component 62 | * 63 | * @param {string} image Processed image 64 | * @param {{ width?: number; height?: number; format?: string }} originalImageInfo Metadata of original image 65 | * @param {ImageOptions} imageOptions Image options 66 | * @param {OptionObject} loaderOptions Loader options 67 | */ 68 | const convertToComponent = ( 69 | image: string, 70 | originalImageInfo: { width?: number; height?: number; format?: string }, 71 | imageOptions: ImageOptions, 72 | loaderOptions: OptionObject, 73 | ): string => { 74 | if (imageOptions.component === 'react') { 75 | const svgr = require('@svgr/core').default; // eslint-disable-line 76 | const babel = require('@babel/core'); // eslint-disable-line 77 | 78 | const code = svgr.sync(image, loaderOptions.svgr || {}, { componentName: 'SvgComponent' }); 79 | const transformed = babel.transformSync(code, { 80 | caller: { 81 | name: 'optimized-images-loader', 82 | }, 83 | babelrc: false, 84 | configFile: false, 85 | presets: [ 86 | babel.createConfigItem(require('@babel/preset-react'), { type: 'preset' }), // eslint-disable-line 87 | babel.createConfigItem([require('@babel/preset-env'), { modules: false }], { type: 'preset' }), // eslint-disable-line 88 | ], 89 | }); 90 | 91 | return transformed.code; 92 | } 93 | 94 | throw new Error(`Unknown component type ${imageOptions.component}`); 95 | }; 96 | 97 | /** 98 | * Process further loaders (url-loader & file-loader) 99 | * 100 | * @param {loader.LoaderContext} context Optimized images loader context 101 | * @param {Buffer | string} image Processed image 102 | * @param {{ width?: number; height?: number; format?: string }} originalImageInfo Metadata of original image 103 | * @param {ImageOptions} imageOptions Image options 104 | * @param {OptionObject} loaderOptions Options for further loaders 105 | * @returns {string} Processed loader output 106 | */ 107 | const processLoaders = ( 108 | context: loader.LoaderContext, 109 | image: Buffer | string | string[], 110 | originalImageInfo: { width?: number; height?: number; format?: string }, 111 | imageOptions: ImageOptions, 112 | loaderOptions: OptionObject, 113 | ): string => { 114 | // do not apply further loaders if not needed 115 | if (imageOptions.processLoaders === false) { 116 | // transform result to a component 117 | if (imageOptions.component === 'react') { 118 | return convertToComponent(image.toString(), originalImageInfo, imageOptions, loaderOptions); 119 | } 120 | 121 | if (Array.isArray(image)) { 122 | return enrichResult(image, originalImageInfo, imageOptions); 123 | } 124 | 125 | const output = Buffer.isBuffer(image) ? image.toString() : image; 126 | 127 | return enrichResult(`module.exports = ${JSON.stringify(output)}`, originalImageInfo, imageOptions); 128 | } 129 | 130 | // create options for further loaders (url-loader & file-loader) 131 | const furtherLoaderOptions = { 132 | ...defaultFurtherLoaderOptions, 133 | ...loaderOptions, 134 | esModule: false, 135 | } as OptionObject; 136 | 137 | // replace name 138 | furtherLoaderOptions.name = replaceName(furtherLoaderOptions.name, originalImageInfo, imageOptions); 139 | 140 | // change extension for converted images 141 | if (imageOptions.convert && furtherLoaderOptions.name) { 142 | furtherLoaderOptions.name = 143 | furtherLoaderOptions.name.indexOf('[ext]') >= 0 144 | ? furtherLoaderOptions.name.replace('[ext]', imageOptions.convert) 145 | : (furtherLoaderOptions.name += `.${imageOptions.convert}`); 146 | } 147 | 148 | // force inlining 149 | if (imageOptions.forceInline) { 150 | furtherLoaderOptions.limit = undefined; 151 | 152 | // force url 153 | } else if (imageOptions.forceUrl) { 154 | furtherLoaderOptions.limit = -1; 155 | } 156 | 157 | // build new loader context 158 | const furtherLoaderContext = { ...context, query: furtherLoaderOptions }; 159 | 160 | // get result of url-loader 161 | const result = urlLoader.call(furtherLoaderContext, image); 162 | 163 | return enrichResult(result, originalImageInfo, imageOptions); 164 | }; 165 | 166 | export default processLoaders; 167 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { promises as fs } from 'fs'; 3 | 4 | let version: string; 5 | 6 | /** 7 | * Returns the version of optimized-images-loader 8 | * 9 | * @async 10 | * @returns {string} Package version 11 | */ 12 | export const getLoaderVersion = async (): Promise => { 13 | if (!version) { 14 | const packageJson = JSON.parse((await fs.readFile(path.resolve(__dirname, '..', 'package.json'))).toString()); 15 | 16 | version = packageJson.version; 17 | } 18 | 19 | return version; 20 | }; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "esnext", 5 | "target": "ES2017", 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "outDir": "lib" 10 | }, 11 | "exclude": ["./lib", "./*.d.ts", "./__tests__"] 12 | } 13 | -------------------------------------------------------------------------------- /types/get-rgba-palette.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'get-rgba-palette'; 2 | -------------------------------------------------------------------------------- /types/url-loader.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'url-loader'; 2 | --------------------------------------------------------------------------------