image-minimizer-webpack-plugin
Version:
Webpack loader and plugin to optimize (compress) images using imagemin
1,078 lines (1,000 loc) • 32.8 kB
JavaScript
const path = require("node:path");
/** @typedef {import("./index").WorkerResult} WorkerResult */
/** @typedef {import("./index").CustomOptions} CustomOptions */
/** @typedef {import("webpack").WebpackError} WebpackError */
/** @typedef {import("webpack").Module} Module */
/** @typedef {import("webpack").AssetInfo} AssetInfo */
// eslint-disable-next-line jsdoc/no-restricted-syntax
/** @typedef {any} EXPECTED_ANY */
/**
* @template T
* @typedef {() => Promise<T>} Task
*/
/**
* @param {string} filename file path without query params (e.g. `path/img.png`)
* @param {string} ext new file extension without `.` (e.g. `webp`)
* @returns {string} new filename `path/img.png` -> `path/img.webp`
*/
function replaceFileExtension(filename, ext) {
let dotIndex = -1;
for (let i = filename.length - 1; i > -1; i--) {
const char = filename[i];
if (char === ".") {
dotIndex = i;
break;
}
if (char === "/" || char === "\\") {
break;
}
}
if (dotIndex === -1) {
return filename;
}
return `${filename.slice(0, dotIndex)}.${ext}`;
}
/**
* Run tasks with limited concurrency.
* @template T
* @param {number} limit Limit of tasks that run at once.
* @param {Task<T>[]} tasks List of tasks to run.
* @returns {Promise<T[]>} A promise that fulfills to an array of the results
*/
function throttleAll(limit, tasks) {
return new Promise((resolve, reject) => {
const result = /** @type {T[]} */[];
const entries = tasks.entries();
let tasksFulfilled = 0;
const next = () => {
const {
done,
value
} = entries.next();
if (done) {
if (tasksFulfilled === tasks.length) {
resolve(result);
return;
}
return;
}
const [index, task] = value;
/**
* @param {T} taskResult task result
*/
const onFulfilled = taskResult => {
result[index] = taskResult;
tasksFulfilled += 1;
next();
};
task().then(onFulfilled, reject);
};
for (let i = 0; i < limit; i++) {
next();
}
});
}
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/;
const WINDOWS_PATH_REGEX = /^[a-zA-Z]:\\/;
const POSIX_PATH_REGEX = /^\//;
/**
* @param {string} url URL
* @returns {boolean} true when URL is absolute, otherwise false
*/
function isAbsoluteURL(url) {
return WINDOWS_PATH_REGEX.test(url) || POSIX_PATH_REGEX.test(url) || ABSOLUTE_URL_REGEX.test(url);
}
/**
* @callback Uint8ArrayUtf8ByteString
* @param {number[] | Uint8Array} array
* @param {number} start
* @param {number} end
* @returns {string}
*/
/** @type {Uint8ArrayUtf8ByteString} */
const uint8ArrayUtf8ByteString = (array, start, end) => String.fromCodePoint(...array.slice(start, end));
/**
* @callback StringToBytes
* @param {string} string
* @returns {number[]}
*/
/** @type {StringToBytes} */
const stringToBytes = string => [...string].map(character => character.charCodeAt(0));
/**
* @param {ArrayBuffer | ArrayLike<number>} input input buffer
* @returns {{ext: string, mime: string} | undefined} file type info
*/
function fileTypeFromBuffer(input) {
if (!(input instanceof Uint8Array || input instanceof ArrayBuffer || Buffer.isBuffer(input))) {
throw new TypeError(`Expected the \`input\` argument to be of type \`Uint8Array\` or \`Buffer\` or \`ArrayBuffer\`, got \`${typeof input}\``);
}
const buffer = input instanceof Uint8Array ? input : new Uint8Array(input);
if (!(buffer && buffer.length > 1)) {
return;
}
/**
* @param {number[]} header header bytes
* @param {{offset: number, mask?: number[]}=} options options
* @returns {boolean} whether the header matches
*/
const check = (header, options) => {
options = {
offset: 0,
...options
};
for (let i = 0; i < header.length; i++) {
if (options.mask) {
if (header[i] !== (options.mask[i] & buffer[i + options.offset])) {
return false;
}
} else if (header[i] !== buffer[i + options.offset]) {
return false;
}
}
return true;
};
/**
* @param {string} header header string
* @param {{offset: number, mask?: number[]}=} options options
* @returns {boolean} whether the header matches
*/
const checkString = (header, options) => check(stringToBytes(header), options);
if (check([0xff, 0xd8, 0xff])) {
return {
ext: "jpg",
mime: "image/jpeg"
};
}
if (check([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
// APNG format (https://wiki.mozilla.org/APNG_Specification)
// 1. Find the first IDAT (image data) chunk (49 44 41 54)
// 2. Check if there is an "acTL" chunk before the IDAT one (61 63 54 4C)
// Offset calculated as follows:
// - 8 bytes: PNG signature
// - 4 (length) + 4 (chunk type) + 13 (chunk data) + 4 (CRC): IHDR chunk
const startIndex = 33;
const firstImageDataChunkIndex = buffer.findIndex((el, i) => i >= startIndex && buffer[i] === 0x49 && buffer[i + 1] === 0x44 && buffer[i + 2] === 0x41 && buffer[i + 3] === 0x54);
const sliced = buffer.subarray(startIndex, firstImageDataChunkIndex);
if (sliced.some((el, i) => sliced[i] === 0x61 && sliced[i + 1] === 0x63 && sliced[i + 2] === 0x54 && sliced[i + 3] === 0x4c)) {
return {
ext: "apng",
mime: "image/apng"
};
}
return {
ext: "png",
mime: "image/png"
};
}
if (check([0x47, 0x49, 0x46])) {
return {
ext: "gif",
mime: "image/gif"
};
}
if (check([0x57, 0x45, 0x42, 0x50], {
offset: 8
})) {
return {
ext: "webp",
mime: "image/webp"
};
}
if (check([0x46, 0x4c, 0x49, 0x46])) {
return {
ext: "flif",
mime: "image/flif"
};
}
// `cr2`, `orf`, and `arw` need to be before `tif` check
if ((check([0x49, 0x49, 0x2a, 0x0]) || check([0x4d, 0x4d, 0x0, 0x2a])) && check([0x43, 0x52], {
offset: 8
})) {
return {
ext: "cr2",
mime: "image/x-canon-cr2"
};
}
if (check([0x49, 0x49, 0x52, 0x4f, 0x08, 0x00, 0x00, 0x00, 0x18])) {
return {
ext: "orf",
mime: "image/x-olympus-orf"
};
}
if (check([0x49, 0x49, 0x2a, 0x00]) && (check([0x10, 0xfb, 0x86, 0x01], {
offset: 4
}) || check([0x08, 0x00, 0x00, 0x00], {
offset: 4
})) &&
// This pattern differentiates ARW from other TIFF-ish file types:
check([0x00, 0xfe, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x03, 0x01], {
offset: 9
})) {
return {
ext: "arw",
mime: "image/x-sony-arw"
};
}
if (check([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]) && (check([0x2d, 0x00, 0xfe, 0x00], {
offset: 8
}) || check([0x27, 0x00, 0xfe, 0x00], {
offset: 8
}))) {
return {
ext: "dng",
mime: "image/x-adobe-dng"
};
}
if (check([0x49, 0x49, 0x2a, 0x00]) && check([0x1c, 0x00, 0xfe, 0x00], {
offset: 8
})) {
return {
ext: "nef",
mime: "image/x-nikon-nef"
};
}
if (check([0x49, 0x49, 0x55, 0x00, 0x18, 0x00, 0x00, 0x00, 0x88, 0xe7, 0x74, 0xd8])) {
return {
ext: "rw2",
mime: "image/x-panasonic-rw2"
};
}
// `raf` is here just to keep all the raw image detectors together.
if (checkString("FUJIFILMCCD-RAW")) {
return {
ext: "raf",
mime: "image/x-fujifilm-raf"
};
}
if (check([0x49, 0x49, 0x2a, 0x0]) || check([0x4d, 0x4d, 0x0, 0x2a])) {
return {
ext: "tif",
mime: "image/tiff"
};
}
if (check([0x42, 0x4d])) {
return {
ext: "bmp",
mime: "image/bmp"
};
}
if (check([0x49, 0x49, 0xbc])) {
return {
ext: "jxr",
mime: "image/vnd.ms-photo"
};
}
if (check([0x38, 0x42, 0x50, 0x53])) {
return {
ext: "psd",
mime: "image/vnd.adobe.photoshop"
};
}
if (checkString("ftyp", {
offset: 4
}) && (buffer[8] & 0x60) !== 0x00 // Brand major, first character ASCII?
) {
// They all can have MIME `video/mp4` except `application/mp4` special-case which is hard to detect.
// For some cases, we're specific, everything else falls to `video/mp4` with `mp4` extension.
const brandMajor = uint8ArrayUtf8ByteString(buffer, 8, 12).replace("\0", " ").trim();
switch (brandMajor) {
case "avif":
return {
ext: "avif",
mime: "image/avif"
};
case "mif1":
return {
ext: "heic",
mime: "image/heif"
};
case "msf1":
return {
ext: "heic",
mime: "image/heif-sequence"
};
case "heic":
case "heix":
return {
ext: "heic",
mime: "image/heic"
};
case "hevc":
case "hevx":
return {
ext: "heic",
mime: "image/heic-sequence"
};
}
}
if (check([0x00, 0x00, 0x01, 0x00])) {
return {
ext: "ico",
mime: "image/x-icon"
};
}
if (check([0x00, 0x00, 0x02, 0x00])) {
return {
ext: "cur",
mime: "image/x-icon"
};
}
if (check([0x42, 0x50, 0x47, 0xfb])) {
return {
ext: "bpg",
mime: "image/bpg"
};
}
if (check([0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a])) {
// JPEG-2000 family
if (check([0x6a, 0x70, 0x32, 0x20], {
offset: 20
})) {
return {
ext: "jp2",
mime: "image/jp2"
};
}
if (check([0x6a, 0x70, 0x78, 0x20], {
offset: 20
})) {
return {
ext: "jpx",
mime: "image/jpx"
};
}
if (check([0x6a, 0x70, 0x6d, 0x20], {
offset: 20
})) {
return {
ext: "jpm",
mime: "image/jpm"
};
}
if (check([0x6d, 0x6a, 0x70, 0x32], {
offset: 20
})) {
return {
ext: "mj2",
mime: "image/mj2"
};
}
}
if (check([0xff, 0x0a]) || check([0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a])) {
return {
ext: "jxl",
mime: "image/jxl"
};
}
if (check([0xab, 0x4b, 0x54, 0x58, 0x20, 0x31, 0x31, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a])) {
return {
ext: "ktx",
mime: "image/ktx"
};
}
}
/**
* @template T
* @typedef {() => T} FunctionReturning
*/
/**
* @template T
* @param {FunctionReturning<T>} fn memorized function
* @returns {FunctionReturning<T>} new function
*/
function memoize(fn) {
let cache = false;
/** @type {T} */
let result;
return () => {
if (cache) {
return result;
}
result = fn();
cache = true;
// Allow to clean up memory for fn
// and all dependent resources
/** @type {FunctionReturning<T> | undefined} */
fn = undefined;
return result;
};
}
/**
* @typedef {object} MetaData
* @property {Array<Error>} warnings warnings
* @property {Array<Error>} errors errors
*/
class InvalidConfigError extends Error {
/**
* @param {string | undefined} message message
*/
constructor(message) {
super(message);
this.name = "InvalidConfigError";
}
}
/**
* @param {Record<string, EXPECTED_ANY>} imageminConfig imagemin configuration
* @returns {Promise<Record<string, EXPECTED_ANY>>} normalized imagemin configuration
*/
async function imageminNormalizeConfig(imageminConfig) {
if (!imageminConfig || !imageminConfig.plugins || imageminConfig.plugins && imageminConfig.plugins.length === 0) {
throw new Error("No plugins found for `imagemin`, please read documentation");
}
/**
* @type {import("imagemin").Plugin[]}
*/
const plugins = [];
for (const plugin of imageminConfig.plugins) {
const isPluginArray = Array.isArray(plugin);
if (typeof plugin === "string" || isPluginArray) {
const pluginName = isPluginArray ? plugin[0] : plugin;
const pluginOptions = isPluginArray ? plugin[1] : undefined;
let requiredPlugin = null;
let requiredPluginName = pluginName.startsWith("imagemin") ? pluginName : `imagemin-${pluginName}`;
try {
requiredPlugin = (await import(requiredPluginName)).default(pluginOptions);
} catch {
requiredPluginName = pluginName;
try {
requiredPlugin = (await import(requiredPluginName)).default(pluginOptions);
} catch (error) {
const pluginNameForError = pluginName.startsWith("imagemin") ? pluginName : `imagemin-${pluginName}`;
throw new Error(`Unknown plugin: ${pluginNameForError}\n\nDid you forget to install the plugin?\nYou can install it with:\n\n$ npm install ${pluginNameForError} --save-dev\n$ yarn add ${pluginNameForError} --dev`, {
cause: error
});
}
// Nothing
}
// let version = "unknown";
// try {
// // eslint-disable-next-line import/no-dynamic-require
// ({ version } = require(`${requiredPluginName}/package.json`));
// } catch {
// // Nothing
// }
// /** @type {Array<Object>} imageminConfig.pluginsMeta */
// pluginsMeta.push([
// {
// name: requiredPluginName,
// options: pluginOptions || {},
// version,
// },
// ]);
plugins.push(requiredPlugin);
} else {
throw new InvalidConfigError(`Invalid plugin configuration '${JSON.stringify(plugin)}', plugin configuration should be 'string' or '[string, object]'"`);
}
}
return {
plugins
};
}
/**
* @param {WorkerResult} original original worker result
* @param {CustomOptions=} options options
* @returns {Promise<WorkerResult | null>} generated result
*/
async function imageminGenerate(original, options) {
/** @typedef {import("imagemin").Options} ImageminOptions */
const optionsNormalized = /** @type {ImageminOptions} */
await imageminNormalizeConfig(/** @type {ImageminOptions} */
options ?? {});
const imagemin = (await import("imagemin")).default;
let result;
try {
result = await imagemin.buffer(original.data, optionsNormalized);
} catch (error) {
const originalError = error instanceof Error ? error : new Error(/** @type {string} */error);
const newError = new Error(`Error with '${original.filename}': ${originalError.message}`);
original.errors.push(newError);
return null;
}
const {
ext: extOutput
} = fileTypeFromBuffer(result) || {};
const extInput = path.extname(original.filename).slice(1).toLowerCase();
let newFilename = original.filename;
if (extOutput && extInput !== extOutput) {
newFilename = replaceFileExtension(original.filename, extOutput);
}
return {
filename: newFilename,
// imagemin@8 returns buffer, but imagemin@9 returns uint8array
data: !Buffer.isBuffer(result) ? Buffer.from(result) : result,
warnings: [...original.warnings],
errors: [...original.errors],
info: {
...original.info,
generated: true,
generatedBy: ["imagemin", ...(original.info?.generatedBy ?? [])]
}
};
}
/**
* @param {WorkerResult} original original worker result
* @param {CustomOptions=} options options
* @returns {Promise<WorkerResult | null>} minified result
*/
async function imageminMinify(original, options) {
/** @typedef {import("imagemin").Options} ImageminOptions */
const optionsNormalized = /** @type {ImageminOptions} */
await imageminNormalizeConfig(/** @type {ImageminOptions} */
options ?? {});
const imagemin = (await import("imagemin")).default;
let result;
try {
result = await imagemin.buffer(original.data, optionsNormalized);
} catch (error) {
const originalError = error instanceof Error ? error : new Error(/** @type {string} */error);
const newError = new Error(`Error with '${original.filename}': ${originalError.message}`);
original.errors.push(newError);
return null;
}
if (!isAbsoluteURL(original.filename)) {
const extInput = path.extname(original.filename).slice(1).toLowerCase();
const {
ext: extOutput
} = fileTypeFromBuffer(result) || {};
if (extOutput && extInput !== extOutput) {
original.warnings.push(new Error(`"imageminMinify" function do not support generate to "${extOutput}" from "${original.filename}". Please use "imageminGenerate" function.`));
return null;
}
}
return {
filename: original.filename,
// imagemin@8 returns buffer, but imagemin@9 returns uint8array
data: !Buffer.isBuffer(result) ? Buffer.from(result) : result,
warnings: [...original.warnings],
errors: [...original.errors],
info: {
...original.info,
minimized: true,
minimizedBy: ["imagemin", ...(original.info?.minimizedBy ?? [])]
}
};
}
/**
* @typedef {object} SquooshImage
* @property {(options: Record<string, unknown>) => Promise<void>} preprocess preprocess
* @property {(options: Record<string, unknown>) => Promise<void>} encode encode
* @property {Record<string, {binary: Uint8Array, extension: string}>} encodedWith encoded with
* @property {{ bitmap: {width: number, height: number}} } decoded decoded
*/
/**
* @typedef {object} SquooshImagePool
* @property {(data: Uint8Array) => SquooshImage} ingestImage ingest image function
* @property {() => Promise<void>} close close function
*/
/**
* @type {SquooshImagePool | undefined}
*/
let pool;
/**
* @param {number} threads The number of threads
* @returns {SquooshImagePool} The image pool
*/
function squooshImagePoolCreate(threads = 1) {
const {
ImagePool
} = require("@squoosh/lib");
// TODO https://github.com/GoogleChromeLabs/squoosh/issues/1111,
// TODO https://github.com/GoogleChromeLabs/squoosh/issues/1012
//
// Due to the above errors, we use the value "1", it is faster and consumes less memory in common use.
//
// Also we don't know how many image (modules are built asynchronously) we will have so we can't setup
// the correct value and creating child processes takes a long time, unfortunately there is no perfect solution here,
// maybe we should provide an option for this (or API for warm up), so if you are reading this feel free to open the issue
return new ImagePool(threads);
}
/**
* @returns {void}
*/
function squooshImagePoolSetup() {
if (!pool) {
const os = require("node:os");
// In some cases cpus() returns undefined
// https://github.com/nodejs/node/issues/19022
const threads = os.cpus()?.length ?? 1;
pool = squooshImagePoolCreate(threads);
// workarounds for https://github.com/GoogleChromeLabs/squoosh/issues/1152
// @ts-expect-error - workaround for squoosh compatibility
// eslint-disable-next-line n/no-unsupported-features/node-builtins
delete globalThis.navigator;
}
}
/**
* @returns {Promise<void>}
*/
async function squooshImagePoolTeardown() {
if (pool) {
await pool.close();
pool = undefined;
}
}
/**
* @param {WorkerResult} original original worker result
* @param {CustomOptions=} options options
* @returns {Promise<WorkerResult | null>} generated result
*/
async function squooshGenerate(original, options) {
// eslint-disable-next-line jsdoc/no-restricted-syntax
/**
* @typedef {{ [key: string]: any }} SquooshOptions
*/
const squoosh = require("@squoosh/lib");
const isReusePool = Boolean(pool);
const imagePool = pool || squooshImagePoolCreate();
const image = imagePool.ingestImage(new Uint8Array(original.data));
const squooshOptions = /** @type {SquooshOptions} */options ?? {};
const preprocEntries = Object.entries(squooshOptions).filter(([key, value]) => {
if (key === "resize" && value?.enabled === false) {
return false;
}
return typeof squoosh.preprocessors[key] !== "undefined";
});
if (preprocEntries.length > 0) {
await image.preprocess(Object.fromEntries(preprocEntries));
}
const {
encodeOptions
} = squooshOptions;
try {
await image.encode(encodeOptions);
} catch (error) {
if (!isReusePool) {
await imagePool.close();
}
const originalError = error instanceof Error ? error : new Error(/** @type {string} */error);
const newError = new Error(`Error with '${original.filename}': ${originalError.message}`);
original.errors.push(newError);
return null;
}
if (!isReusePool) {
await imagePool.close();
}
if (Object.keys(image.encodedWith).length === 0) {
original.errors.push(new Error(`No result from 'squoosh' for '${original.filename}', please configure the 'encodeOptions' option to generate images`));
return null;
}
if (Object.keys(image.encodedWith).length > 1) {
original.errors.push(new Error(`Multiple values for the 'encodeOptions' option is not supported for '${original.filename}', specify only one codec for the generator`));
return null;
}
const {
binary,
extension
} = await Object.values(image.encodedWith)[0];
const {
width,
height
} = (await image.decoded).bitmap;
const filename = replaceFileExtension(original.filename, extension);
return {
filename,
data: Buffer.from(binary),
warnings: [...original.warnings],
errors: [...original.errors],
info: {
...original.info,
width,
height,
generated: true,
generatedBy: ["squoosh", ...(original.info?.generatedBy ?? [])]
}
};
}
squooshGenerate.setup = squooshImagePoolSetup;
squooshGenerate.teardown = squooshImagePoolTeardown;
/**
* @param {WorkerResult} original original worker result
* @param {CustomOptions=} options options
* @returns {Promise<WorkerResult | null>} minified result
*/
async function squooshMinify(original, options) {
// eslint-disable-next-line jsdoc/no-restricted-syntax
/**
* @typedef {{ [key: string]: any }} SquooshOptions
*/
const squoosh = require("@squoosh/lib");
const {
encoders
} = squoosh;
/**
* @type {Record<string, string>}
*/
const targets = {};
for (const [codec, {
extension
}] of Object.entries(encoders)) {
const extensionNormalized = extension.toLowerCase();
if (extensionNormalized === "jpg") {
targets.jpeg = codec;
}
targets[extensionNormalized] = codec;
}
const ext = path.extname(original.filename).slice(1).toLowerCase();
const targetCodec = targets[ext];
if (!targetCodec) {
return null;
}
const isReusePool = Boolean(pool);
const imagePool = pool || squooshImagePoolCreate();
const image = imagePool.ingestImage(new Uint8Array(original.data));
const squooshOptions = /** @type {SquooshOptions} */options ?? {};
const preprocEntries = Object.entries(squooshOptions).filter(([key, value]) => {
if (key === "resize" && value?.enabled === false) {
return false;
}
return typeof squoosh.preprocessors[key] !== "undefined";
});
if (preprocEntries.length > 0) {
await image.preprocess(Object.fromEntries(preprocEntries));
}
const {
encodeOptions = {}
} = squooshOptions;
if (!encodeOptions[targetCodec]) {
encodeOptions[targetCodec] = {};
}
try {
await image.encode({
[targetCodec]: encodeOptions[targetCodec]
});
} catch (error) {
if (!isReusePool) {
await imagePool.close();
}
const originalError = error instanceof Error ? error : new Error(/** @type {string} */error);
const newError = new Error(`Error with '${original.filename}': ${originalError.message}`);
original.errors.push(newError);
return null;
}
if (!isReusePool) {
await imagePool.close();
}
const {
binary
} = await image.encodedWith[targets[ext]];
const {
width,
height
} = (await image.decoded).bitmap;
return {
filename: original.filename,
data: Buffer.from(binary),
warnings: [...original.warnings],
errors: [...original.errors],
info: {
...original.info,
width,
height,
minimized: true,
minimizedBy: ["squoosh", ...(original.info?.minimizedBy ?? [])]
}
};
}
squooshMinify.setup = squooshImagePoolSetup;
squooshMinify.teardown = squooshImagePoolTeardown;
/**
* @typedef SizeSuffix
* @type {(width: number, height: number) => string}
*/
// https://github.com/lovell/sharp/blob/e40a881ab4a5e7b0e37ba17e31b3b186aef8cbf6/lib/output.js#L7-L23
const SHARP_GENERATE_FORMATS = new Map([["avif", "avif"], ["gif", "gif"], ["heic", "heif"], ["heif", "heif"], ["j2c", "jp2"], ["j2k", "jp2"], ["jp2", "jp2"], ["jpeg", "jpeg"], ["jpg", "jpeg"], ["jpx", "jp2"], ["png", "png"], ["raw", "raw"], ["tif", "tiff"], ["tiff", "tiff"], ["webp", "webp"], ["svg", "svg"]]);
const SHARP_MINIFY_FORMATS = new Map([["avif", "avif"], ["gif", "gif"], ["heic", "heif"], ["heif", "heif"], ["j2c", "jp2"], ["j2k", "jp2"], ["jp2", "jp2"], ["jpeg", "jpeg"], ["jpg", "jpeg"], ["jpx", "jp2"], ["png", "png"], ["raw", "raw"], ["tif", "tiff"], ["tiff", "tiff"], ["webp", "webp"]]);
/** @typedef {EXPECTED_ANY} CustomSharpFormat */
/**
* @param {WorkerResult} original original original worker result
* @param {CustomOptions=} options options
* @param {CustomSharpFormat | null=} targetFormat target format
* @returns {Promise<WorkerResult | null>} transformed result
*/
async function sharpTransform(original, options = {}, targetFormat = null) {
const inputExt = path.extname(original.filename).slice(1).toLowerCase();
if (!targetFormat ? !SHARP_MINIFY_FORMATS.has(inputExt) : !SHARP_GENERATE_FORMATS.has(inputExt)) {
if (targetFormat) {
const error = new Error(`Error with '${original.filename}': Input file has an unsupported format`);
original.errors.push(error);
}
return null;
}
/** @type {import("sharp")} */
const sharp = require("sharp");
const imagePipeline = sharp(original.data, {
animated: true
});
// ====== rotate ======
if (typeof options.rotate === "number") {
imagePipeline.rotate(options.rotate);
} else if (options.rotate === "auto") {
imagePipeline.rotate();
}
// ====== resize ======
if (options.resize) {
const {
enabled = true,
unit = "px",
...params
} = options.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);
}
}
imagePipeline.resize(params);
}
}
// ====== convert ======
const imageMetadata = await imagePipeline.metadata();
/**
* @typedef {object} SharpEncodeOptions
* @property {import("sharp").AvifOptions=} avif AVIF options
* @property {import("sharp").GifOptions=} gif GIF options
* @property {import("sharp").HeifOptions=} heif HEIF options
* @property {import("sharp").JpegOptions=} jpeg JPEG options
* @property {import("sharp").JpegOptions=} jpg JPG options
* @property {import("sharp").PngOptions=} png PNG options
* @property {import("sharp").WebpOptions=} webp WebP options
*/
/**
* @typedef SharpFormat
* @type {keyof SharpEncodeOptions}
*/
const outputFormat = targetFormat ?? (/** @type {SharpFormat} */imageMetadata.format);
const encodeOptions = options.encodeOptions?.[outputFormat];
imagePipeline.toFormat(outputFormat, encodeOptions);
const result = await imagePipeline.toBuffer({
resolveWithObject: true
});
// ====== rename ======
const outputExt = targetFormat ? outputFormat : inputExt;
const {
width,
height
} = result.info;
const sizeSuffix = typeof options.sizeSuffix === "function" ? options.sizeSuffix(width, height) : "";
const dotIndex = original.filename.lastIndexOf(".");
const filename = dotIndex > -1 ? `${original.filename.slice(0, dotIndex)}${sizeSuffix}.${outputExt}` : original.filename;
// TODO use this then remove `sizeSuffix`
// const filename = replaceFileExtension(original.filename, outputExt);
const processedFlag = targetFormat ? "generated" : "minimized";
const processedBy = targetFormat ? "generatedBy" : "minimizedBy";
return {
filename,
data: result.data,
warnings: [...original.warnings],
errors: [...original.errors],
info: {
...original.info,
width,
height,
[processedFlag]: true,
[processedBy]: ["sharp", ...(original.info?.[processedBy] ?? [])]
}
};
}
/**
* @param {WorkerResult} original original worker result
* @param {CustomOptions=} options options
* @returns {Promise<WorkerResult | null>} generated result
*/
function sharpGenerate(original, options) {
/**
* @typedef {object} SharpEncodeOptions
* @property {import("sharp").AvifOptions=} avif AVIF options
* @property {import("sharp").GifOptions=} gif GIF options
* @property {import("sharp").HeifOptions=} heif HEIF options
* @property {import("sharp").JpegOptions=} jpeg JPEG options
* @property {import("sharp").JpegOptions=} jpg JPG options
* @property {import("sharp").PngOptions=} png PNG options
* @property {import("sharp").WebpOptions=} webp WebP options
*/
// TODO remove the `SizeSuffix` option in the next major release, because we support `[width]` and `[height]`
/**
* @typedef {object} SharpOptions
* @property {ResizeOptions=} resize resize options
* @property {number | 'auto'=} rotate rotate options
* @property {SizeSuffix=} sizeSuffix size suffix
* @property {SharpEncodeOptions=} encodeOptions encode options
*/
const sharpOptions = /** @type {SharpOptions} */options ?? {};
/**
* @typedef SharpFormat
* @type {keyof SharpEncodeOptions}
*/
const targetFormats = /** @type {SharpFormat[]} */
Object.keys(sharpOptions.encodeOptions ?? {});
if (targetFormats.length === 0) {
const error = new Error(`No result from 'sharp' for '${original.filename}', please configure the 'encodeOptions' option to generate images`);
original.errors.push(error);
return Promise.resolve(null);
}
if (targetFormats.length > 1) {
const error = new Error(`Multiple values for the 'encodeOptions' option is not supported for '${original.filename}', specify only one codec for the generator`);
original.errors.push(error);
return Promise.resolve(null);
}
const [targetFormat] = targetFormats;
return sharpTransform(original, sharpOptions, targetFormat);
}
/**
* @param {WorkerResult} original original worker result
* @param {CustomOptions=} options options
* @returns {Promise<WorkerResult | null>} minified result
*/
function sharpMinify(original, options) {
return sharpTransform(original, options);
}
/**
* @param {WorkerResult} original original worker result
* @param {CustomOptions=} options options
* @returns {Promise<WorkerResult | null>} minified result
*/
async function svgoMinify(original, options) {
/** @typedef {import("svgo")} SvgoLib */
/** @typedef {Omit<import("svgo").Config, "path" | "datauri">} SvgoEncodeOptions */
/**
* @typedef {object} SvgoOptions
* @property {SvgoEncodeOptions=} encodeOptions encode options
*/
if (path.extname(original.filename).toLowerCase() !== ".svg") {
return null;
}
/** @type {SvgoLib} */
// eslint-disable-next-line import/no-unresolved
const {
optimize
} = require("svgo");
const {
encodeOptions
} = /** @type {SvgoOptions} */options ?? {};
/** @type {import("svgo").Output} */
let result;
try {
result = optimize(original.data.toString(), {
path: original.filename,
...encodeOptions
});
} catch (error) {
const originalError = error instanceof Error ? error : new Error(/** @type {string} */error);
const newError = new Error(`Error with '${original.filename}': ${originalError.message}`);
original.errors.push(newError);
return null;
}
return {
filename: original.filename,
data: Buffer.from(result.data),
warnings: [...original.warnings],
errors: [...original.errors],
info: {
...original.info,
minimized: true,
minimizedBy: ["svgo", ...(original.info?.minimizedBy ?? [])]
}
};
}
/** @type {WeakMap<Module, AssetInfo>} */
const IMAGE_MINIMIZER_PLUGIN_INFO_MAPPINGS = new WeakMap();
module.exports = {
ABSOLUTE_URL_REGEX,
IMAGE_MINIMIZER_PLUGIN_INFO_MAPPINGS,
WINDOWS_PATH_REGEX,
imageminGenerate,
imageminMinify,
imageminNormalizeConfig,
isAbsoluteURL,
memoize,
replaceFileExtension,
sharpGenerate,
sharpMinify,
squooshGenerate,
squooshMinify,
svgoMinify,
throttleAll
};
;