diff --git a/img.js b/img.js index aaaf264..dd1e129 100644 --- a/img.js +++ b/img.js @@ -2,8 +2,9 @@ const path = require("path"); const fs = require("fs"); const fsp = fs.promises; const { URL } = require("url"); -const shorthash = require("short-hash"); +const { createHash } = require("crypto"); const {default: PQueue} = require("p-queue"); +const base64url = require("base64url"); const getImageSize = require("image-size"); const sharp = require("sharp"); const debug = require("debug")("EleventyImg"); @@ -123,8 +124,52 @@ function getValidWidths(originalWidth, widths = [], allowUpscale = false) { return filtered.sort((a, b) => a - b); } +let imgHashCache = {}; +function getHash(src, imgOptions={}, length=10) { + if(src in imgHashCache) return imgHashCache[src]; + + if(typeof src === "string" && isFullUrl(src) && !("remoteAssetContent" in imgOptions)) { + throw new Error("When using getHash with URLs, imgOptions.remoteAssetContent should be set to the content of the remote asset."); + } + + const hash = createHash("sha256"); + + let opts = Object.assign({ + "userOptions": {}, + "sharpOptions": {}, + "sharpWebpOptions": {}, + "sharpPngOptions": {}, + "sharpJpegOptions": {}, + "sharpAvifOptions": {}, + "remoteAssetContent": {} + }, imgOptions); + + opts = { + userOptions: opts.userOptions, + sharpOptions: opts.sharpOptions, + sharpWebpOptions: opts.sharpWebpOptions, + sharpPngOptions: opts.sharpPngOptions, + sharpJpegOptions: opts.sharpJpegOptions, + sharpAvifOptions: opts.sharpAvifOptions, + remoteAssetContent: opts.remoteAssetContent + }; + + if(fs.existsSync(src)) { + const fileContent = fs.readFileSync(src); + hash.update(fileContent); + } else { + hash.update(src); + } + + hash.update(JSON.stringify(opts)); + imgHashCache[src] = base64url.encode(hash.digest()).substring(0, length); + + return imgHashCache[src]; +} + function getFilename(src, width, format, options = {}) { - let id = shorthash(src); + let id = getHash(src, options); + if (typeof options.filenameFormat === "function") { let filename = options.filenameFormat(id, src, width, format, options); // if options.filenameFormat returns falsy, use fallback filename @@ -147,7 +192,8 @@ function getStats(src, format, urlPath, width, height, options = {}) { let outputExtension = options.extensions[format] || format; if(options.urlFormat && typeof options.urlFormat === "function") { - let id = shorthash(src); + let id = getHash(src, options); + url = options.urlFormat({ id, src, @@ -285,6 +331,16 @@ async function resizeImage(src, options = {}) { let fullStats = getFullStats(src, metadata, options); for(let outputFormat in fullStats) { for(let stat of fullStats[outputFormat]) { + if(options.useCache && fs.existsSync(stat.outputPath)){ + stat.size = fs.statSync(stat.outputPath).size; + if(options.dryRun) { + stat.buffer = fs.readFileSync(src); + } + + outputFilePromises.push(Promise.resolve(stat)); + continue; + } + let sharpInstance = sharpImage.clone(); if(stat.width < metadata.width || (options.svgAllowUpscale && metadata.format === "svg")) { let resizeOptions = { @@ -398,6 +454,8 @@ function queueImage(src, opts) { // eleventy-cache-assets 2.0.3 and below input = await assetCache.fetch(cacheOptions); } + + options.remoteAssetContent = input; // Only set for remote assets with URL } else { input = src; } @@ -428,11 +486,19 @@ Object.defineProperty(module.exports, "concurrency", { * the correct location yet. */ function statsSync(src, opts) { + if(typeof src === "string" && isFullUrl(src) && !("remoteAssetContent" in opts)) { + throw new Error("When using statsSync or statsByDimensionsSync with URLs, options.remoteAssetContent should be set to the content of the remote asset."); + } + let dimensions = getImageSize(src); return getFullStats(src, dimensions, opts); } function statsByDimensionsSync(src, width, height, opts) { + if(typeof src === "string" && isFullUrl(src) && !("remoteAssetContent" in opts)) { + throw new Error("When using statsSync or statsByDimensionsSync with URLs, options.remoteAssetContent should be set to the content of the remote asset."); + } + let dimensions = { width, height, guess: true }; return getFullStats(src, dimensions, opts); } @@ -441,6 +507,7 @@ module.exports.statsSync = statsSync; module.exports.statsByDimensionsSync = statsByDimensionsSync; module.exports.getFormats = getFormatsArray; module.exports.getWidths = getValidWidths; +module.exports.getHash = getHash; const generateHTML = require("./generate-html"); module.exports.generateHTML = generateHTML; diff --git a/package.json b/package.json index c6aa62e..3ea92f8 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,10 @@ "dependencies": { "@11ty/eleventy-cache-assets": "^2.3.0", "debug": "^4.3.2", + "base64url": "^3.0.1", "image-size": "^1.0.0", "p-queue": "^6.6.2", "sharp": "^0.29.0", - "short-hash": "^1.0.0" }, "devDependencies": { "ava": "^3.15.0", diff --git a/test/test-markup.js b/test/test-markup.js index 08f7b73..73902aa 100644 --- a/test/test-markup.js +++ b/test/test-markup.js @@ -10,7 +10,7 @@ test("Image markup (defaults)", async t => { t.is(generateHTML(results, { alt: "" - }), ``); + }), ``); }); test("Image service", async t => { @@ -23,7 +23,8 @@ test("Image service", async t => { widths: [600], // 260-440 in layout urlFormat: function({ width, format }) { return `${serviceApiDomain}/api/image/?url=${encodeURIComponent(screenshotUrl)}&width=${width}&format=${format}`; - } + }, + remoteAssetContent: 'remote asset content' }; let results = eleventyImage.statsByDimensionsSync(screenshotUrl, 1440, 900, options); @@ -45,13 +46,13 @@ test("Image object (defaults)", async t => { { "source": { type: "image/webp", - srcset: "/img/97854483-1280.webp 1280w", + srcset: "/img/Bok0Qhed6a-1280.webp 1280w", } }, { "img": { alt: "", - src: "/img/97854483-1280.jpeg", + src: "/img/Bok0Qhed6a-1280.jpeg", width: 1280, height: 853, } @@ -70,9 +71,9 @@ test("Image markup (two widths)", async t => { alt: "", sizes: "100vw", }), [``, - ``, - ``, - ``, + ``, + ``, + ``, ``].join("")); }); @@ -95,7 +96,7 @@ test("Image markup (two formats)", async t => { t.is(generateHTML(results, { alt: "" - }), ``); + }), ``); }); test("Image markup (one format)", async t => { @@ -107,7 +108,7 @@ test("Image markup (one format)", async t => { t.is(generateHTML(results, { alt: "", sizes: "100vw" - }), ``); + }), ``); }); test("Image markup (auto format)", async t => { @@ -119,7 +120,7 @@ test("Image markup (auto format)", async t => { t.is(generateHTML(results, { alt: "", sizes: "100vw" - }), ``); + }), ``); }); test("Image markup (one format, two widths)", async t => { @@ -132,7 +133,7 @@ test("Image markup (one format, two widths)", async t => { t.is(generateHTML(results, { alt: "", sizes: "100vw" - }), ``); + }), ``); }); test("Image markup (throws on invalid object)", async t => { @@ -160,8 +161,8 @@ test("Image markup (defaults, inlined)", async t => { }, { whitespaceMode: "block" }), ` - - + + `); }); @@ -175,7 +176,7 @@ test("svgShortCircuit and generateHTML: Issue #48", async t => { let html = eleventyImage.generateHTML(stats, { alt: "Tiger", }); - t.is(html, `Tiger`); + t.is(html, `Tiger`); }); test("Filter out empty format arrays", async t => { diff --git a/test/test.js b/test/test.js index 8fea378..7d6590d 100644 --- a/test/test.js +++ b/test/test.js @@ -170,7 +170,7 @@ test("Use 'auto' format as original", async t => { t.is(stats.auto, undefined); t.is(stats.jpeg.length, 1); - t.is(stats.jpeg[0].outputPath, path.join("test/img/97854483-1280.jpeg")); + t.is(stats.jpeg[0].outputPath, path.join("test/img/Bok0Qhed6a-1280.jpeg")); t.is(stats.jpeg[0].width, 1280); }); @@ -181,7 +181,7 @@ test("Try to use a width larger than original", async t => { outputDir: "./test/img/" }); t.is(stats.jpeg.length, 1); - t.is(stats.jpeg[0].outputPath, path.join("test/img/97854483-1280.jpeg")); + t.is(stats.jpeg[0].outputPath, path.join("test/img/Bok0Qhed6a-1280.jpeg")); t.is(stats.jpeg[0].width, 1280); }); @@ -192,7 +192,7 @@ test("Try to use a width larger than original (two sizes)", async t => { outputDir: "./test/img/" }); t.is(stats.jpeg.length, 1); - t.is(stats.jpeg[0].outputPath, path.join("test/img/97854483-1280.jpeg")); + t.is(stats.jpeg[0].outputPath, path.join("test/img/Bok0Qhed6a-1280.jpeg")); t.is(stats.jpeg[0].width, 1280); }); @@ -203,7 +203,7 @@ test("Try to use a width larger than original (with a null in there)", async t = outputDir: "./test/img/" }); t.is(stats.jpeg.length, 1); - t.is(stats.jpeg[0].outputPath, path.join("test/img/97854483-1280.jpeg")); + t.is(stats.jpeg[0].outputPath, path.join("test/img/Bok0Qhed6a-1280.jpeg")); t.is(stats.jpeg[0].width, 1280); }); @@ -214,7 +214,7 @@ test("Just falsy width", async t => { outputDir: "./test/img/" }); t.is(stats.jpeg.length, 1); - t.is(stats.jpeg[0].outputPath, path.join("test/img/97854483-1280.jpeg")); + t.is(stats.jpeg[0].outputPath, path.join("test/img/Bok0Qhed6a-1280.jpeg")); t.is(stats.jpeg[0].width, 1280); }); @@ -226,7 +226,7 @@ test("Use exact same width as original", async t => { }); t.is(stats.jpeg.length, 1); // breaking change in 0.5: always use width in filename - t.is(stats.jpeg[0].outputPath, path.join("test/img/97854483-1280.jpeg")); + t.is(stats.jpeg[0].outputPath, path.join("test/img/Bok0Qhed6a-1280.jpeg")); t.is(stats.jpeg[0].width, 1280); }); @@ -237,7 +237,7 @@ test("Try to use a width larger than original (statsSync)", t => { }); t.is(stats.jpeg.length, 1); - t.is(stats.jpeg[0].url, "/img/97854483-1280.jpeg"); + t.is(stats.jpeg[0].url, "/img/Bok0Qhed6a-1280.jpeg"); t.is(stats.jpeg[0].width, 1280); }); @@ -248,7 +248,7 @@ test("Use exact same width as original (statsSync)", t => { }); t.is(stats.jpeg.length, 1); - t.is(stats.jpeg[0].url, "/img/97854483-1280.jpeg"); // no width in filename + t.is(stats.jpeg[0].url, "/img/Bok0Qhed6a-1280.jpeg"); // no width in filename t.is(stats.jpeg[0].width, 1280); }); @@ -270,19 +270,20 @@ test("Use custom function to define file names", async (t) => { }); t.is(stats.jpeg.length, 2); - t.is(stats.jpeg[0].outputPath, path.join("test/img/bio-2017-97854483-600.jpeg")); - t.is(stats.jpeg[0].url, "/img/bio-2017-97854483-600.jpeg"); - t.is(stats.jpeg[0].srcset, "/img/bio-2017-97854483-600.jpeg 600w"); + t.is(stats.jpeg[0].outputPath, path.join("test/img/bio-2017-Bok0Qhed6a-600.jpeg")); + t.is(stats.jpeg[0].url, "/img/bio-2017-Bok0Qhed6a-600.jpeg"); + t.is(stats.jpeg[0].srcset, "/img/bio-2017-Bok0Qhed6a-600.jpeg 600w"); t.is(stats.jpeg[0].width, 600); - t.is(stats.jpeg[1].outputPath, path.join("test/img/bio-2017-97854483-1280.jpeg")); - t.is(stats.jpeg[1].url, "/img/bio-2017-97854483-1280.jpeg"); - t.is(stats.jpeg[1].srcset, "/img/bio-2017-97854483-1280.jpeg 1280w"); + t.is(stats.jpeg[1].outputPath, path.join("test/img/bio-2017-Bok0Qhed6a-1280.jpeg")); + t.is(stats.jpeg[1].url, "/img/bio-2017-Bok0Qhed6a-1280.jpeg"); + t.is(stats.jpeg[1].srcset, "/img/bio-2017-Bok0Qhed6a-1280.jpeg 1280w"); t.is(stats.jpeg[1].width, 1280); }); test("Unavatar test", t => { let stats = eleventyImage.statsByDimensionsSync("https://unavatar.now.sh/twitter/zachleat?fallback=false", 400, 400, { - widths: [75] + widths: [75], + remoteAssetContent: 'remote asset content' }); t.is(stats.webp.length, 1); @@ -386,7 +387,7 @@ test("Sync by dimension with jpeg input (wrong dimensions, supplied are smaller // this won’t upscale so it will miss out on higher resolution images but there won’t be any broken image URLs in the output t.is(stats.jpeg.length, 1); - t.is(stats.jpeg[0].outputPath, path.join("img/97854483-164.jpeg")); + t.is(stats.jpeg[0].outputPath, path.join("img/Bok0Qhed6a-164.jpeg")); }); test("Sync by dimension with jpeg input (wrong dimensions, supplied are larger than real)", t => { @@ -396,8 +397,8 @@ test("Sync by dimension with jpeg input (wrong dimensions, supplied are larger t }); t.is(stats.jpeg.length, 2); - t.is(stats.jpeg[0].outputPath, path.join("img/97854483-164.jpeg")); - t.is(stats.jpeg[1].outputPath, path.join("img/97854483-328.jpeg")); + t.is(stats.jpeg[0].outputPath, path.join("img/Bok0Qhed6a-164.jpeg")); + t.is(stats.jpeg[1].outputPath, path.join("img/Bok0Qhed6a-328.jpeg")); }); test("Keep a cache, reuse with same file names and options", async t => { @@ -545,14 +546,14 @@ test("Using `jpg` in formats Issue #64", async t => { t.deepEqual(stats, { jpeg: [ { - filename: '97854483-1280.jpeg', + filename: 'Bok0Qhed6a-1280.jpeg', format: 'jpeg', height: 853, - outputPath: path.join('img/97854483-1280.jpeg'), + outputPath: path.join('img/Bok0Qhed6a-1280.jpeg'), size: 276231, sourceType: "image/jpeg", - srcset: '/img/97854483-1280.jpeg 1280w', - url: '/img/97854483-1280.jpeg', + srcset: '/img/Bok0Qhed6a-1280.jpeg 1280w', + url: '/img/Bok0Qhed6a-1280.jpeg', width: 1280, }, ]