Skip to content

Commit

Permalink
feat: add sharp minifier/generator implementation (#329)
Browse files Browse the repository at this point in the history
  • Loading branch information
RAX7 committed Aug 12, 2022
1 parent 7ef645d commit 5c440f6
Show file tree
Hide file tree
Showing 12 changed files with 896 additions and 144 deletions.
435 changes: 299 additions & 136 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,19 @@
"dist",
"types"
],
"overrides": {
"sharp": "$sharp",
"imagemin-avif": {
"sharp": "$sharp"
}
},
"peerDependencies": {
"webpack": "^5.1.0"
},
"peerDependenciesMeta": {
"sharp": {
"optional": true
},
"@squoosh/lib": {
"optional": true
},
Expand All @@ -68,6 +77,7 @@
"@squoosh/lib": "^0.4.0",
"@types/imagemin": "^8.0.0",
"@types/serialize-javascript": "^5.0.2",
"@types/sharp": "^0.30.4",
"@webpack-contrib/eslint-config-webpack": "^3.0.0",
"babel-jest": "^27.5.1",
"copy-webpack-plugin": "^9.0.0",
Expand Down Expand Up @@ -103,6 +113,7 @@
"prettier": "^2.6.2",
"remark-cli": "^10.0.0",
"remark-preset-lint-itgalaxy": "^16.0.0",
"sharp": "^0.30.7",
"standard-version": "^9.5.0",
"tempy": "^1.0.1",
"typescript": "^4.7.2",
Expand Down
4 changes: 4 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const {
squooshGenerate,
} = require("./utils.js");

const { sharpMinify, sharpGenerate } = require("./sharp-transformer.js");

/** @typedef {import("schema-utils/declarations/validate").Schema} Schema */
/** @typedef {import("webpack").WebpackPluginInstance} WebpackPluginInstance */
/** @typedef {import("webpack").Compiler} Compiler */
Expand Down Expand Up @@ -616,5 +618,7 @@ ImageMinimizerPlugin.imageminMinify = imageminMinify;
ImageMinimizerPlugin.imageminGenerate = imageminGenerate;
ImageMinimizerPlugin.squooshMinify = squooshMinify;
ImageMinimizerPlugin.squooshGenerate = squooshGenerate;
ImageMinimizerPlugin.sharpMinify = sharpMinify;
ImageMinimizerPlugin.sharpGenerate = sharpGenerate;

module.exports = ImageMinimizerPlugin;
185 changes: 185 additions & 0 deletions src/sharp-transformer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
const path = require("path");

/** @typedef {import("./index.js").WorkerResult} WorkerResult */
/** @typedef {import("sharp")} SharpLib */
/** @typedef {import("sharp").Sharp} Sharp */
/** @typedef {import("sharp").ResizeOptions & { enabled?: boolean }} ResizeOptions */

/**
* @typedef SharpEncodeOptions
* @type {object}
* @property {import("sharp").AvifOptions} [avif]
* @property {import("sharp").GifOptions} [gif]
* @property {import("sharp").HeifOptions} [heif]
* @property {import("sharp").JpegOptions} [jpeg]
* @property {import("sharp").JpegOptions} [jpg]
* @property {import("sharp").PngOptions} [png]
* @property {import("sharp").WebpOptions} [webp]
*/

/**
* @typedef SharpFormat
* @type {keyof SharpEncodeOptions}
*/

/**
* @typedef SharpOptions
* @type {object}
* @property {ResizeOptions} [resize]
* @property {number | 'auto'} [rotate]
* @property {SizeSuffix} [sizeSuffix]
* @property {SharpEncodeOptions} [encodeOptions]
*/

/**
* @typedef SizeSuffix
* @type {(width: number, height: number) => string}
*/

// https://github.com/lovell/sharp/blob/e40a881ab4a5e7b0e37ba17e31b3b186aef8cbf6/lib/output.js#L7-L23
const SHARP_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"],
]);

/**
* @param {WorkerResult} original
* @param {SharpOptions} minimizerOptions
* @param {SharpFormat | null} targetFormat
* @returns {Promise<WorkerResult>}
*/
async function sharpTransform(original, minimizerOptions, targetFormat = null) {
const inputExt = path.extname(original.filename).slice(1).toLowerCase();

if (!SHARP_FORMATS.has(inputExt)) {
return original;
}

/** @type {SharpLib} */
// eslint-disable-next-line node/no-unpublished-require
const sharp = require("sharp");
const imagePipeline = sharp(original.data);

// ====== rotate ======

if (typeof minimizerOptions.rotate === "number") {
imagePipeline.rotate(minimizerOptions.rotate);
} else if (minimizerOptions.rotate === "auto") {
imagePipeline.rotate();
}

// ====== resize ======

if (
minimizerOptions.resize &&
(minimizerOptions.resize.width || minimizerOptions.resize.height) &&
minimizerOptions.resize.enabled !== false
) {
imagePipeline.resize(
minimizerOptions.resize.width,
minimizerOptions.resize.height
);
}

// ====== convert ======

const imageMetadata = await imagePipeline.metadata();

const outputFormat =
targetFormat ?? /** @type {SharpFormat} */ (imageMetadata.format);

const encodeOptions = minimizerOptions.encodeOptions?.[outputFormat];

imagePipeline.toFormat(outputFormat, encodeOptions);

const result = await imagePipeline.toBuffer({ resolveWithObject: true });

// ====== rename ======

const outputExt = targetFormat ? outputFormat : inputExt;

const { dir: fileDir, name: fileName } = path.parse(original.filename);

const { width, height } = result.info;
const sizeSuffix =
typeof minimizerOptions.sizeSuffix === "function"
? minimizerOptions.sizeSuffix(width, height)
: "";

const filename = path.join(fileDir, `${fileName}${sizeSuffix}.${outputExt}`);

return {
filename,
data: result.data,
warnings: [...original.warnings],
errors: [...original.errors],
info: {
...original.info,
generated: true,
generatedBy:
original.info && original.info.generatedBy
? ["sharp", ...original.info.generatedBy]
: ["sharp"],
},
};
}

