From 81974882eab5b36eab09bda974c4b9c91925d403 Mon Sep 17 00:00:00 2001 From: Beru Date: Fri, 29 May 2026 15:29:00 +0300 Subject: [PATCH 1/7] Add sharp resize options support --- src/global-options.js | 1 + src/image.js | 30 ++++++++++++++-------- test/test.js | 58 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/global-options.js b/src/global-options.js index 30d80a6..eff53b9 100644 --- a/src/global-options.js +++ b/src/global-options.js @@ -28,6 +28,7 @@ export const getDefaults = () => ({ sharpPngOptions: {}, // options passed to the Sharp png output method sharpJpegOptions: {}, // options passed to the Sharp jpeg output method sharpAvifOptions: {}, // options passed to the Sharp avif output method + sharpResizeOptions: undefined, // options passed to the Sharp resize method formatHooks: { svg: svgHook, diff --git a/src/image.js b/src/image.js index 396dce3..3109bc4 100644 --- a/src/image.js +++ b/src/image.js @@ -358,6 +358,18 @@ export default class Image { return {}; } + getSharpResizeOptions(stat, metadata) { + let resizeOptions = Object.assign({}, this.options.sharpResizeOptions, { + width: stat.width, + }); + + if(metadata.format !== "svg" || !this.options.svgAllowUpscale) { + resizeOptions.withoutEnlargement = true; + } + + return resizeOptions; + } + async getInput() { // internal cache if(!this.#input) { @@ -415,12 +427,17 @@ export default class Image { "sharpWebpOptions", "sharpPngOptions", "sharpJpegOptions", - "sharpAvifOptions" + "sharpAvifOptions", + "sharpResizeOptions", ].sort(); let hashObject = {}; // The code currently assumes are keysToKeep are Object literals (see Util.getSortedObject) for(let key of keysToKeep) { + if(key === "sharpResizeOptions" && (!this.options[key] || Object.keys(this.options[key]).length === 0)) { + continue; + } + if(this.options[key]) { hashObject[key] = Util.getSortedObject(this.options[key]); } @@ -710,15 +727,7 @@ export default class Image { if(!isTransformResize) { if(stat.width < sharpMetadata.width || (this.options.svgAllowUpscale && sharpMetadata.format === "svg")) { - let resizeOptions = { - width: stat.width - }; - - if(sharpMetadata.format !== "svg" || !this.options.svgAllowUpscale) { - resizeOptions.withoutEnlargement = true; - } - - sharpInstance.resize(resizeOptions); + sharpInstance.resize(this.getSharpResizeOptions(stat, sharpMetadata)); } } @@ -927,4 +936,3 @@ export default class Image { return img.statsByDimensionsSync(width, height); } } - diff --git a/test/test.js b/test/test.js index 672e085..e414131 100644 --- a/test/test.js +++ b/test/test.js @@ -721,6 +721,64 @@ test("widths array should be ignored in hashing", t => { t.is(stats2.jpeg[1].url, "/img/KkPMmHd3hP-600.jpeg"); }); +test("empty sharpResizeOptions should be ignored in hashing", t => { + let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { + widths: [1280], + sharpResizeOptions: {} + }); + + t.is(stats.jpeg[0].url, "/img/KkPMmHd3hP-1280.jpeg"); +}); + +test("sharpResizeOptions should apply to resize output and hash", async t => { + let defaultStats = await eleventyImage("./test/bio-2017.jpg", { + widths: [64], + formats: ["jpeg"], + outputDir: "./test/img/", + dryRun: true, + useCache: false, + }); + + let stats = await eleventyImage("./test/bio-2017.jpg", { + widths: [64], + formats: ["jpeg"], + outputDir: "./test/img/", + dryRun: true, + useCache: false, + sharpResizeOptions: { + kernel: "nearest", + }, + }); + + t.is(stats.jpeg[0].width, 64); + t.is(stats.jpeg[0].height, 42); + t.not(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-64.jpeg")); + + let defaultRaw = await sharp(defaultStats.jpeg[0].buffer).ensureAlpha().toFormat(sharp.format.raw).toBuffer(); + let nearestRaw = await sharp(stats.jpeg[0].buffer).ensureAlpha().toFormat(sharp.format.raw).toBuffer(); + + t.true(pixelmatch(defaultRaw, nearestRaw, null, stats.jpeg[0].width, stats.jpeg[0].height, { threshold: 0.15 }) > 0); +}); + +test("sharpResizeOptions should not override Eleventy Image width", async t => { + let stats = await eleventyImage("./test/bio-2017.jpg", { + widths: [300], + formats: ["jpeg"], + outputDir: "./test/img/", + dryRun: true, + useCache: false, + sharpResizeOptions: { + width: 10, + kernel: "nearest", + }, + }); + + let outputMetadata = await sharp(stats.jpeg[0].buffer).metadata(); + + t.is(stats.jpeg[0].width, 300); + t.is(outputMetadata.width, 300); +}); + test("statsSync and eleventyImage output comparison", async t => { let statsSync = eleventyImage.statsSync("./test/bio-2017.jpg", { widths: [399], From 990ee741f8e39a7fbd37491661a7cb240da4353b Mon Sep 17 00:00:00 2001 From: Beru Date: Fri, 29 May 2026 15:38:17 +0300 Subject: [PATCH 2/7] Use empty resize options default --- src/global-options.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/global-options.js b/src/global-options.js index eff53b9..f3d1eff 100644 --- a/src/global-options.js +++ b/src/global-options.js @@ -28,7 +28,7 @@ export const getDefaults = () => ({ sharpPngOptions: {}, // options passed to the Sharp png output method sharpJpegOptions: {}, // options passed to the Sharp jpeg output method sharpAvifOptions: {}, // options passed to the Sharp avif output method - sharpResizeOptions: undefined, // options passed to the Sharp resize method + sharpResizeOptions: {}, // options passed to the Sharp resize method formatHooks: { svg: svgHook, From 3458ee2dc536b0a2838afe6ea86bfbac9614bede Mon Sep 17 00:00:00 2001 From: Beru Date: Fri, 29 May 2026 15:47:03 +0300 Subject: [PATCH 3/7] Use spread for resize options --- src/image.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/image.js b/src/image.js index 3109bc4..89efc6e 100644 --- a/src/image.js +++ b/src/image.js @@ -359,9 +359,10 @@ export default class Image { } getSharpResizeOptions(stat, metadata) { - let resizeOptions = Object.assign({}, this.options.sharpResizeOptions, { + let resizeOptions = { + ...this.options.sharpResizeOptions, width: stat.width, - }); + }; if(metadata.format !== "svg" || !this.options.svgAllowUpscale) { resizeOptions.withoutEnlargement = true; From ae9b9297c6b38322d25c9b63f31373a2d3f85d97 Mon Sep 17 00:00:00 2001 From: Beru Date: Fri, 29 May 2026 15:53:02 +0300 Subject: [PATCH 4/7] Ignore dimension resize options --- src/image.js | 30 +++++++++++++++++++++++++----- test/test.js | 20 +++++++++++++++++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/image.js b/src/image.js index 89efc6e..e9e815a 100644 --- a/src/image.js +++ b/src/image.js @@ -358,9 +358,24 @@ export default class Image { return {}; } + static getNonDimensionSharpResizeOptions(sharpResizeOptions = {}) { + let options = { + ...sharpResizeOptions, + }; + + // Eleventy Image owns output dimensions and up/downscale policy. Allowing + // these here would make returned metadata disagree with the output image. + delete options.width; + delete options.height; + delete options.withoutEnlargement; + delete options.withoutReduction; + + return options; + } + getSharpResizeOptions(stat, metadata) { let resizeOptions = { - ...this.options.sharpResizeOptions, + ...Image.getNonDimensionSharpResizeOptions(this.options.sharpResizeOptions), width: stat.width, }; @@ -435,12 +450,17 @@ export default class Image { let hashObject = {}; // The code currently assumes are keysToKeep are Object literals (see Util.getSortedObject) for(let key of keysToKeep) { - if(key === "sharpResizeOptions" && (!this.options[key] || Object.keys(this.options[key]).length === 0)) { - continue; + let options = this.options[key]; + + if(key === "sharpResizeOptions") { + options = Image.getNonDimensionSharpResizeOptions(options); + if(Object.keys(options).length === 0) { + continue; + } } - if(this.options[key]) { - hashObject[key] = Util.getSortedObject(this.options[key]); + if(options) { + hashObject[key] = Util.getSortedObject(options); } } diff --git a/test/test.js b/test/test.js index e414131..eeae99c 100644 --- a/test/test.js +++ b/test/test.js @@ -730,6 +730,20 @@ test("empty sharpResizeOptions should be ignored in hashing", t => { t.is(stats.jpeg[0].url, "/img/KkPMmHd3hP-1280.jpeg"); }); +test("dimension sharpResizeOptions should be ignored in hashing", t => { + let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { + widths: [1280], + sharpResizeOptions: { + width: 10, + height: 10, + withoutEnlargement: true, + withoutReduction: true, + } + }); + + t.is(stats.jpeg[0].url, "/img/KkPMmHd3hP-1280.jpeg"); +}); + test("sharpResizeOptions should apply to resize output and hash", async t => { let defaultStats = await eleventyImage("./test/bio-2017.jpg", { widths: [64], @@ -760,7 +774,7 @@ test("sharpResizeOptions should apply to resize output and hash", async t => { t.true(pixelmatch(defaultRaw, nearestRaw, null, stats.jpeg[0].width, stats.jpeg[0].height, { threshold: 0.15 }) > 0); }); -test("sharpResizeOptions should not override Eleventy Image width", async t => { +test("sharpResizeOptions should not override Eleventy Image dimensions", async t => { let stats = await eleventyImage("./test/bio-2017.jpg", { widths: [300], formats: ["jpeg"], @@ -769,14 +783,18 @@ test("sharpResizeOptions should not override Eleventy Image width", async t => { useCache: false, sharpResizeOptions: { width: 10, + height: 10, kernel: "nearest", + withoutReduction: true, }, }); let outputMetadata = await sharp(stats.jpeg[0].buffer).metadata(); t.is(stats.jpeg[0].width, 300); + t.is(stats.jpeg[0].height, 199); t.is(outputMetadata.width, 300); + t.true(outputMetadata.height > 10); }); test("statsSync and eleventyImage output comparison", async t => { From b03e122af8e029e6c931fa476ee290c5f9354e71 Mon Sep 17 00:00:00 2001 From: Beru Date: Fri, 29 May 2026 16:03:17 +0300 Subject: [PATCH 5/7] Hash provided resize options --- src/image.js | 7 ++----- test/test.js | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/image.js b/src/image.js index e9e815a..7ff22e6 100644 --- a/src/image.js +++ b/src/image.js @@ -452,11 +452,8 @@ export default class Image { for(let key of keysToKeep) { let options = this.options[key]; - if(key === "sharpResizeOptions") { - options = Image.getNonDimensionSharpResizeOptions(options); - if(Object.keys(options).length === 0) { - continue; - } + if(key === "sharpResizeOptions" && Object.keys(options).length === 0) { + continue; } if(options) { diff --git a/test/test.js b/test/test.js index eeae99c..7461d5a 100644 --- a/test/test.js +++ b/test/test.js @@ -730,7 +730,7 @@ test("empty sharpResizeOptions should be ignored in hashing", t => { t.is(stats.jpeg[0].url, "/img/KkPMmHd3hP-1280.jpeg"); }); -test("dimension sharpResizeOptions should be ignored in hashing", t => { +test("dimension sharpResizeOptions should be included in hashing", t => { let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { widths: [1280], sharpResizeOptions: { @@ -741,7 +741,7 @@ test("dimension sharpResizeOptions should be ignored in hashing", t => { } }); - t.is(stats.jpeg[0].url, "/img/KkPMmHd3hP-1280.jpeg"); + t.not(stats.jpeg[0].url, "/img/KkPMmHd3hP-1280.jpeg"); }); test("sharpResizeOptions should apply to resize output and hash", async t => { From 41da17c9dcccb55915043c9df3f5e264befd7565 Mon Sep 17 00:00:00 2001 From: Beru Date: Fri, 29 May 2026 16:20:35 +0300 Subject: [PATCH 6/7] Only strip resize dimensions --- src/image.js | 12 +++++------- test/test.js | 11 ++++++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/image.js b/src/image.js index 7ff22e6..06d3be5 100644 --- a/src/image.js +++ b/src/image.js @@ -358,28 +358,26 @@ export default class Image { return {}; } - static getNonDimensionSharpResizeOptions(sharpResizeOptions = {}) { + static getSharpResizeOptionsWithoutDimensions(sharpResizeOptions = {}) { let options = { ...sharpResizeOptions, }; - // Eleventy Image owns output dimensions and up/downscale policy. Allowing - // these here would make returned metadata disagree with the output image. + // Eleventy Image owns output dimensions. Allowing these here would make + // returned metadata disagree with the output image. delete options.width; delete options.height; - delete options.withoutEnlargement; - delete options.withoutReduction; return options; } getSharpResizeOptions(stat, metadata) { let resizeOptions = { - ...Image.getNonDimensionSharpResizeOptions(this.options.sharpResizeOptions), + ...Image.getSharpResizeOptionsWithoutDimensions(this.options.sharpResizeOptions), width: stat.width, }; - if(metadata.format !== "svg" || !this.options.svgAllowUpscale) { + if(!("withoutEnlargement" in resizeOptions) && (metadata.format !== "svg" || !this.options.svgAllowUpscale)) { resizeOptions.withoutEnlargement = true; } diff --git a/test/test.js b/test/test.js index 7461d5a..70a7178 100644 --- a/test/test.js +++ b/test/test.js @@ -785,7 +785,6 @@ test("sharpResizeOptions should not override Eleventy Image dimensions", async t width: 10, height: 10, kernel: "nearest", - withoutReduction: true, }, }); @@ -797,6 +796,16 @@ test("sharpResizeOptions should not override Eleventy Image dimensions", async t t.true(outputMetadata.height > 10); }); +test("sharpResizeOptions should allow withoutEnlargement option", t => { + let image = new Image("./test/bio-2017.jpg", { + sharpResizeOptions: { + withoutEnlargement: false, + }, + }); + + t.false(image.getSharpResizeOptions({ width: 300 }, { format: "jpeg" }).withoutEnlargement); +}); + test("statsSync and eleventyImage output comparison", async t => { let statsSync = eleventyImage.statsSync("./test/bio-2017.jpg", { widths: [399], From f4c599229a96366bdc4229f785e980fd680580ad Mon Sep 17 00:00:00 2001 From: Beru Date: Fri, 29 May 2026 16:27:23 +0300 Subject: [PATCH 7/7] Inline resize dimension filtering --- src/image.js | 20 ++++++-------------- test/test.js | 10 ---------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/image.js b/src/image.js index 06d3be5..7dc9a03 100644 --- a/src/image.js +++ b/src/image.js @@ -358,24 +358,16 @@ export default class Image { return {}; } - static getSharpResizeOptionsWithoutDimensions(sharpResizeOptions = {}) { - let options = { - ...sharpResizeOptions, + getSharpResizeOptions(stat, metadata) { + let resizeOptions = { + ...this.options.sharpResizeOptions, }; // Eleventy Image owns output dimensions. Allowing these here would make // returned metadata disagree with the output image. - delete options.width; - delete options.height; - - return options; - } - - getSharpResizeOptions(stat, metadata) { - let resizeOptions = { - ...Image.getSharpResizeOptionsWithoutDimensions(this.options.sharpResizeOptions), - width: stat.width, - }; + delete resizeOptions.width; + delete resizeOptions.height; + resizeOptions.width = stat.width; if(!("withoutEnlargement" in resizeOptions) && (metadata.format !== "svg" || !this.options.svgAllowUpscale)) { resizeOptions.withoutEnlargement = true; diff --git a/test/test.js b/test/test.js index 70a7178..2cbad4b 100644 --- a/test/test.js +++ b/test/test.js @@ -796,16 +796,6 @@ test("sharpResizeOptions should not override Eleventy Image dimensions", async t t.true(outputMetadata.height > 10); }); -test("sharpResizeOptions should allow withoutEnlargement option", t => { - let image = new Image("./test/bio-2017.jpg", { - sharpResizeOptions: { - withoutEnlargement: false, - }, - }); - - t.false(image.getSharpResizeOptions({ width: 300 }, { format: "jpeg" }).withoutEnlargement); -}); - test("statsSync and eleventyImage output comparison", async t => { let statsSync = eleventyImage.statsSync("./test/bio-2017.jpg", { widths: [399],