diff --git a/.cspell.json b/.cspell.json index 745f583..2cf400e 100644 --- a/.cspell.json +++ b/.cspell.json @@ -43,7 +43,8 @@ "libvips", "hspace", "commitlint", - "nodenext" + "nodenext", + "eslintcache" ], "ignorePaths": [ diff --git a/.gitignore b/.gitignore index 6096952..e20ed82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.cache coverage .vscode logs diff --git a/README.md b/README.md index 83a7de1..3a87178 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ with option `"markdown.extension.toc.levels": "2..6"` - [Plugin Options](#plugin-options) - [`test`](#test) - [`include`](#include) + - [`cacheDir`](#cachedir) - [`exclude`](#exclude) - [`minimizer`](#minimizer) - [Available minimizers](#available-minimizers) @@ -76,6 +77,7 @@ with option `"markdown.extension.toc.levels": "2..6"` - [`generator`](#generator-1) - [Loader generator example for `imagemin`](#loader-generator-example-for-imagemin) - [`severityError`](#severityerror-1) + - [`cacheDir`](#cachedir-1) - [Additional API](#additional-api) - [`imageminNormalizeConfig(config)`](#imageminnormalizeconfigconfig) - [Examples](#examples) @@ -699,6 +701,64 @@ module.exports = { }; ``` +### `cacheDir` + +Type: + +```ts +type cacheDir = string; +``` + +Default: `undefined` + +If provided, ensures all image transformations are done only once and cached within this folder. (Sharp only) + +**webpack.config.js** + +```js +const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); + +module.exports = { + optimization: { + minimizer: [ + "...", + new ImageMinimizerPlugin({ + cacheDir: ".cache/image-minimizer", + }), + ], + }, +}; +``` + +### `bypassWebpackCache` + +Type: + +```ts +type bypassWebpackCache = string; +``` + +Default: `undefined` + +If `true`, skips using Webpack's cache. (Typically useful if you're using the `cacheDir` and therefore don't need to double-up on caching with Webpack's cache.) + +**webpack.config.js** + +```js +const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); + +module.exports = { + optimization: { + minimizer: [ + "...", + new ImageMinimizerPlugin({ + bypassWebpackCache: true, + }), + ], + }, +}; +``` + ### `minimizer` Type: @@ -2356,6 +2416,49 @@ module.exports = { }; ``` +### `cacheDir` + +Type: + +```ts +type cacheDir = string; +``` + +Default: `undefined` + +If provided, ensures all image transformations are done only once and cached within this folder. (Sharp only) + +**webpack.config.js** + +```js +const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.(jpe?g|png|gif|svg)$/i, + type: "asset", + }, + { + test: /\.(jpe?g|png|gif|svg)$/i, + use: [ + { + loader: ImageMinimizerPlugin.loader, + options: { + cacheDir: ".cache/image-minimizer", + minimizerOptions: { + plugins: ["gifsicle"], + }, + }, + }, + ], + }, + ], + }, +}; +``` + ## Additional API ### `imageminNormalizeConfig(config)` diff --git a/package-lock.json b/package-lock.json index c1c7379..83cb759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,12 @@ "version": "4.1.3", "license": "MIT", "dependencies": { + "graceful-fs": "^4.2.11", + "mkdirp": "^3.0.1", + "proper-lockfile": "^4.1.2", "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2" + "serialize-javascript": "^6.0.2", + "stream-buffers": "^3.0.3" }, "devDependencies": { "@babel/cli": "^7.24.7", @@ -19,10 +23,13 @@ "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", "@squoosh/lib": "^0.5.3", + "@types/graceful-fs": "^4.1.9", "@types/imagemin": "^9.0.0", "@types/node": "^20.14.9", + "@types/proper-lockfile": "^4.1.4", "@types/serialize-javascript": "^5.0.4", "@types/sharp": "^0.32.0", + "@types/stream-buffers": "^3.0.7", "@webpack-contrib/eslint-config-webpack": "^3.0.0", "babel-jest": "^29.7.0", "copy-webpack-plugin": "^12.0.2", @@ -5066,6 +5073,21 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "node_modules/@types/proper-lockfile": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", + "dev": true, + "dependencies": { + "@types/retry": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -5094,6 +5116,15 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/stream-buffers": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.7.tgz", + "integrity": "sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/supports-color": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/@types/supports-color/-/supports-color-8.1.3.tgz", @@ -13359,8 +13390,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -18307,6 +18337,20 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -19694,6 +19738,21 @@ "dev": true, "peer": true }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, "node_modules/propose": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/propose/-/propose-0.0.5.tgz", @@ -21106,7 +21165,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, "engines": { "node": ">= 4" } @@ -21915,6 +21973,14 @@ "node": ">= 0.4" } }, + "node_modules/stream-buffers": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", + "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/strict-uri-encode": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", diff --git a/package.json b/package.json index 639a5a2..1193e34 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,15 @@ "node": ">= 18.12.0" }, "scripts": { - "start": "npm run build -- -w", + "start": "npm-run-all -p \"watch:**\"", "clean": "del-cli dist types", "prebuild": "npm run clean", - "build:types": "tsc --declaration --emitDeclarationOnly && prettier \"types/**/*.ts\" --write", + "build:types": "tsc --declaration --emitDeclarationOnly", + "postbuild:types": "prettier \"types/**/*.ts\" --write", "build:code": "cross-env NODE_ENV=production babel src -d dist --copy-files", "build": "npm-run-all -p \"build:**\"", + "watch:types": "npm run build:types -- -w", + "watch:code": "npm run build:code -- -w", "commitlint": "commitlint --from=master", "security": "npm audit --production", "lint:prettier": "prettier --cache --list-different .", @@ -69,8 +72,12 @@ } }, "dependencies": { + "graceful-fs": "^4.2.11", + "mkdirp": "^3.0.1", + "proper-lockfile": "^4.1.2", "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2" + "serialize-javascript": "^6.0.2", + "stream-buffers": "^3.0.3" }, "devDependencies": { "@babel/cli": "^7.24.7", @@ -79,10 +86,13 @@ "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", "@squoosh/lib": "^0.5.3", + "@types/graceful-fs": "^4.1.9", "@types/imagemin": "^9.0.0", "@types/node": "^20.14.9", + "@types/proper-lockfile": "^4.1.4", "@types/serialize-javascript": "^5.0.4", "@types/sharp": "^0.32.0", + "@types/stream-buffers": "^3.0.7", "@webpack-contrib/eslint-config-webpack": "^3.0.0", "babel-jest": "^29.7.0", "copy-webpack-plugin": "^12.0.2", diff --git a/src/cache.js b/src/cache.js new file mode 100644 index 0000000..911313e --- /dev/null +++ b/src/cache.js @@ -0,0 +1,156 @@ +const { createHash } = require("crypto"); +const path = require("path"); +const { promisify } = require("util"); +const fs = require("graceful-fs"); +const { mkdirp } = require("mkdirp"); +const { WritableStreamBuffer } = require("stream-buffers"); + +const close = promisify(fs.close); +const open = promisify(fs.open); +const readFile = promisify(fs.readFile); +const stat = promisify(fs.stat); +const unlink = promisify(fs.unlink); +const utimes = promisify(fs.utimes); + +const { lock } = require("proper-lockfile"); + +const cleanups = new Set(); + +// eslint-disable-next-line jest/require-hook +process.on("exit", () => { + for (const cleanup of cleanups) { + cleanup(); + } +}); + +/** + * @template T + * @typedef {import("webpack").LoaderContext} LoaderContext + * */ + +/** + * @typedef {import("stream").Writable} Writable + * */ + +/** + * @typedef {import("crypto").BinaryLike} BinaryLike + * */ + +/** + * @typedef {import("buffer").Buffer} Buffer + * */ + +// let lockCount = 0; +/** + * @template T + * @param {string} file + * @param {() => Promise} cb + * @returns {Promise} + */ +async function lockAndExecuteOnce(file, cb) { + let lockFile = file; + + try { + await stat(file); + } catch { + lockFile = `${file}_lock`; + + try { + const time = new Date(); + + await utimes(lockFile, time, time); + } catch { + try { + await close(await open(lockFile, "w")); + } catch { + // ignore + } + } + } + + const releaseLock = await lock(lockFile, { fs, retries: 10 }); + + const cleanup = async () => { + cleanups.delete(cleanup); + + await releaseLock(); + + if (lockFile !== file) { + try { + await unlink(lockFile); + } catch { + // ignore + } + } + }; + + cleanups.add(cleanup); + + try { + return await cb(); + } finally { + cleanup(); + } +} + +/** @param {BinaryLike} content */ +export function hashContent(content) { + return createHash("sha1").update(content).digest("hex"); +} + +/** + * @param {Buffer} content + * @param {(resultWriteable: Writable) => Promise} processContent + * @param {string} [cacheDir] If provided, will attempt to read the corresponding cached file instead of calling + * processContent. + * @returns {Promise} + */ +export async function processAndMaybeCacheContent( + content, + processContent, + cacheDir, +) { + if (cacheDir) { + const cacheFile = path.resolve( + path.join(cacheDir, hashContent(/** @type {BinaryLike} */ (content))), + ); + + try { + await stat(path.dirname(cacheFile)); + } catch { + await mkdirp(path.dirname(cacheFile)); + } + + return /** @type {Promise} */ ( + lockAndExecuteOnce(cacheFile, async () => { + try { + await stat(cacheFile); + } catch { + // this cleanup is for if the writing to the cache file gets _interrupted_ (by a kill signal or the like), to + // ensure we don't allow a partially generated file to be used as the cached file. + const cleanup = () => { + unlink(cacheFile); + }; + + cleanups.add(cleanup); + + await processContent(fs.createWriteStream(cacheFile)); + + cleanups.delete(cleanup); + } + + return readFile(cacheFile); + }) + ); + } + + const outputStreamBuffer = new WritableStreamBuffer(); + + await processContent(outputStreamBuffer); + + const output = outputStreamBuffer.getContents(); + + if (output) { + return output; + } +} diff --git a/src/index.js b/src/index.js index 241d8f2..f182851 100644 --- a/src/index.js +++ b/src/index.js @@ -172,6 +172,8 @@ const { * @property {number} [concurrency] Maximum number of concurrency optimization processes in one time. * @property {string} [severityError] Allows to choose how errors are displayed. * @property {boolean} [deleteOriginalAssets] Allows to remove original assets. Useful for converting to a `webp` and remove original assets. + * @property {string} [cacheDir] If provided, ensures all image transformations are done only once and cached within this folder. (Sharp only) + * @property {boolean} [bypassWebpackCache] If true, skips using Webpack's cache. */ const getSerializeJavascript = memoize(() => require("serialize-javascript")); @@ -200,6 +202,8 @@ class ImageMinimizerPlugin { loader = true, concurrency, deleteOriginalAssets = true, + cacheDir, + bypassWebpackCache, } = options; if (!minimizer && !generator) { @@ -221,6 +225,8 @@ class ImageMinimizerPlugin { concurrency, test, deleteOriginalAssets, + cacheDir, + bypassWebpackCache, }; } @@ -232,22 +238,48 @@ class ImageMinimizerPlugin { * @returns {Promise} */ async optimize(compiler, compilation, assets) { - const minimizers = + const minimizers = /** @type {Minimizer[]} */ ( typeof this.options.minimizer !== "undefined" ? Array.isArray(this.options.minimizer) ? this.options.minimizer : [this.options.minimizer] - : []; - - const generators = Array.isArray(this.options.generator) - ? this.options.generator.filter((item) => item.type === "asset") - : []; + : [] + ).map((each) => ({ + ...each, + ...(this.options.cacheDir || each.options + ? { + options: { + cacheDir: this.options.cacheDir, + // eslint-disable-next-line unicorn/no-useless-fallback-in-spread + ...(each.options || {}), + }, + } + : {}), + })); + + const generators = /** @type {Generator[]} */ ( + Array.isArray(this.options.generator) + ? this.options.generator.filter((item) => item.type === "asset") + : [] + ).map((each) => ({ + ...each, + ...(this.options.cacheDir || each.options + ? { + options: { + cacheDir: this.options.cacheDir, + // eslint-disable-next-line unicorn/no-useless-fallback-in-spread + ...(each.options || {}), + }, + } + : {}), + })); if (minimizers.length === 0 && generators.length === 0) { return; } const cache = compilation.getCache("ImageMinimizerWebpackPlugin"); + const assetsForTransformers = ( await Promise.all( Object.keys(assets) @@ -270,7 +302,7 @@ class ImageMinimizerPlugin { return true; }) - .map(async (name) => { + .map((name) => { const { info, source } = /** @type {Asset} */ ( compilation.getAsset(name) ); @@ -278,19 +310,28 @@ class ImageMinimizerPlugin { /** * @template Z * @param {Transformer | Array>} transformer - * @returns {Promise>} + * @returns {Task} */ - const getFromCache = async (transformer) => { - const cacheName = getSerializeJavascript()({ name, transformer }); + const getFromCache = (transformer) => { const eTag = cache.getLazyHashedEtag(source); + const cacheName = getSerializeJavascript()({ + eTag: eTag.toString(), + transformer: (Array.isArray(transformer) + ? transformer + : [transformer] + ).map(({ implementation, options }) => ({ + implementation, + options, + })), + }); + const cacheItem = cache.getItemCache(cacheName, eTag); - const output = await cacheItem.getPromise(); return { name, info, inputSource: source, - output, + output: undefined, cacheItem, transformer, }; @@ -303,15 +344,15 @@ class ImageMinimizerPlugin { if (generators.length > 0) { tasks.push( - ...(await Promise.all( - generators.map((generator) => getFromCache(generator)), - )), + ...generators.map((generator) => + getFromCache(/** @type {Generator} */ (generator)), + ), ); } if (minimizers.length > 0) { tasks.push( - await getFromCache( + getFromCache( /** @type {Minimizer[]} */ (minimizers), ), @@ -338,6 +379,9 @@ class ImageMinimizerPlugin { const sourceFromInputSource = inputSource.source(); + const pluginName = this.constructor.name; + const logger = compilation.getLogger(pluginName); + if (!output) { input = sourceFromInputSource; @@ -356,17 +400,22 @@ class ImageMinimizerPlugin { generateFilename: compilation.getAssetPath.bind(compilation), }); - output = await worker(minifyOptions); + output = this.options.bypassWebpackCache + ? await worker(minifyOptions) + : await cacheItem.providePromise(async () => { + logger.debug(`optimize cache miss: ${name}`); - output.source = new RawSource(output.data); + const result = await worker(minifyOptions); - await cacheItem.storePromise({ - source: output.source, - info: output.info, - filename: output.filename, - warnings: output.warnings, - errors: output.errors, - }); + return { + data: result.data, + source: new RawSource(result.data), + info: result.info, + filename: result.filename, + warnings: result.warnings, + errors: result.errors, + }; + }); } compilation.warnings = [ @@ -504,8 +553,15 @@ class ImageMinimizerPlugin { }); compiler.hooks.afterPlugins.tap({ name: pluginName }, () => { - const { minimizer, generator, test, include, exclude, severityError } = - this.options; + const { + minimizer, + generator, + test, + include, + exclude, + severityError, + cacheDir, + } = this.options; const minimizerForLoader = minimizer; let generatorForLoader = generator; @@ -539,6 +595,7 @@ class ImageMinimizerPlugin { generator: generatorForLoader, minimizer: minimizerForLoader, severityError, + cacheDir, }), }); const dataURILoader = /** @type {InternalLoaderOptions} */ ({ @@ -552,6 +609,7 @@ class ImageMinimizerPlugin { generator: generatorForLoader, minimizer: minimizerForLoader, severityError, + cacheDir, }), }); diff --git a/src/loader-options.json b/src/loader-options.json index 7088d5f..d1dc955 100644 --- a/src/loader-options.json +++ b/src/loader-options.json @@ -106,6 +106,16 @@ "description": "Allows to choose how errors are displayed.", "link": "https://github.com/webpack-contrib/image-minimizer-webpack-plugin#severityerror", "enum": ["off", "warning", "error"] + }, + "cacheDir": { + "description": "If provided, ensures all image transformations are done only once and cached within this folder. (Sharp only)", + "link": "https://github.com/webpack-contrib/image-minimizer-webpack-plugin#cachedir", + "type": "string" + }, + "bypassWebpackCache": { + "description": "If true, skips using Webpack's cache.", + "link": "https://github.com/webpack-contrib/image-minimizer-webpack-plugin#bypasswebpackcache", + "type": "boolean" } } } diff --git a/src/loader.js b/src/loader.js index 94453a4..67e503d 100644 --- a/src/loader.js +++ b/src/loader.js @@ -6,7 +6,9 @@ const { IMAGE_MINIMIZER_PLUGIN_INFO_MAPPINGS, ABSOLUTE_URL_REGEX, WINDOWS_PATH_REGEX, + memoize, } = require("./utils.js"); +const getSerializeJavascript = memoize(() => require("serialize-javascript")); /** @typedef {import("schema-utils/declarations/validate").Schema} Schema */ /** @typedef {import("webpack").Compilation} Compilation */ @@ -28,6 +30,8 @@ const { * @property {string} [severityError] Allows to choose how errors are displayed. * @property {Minimizer | Minimizer[]} [minimizer] * @property {Generator[]} [generator] + * @property {string} [cacheDir] If provided, ensures all image transformations are done only once and cached within this folder. (Sharp only) + * @property {boolean} [bypassWebpackCache] If true, skips using Webpack's cache. */ // Workaround - https://github.com/webpack-contrib/image-minimizer-webpack-plugin/issues/341 @@ -115,7 +119,8 @@ async function loader(content) { // @ts-ignore const options = this.getOptions(/** @type {Schema} */ (schema)); const callback = this.async(); - const { generator, minimizer, severityError } = options; + const { generator, minimizer, severityError, cacheDir, bypassWebpackCache } = + options; if (!minimizer && !generator) { callback( @@ -210,18 +215,56 @@ async function loader(content) { ? this.resourcePath : path.relative(this.rootContext, this.resourcePath); + if (!this._compilation || !this._compiler) { + callback(new Error("_compilation and/or _compiler unavailable")); + return; + } + + const logger = this._compilation.getLogger("ImageMinimizerPlugin"); + const cache = this._compilation.getCache("ImageMinimizerWebpackPlugin"); + + const { RawSource } = this._compiler.webpack.sources; + const eTag = cache.getLazyHashedEtag(new RawSource(content)); + const cacheName = getSerializeJavascript()({ + eTag: eTag.toString(), + transformer: (Array.isArray(transformer) ? transformer : [transformer]).map( + (each) => ({ + implementation: each.implementation, + options: each.options, + }), + ), + }); const minifyOptions = /** @type {import("./index").InternalWorkerOptions} */ ({ input: content, filename, severityError, - transformer, + transformer: (Array.isArray(transformer) + ? transformer + : [transformer] + ).map((each) => ({ + ...each, + ...(cacheDir || each.options + ? { + options: { + cacheDir, + // eslint-disable-next-line unicorn/no-useless-fallback-in-spread + ...(each.options || {}), + }, + } + : {}), + })), generateFilename: /** @type {Compilation} */ (this._compilation).getAssetPath.bind(this._compilation), }); + const output = bypassWebpackCache + ? await worker(minifyOptions) + : await cache.getItemCache(cacheName, eTag).providePromise(() => { + logger.debug(`loader cache miss: ${filename}`); - const output = await worker(minifyOptions); + return worker(minifyOptions); + }); if (output.errors && output.errors.length > 0) { for (const error of output.errors) { diff --git a/src/plugin-options.json b/src/plugin-options.json index 9fbc63c..4555438 100644 --- a/src/plugin-options.json +++ b/src/plugin-options.json @@ -178,6 +178,16 @@ "type": "boolean", "description": "Allows to remove original assets after minimization.", "link": "https://github.com/webpack-contrib/image-minimizer-webpack-plugin#deleteoriginalassets" + }, + "cacheDir": { + "description": "If provided, ensures all image transformations are done only once and cached within this folder. (Sharp only)", + "link": "https://github.com/webpack-contrib/image-minimizer-webpack-plugin#cachedir", + "type": "string" + }, + "bypassWebpackCache": { + "description": "If true, skips using Webpack's cache.", + "link": "https://github.com/webpack-contrib/image-minimizer-webpack-plugin#bypasswebpackcache", + "type": "boolean" } } } diff --git a/src/utils.js b/src/utils.js index a3811aa..f3e6fbe 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,5 @@ const path = require("path"); +const { processAndMaybeCacheContent, hashContent } = require("./cache"); /** @typedef {import("./index").WorkerResult} WorkerResult */ /** @typedef {import("./index").SquooshOptions} SquooshOptions */ @@ -1007,6 +1008,7 @@ squooshMinify.teardown = squooshImagePoolTeardown; * @property {number | 'auto'} [rotate] * @property {SizeSuffix} [sizeSuffix] * @property {SharpEncodeOptions} [encodeOptions] + * @property {string} [cacheDir] */ /** @@ -1084,76 +1086,111 @@ async function sharpTransform( /** @type {SharpLib} */ // eslint-disable-next-line node/no-unpublished-require const sharp = require("sharp"); - const imagePipeline = sharp(original.data, { animated: true }); + const { format } = await sharp(original.data, { animated: true }).metadata(); - // ====== rotate ====== + const hashedOptions = {}; + const { rotate, resize, cacheDir } = minimizerOptions; - if (typeof minimizerOptions.rotate === "number") { - imagePipeline.rotate(minimizerOptions.rotate); - } else if (minimizerOptions.rotate === "auto") { - imagePipeline.rotate(); - } + Object.assign(hashedOptions, { rotate, resize }); - // ====== resize ====== + const outputFormat = targetFormat ?? /** @type {SharpFormat} */ (format); + const encodeOptions = minimizerOptions.encodeOptions?.[outputFormat]; - if (minimizerOptions.resize) { - const { enabled = true, unit = "px", ...params } = minimizerOptions.resize; + Object.assign(hashedOptions, { outputFormat, ...encodeOptions }); - if ( - enabled && - (typeof params.width === "number" || typeof params.height === "number") - ) { - if (unit === "percent") { - const originalMetadata = await sharp(original.data).metadata(); - - if ( - typeof params.width === "number" && - originalMetadata.width && - Number.isFinite(originalMetadata.width) && - originalMetadata.width > 0 - ) { - params.width = Math.ceil( - (originalMetadata.width * params.width) / 100, - ); - } + async function doTransformation() { + const imagePipeline = sharp(original.data, { animated: true }); - if ( - typeof params.height === "number" && - originalMetadata.height && - Number.isFinite(originalMetadata.height) && - originalMetadata.height > 0 - ) { - params.height = Math.ceil( - (originalMetadata.height * params.height) / 100, - ); - } - } + // ====== rotate ====== - imagePipeline.resize(params); + if (typeof rotate === "number") { + imagePipeline.rotate(rotate); + } else if (rotate === "auto") { + imagePipeline.rotate(); } - } - // ====== convert ====== + // ====== resize ====== + + if (resize) { + const { enabled = true, unit = "px", ...params } = resize; + + if ( + enabled && + (typeof params.width === "number" || typeof params.height === "number") + ) { + if (unit === "percent") { + const originalMetadata = await sharp(original.data).metadata(); + + if ( + typeof params.width === "number" && + originalMetadata.width && + Number.isFinite(originalMetadata.width) && + originalMetadata.width > 0 + ) { + params.width = Math.ceil( + (originalMetadata.width * params.width) / 100, + ); + } + + if ( + typeof params.height === "number" && + originalMetadata.height && + Number.isFinite(originalMetadata.height) && + originalMetadata.height > 0 + ) { + params.height = Math.ceil( + (originalMetadata.height * params.height) / 100, + ); + } + } - const imageMetadata = await imagePipeline.metadata(); + imagePipeline.resize(params); + } + } - const outputFormat = - targetFormat ?? /** @type {SharpFormat} */ (imageMetadata.format); + // ====== convert ====== - const encodeOptions = minimizerOptions.encodeOptions?.[outputFormat]; + imagePipeline.toFormat(outputFormat, encodeOptions); + + return imagePipeline; + } - imagePipeline.toFormat(outputFormat, encodeOptions); + const data = await processAndMaybeCacheContent( + original.data, + (resultWriteable) => + doTransformation().then( + (imagePipeline) => + /** @type {Promise} */ ( + new Promise((resolve, reject) => { + imagePipeline + .pipe(resultWriteable) + .on("finish", () => resolve()) + .on("error", (err) => reject(err)); + }) + ), + ), + cacheDir && path.join(cacheDir, hashContent(JSON.stringify(hashedOptions))), + ); - const result = await imagePipeline.toBuffer({ resolveWithObject: true }); + if (!data) { + throw new Error("Unknown error: cannot do sharp transform"); + } // ====== rename ====== const outputExt = targetFormat ? outputFormat : inputExt; - const { width, height } = result.info; + const { width, height } = await sharp(data).metadata(); + + if (!width || !height) { + throw new Error("Unknown error: cannot read size metadata"); + } const sizeSuffix = typeof minimizerOptions.sizeSuffix === "function" - ? minimizerOptions.sizeSuffix(width, height) + ? minimizerOptions.sizeSuffix( + /** @type {number} */ (width), + /** @type {number} */ (height), + ) : ""; const dotIndex = original.filename.lastIndexOf("."); @@ -1170,7 +1207,7 @@ async function sharpTransform( return { filename, - data: result.data, + data, warnings: [...original.warnings], errors: [...original.errors], info: { diff --git a/test/ImageminPlugin.test.js b/test/ImageminPlugin.test.js index 1232d23..4adaf9e 100644 --- a/test/ImageminPlugin.test.js +++ b/test/ImageminPlugin.test.js @@ -755,6 +755,7 @@ describe("imagemin plugin", () => { preset: "webp", implementation: ImageMinimizerPlugin.sharpGenerate, options: { + cacheDir: ".cache/image-minimizer", encodeOptions: { webp: { lossless: true, @@ -804,8 +805,8 @@ describe("imagemin plugin", () => { path.resolve(outputDir, "plugin-test.jpg"), ); - expect(/image\/webp/i.test(extLoaderWebp.mime)).toBe(true); - expect(/image\/jpeg/i.test(extLoaderJpg.mime)).toBe(true); + expect(extLoaderWebp.mime).toMatch(/image\/webp/i); + expect(extLoaderJpg.mime).toMatch(/image\/jpeg/i); expect(secondStats.compilation.emittedAssets.size).toBe(0); }); @@ -816,7 +817,10 @@ describe("imagemin plugin", () => { imageminPluginOptions: { minimizer: { implementation: ImageMinimizerPlugin.imageminMinify, - options: { plugins }, + options: { + cacheDir: ".cache/image-minimizer", + plugins, + }, }, }, }); @@ -962,7 +966,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); await expect(isOptimized("loader-test.gif", compilation)).resolves.toBe( true, @@ -1026,7 +1030,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); // TODO fix me await expect(isOptimized("loader-test.gif", compilation)).resolves.toBe( @@ -1049,6 +1053,8 @@ describe("imagemin plugin", () => { entry: path.join(fixturesPath, "generator-and-minimizer-animation.js"), imageminPluginOptions: { test: /\.(jpe?g|png|webp|gif)$/i, + cacheDir: ".cache/image-minimizer", + bypassWebpackCache: true, generator: [ { preset: "webp", @@ -1205,7 +1211,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); // TODO fix me await expect(isOptimized("loader-test.gif", compilation)).resolves.toBe( @@ -1251,7 +1257,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/avif/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/avif/i); }); it("should generate and allow to use any name in the 'preset' option using 'imageminGenerate'", async () => { @@ -1283,7 +1289,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); }); ifit(needSquooshTest)( @@ -1321,7 +1327,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); }, ); @@ -1468,7 +1474,7 @@ describe("imagemin plugin", () => { const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); const webpAsset = compilation.getAsset("loader-test.webp"); @@ -1546,7 +1552,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(webpAsset); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); const webpAsset1 = path.resolve( __dirname, @@ -1555,7 +1561,7 @@ describe("imagemin plugin", () => { ); const ext1 = await fileType.fromFile(webpAsset1); - expect(/image\/webp/i.test(ext1.mime)).toBe(true); + expect(ext1.mime).toMatch(/image\/webp/i); const webpAsset2 = path.resolve( __dirname, @@ -1564,7 +1570,7 @@ describe("imagemin plugin", () => { ); const ext2 = await fileType.fromFile(webpAsset2); - expect(/image\/webp/i.test(ext2.mime)).toBe(true); + expect(ext2.mime).toMatch(/image\/webp/i); await expect( isOptimized( @@ -1647,7 +1653,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(webpAsset); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); await expect( isOptimized( @@ -1709,7 +1715,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/png/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/png/i); }); ifit(needSquooshTest)( @@ -1844,7 +1850,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/avif/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/avif/i); }); it("should generate image for 'asset' type", async () => { @@ -1878,7 +1884,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/avif/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/avif/i); }); it("should not throw an error when no presets found using the `asset` type", async () => { @@ -1911,7 +1917,7 @@ describe("imagemin plugin", () => { ); const loaderExt = await fileType.fromFile(loaderFile); - expect(/image\/avif/i.test(loaderExt.mime)).toBe(true); + expect(loaderExt.mime).toMatch(/image\/avif/i); }); it("should generate image for 'import' and 'asset' types", async () => { @@ -1954,7 +1960,7 @@ describe("imagemin plugin", () => { ); const loaderExt = await fileType.fromFile(loaderFile); - expect(/image\/avif/i.test(loaderExt.mime)).toBe(true); + expect(loaderExt.mime).toMatch(/image\/avif/i); const pluginFile = path.resolve( __dirname, @@ -1963,7 +1969,7 @@ describe("imagemin plugin", () => { ); const pluginExt = await fileType.fromFile(pluginFile); - expect(/image\/avif/i.test(pluginExt.mime)).toBe(true); + expect(pluginExt.mime).toMatch(/image\/avif/i); }); it("should generate images for 'import' and 'asset' types and keep original assets", async () => { @@ -2008,7 +2014,7 @@ describe("imagemin plugin", () => { ); const loaderExt = await fileType.fromFile(loaderFile); - expect(/image\/avif/i.test(loaderExt.mime)).toBe(true); + expect(loaderExt.mime).toMatch(/image\/avif/i); const pluginFile = path.resolve( __dirname, @@ -2017,7 +2023,7 @@ describe("imagemin plugin", () => { ); const pluginExt = await fileType.fromFile(pluginFile); - expect(/image\/avif/i.test(pluginExt.mime)).toBe(true); + expect(pluginExt.mime).toMatch(/image\/avif/i); }); it("should generate image for 'import' and 'asset' types, minimizer original asset and keep", async () => { @@ -2068,7 +2074,7 @@ describe("imagemin plugin", () => { ); const loaderExt = await fileType.fromFile(loaderFile); - expect(/image\/avif/i.test(loaderExt.mime)).toBe(true); + expect(loaderExt.mime).toMatch(/image\/avif/i); const pluginFile = path.resolve( __dirname, @@ -2077,7 +2083,7 @@ describe("imagemin plugin", () => { ); const pluginExt = await fileType.fromFile(pluginFile); - expect(/image\/avif/i.test(pluginExt.mime)).toBe(true); + expect(pluginExt.mime).toMatch(/image\/avif/i); await expect(isOptimized("plugin-test.jpg", compilation)).resolves.toBe( true, @@ -2140,7 +2146,7 @@ describe("imagemin plugin", () => { ); const loaderExt = await fileType.fromFile(loaderFile); - expect(/image\/avif/i.test(loaderExt.mime)).toBe(true); + expect(loaderExt.mime).toMatch(/image\/avif/i); const pluginFile = path.resolve( __dirname, @@ -2149,7 +2155,7 @@ describe("imagemin plugin", () => { ); const pluginExt = await fileType.fromFile(pluginFile); - expect(/image\/avif/i.test(pluginExt.mime)).toBe(true); + expect(pluginExt.mime).toMatch(/image\/avif/i); const pluginWebp = path.resolve( __dirname, @@ -2158,7 +2164,7 @@ describe("imagemin plugin", () => { ); const pluginWebExt = await fileType.fromFile(pluginWebp); - expect(/image\/webp/i.test(pluginWebExt.mime)).toBe(true); + expect(pluginWebExt.mime).toMatch(/image\/webp/i); await expect(isOptimized("plugin-test.jpg", compilation)).resolves.toBe( true, @@ -2237,7 +2243,7 @@ describe("imagemin plugin", () => { ); const loaderExt = await fileType.fromFile(loaderFile); - expect(/image\/avif/i.test(loaderExt.mime)).toBe(true); + expect(loaderExt.mime).toMatch(/image\/avif/); const pluginFile = path.resolve( __dirname, @@ -2246,7 +2252,7 @@ describe("imagemin plugin", () => { ); const pluginExt = await fileType.fromFile(pluginFile); - expect(/image\/avif/i.test(pluginExt.mime)).toBe(true); + expect(pluginExt.mime).toMatch(/image\/avif/i); const pluginWebp = path.resolve( __dirname, @@ -2255,7 +2261,7 @@ describe("imagemin plugin", () => { ); const pluginWebExt = await fileType.fromFile(pluginWebp); - expect(/image\/webp/i.test(pluginWebExt.mime)).toBe(true); + expect(pluginWebExt.mime).toMatch(/image\/webp/i); // await expect(isOptimized("plugin-test.jpg", compilation)).resolves.toBe( // true @@ -2327,7 +2333,7 @@ describe("imagemin plugin", () => { ); const loaderExt = await fileType.fromFile(loaderFile); - expect(/image\/avif/i.test(loaderExt.mime)).toBe(true); + expect(loaderExt.mime).toMatch(/image\/avif/i); const pluginFile = path.resolve( __dirname, @@ -2336,7 +2342,7 @@ describe("imagemin plugin", () => { ); const pluginExt = await fileType.fromFile(pluginFile); - expect(/image\/avif/i.test(pluginExt.mime)).toBe(true); + expect(pluginExt.mime).toMatch(/image\/avif/i); const pluginWebp = path.resolve( __dirname, @@ -2345,7 +2351,7 @@ describe("imagemin plugin", () => { ); const pluginWebExt = await fileType.fromFile(pluginWebp); - expect(/image\/webp/i.test(pluginWebExt.mime)).toBe(true); + expect(pluginWebExt.mime).toMatch(/image\/webp/i); await expect(isOptimized("plugin-test.jpg", compilation)).resolves.toBe( true, @@ -2398,7 +2404,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); }); it("should generate image from copied assets, minimize and keep original", async () => { @@ -2444,7 +2450,7 @@ describe("imagemin plugin", () => { ); const webpExt = await fileType.fromFile(webpFile); - expect(/image\/webp/i.test(webpExt.mime)).toBe(true); + expect(webpExt.mime).toMatch(/image\/webp/i); const fileJpg = path.resolve( __dirname, @@ -2453,7 +2459,7 @@ describe("imagemin plugin", () => { ); const extJpg = await fileType.fromFile(fileJpg); - expect(/image\/jpeg/i.test(extJpg.mime)).toBe(true); + expect(extJpg.mime).toMatch(/image\/jpeg/i); await expect(isOptimized("plugin-test.jpg", compilation)).resolves.toBe( true, @@ -2515,7 +2521,7 @@ describe("imagemin plugin", () => { // ); // const pngExt = await fileType.fromFile(pngFile); // - // expect(/image\/png/i.test(pngExt.mime)).toBe(true); + // expect(pngExt.mime).toMatch(/image\/png/i); // // const webpFile = path.resolve( // __dirname, @@ -2524,7 +2530,7 @@ describe("imagemin plugin", () => { // ); // const webpExt = await fileType.fromFile(webpFile); // - // expect(/image\/webp/i.test(webpExt.mime)).toBe(true); + // expect(webpExt.mime).toMatch(/image\/webp/i); // const cssFile = path.resolve( __dirname, @@ -2634,7 +2640,7 @@ describe("imagemin plugin", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); }); it("should optimizes images svg image and prefix id (svgoMinify)", async () => { diff --git a/test/__snapshots__/validate-loader-options.test.js.snap b/test/__snapshots__/validate-loader-options.test.js.snap index adaab3c..4645abc 100644 --- a/test/__snapshots__/validate-loader-options.test.js.snap +++ b/test/__snapshots__/validate-loader-options.test.js.snap @@ -183,47 +183,47 @@ exports[`validate loader options should throw an error on the "severityError" op exports[`validate loader options should throw an error on the "unknown" option with "/test/" value 1`] = ` "Invalid options object. Image Minimizer Plugin Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'unknown'. These properties are valid: - object { minimizer?, generator?, severityError? }" + object { minimizer?, generator?, severityError?, cacheDir?, bypassWebpackCache? }" `; exports[`validate loader options should throw an error on the "unknown" option with "[]" value 1`] = ` "Invalid options object. Image Minimizer Plugin Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'unknown'. These properties are valid: - object { minimizer?, generator?, severityError? }" + object { minimizer?, generator?, severityError?, cacheDir?, bypassWebpackCache? }" `; exports[`validate loader options should throw an error on the "unknown" option with "{"foo":"bar"}" value 1`] = ` "Invalid options object. Image Minimizer Plugin Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'unknown'. These properties are valid: - object { minimizer?, generator?, severityError? }" + object { minimizer?, generator?, severityError?, cacheDir?, bypassWebpackCache? }" `; exports[`validate loader options should throw an error on the "unknown" option with "{}" value 1`] = ` "Invalid options object. Image Minimizer Plugin Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'unknown'. These properties are valid: - object { minimizer?, generator?, severityError? }" + object { minimizer?, generator?, severityError?, cacheDir?, bypassWebpackCache? }" `; exports[`validate loader options should throw an error on the "unknown" option with "1" value 1`] = ` "Invalid options object. Image Minimizer Plugin Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'unknown'. These properties are valid: - object { minimizer?, generator?, severityError? }" + object { minimizer?, generator?, severityError?, cacheDir?, bypassWebpackCache? }" `; exports[`validate loader options should throw an error on the "unknown" option with "false" value 1`] = ` "Invalid options object. Image Minimizer Plugin Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'unknown'. These properties are valid: - object { minimizer?, generator?, severityError? }" + object { minimizer?, generator?, severityError?, cacheDir?, bypassWebpackCache? }" `; exports[`validate loader options should throw an error on the "unknown" option with "test" value 1`] = ` "Invalid options object. Image Minimizer Plugin Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'unknown'. These properties are valid: - object { minimizer?, generator?, severityError? }" + object { minimizer?, generator?, severityError?, cacheDir?, bypassWebpackCache? }" `; exports[`validate loader options should throw an error on the "unknown" option with "true" value 1`] = ` "Invalid options object. Image Minimizer Plugin Loader has been initialized using an options object that does not match the API schema. - options has an unknown property 'unknown'. These properties are valid: - object { minimizer?, generator?, severityError? }" + object { minimizer?, generator?, severityError?, cacheDir?, bypassWebpackCache? }" `; diff --git a/test/loader-generator-option.test.js b/test/loader-generator-option.test.js index fb8646c..8944ba7 100644 --- a/test/loader-generator-option.test.js +++ b/test/loader-generator-option.test.js @@ -40,7 +40,7 @@ describe("loader generator option", () => { const transformedExt = await fileType.fromFile(transformedAsset); - expect(/image\/webp/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/webp/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); @@ -148,7 +148,7 @@ describe("loader generator option", () => { const transformedExt = await fileType.fromFile(transformedAsset); - expect(/image\/webp/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/webp/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); @@ -187,7 +187,7 @@ describe("loader generator option", () => { const transformedExt = await fileType.fromFile(transformedAsset); - expect(/image\/webp/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/webp/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); @@ -237,7 +237,7 @@ describe("loader generator option", () => { expect(dimensions.height).toBe(50); expect(dimensions.width).toBe(100); - expect(/image\/webp/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/webp/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }, @@ -288,7 +288,7 @@ describe("loader generator option", () => { expect(dimensions.height).toBe(1); expect(dimensions.width).toBe(1); - expect(/image\/webp/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/webp/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }, @@ -334,7 +334,7 @@ describe("loader generator option", () => { expect(dimensions.height).toBe(50); expect(dimensions.width).toBe(100); - expect(/image\/webp/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/webp/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); @@ -379,7 +379,7 @@ describe("loader generator option", () => { expect(dimensions.height).toBe(1); expect(dimensions.width).toBe(1); - expect(/image\/webp/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/webp/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); @@ -411,7 +411,7 @@ describe("loader generator option", () => { const transformedExt = await fileType.fromFile(transformedAsset); - expect(/image\/jpeg/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/jpeg/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); @@ -457,7 +457,7 @@ describe("loader generator option", () => { expect(dimensions.height).toBe(50); expect(dimensions.width).toBe(100); - expect(/image\/webp/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/webp/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); @@ -489,7 +489,7 @@ describe("loader generator option", () => { const transformedExt = await fileType.fromFile(transformedAsset); - expect(/image\/jpeg/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/jpeg/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); @@ -535,7 +535,7 @@ describe("loader generator option", () => { expect(dimensions.height).toBe(50); expect(dimensions.width).toBe(100); - expect(/image\/webp/i.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(/image\/webp/i); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); diff --git a/test/loader.test.js b/test/loader.test.js index dbfa6e9..0e7232a 100644 --- a/test/loader.test.js +++ b/test/loader.test.js @@ -418,7 +418,7 @@ describe("loader", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); }); ifit(needSquooshTest)( @@ -455,7 +455,7 @@ describe("loader", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); }, ); @@ -494,7 +494,7 @@ describe("loader", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); }, ); @@ -531,7 +531,7 @@ describe("loader", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/webp/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/webp/i); }); it("should throw an error on empty minimizer", async () => { diff --git a/test/plugin-minimizer-option.test.js b/test/plugin-minimizer-option.test.js index ee32fbb..b8beb05 100644 --- a/test/plugin-minimizer-option.test.js +++ b/test/plugin-minimizer-option.test.js @@ -514,7 +514,7 @@ describe("plugin minify option", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/png/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/png/i); expect(transformedAssets).toHaveLength(1); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); @@ -546,7 +546,7 @@ describe("plugin minify option", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/png/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/png/i); expect(transformedAssets).toHaveLength(1); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); @@ -578,7 +578,7 @@ describe("plugin minify option", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/png/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/png/i); expect(transformedAssets).toHaveLength(1); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); @@ -610,7 +610,7 @@ describe("plugin minify option", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/png/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/png/i); expect(transformedAssets).toHaveLength(1); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); @@ -642,7 +642,7 @@ describe("plugin minify option", () => { ); const ext = await fileType.fromFile(file); - expect(/image\/png/i.test(ext.mime)).toBe(true); + expect(ext.mime).toMatch(/image\/png/i); expect(transformedAssets).toHaveLength(1); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); diff --git a/test/resize-query.test.js b/test/resize-query.test.js index 0435dfb..cf8446b 100644 --- a/test/resize-query.test.js +++ b/test/resize-query.test.js @@ -85,7 +85,7 @@ describe("resize query (sharp)", () => { expect(dimensions.width).toBe(width); expect(dimensions.height).toBe(height); - expect(mimeRegExp.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(mimeRegExp); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); } @@ -159,7 +159,7 @@ describe("resize query (sharp)", () => { expect(dimensions.width).toBe(width); expect(dimensions.height).toBe(height); - expect(mimeRegExp.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(mimeRegExp); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); } @@ -243,7 +243,7 @@ describe("resize query (sharp)", () => { expect(dimensions.width).toBe(width); expect(dimensions.height).toBe(height); - expect(mimeRegExp.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(mimeRegExp); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); } @@ -317,7 +317,7 @@ describe("resize query (sharp)", () => { expect(dimensions.width).toBe(width); expect(dimensions.height).toBe(height); - expect(mimeRegExp.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(mimeRegExp); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); } @@ -403,7 +403,7 @@ describe("resize query (squoosh)", () => { expect(dimensions.width).toBe(width); expect(dimensions.height).toBe(height); - expect(mimeRegExp.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(mimeRegExp); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); } @@ -480,7 +480,7 @@ describe("resize query (squoosh)", () => { expect(dimensions.width).toBe(width); expect(dimensions.height).toBe(height); - expect(mimeRegExp.test(transformedExt.mime)).toBe(true); + expect(transformedExt.mime).toMatch(mimeRegExp); expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); } diff --git a/types/cache.d.ts b/types/cache.d.ts new file mode 100644 index 0000000..258debc --- /dev/null +++ b/types/cache.d.ts @@ -0,0 +1,21 @@ +/** @param {BinaryLike} content */ +export function hashContent(content: BinaryLike): string; +/** + * @param {Buffer} content + * @param {(resultWriteable: Writable) => Promise} processContent + * @param {string} [cacheDir] If provided, will attempt to read the corresponding cached file instead of calling + * processContent. + * @returns {Promise} + */ +export function processAndMaybeCacheContent( + content: Buffer, + processContent: (resultWriteable: Writable) => Promise, + cacheDir?: string | undefined, +): Promise; +/** + * + */ +export type LoaderContext = import("webpack").LoaderContext; +export type Writable = import("stream").Writable; +export type BinaryLike = import("crypto").BinaryLike; +export type Buffer = import("buffer").Buffer; diff --git a/types/index.d.ts b/types/index.d.ts index 9f5ee9b..525c8f0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -221,4 +221,12 @@ type PluginOptions = { * Allows to remove original assets. Useful for converting to a `webp` and remove original assets. */ deleteOriginalAssets?: boolean | undefined; + /** + * If provided, ensures all image transformations are done only once and cached within this folder. (Sharp only) + */ + cacheDir?: string | undefined; + /** + * If true, skips using Webpack's cache. + */ + bypassWebpackCache?: boolean | undefined; }; diff --git a/types/loader.d.ts b/types/loader.d.ts index be5099b..ed1be68 100644 --- a/types/loader.d.ts +++ b/types/loader.d.ts @@ -45,4 +45,12 @@ type LoaderOptions = { | import("./index").Minimizer[] | undefined; generator?: import("./index").Generator[] | undefined; + /** + * If provided, ensures all image transformations are done only once and cached within this folder. (Sharp only) + */ + cacheDir?: string | undefined; + /** + * If true, skips using Webpack's cache. + */ + bypassWebpackCache?: boolean | undefined; }; diff --git a/types/utils.d.ts b/types/utils.d.ts index f399047..b3aaf26 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -41,6 +41,7 @@ export type SharpOptions = { rotate?: number | "auto" | undefined; sizeSuffix?: SizeSuffix | undefined; encodeOptions?: SharpEncodeOptions | undefined; + cacheDir?: string | undefined; }; export type SizeSuffix = (width: number, height: number) => string; /**