/**
* @param {WorkerResult} original
* @param {SharpOptions} minimizerOptions
* @returns {Promise<WorkerResult>}
*/
function sharpGenerate(original, minimizerOptions) {
const targetFormats = /** @type {SharpFormat[]} */ (
Object.keys(minimizerOptions.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(original);
}

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(original);
}

const [targetFormat] = targetFormats;

return sharpTransform(original, minimizerOptions, targetFormat);
}

/**
* @param {WorkerResult} original
* @param {SharpOptions} [minimizerOptions]
* @returns {Promise<WorkerResult>}
*/
function sharpMinify(original, minimizerOptions = {}) {
return sharpTransform(original, minimizerOptions);
}

module.exports = {
sharpMinify,
sharpGenerate,
};
57 changes: 57 additions & 0 deletions test/ImageminPlugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,63 @@ describe("imagemin plugin", () => {
);
});

it("should generate throw an error on multiple 'encodeOptions' options using 'sharpGenerate'", async () => {
const stats = await runWebpack({
entry: path.join(fixturesPath, "generator-and-minimizer-3.js"),
imageminPluginOptions: {
test: /\.(jpe?g|gif|json|svg|png|webp)$/i,
generator: [
{
preset: "webp-other",
implementation: ImageMinimizerPlugin.sharpGenerate,
options: {
encodeOptions: {
webp: {
lossless: true,
},
avif: {},
},
},
},
],
},
});
const { compilation } = stats;
const { warnings, errors } = compilation;

expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0].message).toMatch(
/Multiple values for the 'encodeOptions' option is not supported for 'loader-test.png', specify only one codec for the generator/
);
});

it("should return error on empty encodeOptions with 'sharpGenerate'", async () => {
const stats = await runWebpack({
entry: path.join(fixturesPath, "generator-and-minimizer-3.js"),
imageminPluginOptions: {
test: /\.(jpe?g|gif|json|svg|png|webp)$/i,
generator: [
{
preset: "webp-other",
implementation: ImageMinimizerPlugin.sharpGenerate,
options: {
encodeOptions: {},
},
},
],
},
});
const { compilation } = stats;
const { warnings, errors } = compilation;

expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0].message).toMatch(
/No result from 'sharp' for '.+', please configure the 'encodeOptions' option to generate images/
);
});

it("should not try to generate and minimize twice", async () => {
const stats = await runWebpack({
entry: path.join(fixturesPath, "generator-and-minimizer.js"),
Expand Down
50 changes: 49 additions & 1 deletion test/loader-generator-option.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ describe("loader generator option", () => {
expect(errors).toHaveLength(0);
});

it("should generate and resize", async () => {
it("should generate and resize (squooshGenerate)", async () => {
const stats = await runWebpack({
entry: path.join(fixturesPath, "./loader-single.js"),
imageminLoaderOptions: {
Expand Down Expand Up @@ -237,4 +237,52 @@ describe("loader generator option", () => {
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});

it("should generate and resize (sharpGenerate)", async () => {
const stats = await runWebpack({
entry: path.join(fixturesPath, "./loader-single.js"),
imageminLoaderOptions: {
generator: [
{
preset: "webp",
implementation: ImageMinimizerPlugin.sharpGenerate,
options: {
resize: {
enabled: true,
width: 100,
height: 50,
},
rotate: {
numRotations: 90,
},
encodeOptions: {
webp: {
lossless: true,
},
},
},
},
],
},
});

const { compilation } = stats;
const { warnings, errors } = compilation;

const transformedAsset = path.resolve(
__dirname,
compilation.options.output.path,
"./nested/deep/loader-test.webp"
);

const transformedExt = await fileType.fromFile(transformedAsset);
const sizeOf = promisify(imageSize);
const dimensions = await sizeOf(transformedAsset);

expect(dimensions.height).toBe(50);
expect(dimensions.width).toBe(100);
expect(/image\/webp/i.test(transformedExt.mime)).toBe(true);
expect(warnings).toHaveLength(0);
expect(errors).toHaveLength(0);
});
});
Loading

0 comments on commit 5c440f6

Please sign in to comment.