├── .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 [](https://www.npmjs.com/package/optimized-images-loader) [](https://github.com/cyrilwanner/optimized-images-loader/blob/master/LICENSE) [](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 |
--------------------------------------------------------------------------------