├── src ├── adapters │ ├── sharp.js │ ├── sharp.core.js │ ├── brotli-size.core.js │ └── brotli-size.js ├── caches.js ├── defer-counter.js ├── format-hooks │ └── svg.js ├── directory-manager.js ├── exists-cache.js ├── image-path.js ├── memory-cache.js ├── disk-cache.js ├── build-logger.js ├── util.js ├── image-attrs-to-posthtml-node.js ├── global-options.js ├── on-request-during-serve-plugin.js ├── transform-plugin.js ├── generate-html.js └── image.js ├── test ├── car.mp4 ├── bio-2017.jpg ├── automatisés.jpg ├── bio-2017.webp ├── orientation.jpg ├── david-mascot.png ├── earth-animated.gif ├── exif-Landscape_3.jpg ├── exif-Landscape_5.jpg ├── exif-Landscape_6.jpg ├── exif-Landscape_7.jpg ├── exif-Landscape_8.jpg ├── issue-244-sharp.png ├── exif-Landscape_15.jpg ├── exif-sample-large.jpg ├── modify-bio-grayscale.jpg ├── modify-bio-original.jpg ├── modify2-bio-grayscale.jpg ├── modify2-bio-original.jpg ├── util │ └── utils.js ├── exif-Landscape_3-bakedOrientation.jpg ├── exif-Landscape_3-bakedOrientation-200.jpg ├── les sous titres automatisés de youtube.jpg ├── île-de-myst-en-lego │ └── les sous titres automatisés de youtube.jpg ├── 20240705.île-de-myst-en-lego │ └── les sous titres automatisés de youtube.jpg ├── transform-hooks-test.js ├── logo.svg ├── test-markup.js ├── transform-test.js └── test.js ├── .npmignore ├── .gitattributes ├── .gitignore ├── .editorconfig ├── sample ├── sample-issue-39.js ├── sample-issue-220.js ├── sample-website.js └── sample.js ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── eslint.config.js ├── package.json ├── README.md └── img.js /src/adapters/sharp.js: -------------------------------------------------------------------------------- 1 | export { default } from "sharp"; 2 | 3 | -------------------------------------------------------------------------------- /test/car.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/car.mp4 -------------------------------------------------------------------------------- /test/bio-2017.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/bio-2017.jpg -------------------------------------------------------------------------------- /test/automatisés.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/automatisés.jpg -------------------------------------------------------------------------------- /test/bio-2017.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/bio-2017.webp -------------------------------------------------------------------------------- /test/orientation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/orientation.jpg -------------------------------------------------------------------------------- /test/david-mascot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/david-mascot.png -------------------------------------------------------------------------------- /test/earth-animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/earth-animated.gif -------------------------------------------------------------------------------- /test/exif-Landscape_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/exif-Landscape_3.jpg -------------------------------------------------------------------------------- /test/exif-Landscape_5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/exif-Landscape_5.jpg -------------------------------------------------------------------------------- /test/exif-Landscape_6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/exif-Landscape_6.jpg -------------------------------------------------------------------------------- /test/exif-Landscape_7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/exif-Landscape_7.jpg -------------------------------------------------------------------------------- /test/exif-Landscape_8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/exif-Landscape_8.jpg -------------------------------------------------------------------------------- /test/issue-244-sharp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/issue-244-sharp.png -------------------------------------------------------------------------------- /test/exif-Landscape_15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/exif-Landscape_15.jpg -------------------------------------------------------------------------------- /test/exif-sample-large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/exif-sample-large.jpg -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | sample 3 | .cache 4 | .editorconfig 5 | .eslintrc.js 6 | .gitattributes 7 | .github/* 8 | -------------------------------------------------------------------------------- /test/modify-bio-grayscale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/modify-bio-grayscale.jpg -------------------------------------------------------------------------------- /test/modify-bio-original.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/modify-bio-original.jpg -------------------------------------------------------------------------------- /test/modify2-bio-grayscale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/modify2-bio-grayscale.jpg -------------------------------------------------------------------------------- /test/modify2-bio-original.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/modify2-bio-original.jpg -------------------------------------------------------------------------------- /test/util/utils.js: -------------------------------------------------------------------------------- 1 | export function normalizeEscapedPaths(p) { 2 | return p.replaceAll("%5C", "%2F"); 3 | } 4 | -------------------------------------------------------------------------------- /src/adapters/sharp.core.js: -------------------------------------------------------------------------------- 1 | export default function() { 2 | throw new Error("Sharp is not supported in browser."); 3 | }; 4 | -------------------------------------------------------------------------------- /test/exif-Landscape_3-bakedOrientation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/exif-Landscape_3-bakedOrientation.jpg -------------------------------------------------------------------------------- /test/exif-Landscape_3-bakedOrientation-200.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/exif-Landscape_3-bakedOrientation-200.jpg -------------------------------------------------------------------------------- /test/les sous titres automatisés de youtube.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/les sous titres automatisés de youtube.jpg -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js text eol=lf 2 | 3 | # Templates 4 | *.webc text diff=html 5 | 6 | # Reclassify .webc files as HTML: 7 | *.webc linguist-language=HTML -------------------------------------------------------------------------------- /src/adapters/brotli-size.core.js: -------------------------------------------------------------------------------- 1 | export default function() { 2 | throw new Error("`svgCompressionSize: 'br'` feature is not supported in browser."); 3 | }; 4 | -------------------------------------------------------------------------------- /src/adapters/brotli-size.js: -------------------------------------------------------------------------------- 1 | import brotliSize from "brotli-size"; 2 | 3 | export default function(contents) { 4 | return brotliSize.sync(contents); 5 | }; 6 | -------------------------------------------------------------------------------- /test/île-de-myst-en-lego/les sous titres automatisés de youtube.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/île-de-myst-en-lego/les sous titres automatisés de youtube.jpg -------------------------------------------------------------------------------- /test/20240705.île-de-myst-en-lego/les sous titres automatisés de youtube.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-img/main/test/20240705.île-de-myst-en-lego/les sous titres automatisés de youtube.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test/img/ 3 | sample/img/ 4 | sample/.cache/ 5 | .cache 6 | img/*.webp 7 | img/*.jpeg 8 | img/*.avif 9 | img/*.gif 10 | img/*.tiff 11 | 12 | # generated by tests 13 | test/generated-* 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | insert_final_newline = false 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | [*.js] 12 | insert_final_newline = true -------------------------------------------------------------------------------- /sample/sample-issue-39.js: -------------------------------------------------------------------------------- 1 | import eleventyImage from "../img.js"; 2 | 3 | let results = await eleventyImage("../test/bio-2017.jpg", { 4 | formats: [null], 5 | widths: [null], 6 | sharpJpegOptions: { 7 | quality: 99 8 | }, 9 | }); 10 | 11 | console.log( results ); 12 | -------------------------------------------------------------------------------- /sample/sample-issue-220.js: -------------------------------------------------------------------------------- 1 | import eleventyImage from "../img.js"; 2 | 3 | let results = await eleventyImage("https://images.ctfassets.net/qbmf238cr6te/2ExPY7uYyafazH0IfFnpTD/610bce5faa1598685f2985d2062dcf1f/heyflow-logo.svg", { 4 | formats: [null], 5 | widths: [null], 6 | sharpOptions: { 7 | unlimited: true 8 | } 9 | }); 10 | 11 | console.log( results ); 12 | -------------------------------------------------------------------------------- /src/caches.js: -------------------------------------------------------------------------------- 1 | import MemoryCache from "./memory-cache.js"; 2 | import DiskCache from "./disk-cache.js"; 3 | import ExistsCache from "./exists-cache.js"; 4 | 5 | let memCache = new MemoryCache(); 6 | 7 | let existsCache = new ExistsCache(); 8 | 9 | let diskCache = new DiskCache(); 10 | diskCache.setExistsCache(existsCache); 11 | 12 | export { 13 | memCache, 14 | diskCache, 15 | existsCache, 16 | }; 17 | -------------------------------------------------------------------------------- /src/defer-counter.js: -------------------------------------------------------------------------------- 1 | export default class DeferCounter { 2 | constructor() { 3 | this.resetCount(); 4 | } 5 | 6 | resetCount() { 7 | this.deferCount = 0; 8 | this.inputs = new Map(); 9 | } 10 | 11 | getCount() { 12 | return this.deferCount; 13 | } 14 | 15 | increment(input) { 16 | if(input) { 17 | if(this.inputs.has(input)) { 18 | return; 19 | } 20 | this.inputs.set(input, true); 21 | } 22 | 23 | this.deferCount++; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/format-hooks/svg.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import debugUtil from "debug"; 3 | 4 | const debugAssets = debugUtil("Eleventy:Assets"); 5 | 6 | export default async function createSvg(sharpInstance) { 7 | let input = sharpInstance.options.input; 8 | let svgBuffer = input.buffer; 9 | if(svgBuffer) { // remote URL already has buffer 10 | return svgBuffer; 11 | } else { // local file system 12 | debugAssets("[11ty/eleventy-img] Reading %o", input.file); 13 | return fs.readFileSync(input.file); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/directory-manager.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import debugUtil from "debug"; 4 | 5 | const debugAssets = debugUtil("Eleventy:Assets"); 6 | 7 | export default class DirectoryManager { 8 | #dirs = new Set(); 9 | 10 | isCreated(dir) { 11 | return this.#dirs.has(dir); 12 | } 13 | 14 | create(dir) { 15 | if(this.isCreated(dir)) { 16 | return; 17 | } 18 | 19 | this.#dirs.add(dir); 20 | debugAssets("[11ty/eleventy-img] Creating directory %o", dir); 21 | fs.mkdirSync(dir, { recursive: true }); 22 | } 23 | 24 | createFromFile(filepath) { 25 | let dir = path.dirname(filepath); 26 | this.create(dir); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node Unit Tests 2 | on: [push] 3 | permissions: read-all 4 | jobs: 5 | build: 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | os: ["ubuntu-latest", "macos-latest", "windows-latest"] 10 | node: ["18", "20", "22", "24"] 11 | name: Node.js ${{ matrix.node }} on ${{ matrix.os }} 12 | steps: 13 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # 4.1.7 14 | - name: Setup node 15 | uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # 4.0.3 16 | with: 17 | node-version: ${{ matrix.node }} 18 | # cache: npm 19 | - run: npm install 20 | - run: npm test 21 | env: 22 | YARN_GPG: no 23 | -------------------------------------------------------------------------------- /sample/sample-website.js: -------------------------------------------------------------------------------- 1 | import eleventyImage from "../img.js"; 2 | 3 | let metadata = await eleventyImage("./test/Flag_of_Mexico.svg", { 4 | formats: ["svg", "avif"], 5 | widths: [600, null], 6 | dryRun: true, 7 | }); 8 | 9 | let attrs = { 10 | alt: "Flag of Mexico", 11 | sizes: "100vw", 12 | }; 13 | 14 | const html = ` 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |

eleventy-img

25 |
26 |
27 | ${await eleventyImage.generateHTML(metadata, attrs)} 28 |
29 | 30 | `; 31 | 32 | console.log( html ); 33 | 34 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "eslint/config"; 2 | import pluginJs from "@eslint/js"; 3 | import pluginStylistic from "@stylistic/eslint-plugin-js"; 4 | import globals from "globals"; 5 | 6 | const GLOB_JS = '**/*.?([cm])js'; 7 | 8 | export default defineConfig([ 9 | { 10 | files: [GLOB_JS], 11 | plugins: { 12 | js: pluginJs, 13 | "@stylistic/js": pluginStylistic 14 | }, 15 | extends: [ 16 | "js/recommended", 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2022, 20 | sourceType: "module", 21 | globals: { ...globals.node }, 22 | }, 23 | rules: { 24 | "@stylistic/js/indent": ["error", 2], 25 | "@stylistic/js/linebreak-style": ["error", "unix"], 26 | "@stylistic/js/semi": ["error", "always"], 27 | }, 28 | }, 29 | ]); 30 | -------------------------------------------------------------------------------- /src/exists-cache.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import Util from "./util.js"; 3 | 4 | // Checks both files and directories 5 | export default class ExistsCache { 6 | #exists = new Map(); 7 | 8 | constructor() { 9 | this.lookupCount = 0; 10 | } 11 | 12 | get size() { 13 | return this.#exists.size; 14 | } 15 | 16 | has(path) { 17 | return this.#exists.has(path); 18 | } 19 | 20 | // Relative paths (to root directory) expected (but not enforced due to perf costs) 21 | exists(path) { 22 | if(Util.isFullUrl(path)) { 23 | return false; 24 | } 25 | 26 | if (!this.#exists.has(path)) { 27 | let exists = fs.existsSync(path); 28 | this.lookupCount++; 29 | 30 | // mark for next time 31 | this.#exists.set(path, Boolean(exists)); 32 | 33 | return exists; 34 | } 35 | 36 | return this.#exists.get(path); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/image-path.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | export default class ImagePath { 4 | static filenameFormat(id, src, width, format) { // and options 5 | if (width) { 6 | return `${id}-${width}.${format}`; 7 | } 8 | 9 | return `${id}.${format}`; 10 | } 11 | 12 | static getFilename(id, src, width, format, options = {}) { 13 | if (typeof options.filenameFormat === "function") { 14 | let filename = options.filenameFormat(id, src, width, format, options); 15 | // if options.filenameFormat returns falsy, use fallback filename 16 | if(filename) { 17 | return filename; 18 | } 19 | } 20 | 21 | return ImagePath.filenameFormat(id, src, width, format, options); 22 | } 23 | 24 | static convertFilePathToUrl(dir, filename) { 25 | let src = path.join(dir, filename); 26 | return src.split(path.sep).join("/"); 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release to npm 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | environment: GitHub Publish 9 | permissions: 10 | contents: read 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # 4.1.7 14 | - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # 4.0.3 15 | with: 16 | node-version: "22" 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: npm install -g npm@latest 19 | - run: npm ci 20 | - run: npm test 21 | - if: ${{ github.event.release.tag_name != '' && env.NPM_PUBLISH_TAG != '' }} 22 | run: npm publish --provenance --access=public --tag=${{ env.NPM_PUBLISH_TAG }} 23 | env: 24 | NPM_PUBLISH_TAG: ${{ contains(github.event.release.tag_name, '-alpha.') && 'alpha' || contains(github.event.release.tag_name, '-beta.') && 'beta' || 'latest' }} 25 | -------------------------------------------------------------------------------- /src/memory-cache.js: -------------------------------------------------------------------------------- 1 | import debugUtil from "debug"; 2 | 3 | const debug = debugUtil("Eleventy:Image"); 4 | 5 | export default class MemoryCache { 6 | constructor() { 7 | this.cache = {}; 8 | this.hitCounter = 0; 9 | this.missCounter = 0; 10 | } 11 | 12 | resetCount() { 13 | this.hitCounter = 0; 14 | this.missCounter = 0; 15 | } 16 | 17 | getCount() { 18 | return [this.hitCounter, this.missCounter]; 19 | } 20 | 21 | add(key, results) { 22 | this.cache[key] = { 23 | results 24 | }; 25 | 26 | debug("Unique images processed: %o", this.size()); 27 | } 28 | 29 | get(key, incrementCounts = false) { 30 | if(this.cache[key]) { 31 | if(incrementCounts) { 32 | this.hitCounter++; 33 | } 34 | // debug("Images re-used (via in-memory cache): %o", this.hitCounter); 35 | 36 | // may return promise 37 | return this.cache[key].results; 38 | } 39 | 40 | if(incrementCounts) { 41 | this.missCounter++; 42 | } 43 | 44 | return false; 45 | } 46 | 47 | has(key) { 48 | return key in this.cache; 49 | } 50 | 51 | size() { 52 | return Object.keys(this.cache).length; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/disk-cache.js: -------------------------------------------------------------------------------- 1 | // const debug = require("debug")("Eleventy:Image"); 2 | 3 | export default class DiskCache { 4 | #existsCache; 5 | 6 | constructor() { 7 | this.hitCounter = 0; 8 | this.missCounter = 0; 9 | this.inputs = new Map(); 10 | } 11 | 12 | setExistsCache(existsCache) { 13 | this.#existsCache = existsCache; 14 | } 15 | 16 | resetCount() { 17 | this.hitCounter = 0; 18 | this.missCounter = 0; 19 | } 20 | 21 | getCount() { 22 | return [this.hitCounter, this.missCounter]; 23 | } 24 | 25 | isCached(targetFile, sourceInput, incrementCounts = true) { 26 | if(!this.#existsCache) { 27 | throw new Error("Missing `#existsCache`"); 28 | } 29 | 30 | // Disk cache runs once per output file, so we only increment counts once per input 31 | if(this.inputs.has(sourceInput)) { 32 | incrementCounts = false; 33 | } 34 | 35 | this.inputs.set(sourceInput, true); 36 | 37 | if(this.#existsCache?.exists(targetFile)) { 38 | if(incrementCounts) { 39 | this.hitCounter++; 40 | } 41 | 42 | // debug("Images re-used (via disk cache): %o", this.hitCounter); 43 | return true; 44 | } 45 | 46 | if(incrementCounts) { 47 | this.missCounter++; 48 | } 49 | 50 | return false; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/transform-hooks-test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import exifr from "exifr"; 3 | 4 | import eleventyImage from "../img.js"; 5 | 6 | test("Transform Empty", async t => { 7 | let stats = await eleventyImage("./test/exif-sample-large.jpg", { 8 | formats: ["auto"], 9 | // transform: undefined, 10 | dryRun: true, 11 | }); 12 | 13 | let exif = await exifr.parse(stats.jpeg[0].buffer); 14 | t.deepEqual(exif, undefined); 15 | }); 16 | 17 | test("Transform to keep exif", async t => { 18 | let stats = await eleventyImage("./test/exif-sample-large.jpg", { 19 | formats: ["auto"], 20 | // Keep exif metadata 21 | transform: function customNameForCacheKey1(sharp) { 22 | sharp.keepExif(); 23 | }, 24 | dryRun: true, 25 | }); 26 | 27 | let exif = await exifr.parse(stats.jpeg[0].buffer); 28 | 29 | t.is(Math.round(exif.latitude), 50); 30 | t.is(Math.round(exif.longitude), 15); 31 | t.is(exif.ApertureValue, 2); 32 | t.is(exif.BrightnessValue, 9.38); 33 | }); 34 | 35 | test("Transform to crop an image", async t => { 36 | let stats = await eleventyImage("./test/exif-sample-large.jpg", { 37 | formats: ["auto"], 38 | transform: function customNameForCacheKey2(sharp) { 39 | sharp.resize(300, 300); 40 | }, 41 | dryRun: true, 42 | }); 43 | 44 | t.is(stats.jpeg[0].width, 300); 45 | t.is(stats.jpeg[0].height, 300); 46 | t.true(stats.jpeg[0].size < 50000); 47 | }); 48 | 49 | test("Resize in a transform an image takes precedence", async t => { 50 | let stats = await eleventyImage("./test/exif-sample-large.jpg", { 51 | formats: ["auto"], 52 | transform: function customNameForCacheKey3(sharp) { 53 | sharp.resize(400); 54 | }, 55 | dryRun: true, 56 | }); 57 | 58 | t.is(stats.jpeg[0].width, 400); 59 | t.is(stats.jpeg[0].height, 300); 60 | t.true(stats.jpeg[0].size < 50000); 61 | }); 62 | 63 | -------------------------------------------------------------------------------- /test/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/build-logger.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { TemplatePath } from "@11ty/eleventy-utils"; 3 | 4 | import Util from "./util.js"; 5 | 6 | export default class BuildLogger { 7 | #eleventyConfig; 8 | 9 | constructor() { 10 | this.hasAssigned = false; 11 | } 12 | 13 | setupOnce(eleventyConfig, beforeCallback, afterCallback) { 14 | if(this.hasAssigned) { 15 | return; 16 | } 17 | 18 | this.hasAssigned = true; 19 | this.#eleventyConfig = eleventyConfig; 20 | 21 | eleventyConfig.on("eleventy.before", beforeCallback); 22 | eleventyConfig.on("eleventy.after", afterCallback); 23 | 24 | eleventyConfig.on("eleventy.reset", () => { 25 | this.hasAssigned = false; 26 | beforeCallback(); // we run this on reset because the before callback will have disappeared (as the config reset) 27 | }); 28 | } 29 | 30 | getFriendlyImageSource(imageSource) { 31 | if(Buffer.isBuffer(imageSource)) { 32 | return ``; 33 | } 34 | 35 | if(Util.isRemoteUrl(imageSource)) { 36 | return imageSource; 37 | } 38 | if(path.isAbsolute(imageSource)) { 39 | // convert back to relative url 40 | return TemplatePath.addLeadingDotSlash(path.relative(path.resolve("."), imageSource)); 41 | } 42 | 43 | return TemplatePath.addLeadingDotSlash(imageSource); 44 | } 45 | 46 | log(message, options = {}, logOptions = {}) { 47 | if(typeof this.#eleventyConfig?.logger?.logWithOptions !== "function" || options.transformOnRequest) { 48 | return; 49 | } 50 | 51 | this.#eleventyConfig.logger.logWithOptions(Object.assign({ 52 | message: `${message}${options.generatedVia ? ` (${options.generatedVia})` : ""}`, 53 | type: "log", 54 | prefix: "[11ty/eleventy-img]" 55 | }, logOptions)); 56 | } 57 | 58 | error(message, options = {}, logOptions = {}) { 59 | logOptions.type = "error"; 60 | logOptions.force = true; 61 | 62 | this.log(message, options, logOptions); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@11ty/eleventy-img", 3 | "version": "7.0.0-alpha.2", 4 | "description": "Low level utility to perform build-time image transformations.", 5 | "type": "module", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "main": "img.js", 10 | "engines": { 11 | "node": ">=18" 12 | }, 13 | "scripts": { 14 | "pretest": "eslint img.js src/**.js test/**.js", 15 | "test": "ava --no-worker-threads", 16 | "watch": "ava --no-worker-threads --watch", 17 | "sample": "cd sample && node sample.js" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/11ty/eleventy-img.git" 22 | }, 23 | "funding": { 24 | "type": "opencollective", 25 | "url": "https://opencollective.com/11ty" 26 | }, 27 | "keywords": [ 28 | "eleventy", 29 | "eleventy-utility" 30 | ], 31 | "author": { 32 | "name": "Zach Leatherman", 33 | "email": "zachleatherman@gmail.com", 34 | "url": "https://zachleat.com/" 35 | }, 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/11ty/eleventy-img/issues" 39 | }, 40 | "homepage": "https://github.com/11ty/eleventy-img#readme", 41 | "dependencies": { 42 | "@11ty/eleventy-fetch": "^5.1.0", 43 | "@11ty/eleventy-utils": "^2.0.7", 44 | "brotli-size": "^4.0.0", 45 | "debug": "^4.4.1", 46 | "entities": "^6.0.1", 47 | "image-size": "^1.2.1", 48 | "p-queue": "^8.1.0", 49 | "sharp": "^0.34.3" 50 | }, 51 | "devDependencies": { 52 | "@11ty/eleventy": "^3.1.2", 53 | "@eslint/js": "^9.34.0", 54 | "@stylistic/eslint-plugin-js": "^4.4.1", 55 | "ava": "^6.4.1", 56 | "eslint": "^9.34.0", 57 | "exifr": "^7.1.3", 58 | "globals": "^16.3.0", 59 | "pixelmatch": "^7.1.0" 60 | }, 61 | "ava": { 62 | "failFast": false, 63 | "files": [ 64 | "./test/*.{js,cjs,mjs}" 65 | ], 66 | "watchMode": { 67 | "ignoreChanges": [ 68 | "./.cache/*", 69 | "./img/*", 70 | "./test/img/*", 71 | "./test/**/generated*" 72 | ] 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | export default class Util { 4 | static KEYS = { 5 | requested: "requested" 6 | }; 7 | 8 | /* 9 | * Does not mutate, returns new Object. 10 | */ 11 | static getSortedObject(unordered) { 12 | let keys = Object.keys(unordered).sort(); 13 | let obj = {}; 14 | for(let key of keys) { 15 | obj[key] = unordered[key]; 16 | } 17 | return obj; 18 | } 19 | 20 | // Temporary alias for changes made in https://github.com/11ty/eleventy-img/pull/138 21 | static isFullUrl(url) { 22 | return this.isRemoteUrl(url); 23 | } 24 | 25 | static isRemoteUrl(url) { 26 | try { 27 | const validUrl = new URL(url); 28 | 29 | if (validUrl.protocol.startsWith("https:") || validUrl.protocol.startsWith("http:")) { 30 | return true; 31 | } 32 | 33 | return false; 34 | // eslint-disable-next-line no-unused-vars 35 | } catch(e) { 36 | // invalid url OR local path 37 | return false; 38 | } 39 | } 40 | 41 | static normalizeImageSource({ input, inputPath }, src, options = {}) { 42 | let { isViaHtml } = Object.assign({ 43 | isViaHtml: false 44 | }, options); 45 | 46 | if(Util.isFullUrl(src)) { 47 | return src; 48 | } 49 | 50 | if(isViaHtml) { 51 | src = decodeURIComponent(src); 52 | } 53 | 54 | if(!path.isAbsolute(src)) { 55 | // if the image src is relative, make it relative to the template file (inputPath); 56 | let dir = path.dirname(inputPath); 57 | return path.join(dir, src); 58 | } 59 | 60 | // if the image src is absolute, make it relative to the input/content directory. 61 | return path.join(input, src); 62 | } 63 | 64 | static isRequested(generatedVia) { 65 | return generatedVia === this.KEYS.requested; 66 | } 67 | 68 | static addConfig(eleventyConfig, options) { 69 | if(!eleventyConfig) { 70 | return; 71 | } 72 | 73 | Object.defineProperty(options, "eleventyConfig", { 74 | value: eleventyConfig, 75 | enumerable: false, 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /sample/sample.js: -------------------------------------------------------------------------------- 1 | import eleventyImage, { statsSync } from "../img.js"; 2 | 3 | // upscale svg issue #32 4 | let leaves1 = await eleventyImage(`https://www.netlify.com/v3/img/components/leaves.svg`, { 5 | formats: ["png", "avif"], 6 | widths: [2000], 7 | }); 8 | console.log( "https://www.netlify.com/v3/img/components/leaves.svg" ); 9 | console.dir( leaves1 ); 10 | 11 | let leaves2 = await eleventyImage(`https://www.netlify.com/v3/img/components/leaves.svg`, { 12 | formats: ["svg", "webp", "jpeg", "png"], 13 | widths: [400, 800, null], 14 | svgShortCircuit: true, 15 | }); 16 | console.log( "https://www.netlify.com/v3/img/components/leaves.svg" ); 17 | console.dir( leaves2 ); 18 | 19 | let possum = await eleventyImage(`https://www.11ty.dev/img/possum-balloon-original-sm.png`, { 20 | formats: ["webp", "jpeg", "png"], 21 | widths: [null], 22 | }); 23 | console.log( "https://www.11ty.dev/img/possum-balloon-original-sm.png" ); 24 | console.dir( possum ); 25 | 26 | // let possumStats = statsSync("https://www.11ty.dev/img/possum-balloon-original-sm.png", { 27 | // formats: ["avif", "jpeg"], 28 | // widths: [400, 1280], 29 | // }); 30 | // console.log( "https://www.11ty.dev/img/possum-balloon-original-sm.png (statsSync)" ); 31 | // console.dir( possumStats ); 32 | 33 | 34 | /* Local images */ 35 | let mexicoFlag = await eleventyImage("../test/Flag_of_Mexico.svg", { 36 | formats: ["svg", "avif"], 37 | widths: [600, null], 38 | }); 39 | console.log( "../test/Flag_of_Mexico.svg"); 40 | console.dir( mexicoFlag ); 41 | 42 | let bioImage = await eleventyImage("../test/bio-2017.jpg", { 43 | formats: ["avif", "jpeg"], 44 | widths: [400, 1280], 45 | }); 46 | 47 | console.log( "./test/bio-2017.jpg" ); 48 | console.dir( bioImage ); 49 | 50 | let bioImageStats = statsSync("../test/bio-2017.jpg", { 51 | formats: ["avif", "jpeg"], 52 | widths: [400, 1280], 53 | }); 54 | console.log( "./test/bio-2017.jpg (statsSync)" ); 55 | console.dir( bioImageStats ); 56 | 57 | let bioImageDryRun = await eleventyImage("../test/bio-2017.jpg", { 58 | dryRun: true, 59 | formats: ["avif", "jpeg"], 60 | widths: [400, 1280], 61 | }); 62 | 63 | console.log( "./test/bio-2017.jpg (dryRun)" ); 64 | console.dir( bioImageDryRun ); 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

eleventy Logo

2 | 3 | # eleventy-img 4 | 5 | Requires Node 18+ 6 | 7 | Low level utility to perform build-time image transformations for both vector and raster images. Output multiple sizes, save multiple formats, cache remote images locally. Uses the [sharp](https://sharp.pixelplumbing.com/) image processor. 8 | 9 | You maintain full control of your HTML. Use with `` or `` or CSS `background-image`, or others! Works great to add `width` and `height` to your images! 10 | 11 | ## [The full `eleventy-img` documentation is on 11ty.dev](https://www.11ty.dev/docs/plugins/image/). 12 | 13 | * _This is a plugin for the [Eleventy static site generator](https://www.11ty.dev/)._ 14 | * Find more [Eleventy plugins](https://www.11ty.dev/docs/plugins/). 15 | * Please star [Eleventy on GitHub](https://github.com/11ty/eleventy/), follow [@eleven_ty](https://twitter.com/eleven_ty) on Twitter, and support [11ty on Open Collective](https://opencollective.com/11ty) 16 | 17 | [![npm Version](https://img.shields.io/npm/v/@11ty/eleventy-img.svg?style=for-the-badge)](https://www.npmjs.com/package/@11ty/eleventy-img) [![GitHub issues](https://img.shields.io/github/issues/11ty/eleventy-img.svg?style=for-the-badge)](https://github.com/11ty/eleventy-img/issues) 18 | 19 | ## Installation 20 | 21 | ``` 22 | npm install --save-dev @11ty/eleventy-img 23 | ``` 24 | 25 | _[The full `eleventy-img` documentation is on 11ty.dev](https://www.11ty.dev/docs/plugins/image/)._ 26 | 27 | ## Tests 28 | 29 | ``` 30 | npm run test 31 | ``` 32 | 33 | - We use the [ava JavaScript test runner](https://github.com/avajs/ava) ([Assertions documentation](https://github.com/avajs/ava/blob/master/docs/03-assertions.md)) 34 | - ℹ️ To keep tests fast, thou shalt try to avoid writing files in tests. 35 | 36 | ## Community Roadmap 37 | 38 | - [Top Feature Requests](https://github.com/11ty/eleventy-img/issues?q=label%3Aneeds-votes+sort%3Areactions-%2B1-desc+label%3Aenhancement) (Add your own votes using the 👍 reaction) 39 | - [Top Bugs 😱](https://github.com/11ty/eleventy-img/issues?q=is%3Aissue+is%3Aopen+label%3Abug+sort%3Areactions-%2B1-desc) (Add your own votes using the 👍 reaction) 40 | - [Newest Bugs 🙀](https://github.com/11ty/eleventy-img/issues?q=is%3Aopen+is%3Aissue+label%3Abug) 41 | -------------------------------------------------------------------------------- /src/image-attrs-to-posthtml-node.js: -------------------------------------------------------------------------------- 1 | import eleventyImage from "../img.js"; 2 | import Util from "./util.js"; 3 | import { generateObject } from "./generate-html.js"; 4 | 5 | const ATTR_PREFIX = "eleventy:"; 6 | 7 | const CHILDREN_OBJECT_KEY = "@children"; 8 | 9 | const ATTR = { 10 | IGNORE: `${ATTR_PREFIX}ignore`, 11 | WIDTHS: `${ATTR_PREFIX}widths`, 12 | FORMATS: `${ATTR_PREFIX}formats`, 13 | OUTPUT: `${ATTR_PREFIX}output`, 14 | OPTIONAL: `${ATTR_PREFIX}optional`, 15 | PICTURE: `${ATTR_PREFIX}pictureattr:`, 16 | }; 17 | 18 | function getPictureAttributesFromImgNode(attrs = {}) { 19 | let pictureAttrs = {}; 20 | for(let key in attrs) { 21 | // hoists to ` 22 | // e.g. hoists to 23 | if(key.startsWith(ATTR.PICTURE)) { 24 | pictureAttrs[key.slice(ATTR.PICTURE.length)] = attrs[key]; 25 | } 26 | } 27 | return pictureAttrs; 28 | } 29 | 30 | function convertToPosthtmlNode(obj) { 31 | // node.tag 32 | // node.attrs 33 | // node.content 34 | 35 | let node = {}; 36 | let [key] = Object.keys(obj); 37 | node.tag = key; 38 | 39 | let children = obj[key]?.[CHILDREN_OBJECT_KEY]; 40 | let attributes = {}; 41 | for(let attrKey in obj[key]) { 42 | if(attrKey !== CHILDREN_OBJECT_KEY) { 43 | attributes[attrKey] = obj[key][attrKey]; 44 | } 45 | } 46 | node.attrs = attributes; 47 | 48 | if(Array.isArray(children)) { 49 | node.content = obj[key]?.[CHILDREN_OBJECT_KEY] 50 | .filter(child => Boolean(child)) 51 | .map(child => { 52 | return convertToPosthtmlNode(child); 53 | }); 54 | } 55 | 56 | return node; 57 | } 58 | 59 | function isValidSimpleWidthAttribute(width) { 60 | // `width` must be a single integer (not comma separated). Don’t use invalid HTML in width attribute. Use eleventy:widths if you want more complex support 61 | return (""+width) == (""+parseInt(width, 10)); 62 | } 63 | 64 | export async function imageAttributesToPosthtmlNode(attributes, instanceOptions, globalPluginOptions) { 65 | if(!attributes.src) { 66 | throw new Error("Missing `src` attribute for `@11ty/eleventy-img`"); 67 | } 68 | 69 | if(!globalPluginOptions) { 70 | throw new Error("Missing global defaults for `@11ty/eleventy-img`: did you call addPlugin?"); 71 | } 72 | 73 | if(!instanceOptions) { 74 | instanceOptions = {}; 75 | } 76 | 77 | // overrides global widths 78 | if(attributes.width && isValidSimpleWidthAttribute(attributes.width)) { 79 | // Support `width` but only single value 80 | instanceOptions.widths = [ parseInt(attributes.width, 10) ]; 81 | } else if(attributes[ATTR.WIDTHS] && typeof attributes[ATTR.WIDTHS] === "string") { 82 | instanceOptions.widths = attributes[ATTR.WIDTHS].split(",").map(entry => parseInt(entry, 10)); 83 | } 84 | 85 | if(attributes[ATTR.FORMATS] && typeof attributes[ATTR.FORMATS] === "string") { 86 | instanceOptions.formats = attributes[ATTR.FORMATS].split(","); 87 | } 88 | 89 | let options = Object.assign({}, globalPluginOptions, instanceOptions); 90 | Util.addConfig(globalPluginOptions.eleventyConfig, options); 91 | 92 | let metadata = await eleventyImage(attributes.src, options); 93 | let pictureAttributes = getPictureAttributesFromImgNode(attributes); 94 | 95 | cleanAttrs(attributes); 96 | 97 | // You bet we throw an error on missing alt in `imageAttributes` (alt="" works okay) 98 | let obj = await generateObject(metadata, attributes, pictureAttributes, options); 99 | return convertToPosthtmlNode(obj); 100 | } 101 | 102 | function cleanAttrs(attrs = {}) { 103 | for(let key in attrs) { 104 | if(key.startsWith(ATTR_PREFIX)) { 105 | delete attrs?.[key]; 106 | } 107 | } 108 | } 109 | 110 | export function cleanTag(node) { 111 | // Delete all prefixed attributes 112 | cleanAttrs(node?.attrs); 113 | } 114 | 115 | export function isIgnored(node) { 116 | return node?.attrs && node?.attrs?.[ATTR.IGNORE] !== undefined; 117 | } 118 | 119 | export function isOptional(node, comparisonValue) { 120 | let attrValue = node?.attrs && node?.attrs?.[ATTR.OPTIONAL]; 121 | if(attrValue !== undefined) { 122 | // if comparisonValue is not specified, return true 123 | if(comparisonValue === undefined) { 124 | return true; 125 | } 126 | return attrValue === comparisonValue; 127 | } 128 | return false; 129 | } 130 | 131 | export function getOutputDirectory(node) { 132 | return node?.attrs?.[ATTR.OUTPUT]; 133 | } 134 | 135 | -------------------------------------------------------------------------------- /src/global-options.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import os from "node:os"; 3 | 4 | import eleventyImage from "../img.js"; 5 | import Util from "./util.js"; 6 | import svgHook from "./format-hooks/svg.js"; 7 | 8 | export const DEFAULTS = { 9 | widths: ["auto"], 10 | formats: ["webp", "jpeg"], // "png", "svg", "avif" 11 | 12 | formatFiltering: ["transparent", "animated"], 13 | 14 | // Via https://github.com/11ty/eleventy-img/issues/258 15 | concurrency: Math.min(Math.max(8, os.availableParallelism()), 16), 16 | 17 | urlPath: "/img/", 18 | outputDir: "img/", 19 | 20 | // true to skip raster formats if SVG input is found 21 | // "size" to skip raster formats if larger than SVG input 22 | svgShortCircuit: false, 23 | svgAllowUpscale: true, 24 | svgCompressionSize: "", // "br" to report SVG `size` property in metadata as Brotli compressed 25 | // overrideInputFormat: false, // internal, used to force svg output in statsSync et al 26 | sharpOptions: {}, // options passed to the Sharp constructor 27 | sharpWebpOptions: {}, // options passed to the Sharp webp output method 28 | sharpPngOptions: {}, // options passed to the Sharp png output method 29 | sharpJpegOptions: {}, // options passed to the Sharp jpeg output method 30 | sharpAvifOptions: {}, // options passed to the Sharp avif output method 31 | 32 | formatHooks: { 33 | svg: svgHook, 34 | }, 35 | 36 | cacheDuration: "1d", // deprecated, use cacheOptions.duration 37 | 38 | // disk cache for remote assets 39 | cacheOptions: { 40 | // duration: "1d", 41 | // directory: ".cache", 42 | // removeUrlQueryParams: false, 43 | // fetchOptions: {}, 44 | }, 45 | 46 | filenameFormat: null, 47 | 48 | // urlFormat allows you to return a full URL to an image including the domain. 49 | // Useful when you’re using your own hosted image service (probably via .statsSync or .statsByDimensionsSync) 50 | // Note: when you use this, metadata will not include .filename or .outputPath 51 | urlFormat: null, 52 | 53 | // If true, skips all image processing, just return stats. Doesn’t read files, doesn’t write files. 54 | // Important to note that `dryRun: true` performs image processing and includes a buffer—this does not. 55 | // Useful when used with `urlFormat` above. 56 | // Better than .statsSync* functions, because this will use the in-memory cache and de-dupe requests. Those will not. 57 | statsOnly: false, 58 | remoteImageMetadata: {}, // For `statsOnly` remote images, this needs to be populated with { width, height, format? } 59 | 60 | useCache: true, // in-memory and disk cache 61 | dryRun: false, // Also returns a buffer instance in the return object. Doesn’t write anything to the file system 62 | 63 | hashLength: 10, // Truncates the hash to this length 64 | 65 | fixOrientation: false, // always rotate images to ensure correct orientation 66 | 67 | // When the original width is smaller than the desired output width, this is the minimum size difference 68 | // between the next smallest image width that will generate one extra width in the output. 69 | // e.g. when using `widths: [400, 800]`, the source image would need to be at least (400 * 1.25 =) 500px wide 70 | // to generate two outputs (400px, 500px). If the source image is less than 500px, only one output will 71 | // be generated (400px). 72 | // Read more at https://github.com/11ty/eleventy-img/issues/184 and https://github.com/11ty/eleventy-img/pull/190 73 | minimumThreshold: 1.25, 74 | 75 | // During --serve mode in Eleventy, this will generate images on request instead of part of the build skipping 76 | // writes to the file system and speeding up builds! 77 | transformOnRequest: false, 78 | 79 | // operate on Sharp instance manually. 80 | transform: undefined, 81 | 82 | // return HTML from generateHTML directly 83 | returnType: "object", // or "html" 84 | 85 | // Defaults used when generateHTML is called from a result set 86 | htmlOptions: { 87 | imgAttributes: {}, 88 | pictureAttributes: {}, 89 | 90 | whitespaceMode: "inline", // "block" 91 | 92 | // the will use the largest dimensions for width/height (when multiple output widths are specified) 93 | // see https://github.com/11ty/eleventy-img/issues/63 94 | fallback: "largest", // or "smallest" 95 | }, 96 | 97 | // v5.0.0 Removed `extensions`, option to override output format with new file extension. It wasn’t being used anywhere or documented. 98 | // v6.0.0, removed `useCacheValidityInHash: true` see https://github.com/11ty/eleventy-img/issues/146#issuecomment-2555741376 99 | }; 100 | 101 | export function getGlobalOptions(eleventyConfig, options, via) { 102 | let directories = eleventyConfig.directories; 103 | let globalOptions = Object.assign({ 104 | packages: { 105 | image: eleventyImage, 106 | }, 107 | outputDir: path.join(directories.output, options.urlPath || ""), 108 | failOnError: true, 109 | }, options); 110 | 111 | globalOptions.directories = directories; 112 | globalOptions.generatedVia = via; 113 | 114 | Util.addConfig(eleventyConfig, globalOptions); 115 | 116 | return globalOptions; 117 | } 118 | -------------------------------------------------------------------------------- /img.js: -------------------------------------------------------------------------------- 1 | import PQueue from "p-queue"; 2 | import debugUtil from "debug"; 3 | 4 | import DeferCounter from "./src/defer-counter.js"; 5 | import BuildLogger from "./src/build-logger.js"; 6 | import Util from "./src/util.js"; 7 | import Image from "./src/image.js"; 8 | import DirectoryManager from "./src/directory-manager.js"; 9 | import { DEFAULTS as GLOBAL_OPTIONS } from "./src/global-options.js"; 10 | import { memCache, diskCache } from "./src/caches.js"; 11 | 12 | const debug = debugUtil("Eleventy:Image"); 13 | 14 | let deferCounter = new DeferCounter(); 15 | let buildLogger = new BuildLogger(); 16 | let directoryManager = new DirectoryManager(); 17 | 18 | /* Queue */ 19 | let processingQueue = new PQueue({ 20 | concurrency: GLOBAL_OPTIONS.concurrency 21 | }); 22 | processingQueue.on("active", () => { 23 | debug( `Concurrency: ${processingQueue.concurrency}, Size: ${processingQueue.size}, Pending: ${processingQueue.pending}` ); 24 | }); 25 | 26 | // TODO move this into build-logger.js 27 | export function setupLogger(eleventyConfig, opts) { 28 | if(typeof eleventyConfig?.logger?.logWithOptions !== "function" || Util.isRequested(opts?.generatedVia)) { 29 | return; 30 | } 31 | 32 | buildLogger.setupOnce(eleventyConfig, () => { 33 | // before build 34 | deferCounter.resetCount(); 35 | memCache.resetCount(); 36 | diskCache.resetCount(); 37 | }, () => { 38 | // after build 39 | let [memoryCacheHit] = memCache.getCount(); 40 | let [diskCacheHit, diskCacheMiss] = diskCache.getCount(); 41 | // these are unique images, multiple requests to optimize the same image are de-duplicated 42 | let deferCount = deferCounter.getCount(); 43 | 44 | let cachedCount = memoryCacheHit + diskCacheHit; 45 | let optimizedCount = diskCacheMiss + diskCacheHit + memoryCacheHit + deferCount; 46 | 47 | let msg = []; 48 | msg.push(`${optimizedCount} ${optimizedCount !== 1 ? "images" : "image"} optimized`); 49 | 50 | if(cachedCount > 0 || deferCount > 0) { 51 | let innerMsg = []; 52 | if(cachedCount > 0) { 53 | innerMsg.push(`${cachedCount} cached`); 54 | } 55 | if(deferCount > 0) { 56 | innerMsg.push(`${deferCount} deferred`); 57 | } 58 | msg.push(` (${innerMsg.join(", ")})`); 59 | } 60 | 61 | if(optimizedCount > 0 || cachedCount > 0 || deferCount > 0) { 62 | eleventyConfig?.logger?.logWithOptions({ 63 | message: msg.join(""), 64 | prefix: "[11ty/eleventy-img]", 65 | color: "green", 66 | }); 67 | } 68 | }); 69 | } 70 | 71 | function createImage(src, opts = {}) { 72 | let eleventyConfig = opts.eleventyConfig; 73 | 74 | if(opts?.eleventyConfig && {}.propertyIsEnumerable.call(opts, "eleventyConfig")) { 75 | delete opts.eleventyConfig; 76 | Util.addConfig(eleventyConfig, opts); 77 | } 78 | 79 | let img = Image.create(src, opts); 80 | 81 | img.setQueue(processingQueue); 82 | img.setBuildLogger(buildLogger); 83 | img.setDirectoryManager(directoryManager); 84 | 85 | setupLogger(eleventyConfig, opts); 86 | 87 | if(opts.transformOnRequest) { 88 | deferCounter.increment(src); 89 | } 90 | 91 | return img; 92 | }; 93 | 94 | export default function queueImage(src, opts = {}) { 95 | if(src.constructor?.name === "UserConfig") { 96 | throw new Error(`Eleventy Image’s default export is not an Eleventy Plugin and cannot be used with \`eleventyConfig.addPlugin()\`. Use the \`eleventyImageTransformPlugin\` named export instead, like this: \`import { eleventyImageTransformPlugin } from '@11ty/eleventy-img';\` or this: \`const { eleventyImageTransformPlugin } = require('@11ty/eleventy-img');\``); 97 | } 98 | 99 | let img = createImage(src, opts); 100 | return img.queue(); 101 | } 102 | 103 | Object.defineProperty(queueImage, "concurrency", { 104 | get: function() { 105 | return processingQueue.concurrency; 106 | }, 107 | set: function(concurrency) { 108 | processingQueue.concurrency = concurrency; 109 | }, 110 | }); 111 | 112 | // Support default export and named exports for backwards compat 113 | import { default as ImagePath } from "./src/image-path.js"; 114 | import { default as ImageSize } from "image-size"; 115 | import { generateHTML, generateObject } from "./src/generate-html.js"; 116 | 117 | export { 118 | Util, 119 | Image, 120 | ImagePath, 121 | ImageSize, 122 | generateHTML, 123 | generateObject 124 | }; 125 | 126 | Object.assign(queueImage, { 127 | Util, 128 | Image, 129 | ImagePath, 130 | ImageSize, 131 | generateHTML, 132 | generateObject, 133 | 134 | // Support default export only for backwards compat 135 | // TODO move folks to use Image.* instead 136 | statsSync: Image.statsSync, 137 | statsByDimensionsSync: Image.statsByDimensionsSync, 138 | getFormats: Image.getFormatsArray, 139 | getWidths: Image.getValidWidths, 140 | getHash: function(src, options) { 141 | let img = new Image(src, options); 142 | return img.getHash(); 143 | }, 144 | }); 145 | 146 | // Eleventy Plugins (named exports only) 147 | export { eleventyImageTransformPlugin } from "./src/transform-plugin.js"; 148 | export { eleventyImageOnRequestDuringServePlugin } from "./src/on-request-during-serve-plugin.js"; 149 | -------------------------------------------------------------------------------- /src/on-request-during-serve-plugin.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import debugUtil from "debug"; 3 | import { TemplatePath } from "@11ty/eleventy-utils"; 4 | 5 | import eleventyImage, { setupLogger } from "../img.js"; 6 | import Util from "./util.js"; 7 | 8 | const debug = debugUtil("Eleventy:Image"); 9 | 10 | export function eleventyImageOnRequestDuringServePlugin(eleventyConfig, options = {}) { 11 | try { 12 | // Throw an error if the application is not using Eleventy 3.0.0-alpha.7 or newer (including prereleases). 13 | eleventyConfig.versionCheck(">=3.0.0-alpha.7"); 14 | } catch(e) { 15 | console.log( `[11ty/eleventy-img] Warning: your version of Eleventy is incompatible with the dynamic image rendering plugin (see \`eleventyImageOnRequestDuringServePlugin\`). Any dynamically rendered images will 404 (be missing) during --serve mode but will not affect the standard build output: ${e.message}` ); 16 | } 17 | 18 | setupLogger(eleventyConfig, {}); 19 | 20 | // Eleventy 3.0 or newer only. 21 | eleventyConfig.setServerOptions({ 22 | onRequest: { 23 | // TODO work with dev-server’s option for `injectedScriptsFolder` 24 | "/.11ty/image/": async function({ url }) { 25 | // src could be file path or full url 26 | let src = url.searchParams.get("src"); 27 | let imageFormat = url.searchParams.get("format"); 28 | let width = parseInt(url.searchParams.get("width"), 10); 29 | let via = url.searchParams.get("via"); 30 | 31 | let defaultOptions; 32 | if(via === "transform") { 33 | defaultOptions = eleventyConfig.getFilter("__private_eleventyImageTransformConfigurationOptions")(); 34 | } 35 | // if using this plugin directly (not via transform), global default options will need to be passed in to the `addPlugin` call directly 36 | 37 | // Prefer options passed to this plugin, fallback to Transform plugin options if the image source was generated via those options. 38 | let opts = Object.assign({}, defaultOptions, options, { 39 | widths: [width || "auto"], 40 | formats: [imageFormat || "auto"], 41 | 42 | dryRun: true, 43 | cacheOptions: { 44 | // We *do* want to write files to .cache for re-use here. 45 | dryRun: false 46 | }, 47 | 48 | transformOnRequest: false, // use the built images so we don’t go in a loop 49 | generatedVia: Util.KEYS.requested, 50 | }); 51 | 52 | Util.addConfig(eleventyConfig, opts); 53 | 54 | debug( `%o transformed on request to %o at %o width.`, src, imageFormat, width ); 55 | 56 | try { 57 | if(!Util.isFullUrl(src)) { 58 | // Image path on file system must be in working directory 59 | src = TemplatePath.absolutePath(".", src); 60 | 61 | if(!fs.existsSync(src) || !src.startsWith(TemplatePath.absolutePath("."))) { 62 | throw new Error(`Invalid path: ${src}`); 63 | } 64 | } 65 | 66 | let stats = await eleventyImage(src, opts); 67 | 68 | let format = Object.keys(stats).pop(); 69 | let stat = stats[format][0]; 70 | if(!stat) { 71 | throw new Error("Invalid image format."); 72 | } 73 | if(!stat.buffer) { 74 | throw new Error("Could not find `buffer` property for image."); 75 | } 76 | 77 | return { 78 | headers: { 79 | // TODO Set cache headers to match eleventy-fetch cache options (though remote fetchs are still written to .cache) 80 | "Content-Type": stat.sourceType, 81 | }, 82 | body: stat.buffer, 83 | }; 84 | } catch (error) { 85 | debug("Error attempting to transform %o: %O", src, error); 86 | 87 | return { 88 | status: 500, 89 | headers: { 90 | "Content-Type": "image/svg+xml", 91 | "x-error-message": error.message 92 | }, 93 | body: ``, 94 | }; 95 | } 96 | } 97 | } 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /src/transform-plugin.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import Util from "./util.js"; 3 | import { imageAttributesToPosthtmlNode, getOutputDirectory, cleanTag, isIgnored, isOptional } from "./image-attrs-to-posthtml-node.js"; 4 | import { getGlobalOptions } from "./global-options.js"; 5 | import { eleventyImageOnRequestDuringServePlugin } from "./on-request-during-serve-plugin.js"; 6 | 7 | const PLACEHOLDER_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="; 8 | 9 | const ATTRS = { 10 | ORIGINAL_SOURCE: "eleventy:internal_original_src", 11 | }; 12 | 13 | function getSrcAttributeValue(sourceNode/*, rootTargetNode*/) { 14 | // Debatable TODO: use rootTargetNode (if `picture`) to retrieve a potentially higher quality source from 15 | return sourceNode.attrs?.src; 16 | } 17 | 18 | function assignAttributes(rootTargetNode, newNode) { 19 | // only copy attributes if old and new tag name are the same (picture => picture, img => img) 20 | if(rootTargetNode.tag !== newNode.tag) { 21 | delete rootTargetNode.attrs; 22 | } 23 | 24 | if(!rootTargetNode.attrs) { 25 | rootTargetNode.attrs = {}; 26 | } 27 | 28 | // Copy all new attributes to target 29 | if(newNode.attrs) { 30 | Object.assign(rootTargetNode.attrs, newNode.attrs); 31 | } 32 | } 33 | 34 | function getOutputLocations(originalSource, outputDirectoryFromAttribute, pageContext, options) { 35 | let projectOutputDirectory = options.directories.output; 36 | 37 | if(outputDirectoryFromAttribute) { 38 | if(path.isAbsolute(outputDirectoryFromAttribute)) { 39 | return { 40 | outputDir: path.join(projectOutputDirectory, outputDirectoryFromAttribute), 41 | urlPath: outputDirectoryFromAttribute, 42 | }; 43 | } 44 | return { 45 | outputDir: path.join(projectOutputDirectory, pageContext.url, outputDirectoryFromAttribute), 46 | urlPath: path.join(pageContext.url, outputDirectoryFromAttribute), 47 | }; 48 | } 49 | 50 | if(options.urlPath) { 51 | // do nothing, user has specified directories in the plugin options. 52 | return {}; 53 | } 54 | 55 | if(path.isAbsolute(originalSource)) { 56 | // if the path is an absolute one (relative to the content directory) write to a global output directory to avoid duplicate writes for identical source images. 57 | return { 58 | outputDir: path.join(projectOutputDirectory, "/img/"), 59 | urlPath: "/img/", 60 | }; 61 | } 62 | 63 | // If original source is a relative one, this colocates images to the template output. 64 | let dir = path.dirname(pageContext.outputPath); 65 | 66 | // filename is included in url: ./dir/post.html => /dir/post.html 67 | if(pageContext.outputPath.endsWith(pageContext.url)) { 68 | // remove file name 69 | let split = pageContext.url.split("/"); 70 | split[split.length - 1] = ""; 71 | 72 | return { 73 | outputDir: dir, 74 | urlPath: split.join("/"), 75 | }; 76 | } 77 | 78 | // filename is not included in url: ./dir/post/index.html => /dir/post/ 79 | return { 80 | outputDir: dir, 81 | urlPath: pageContext.url, 82 | }; 83 | } 84 | 85 | function transformTag(context, sourceNode, rootTargetNode, opts) { 86 | let originalSource = getSrcAttributeValue(sourceNode, rootTargetNode); 87 | 88 | if(!originalSource) { 89 | return sourceNode; 90 | } 91 | 92 | let { inputPath } = context.page; 93 | 94 | sourceNode.attrs.src = Util.normalizeImageSource({ 95 | input: opts.directories.input, 96 | inputPath, 97 | }, originalSource, { 98 | isViaHtml: true, // this reference came from HTML, so we can decode the file name 99 | }); 100 | 101 | if(sourceNode.attrs.src !== originalSource) { 102 | sourceNode.attrs[ATTRS.ORIGINAL_SOURCE] = originalSource; 103 | } 104 | 105 | let outputDirectoryFromAttribute = getOutputDirectory(sourceNode); 106 | let instanceOptions = getOutputLocations(originalSource, outputDirectoryFromAttribute, context.page, opts); 107 | 108 | // returns promise 109 | return imageAttributesToPosthtmlNode(sourceNode.attrs, instanceOptions, opts).then(newNode => { 110 | // node.tag 111 | // node.attrs 112 | // node.content 113 | 114 | assignAttributes(rootTargetNode, newNode); 115 | 116 | rootTargetNode.tag = newNode.tag; 117 | rootTargetNode.content = newNode.content; 118 | }, (error) => { 119 | if(isOptional(sourceNode) || !opts.failOnError) { 120 | if(isOptional(sourceNode, "keep")) { 121 | // replace with the original source value, no image transformation is taking place 122 | if(sourceNode.attrs[ATTRS.ORIGINAL_SOURCE]) { 123 | sourceNode.attrs.src = sourceNode.attrs[ATTRS.ORIGINAL_SOURCE]; 124 | } 125 | // leave as-is, likely 404 when a user visits the page 126 | } else if(isOptional(sourceNode, "placeholder")) { 127 | // transparent png 128 | sourceNode.attrs.src = PLACEHOLDER_DATA_URI; 129 | } else if(isOptional(sourceNode)) { 130 | delete sourceNode.attrs.src; 131 | } 132 | 133 | // optional or don’t fail on error 134 | cleanTag(sourceNode); 135 | 136 | return Promise.resolve(); 137 | } 138 | 139 | return Promise.reject(error); 140 | }); 141 | } 142 | 143 | export function eleventyImageTransformPlugin(eleventyConfig, options = {}) { 144 | options = Object.assign({ 145 | extensions: "html", 146 | transformOnRequest: process.env.ELEVENTY_RUN_MODE === "serve", 147 | }, options); 148 | 149 | if(options.transformOnRequest !== false) { 150 | // Add the on-request plugin automatically (unless opt-out in this plugins options only) 151 | eleventyConfig.addPlugin(eleventyImageOnRequestDuringServePlugin); 152 | } 153 | 154 | let opts = getGlobalOptions(eleventyConfig, options, "transform"); 155 | 156 | eleventyConfig.addJavaScriptFunction("__private_eleventyImageTransformConfigurationOptions", () => { 157 | return opts; 158 | }); 159 | 160 | function posthtmlPlugin(context) { 161 | return async (tree) => { 162 | let promises = []; 163 | let match = tree.match; 164 | 165 | tree.match({ tag: 'picture' }, pictureNode => { 166 | match.call(pictureNode, { tag: 'img' }, imgNode => { 167 | imgNode._insideOfPicture = true; 168 | 169 | if(!isIgnored(imgNode) && !imgNode?.attrs?.src?.startsWith("data:")) { 170 | promises.push(transformTag(context, imgNode, pictureNode, opts)); 171 | } 172 | 173 | return imgNode; 174 | }); 175 | 176 | return pictureNode; 177 | }); 178 | 179 | tree.match({ tag: 'img' }, (imgNode) => { 180 | if(imgNode._insideOfPicture) { 181 | delete imgNode._insideOfPicture; 182 | } else if(isIgnored(imgNode) || imgNode?.attrs?.src?.startsWith("data:")) { 183 | cleanTag(imgNode); 184 | } else { 185 | promises.push(transformTag(context, imgNode, imgNode, opts)); 186 | } 187 | 188 | return imgNode; 189 | }); 190 | 191 | await Promise.all(promises); 192 | 193 | return tree; 194 | }; 195 | } 196 | 197 | if(!eleventyConfig.htmlTransformer || !("addPosthtmlPlugin" in eleventyConfig.htmlTransformer)) { 198 | throw new Error("[@11ty/eleventy-img] `eleventyImageTransformPlugin` is not compatible with this version of Eleventy. You will need to use v3.0.0 or newer."); 199 | } 200 | 201 | eleventyConfig.htmlTransformer.addPosthtmlPlugin(options.extensions, posthtmlPlugin, { 202 | priority: -1, // we want this to go before or inputpath to url 203 | }); 204 | } 205 | -------------------------------------------------------------------------------- /src/generate-html.js: -------------------------------------------------------------------------------- 1 | import { escapeAttribute } from "entities"; 2 | 3 | const LOWSRC_FORMAT_PREFERENCE = ["jpeg", "png", "gif", "svg", "webp", "avif"]; 4 | 5 | const CHILDREN_OBJECT_KEY = "@children"; 6 | 7 | function generateSrcset(metadataFormatEntry) { 8 | if(!Array.isArray(metadataFormatEntry)) { 9 | return ""; 10 | } 11 | 12 | return metadataFormatEntry.map(entry => entry.srcset).join(", "); 13 | } 14 | 15 | /* 16 | Returns: 17 | e.g. { img: { alt: "", src: "" } 18 | e.g. { img: { alt: "", src: "", srcset: "", sizes: "" } } 19 | e.g. { picture: { 20 | class: "", 21 | @children: [ 22 | { source: { srcset: "", sizes: "" } }, 23 | { source: { srcset: "", sizes: "" } }, 24 | { img: { alt: "", src: "", srcset: "", sizes: "" } }, 25 | ] 26 | } 27 | */ 28 | export function generateObject(metadata, userDefinedImgAttributes = {}, userDefinedPictureAttributes = {}, options = {}) { 29 | let htmlOptions = options?.htmlOptions || {}; 30 | let imgAttributes = Object.assign({}, options?.defaultAttributes, htmlOptions?.imgAttributes, userDefinedImgAttributes); 31 | let pictureAttributes = Object.assign({}, htmlOptions?.pictureAttributes, userDefinedPictureAttributes); 32 | 33 | // The attributes.src gets overwritten later on. Save it here to make the error outputs less cryptic. 34 | let originalSrc = imgAttributes.src; 35 | 36 | if(imgAttributes.alt === undefined) { 37 | // You bet we throw an error on missing alt (alt="" works okay) 38 | throw new Error(`Missing \`alt\` attribute on eleventy-img shortcode from: ${originalSrc}`); 39 | } 40 | 41 | let formats = Object.keys(metadata); 42 | let values = Object.values(metadata); 43 | let entryCount = 0; 44 | for(let imageFormat of values) { 45 | entryCount += imageFormat.length; 46 | } 47 | 48 | if(entryCount === 0) { 49 | throw new Error("No image results found from `eleventy-img` in generateHTML. Expects a results object similar to: https://www.11ty.dev/docs/plugins/image/#usage."); 50 | } 51 | 52 | let lowsrc; 53 | let lowsrcFormat; 54 | for(let format of LOWSRC_FORMAT_PREFERENCE) { 55 | if((format in metadata) && metadata[format].length) { 56 | lowsrcFormat = format; 57 | lowsrc = metadata[lowsrcFormat]; 58 | break; 59 | } 60 | } 61 | 62 | // Handle if empty intersection between format and LOWSRC_FORMAT_PREFERENCE (e.g. gif) 63 | // If there’s only one format in the results, use that 64 | if(!lowsrc && formats.length === 1) { 65 | lowsrcFormat = formats[0]; 66 | lowsrc = metadata[lowsrcFormat]; 67 | } 68 | 69 | if(!lowsrc || !lowsrc.length) { 70 | throw new Error(`Could not find the lowest source for responsive markup for ${originalSrc}`); 71 | } 72 | 73 | imgAttributes.src = lowsrc[0].url; 74 | 75 | if(htmlOptions.fallback === "largest" || htmlOptions.fallback === undefined) { 76 | imgAttributes.width = lowsrc[lowsrc.length - 1].width; 77 | imgAttributes.height = lowsrc[lowsrc.length - 1].height; 78 | } else if(htmlOptions.fallback === "smallest") { 79 | imgAttributes.width = lowsrc[0].width; 80 | imgAttributes.height = lowsrc[0].height; 81 | } else { 82 | throw new Error("Invalid `fallback` option specified. 'largest' and 'smallest' are supported. Received: " + htmlOptions.fallback); 83 | } 84 | 85 | let imgAttributesWithoutSizes = Object.assign({}, imgAttributes); 86 | delete imgAttributesWithoutSizes.sizes; 87 | 88 | // : one format and one size 89 | if(entryCount === 1) { 90 | return { 91 | img: imgAttributesWithoutSizes 92 | }; 93 | } 94 | 95 | // Per the HTML specification sizes is required srcset is using the `w` unit 96 | // https://html.spec.whatwg.org/dev/semantics.html#the-link-element:attr-link-imagesrcset-4 97 | // Using the default "100vw" is okay 98 | let missingSizesErrorMessage = `Missing \`sizes\` attribute on eleventy-img shortcode from: ${originalSrc}. Workarounds: 1. Use a single output width for this image 2. Use \`loading="lazy"\` (which uses sizes="auto" though browser support currently varies)`; 99 | 100 | // : one format and multiple sizes 101 | if(formats.length === 1) { // implied entryCount > 1 102 | if(entryCount > 1 && !imgAttributes.sizes) { 103 | // Use `sizes="auto"` when using `loading="lazy"` instead of throwing an error. 104 | if(imgAttributes.loading === "lazy") { 105 | imgAttributes.sizes = "auto"; 106 | } else { 107 | throw new Error(missingSizesErrorMessage); 108 | } 109 | } 110 | 111 | let imgAttributesCopy = Object.assign({}, imgAttributesWithoutSizes); 112 | imgAttributesCopy.srcset = generateSrcset(lowsrc); 113 | imgAttributesCopy.sizes = imgAttributes.sizes; 114 | 115 | return { 116 | img: imgAttributesCopy 117 | }; 118 | } 119 | 120 | let children = []; 121 | values.filter(imageFormat => { 122 | return imageFormat.length > 0 && (lowsrcFormat !== imageFormat[0].format); 123 | }).forEach(imageFormat => { 124 | if(imageFormat.length > 1 && !imgAttributes.sizes) { 125 | if(imgAttributes.loading === "lazy") { 126 | imgAttributes.sizes = "auto"; 127 | } else { 128 | throw new Error(missingSizesErrorMessage); 129 | } 130 | } 131 | 132 | let sourceAttrs = { 133 | type: imageFormat[0].sourceType, 134 | srcset: generateSrcset(imageFormat), 135 | }; 136 | 137 | if(imgAttributes.sizes) { 138 | sourceAttrs.sizes = imgAttributes.sizes; 139 | } 140 | 141 | children.push({ 142 | "source": sourceAttrs 143 | }); 144 | }); 145 | 146 | /* 147 | Add lowsrc as an img, for browsers that don’t support picture or the formats provided in source 148 | 149 | If we have more than one size, we can use srcset and sizes. 150 | If the browser doesn't support those attributes, it should ignore them. 151 | */ 152 | let imgAttributesForPicture = Object.assign({}, imgAttributesWithoutSizes); 153 | if (lowsrc.length > 1) { 154 | if (!imgAttributes.sizes) { 155 | // Per the HTML specification sizes is required srcset is using the `w` unit 156 | // https://html.spec.whatwg.org/dev/semantics.html#the-link-element:attr-link-imagesrcset-4 157 | // Using the default "100vw" is okay 158 | throw new Error(missingSizesErrorMessage); 159 | } 160 | 161 | imgAttributesForPicture.srcset = generateSrcset(lowsrc); 162 | imgAttributesForPicture.sizes = imgAttributes.sizes; 163 | } 164 | 165 | children.push({ 166 | "img": imgAttributesForPicture 167 | }); 168 | 169 | return { 170 | "picture": { 171 | ...pictureAttributes, 172 | [CHILDREN_OBJECT_KEY]: children, 173 | } 174 | }; 175 | } 176 | 177 | function mapObjectToHTML(tagName, attrs = {}) { 178 | let attrHtml = Object.entries(attrs).map(entry => { 179 | let [key, value] = entry; 180 | if(key === CHILDREN_OBJECT_KEY) { 181 | return false; 182 | } 183 | 184 | // Issue #82 185 | if(key === "alt") { 186 | return `${key}="${value ? escapeAttribute(value) : ""}"`; 187 | } 188 | 189 | return `${key}="${value}"`; 190 | }).filter(keyPair => Boolean(keyPair)).join(" "); 191 | 192 | return `<${tagName}${attrHtml ? ` ${attrHtml}` : ""}>`; 193 | } 194 | 195 | export function generateHTML(metadata, attributes = {}, htmlOptionsOverride = {}) { 196 | let htmlOptions = Object.assign({}, metadata?.eleventyImage?.htmlOptions, htmlOptionsOverride); 197 | 198 | let isInline = htmlOptions.whitespaceMode !== "block"; 199 | let markup = []; 200 | 201 | // htmlOptions.imgAttributes and htmlOptions.pictureAttributes are merged in generateObject 202 | let obj = generateObject(metadata, attributes, {}, { htmlOptions }); 203 | for(let tag in obj) { 204 | markup.push(mapObjectToHTML(tag, obj[tag])); 205 | 206 | // 207 | if(Array.isArray(obj[tag]?.[CHILDREN_OBJECT_KEY])) { 208 | for(let child of obj[tag][CHILDREN_OBJECT_KEY]) { 209 | let childTagName = Object.keys(child)[0]; 210 | markup.push((!isInline ? " " : "") + mapObjectToHTML(childTagName, child[childTagName])); 211 | } 212 | 213 | markup.push(``); 214 | } 215 | } 216 | return markup.join(!isInline ? "\n" : ""); 217 | } 218 | -------------------------------------------------------------------------------- /test/test-markup.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import eleventyImage from "../img.js"; 3 | import { generateHTML, generateObject } from "../src/generate-html.js"; 4 | 5 | test("Image markup (defaults)", async t => { 6 | let results = await eleventyImage("./test/bio-2017.jpg", { 7 | dryRun: true 8 | }); 9 | 10 | t.is(generateHTML(results, { 11 | alt: "" 12 | }), ``); 13 | }); 14 | 15 | test("Image file with diacritics #253", async t => { 16 | let results = await eleventyImage("./test/les sous titres automatisés de youtube.jpg", { 17 | dryRun: true 18 | }); 19 | 20 | t.is(generateHTML(results, { 21 | alt: "" 22 | }), ``); 23 | }); 24 | 25 | test("Image service", async t => { 26 | let serviceApiDomain = "https://zachleat.com"; 27 | let siteUrl = "https://heydonworks.com/"; 28 | let screenshotUrl = `${serviceApiDomain}/api/screenshot/?url=${encodeURIComponent(siteUrl)}&js=false`; 29 | 30 | let options = { 31 | formats: ["jpeg"], 32 | widths: [600], // 260-440 in layout 33 | urlFormat: function({ width, format }) { 34 | return `${serviceApiDomain}/api/image/?url=${encodeURIComponent(screenshotUrl)}&width=${width}&format=${format}`; 35 | }, 36 | remoteAssetContent: 'remote asset content' 37 | }; 38 | 39 | let results = eleventyImage.statsByDimensionsSync(screenshotUrl, 1440, 900, options); 40 | 41 | t.is(generateHTML(results, { 42 | alt: "", 43 | }), ``); 44 | }); 45 | 46 | test("Image object (defaults)", async t => { 47 | let results = await eleventyImage("./test/bio-2017.jpg", { 48 | dryRun: true 49 | }); 50 | 51 | t.deepEqual(generateObject(results, { 52 | alt: "" 53 | }), { 54 | "picture": { 55 | "@children": [ 56 | { 57 | "source": { 58 | type: "image/webp", 59 | srcset: "/img/KkPMmHd3hP-1280.webp 1280w", 60 | } 61 | }, 62 | { 63 | "img": { 64 | alt: "", 65 | src: "/img/KkPMmHd3hP-1280.jpeg", 66 | width: 1280, 67 | height: 853, 68 | } 69 | } 70 | ] 71 | } 72 | }); 73 | }); 74 | 75 | test("Image markup (two widths)", async t => { 76 | let results = await eleventyImage("./test/bio-2017.jpg", { 77 | dryRun: true, 78 | widths: [200,400] 79 | }); 80 | 81 | t.is(generateHTML(results, { 82 | alt: "", 83 | sizes: "100vw", 84 | }), [``, 85 | ``, 86 | ``, 87 | ``].join("")); 88 | }); 89 | 90 | test("Image markup (two widths, no sizes—throws an error)", async t => { 91 | let results = await eleventyImage("./test/bio-2017.jpg", { 92 | dryRun: true, 93 | widths: [200, 400] 94 | }); 95 | 96 | t.throws(() => generateHTML(results, { 97 | alt: "", 98 | })); 99 | }); 100 | 101 | test("Image markup (two formats)", async t => { 102 | let results = await eleventyImage("./test/bio-2017.jpg", { 103 | dryRun: true, 104 | formats: ["avif", "webp"], 105 | }); 106 | 107 | t.is(generateHTML(results, { 108 | alt: "" 109 | }), ``); 110 | }); 111 | 112 | test("Image markup (one format)", async t => { 113 | let results = await eleventyImage("./test/bio-2017.jpg", { 114 | dryRun: true, 115 | formats: [null], 116 | }); 117 | 118 | t.is(generateHTML(results, { 119 | alt: "", 120 | sizes: "100vw" 121 | }), ``); 122 | }); 123 | 124 | test("Image markup (auto format)", async t => { 125 | let results = await eleventyImage("./test/bio-2017.jpg", { 126 | dryRun: true, 127 | formats: ["auto"], 128 | }); 129 | 130 | t.is(generateHTML(results, { 131 | alt: "", 132 | sizes: "100vw" 133 | }), ``); 134 | }); 135 | 136 | test("Image markup (one format, two widths)", async t => { 137 | let results = await eleventyImage("./test/bio-2017.jpg", { 138 | dryRun: true, 139 | formats: [null], 140 | widths: [100,200], 141 | }); 142 | 143 | t.is(generateHTML(results, { 144 | alt: "", 145 | sizes: "100vw" 146 | }), ``); 147 | }); 148 | 149 | test("Image markup (throws on invalid object)", async t => { 150 | t.throws(() => generateHTML({}, { alt: "" })); 151 | t.throws(() => generateHTML({ jpeg: [] }, { alt: "" })); 152 | t.throws(() => generateHTML({ webp: [], avif: [] }, { alt: "" })); 153 | t.notThrows(() => generateHTML({ jpeg: [{}] }, { alt: "" })); 154 | }); 155 | 156 | test("Image markup (throws on missing alt)", async t => { 157 | let results = await eleventyImage("./test/bio-2017.jpg", { 158 | dryRun: true 159 | }); 160 | 161 | t.throws(() => generateHTML(results, { 162 | src: "./test/bio-2017.jpg" 163 | }), { 164 | message: "Missing `alt` attribute on eleventy-img shortcode from: ./test/bio-2017.jpg" 165 | }); 166 | }); 167 | 168 | test("Image markup (throws on missing alt return html)", async t => { 169 | await t.throwsAsync(() => eleventyImage("./test/bio-2017.jpg", { 170 | dryRun: true, 171 | returnType: "html" 172 | }), { 173 | message: "Missing `alt` attribute on eleventy-img shortcode from: ./test/bio-2017.jpg" 174 | }); 175 | }); 176 | 177 | test("Image markup (throws on missing sizes return html)", async t => { 178 | await t.throwsAsync(() => eleventyImage("./test/bio-2017.jpg", { 179 | dryRun: true, 180 | widths: [100,200], 181 | returnType: "html", 182 | htmlOptions: { 183 | imgAttributes: { 184 | alt: "", 185 | } 186 | } 187 | }), { 188 | message: 'Missing `sizes` attribute on eleventy-img shortcode from: ./test/bio-2017.jpg. Workarounds: 1. Use a single output width for this image 2. Use `loading="lazy"` (which uses sizes="auto" though browser support currently varies)' 189 | }); 190 | }); 191 | 192 | test("#207 Uses sizes=auto as fallback when loading=lazy to avoid error message", async t => { 193 | let html = await eleventyImage("./test/bio-2017.jpg", { 194 | dryRun: true, 195 | widths: [100,200], 196 | returnType: "html", 197 | htmlOptions: { 198 | imgAttributes: { 199 | alt: "", 200 | loading: "lazy" 201 | } 202 | } 203 | }); 204 | 205 | t.is(html, ''); 206 | }); 207 | 208 | test("Image markup (defaults, inlined)", async t => { 209 | let results = await eleventyImage("./test/bio-2017.jpg", { 210 | dryRun: true 211 | }); 212 | 213 | t.is(generateHTML(results, { 214 | alt: "" 215 | }, { 216 | whitespaceMode: "block" 217 | }), ` 218 | 219 | 220 | `); 221 | }); 222 | 223 | test("svgShortCircuit and generateHTML: Issue #48", async t => { 224 | // let img = new eleventyImage.Image("./test/Ghostscript_Tiger.svg", { 225 | // formats: ["webp", "png", "svg"], 226 | // svgShortCircuit: true, 227 | // dryRun: true, 228 | // }); 229 | // let svgStats = eleventyImage.ImageStat.getStat("./test/Ghostscript_Tiger.svg", "svg", "/img/", 900, 900, img.options); 230 | // console.log( svgStats ); 231 | 232 | let stats = await eleventyImage("./test/Ghostscript_Tiger.svg", { 233 | formats: ["webp", "png", "svg"], 234 | svgShortCircuit: true, 235 | dryRun: true, 236 | }); 237 | t.is(stats.svg.length, 1); 238 | t.is(stats.webp, undefined); 239 | t.is(stats.png, undefined); 240 | t.is(stats.svg[0].url, "/img/wGeeKEWkof-900.svg"); 241 | 242 | let html = eleventyImage.generateHTML(stats, { 243 | alt: "Tiger", 244 | }); 245 | t.is(html, `Tiger`); 246 | }); 247 | 248 | test("svgShortCircuit (on a raster source) #242 generateHTML function", async t => { 249 | let stats = await eleventyImage("./test/bio-2017.jpg", { 250 | widths: ["auto"], 251 | formats: ["svg", "png"], 252 | svgShortCircuit: true, 253 | useCache: false, 254 | dryRun: true, 255 | }); 256 | 257 | let html = eleventyImage.generateHTML(stats, { 258 | alt: "Zach’s ugly mug", 259 | }); 260 | t.is(html, `Zach’s ugly mug`); 261 | }); 262 | 263 | test("Filter out empty format arrays", async t => { 264 | let stats = { 265 | svg: [], 266 | jpeg: [ 267 | { 268 | format: 'jpeg', 269 | width: 164, 270 | height: 164, 271 | filename: '78c26ccd-164.jpeg', 272 | outputPath: '_site/v3/img/build/78c26ccd-164.jpeg', 273 | url: '/v3/img/build/78c26ccd-164.jpeg', 274 | sourceType: 'image/jpeg', 275 | srcset: '/v3/img/build/78c26ccd-164.jpeg 164w' 276 | }, 277 | { 278 | format: 'jpeg', 279 | width: 328, 280 | height: 328, 281 | filename: '78c26ccd-328.jpeg', 282 | outputPath: '_site/v3/img/build/78c26ccd-328.jpeg', 283 | url: '/v3/img/build/78c26ccd-328.jpeg', 284 | sourceType: 'image/jpeg', 285 | srcset: '/v3/img/build/78c26ccd-328.jpeg 328w' 286 | } 287 | ] 288 | }; 289 | 290 | let html = eleventyImage.generateHTML(stats, { 291 | alt: "Tiger", 292 | sizes: "100vw", 293 | }); 294 | t.truthy(!!html); 295 | }); 296 | 297 | test("Image markup (animated gif)", async t => { 298 | let results = await eleventyImage("./test/earth-animated.gif", { 299 | dryRun: true, 300 | formats: ["auto"] 301 | }); 302 | 303 | t.is(generateHTML(results, { 304 | alt: "" 305 | }), ``); 306 | }); 307 | 308 | test("Image markup (animated gif, two formats)", async t => { 309 | let results = await eleventyImage("./test/earth-animated.gif", { 310 | dryRun: true, 311 | formats: ["webp", "auto"] 312 | }); 313 | 314 | t.is(generateHTML(results, { 315 | alt: "" 316 | }), ``); 317 | }); 318 | 319 | test("Image markup (two formats, neither priority defined)", async t => { 320 | let results = await eleventyImage("./test/earth-animated.gif", { 321 | dryRun: true, 322 | formats: ["tif", "heic"] 323 | }); 324 | 325 | let e = t.throws(() => generateHTML(results, { alt: "" })); 326 | t.true(e.message.startsWith("Could not find the lowest ")); 327 | }); 328 | 329 | test("Image markup (escaped `alt`)", async t => { 330 | let results = await eleventyImage("./test/bio-2017.jpg", { 331 | formats: ["auto"], 332 | dryRun: true, 333 | }); 334 | 335 | t.is(generateHTML(results, { 336 | alt: "This is a \"test" 337 | }), `This is a "test`); 338 | }); 339 | 340 | test("Image markup ( with attributes issue #197)", async t => { 341 | let results = await eleventyImage("./test/bio-2017.jpg", { 342 | formats: ["webp", "auto"], 343 | dryRun: true, 344 | widths: [200,400] 345 | }); 346 | 347 | t.is(generateHTML(results, { 348 | alt: "", 349 | sizes: "100vw", 350 | }, { 351 | pictureAttributes: { 352 | class: "pic" 353 | } 354 | }), [``, 355 | ``, 356 | ``, 357 | ``].join("")); 358 | }); 359 | 360 | test("Issue #177", t => { 361 | let src = "https://www.zachleat.com/img/avatar-2017.png?q=1"; 362 | 363 | const options = { 364 | widths: [700, 1200, 2000], 365 | formats: ['avif', 'jpeg'], 366 | outputDir: './_site/img/', 367 | urlPath: '/img/', 368 | cacheOptions: { 369 | duration: '1d', 370 | }, 371 | }; 372 | 373 | let metadata = eleventyImage.statsByDimensionsSync(src, 160, 160, options); 374 | 375 | const imageAttributes = { 376 | alt: "", 377 | sizes: '(max-width: 0px) 100vw', 378 | loading: 'lazy', 379 | decoding: 'async', 380 | fetchPriority: 'high', 381 | class: 'w-full h-full object-cover', 382 | }; 383 | 384 | t.is(eleventyImage.generateHTML(metadata, imageAttributes), ``); 385 | }); 386 | 387 | test("Image markup with smallest fallback dimensions", async t => { 388 | let results = await eleventyImage("./test/bio-2017.jpg", { 389 | dryRun: true, 390 | widths: [300, "auto"], 391 | formats: ["auto"], 392 | htmlOptions: { 393 | fallback: "smallest", 394 | }, 395 | }); 396 | 397 | t.is(generateHTML(results, { 398 | alt: "", 399 | sizes: "100vw" 400 | }), ``); 401 | }); 402 | 403 | test("returnType: html to ", async t => { 404 | let html = await eleventyImage("./test/bio-2017.jpg", { 405 | dryRun: true, 406 | formats: ["auto"], 407 | returnType: "html", 408 | 409 | // passed to generateHTML 410 | htmlOptions: { 411 | imgAttributes: { 412 | alt: "", 413 | }, 414 | }, 415 | }); 416 | 417 | t.is(html, ``); 418 | }); 419 | 420 | test("returnType: html to ", async t => { 421 | let html = await eleventyImage("./test/bio-2017.jpg", { 422 | dryRun: true, 423 | returnType: "html", 424 | 425 | // passed to generateHTML 426 | htmlOptions: { 427 | imgAttributes: { 428 | alt: "", 429 | class: "inner", 430 | }, 431 | pictureAttributes: { 432 | class: "outer" 433 | } 434 | }, 435 | }); 436 | 437 | t.is(html, ``); 438 | }); 439 | 440 | test("#239 full urls in urlPath", async t => { 441 | let html = await eleventyImage("./test/bio-2017.jpg", { 442 | dryRun: true, 443 | formats: ["auto"], 444 | returnType: "html", 445 | urlPath: "http://example.com/img/", 446 | 447 | htmlOptions: { 448 | imgAttributes: { 449 | alt: "", 450 | }, 451 | }, 452 | }); 453 | 454 | t.is(html, ``); 455 | }); 456 | -------------------------------------------------------------------------------- /test/transform-test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import Eleventy from "@11ty/eleventy"; 3 | import { eleventyImageTransformPlugin } from "../img.js"; 4 | import { normalizeEscapedPaths } from "./util/utils.js"; 5 | 6 | test("Using the transform plugin", async t => { 7 | let elev = new Eleventy( "test", "test/_site", { 8 | config: eleventyConfig => { 9 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 10 | 11 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 12 | dryRun: true // don’t write image files! 13 | }); 14 | } 15 | }); 16 | elev.disableLogger(); 17 | 18 | let results = await elev.toJSON(); 19 | t.is(results[0].content, `My ugly mug`); 20 | }); 21 | 22 | test("Using the transform plugin, data URI #238", async t => { 23 | let elev = new Eleventy( "test", "test/_site", { 24 | config: eleventyConfig => { 25 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 26 | 27 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 28 | dryRun: true // don’t write image files! 29 | }); 30 | } 31 | }); 32 | 33 | let results = await elev.toJSON(); 34 | t.is(results[0].content, `My ugly mug`); 35 | }); 36 | 37 | test("Using the transform plugin (override options)", async t => { 38 | let elev = new Eleventy( "test", "test/_site", { 39 | config: eleventyConfig => { 40 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 41 | 42 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 43 | formats: ["auto"], 44 | dryRun: true // don’t write image files! 45 | }); 46 | } 47 | }); 48 | elev.disableLogger(); 49 | 50 | let results = await elev.toJSON(); 51 | t.is(results[0].content, `My ugly mug`); 52 | }); 53 | 54 | test("Using the transform plugin with transform on request during dev mode", async t => { 55 | let elev = new Eleventy( "test", "test/_site", { 56 | config: eleventyConfig => { 57 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 58 | 59 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 60 | formats: ["auto"], 61 | transformOnRequest: true, 62 | dryRun: true, // don’t write image files! 63 | 64 | defaultAttributes: {} 65 | }); 66 | } 67 | }); 68 | elev.disableLogger(); 69 | 70 | let results = await elev.toJSON(); 71 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 72 | }); 73 | 74 | test("Using the transform plugin with transform on request during dev mode (with default attributes)", async t => { 75 | let elev = new Eleventy( "test", "test/_site", { 76 | config: eleventyConfig => { 77 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 78 | 79 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 80 | formats: ["auto"], 81 | transformOnRequest: true, 82 | dryRun: true, // don’t write image files! 83 | 84 | defaultAttributes: { 85 | loading: "lazy", 86 | } 87 | }); 88 | } 89 | }); 90 | 91 | let results = await elev.toJSON(); 92 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 93 | }); 94 | 95 | 96 | test("Using the transform plugin with transform on request during dev mode but don’t override existing urlFormat", async t => { 97 | let elev = new Eleventy( "test", "test/_site", { 98 | config: eleventyConfig => { 99 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 100 | 101 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 102 | urlFormat: function() { 103 | return 'https://example.com/'; 104 | }, 105 | formats: ["auto"], 106 | transformOnRequest: true, 107 | dryRun: true, // don’t write image files! 108 | 109 | defaultAttributes: { 110 | loading: "lazy", 111 | } 112 | }); 113 | } 114 | }); 115 | 116 | let results = await elev.toJSON(); 117 | t.is(results[0].content, `My ugly mug`); 118 | }); 119 | 120 | test("Throw a good error with a bad remote image request", async t => { 121 | let elev = new Eleventy( "test", "test/_site", { 122 | config: eleventyConfig => { 123 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 124 | 125 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 126 | formats: ["auto"], 127 | // transformOnRequest: true, 128 | // dryRun: true, // don’t write image files! 129 | 130 | defaultAttributes: { 131 | loading: "lazy", 132 | } 133 | }); 134 | } 135 | }); 136 | elev.disableLogger(); 137 | 138 | let e = await t.throwsAsync(() => elev.toJSON()); 139 | t.is(e.message, `Having trouble writing to "./test/_site/virtual/index.html" from "./test/virtual.html"`); 140 | }); 141 | 142 | test("Transform image file with diacritics #253", async t => { 143 | let elev = new Eleventy( "test", "test/_site", { 144 | config: eleventyConfig => { 145 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 146 | 147 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 148 | formats: ["auto"], 149 | dryRun: true, // don’t write image files! 150 | 151 | defaultAttributes: {} 152 | }); 153 | } 154 | }); 155 | elev.disableLogger(); 156 | 157 | let results = await elev.toJSON(); 158 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 159 | }); 160 | 161 | test("Transform image file in folder with diacritics #253", async t => { 162 | let elev = new Eleventy( "test", "test/_site", { 163 | config: eleventyConfig => { 164 | // Broken: 20240705.île-de-myst-en-lego 165 | // Working: 20240705.île-de-myst-en-lego 166 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 167 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 168 | formats: ["auto"], 169 | dryRun: true, // don’t write image files! 170 | defaultAttributes: {} 171 | }); 172 | } 173 | }); 174 | 175 | elev.disableLogger(); 176 | 177 | let results = await elev.toJSON(); 178 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 179 | }); 180 | 181 | test("Transform image file in markdown with diacritics #253", async t => { 182 | let elev = new Eleventy( "test", "test/_site", { 183 | config: eleventyConfig => { 184 | eleventyConfig.addTemplate("virtual.md", `![My ugly mug](./automatisés.jpg)`); 185 | 186 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 187 | formats: ["auto"], 188 | dryRun: true, // don’t write image files! 189 | defaultAttributes: {}, 190 | }); 191 | } 192 | }); 193 | elev.disableLogger(); 194 | 195 | let results = await elev.toJSON(); 196 | t.is(normalizeEscapedPaths(results[0].content).trim(), `

My ugly mug

`); 197 | }); 198 | 199 | // Doesn’t work on Ubuntu 200 | test.skip("Transform image file in folder with *combining* diacritics #253", async t => { 201 | let elev = new Eleventy( "test", "test/_site", { 202 | config: eleventyConfig => { 203 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 204 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 205 | formats: ["auto"], 206 | dryRun: true, // don’t write image files! 207 | defaultAttributes: {} 208 | }); 209 | } 210 | }); 211 | 212 | let results = await elev.toJSON(); 213 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 214 | }); 215 | 216 | test("Don’t throw an error when failOnError: false with a bad remote image request", async t => { 217 | let elev = new Eleventy( "test", "test/_site", { 218 | config: eleventyConfig => { 219 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 220 | 221 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 222 | formats: ["auto"], 223 | // transformOnRequest: true, 224 | // dryRun: true, // don’t write image files! 225 | 226 | failOnError: false, 227 | 228 | defaultAttributes: { 229 | loading: "lazy", 230 | } 231 | }); 232 | } 233 | }); 234 | elev.disableLogger(); 235 | 236 | let results = await elev.toJSON(); 237 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 238 | }); 239 | 240 | test("Don’t throw an error when failOnError: true but `eleventy:optional=keep` attribute with a bad remote image request", async t => { 241 | let elev = new Eleventy( "test", "test/_site", { 242 | config: eleventyConfig => { 243 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 244 | 245 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 246 | formats: ["auto"], 247 | // transformOnRequest: true, 248 | // dryRun: true, // don’t write image files! 249 | 250 | failOnError: true, 251 | 252 | defaultAttributes: { 253 | loading: "lazy", 254 | } 255 | }); 256 | } 257 | }); 258 | elev.disableLogger(); 259 | 260 | let results = await elev.toJSON(); 261 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 262 | }); 263 | 264 | test("Don’t throw an error when failOnError: false and `eleventy:optional=keep` attribute with a bad remote image request", async t => { 265 | let elev = new Eleventy( "test", "test/_site", { 266 | config: eleventyConfig => { 267 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 268 | 269 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 270 | formats: ["auto"], 271 | // transformOnRequest: true, 272 | // dryRun: true, // don’t write image files! 273 | 274 | failOnError: false, 275 | 276 | defaultAttributes: { 277 | loading: "lazy", 278 | } 279 | }); 280 | } 281 | }); 282 | elev.disableLogger(); 283 | 284 | let results = await elev.toJSON(); 285 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 286 | }); 287 | 288 | test("Don’t throw an error when failOnError: false and `eleventy:optional` attribute with a bad remote image request", async t => { 289 | let elev = new Eleventy( "test", "test/_site", { 290 | config: eleventyConfig => { 291 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 292 | 293 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 294 | formats: ["auto"], 295 | 296 | defaultAttributes: { 297 | loading: "lazy", 298 | } 299 | }); 300 | } 301 | }); 302 | elev.disableLogger(); 303 | 304 | let results = await elev.toJSON(); 305 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 306 | }); 307 | 308 | test("Don’t throw an error when failOnError: false and `eleventy:optional=placeholder` attribute with a bad remote image request", async t => { 309 | let elev = new Eleventy( "test", "test/_site", { 310 | config: eleventyConfig => { 311 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 312 | 313 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 314 | formats: ["auto"], 315 | 316 | defaultAttributes: { 317 | loading: "lazy", 318 | } 319 | }); 320 | } 321 | }); 322 | elev.disableLogger(); 323 | 324 | let results = await elev.toJSON(); 325 | t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); 326 | }); 327 | 328 | test("Using the transform plugin, #257", async t => { 329 | let elev = new Eleventy( "test", "test/_site", { 330 | config: eleventyConfig => { 331 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 332 | 333 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 334 | dryRun: true, // don’t write image files! 335 | }); 336 | } 337 | }); 338 | 339 | let results = await elev.toJSON(); 340 | t.is(results[0].content, `My ugly mug`); 341 | }); 342 | 343 | test("Using the transform plugin, to #214", async t => { 344 | let elev = new Eleventy( "test", "test/_site", { 345 | config: eleventyConfig => { 346 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 347 | 348 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 349 | dryRun: true, // don’t write image files! 350 | }); 351 | } 352 | }); 353 | elev.disableLogger(); 354 | 355 | let results = await elev.toJSON(); 356 | t.is(results[0].content, `My ugly mug`); 357 | }); 358 | 359 | test("Using the transform plugin, to #214", async t => { 360 | let elev = new Eleventy( "test", "test/_site", { 361 | config: eleventyConfig => { 362 | // Uses only the right now, see the debatable TODO in transform-plugin.js->getSourcePath 363 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 364 | 365 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 366 | formats: ["auto"], 367 | dryRun: true, // don’t write image files! 368 | }); 369 | } 370 | }); 371 | elev.disableLogger(); 372 | 373 | let results = await elev.toJSON(); 374 | t.is(results[0].content, `My ugly mug`); 375 | }); 376 | 377 | test("Using the transform plugin, to #214", async t => { 378 | let elev = new Eleventy( "test", "test/_site", { 379 | config: eleventyConfig => { 380 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 381 | 382 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 383 | dryRun: true, // don’t write image files! 384 | }); 385 | } 386 | }); 387 | elev.disableLogger(); 388 | 389 | let results = await elev.toJSON(); 390 | t.is(results[0].content, `My ugly mug`); 391 | }); 392 | 393 | test("Using the transform plugin, to , keeps slot attribute #241", async t => { 394 | let elev = new Eleventy( "test", "test/_site", { 395 | config: eleventyConfig => { 396 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 397 | 398 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 399 | formats: ["auto"], 400 | dryRun: true, // don’t write image files! 401 | }); 402 | } 403 | }); 404 | elev.disableLogger(); 405 | 406 | let results = await elev.toJSON(); 407 | t.is(results[0].content, `My ugly mug`); 408 | }); 409 | 410 | test("Using the transform plugin, to , keeps slot attribute #241", async t => { 411 | let elev = new Eleventy( "test", "test/_site", { 412 | config: eleventyConfig => { 413 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 414 | 415 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 416 | dryRun: true, // don’t write image files! 417 | }); 418 | } 419 | }); 420 | elev.disableLogger(); 421 | 422 | let results = await elev.toJSON(); 423 | t.is(results[0].content, `My ugly mug`); 424 | }); 425 | 426 | test("#234 Use existing `width` attribute for `widths` config", async t => { 427 | let elev = new Eleventy( "test", "test/_site", { 428 | config: eleventyConfig => { 429 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 430 | 431 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 432 | dryRun: true, // don’t write image files! 433 | }); 434 | } 435 | }); 436 | elev.disableLogger(); 437 | 438 | let results = await elev.toJSON(); 439 | t.is(results[0].content, `My ugly mug`); 440 | }); 441 | 442 | test("#234 Use existing `width` attribute for `widths` config (huge width uses max intrinsic)", async t => { 443 | let elev = new Eleventy( "test", "test/_site", { 444 | config: eleventyConfig => { 445 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 446 | 447 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 448 | dryRun: true, // don’t write image files! 449 | }); 450 | } 451 | }); 452 | elev.disableLogger(); 453 | 454 | let results = await elev.toJSON(); 455 | t.is(results[0].content, `My ugly mug`); 456 | }); 457 | 458 | test("#234 Use existing `width` attribute for `widths` config (comma separated widths are ignored as invalid. Discourage invalid `width` HTML attribute, use eleventy:widths instead)", async t => { 459 | let elev = new Eleventy( "test", "test/_site", { 460 | config: eleventyConfig => { 461 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 462 | 463 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 464 | dryRun: true, // don’t write image files! 465 | }); 466 | } 467 | }); 468 | elev.disableLogger(); 469 | 470 | let results = await elev.toJSON(); 471 | t.is(results[0].content, `My ugly mug`); 472 | }); 473 | 474 | test("#236 Use with permalink with file name", async t => { 475 | let elev = new Eleventy( "test", "test/_site", { 476 | config: eleventyConfig => { 477 | eleventyConfig.addTemplate("virtual.md", `![alt text](./bio-2017.jpg)`, { 478 | permalink: "blog/posts/blog-post.html" 479 | }); 480 | 481 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 482 | formats: ["auto"], 483 | dryRun: true, // don’t write image files! 484 | }); 485 | } 486 | }); 487 | elev.disableLogger(); 488 | 489 | let results = await elev.toJSON(); 490 | 491 | t.is(results[0].content.trim(), `

alt text

`); 492 | }); 493 | 494 | test("Using imgAttributes/pictureAttributes alongside defaultAttributes (removing this from docs) in transform method", async t => { 495 | let elev = new Eleventy( "test", "test/_site", { 496 | config: eleventyConfig => { 497 | eleventyConfig.addTemplate("virtual.html", `My ugly mug`); 498 | 499 | eleventyConfig.addPlugin(eleventyImageTransformPlugin, { 500 | // formats: ["auto"], 501 | dryRun: true, // don’t write image files! 502 | htmlOptions: { 503 | imgAttributes: { 504 | class: "inner", 505 | }, 506 | pictureAttributes: { 507 | class: "outer", 508 | } 509 | }, 510 | defaultAttributes: { 511 | class: "lol", 512 | } 513 | }); 514 | } 515 | }); 516 | 517 | let results = await elev.toJSON(); 518 | t.is(results[0].content, `My ugly mug`); 519 | }); 520 | -------------------------------------------------------------------------------- /src/image.js: -------------------------------------------------------------------------------- 1 | import fs, { promises as fsp } from "node:fs"; 2 | import path from "node:path"; 3 | import getImageSize from "image-size"; 4 | import debugUtil from "debug"; 5 | 6 | import { createHashSync } from "@11ty/eleventy-utils"; 7 | import { Fetch } from "@11ty/eleventy-fetch"; 8 | 9 | import sharp from "./adapters/sharp.js"; 10 | import brotliSize from "./adapters/brotli-size.js"; 11 | import Util from "./util.js"; 12 | import ImagePath from "./image-path.js"; 13 | import { generateHTML } from "./generate-html.js"; 14 | 15 | import { DEFAULTS as GLOBAL_OPTIONS } from "./global-options.js"; 16 | import { existsCache, memCache, diskCache } from "./caches.js"; 17 | 18 | const debug = debugUtil("Eleventy:Image"); 19 | const debugAssets = debugUtil("Eleventy:Assets"); 20 | 21 | const MIME_TYPES = { 22 | "jpeg": "image/jpeg", 23 | "webp": "image/webp", 24 | "png": "image/png", 25 | "svg": "image/svg+xml", 26 | "avif": "image/avif", 27 | "gif": "image/gif", 28 | }; 29 | 30 | const FORMAT_ALIASES = { 31 | "jpg": "jpeg", 32 | // if you’re working from a mime type input, let’s alias it back to svg 33 | "svg+xml": "svg", 34 | }; 35 | 36 | const ANIMATED_TYPES = [ 37 | "webp", 38 | "gif", 39 | ]; 40 | 41 | const TRANSPARENCY_TYPES = [ 42 | "avif", 43 | "png", 44 | "webp", 45 | "gif", 46 | "svg", 47 | ]; 48 | 49 | const MINIMUM_TRANSPARENCY_TYPES = [ 50 | "png", 51 | "gif", 52 | "svg", 53 | ]; 54 | 55 | export default class Image { 56 | #input; 57 | #contents = {}; 58 | #queue; 59 | #queuePromise; 60 | #buildLogger; 61 | #computedHash; 62 | #directoryManager; 63 | 64 | constructor(src, options = {}) { 65 | if(!src) { 66 | throw new Error("`src` is a required argument to the eleventy-img utility (can be a String file path, String URL, or Buffer)."); 67 | } 68 | 69 | this.src = src; 70 | this.isRemoteUrl = typeof src === "string" && Util.isRemoteUrl(src); 71 | 72 | this.rawOptions = options; 73 | this.options = Object.assign({}, GLOBAL_OPTIONS, options); 74 | 75 | // Compatible with eleventy-dev-server and Eleventy 3.0.0-alpha.7+ in serve mode. 76 | if(this.options.transformOnRequest && !this.options.urlFormat) { 77 | this.options.urlFormat = function({ src, width, format }/*, imageOptions*/, options) { 78 | return `/.11ty/image/?src=${encodeURIComponent(src)}&width=${width}&format=${format}${options.generatedVia ? `&via=${options.generatedVia}` : ""}`; 79 | }; 80 | 81 | this.options.statsOnly = true; 82 | } 83 | 84 | if(this.isRemoteUrl) { 85 | this.cacheOptions = Object.assign({ 86 | type: "buffer", 87 | // deprecated in Eleventy Image, but we already prefer this.cacheOptions.duration automatically 88 | duration: this.options.cacheDuration, 89 | // Issue #117: re-use eleventy-img dryRun option value for eleventy-fetch dryRun 90 | dryRun: this.options.dryRun, 91 | }, this.options.cacheOptions); 92 | 93 | // v6.0.0 this now inherits eleventy-fetch option defaults 94 | this.assetCache = Fetch(src, this.cacheOptions); 95 | } 96 | } 97 | 98 | setQueue(queue) { 99 | this.#queue = queue; 100 | } 101 | 102 | setBuildLogger(buildLogger) { 103 | this.#buildLogger = buildLogger; 104 | } 105 | 106 | setDirectoryManager(manager) { 107 | this.#directoryManager = manager; 108 | } 109 | 110 | get directoryManager() { 111 | if(!this.#directoryManager) { 112 | throw new Error("Missing #directoryManager"); 113 | } 114 | 115 | return this.#directoryManager; 116 | } 117 | 118 | get buildLogger() { 119 | if(!this.#buildLogger) { 120 | throw new Error("Missing #buildLogger. Call `setBuildLogger`"); 121 | } 122 | return this.#buildLogger; 123 | } 124 | 125 | // In memory cache is up front, handles promise de-duping from input (this does not use getHash) 126 | // Note: output cache is also in play below (uses getHash) 127 | getInMemoryCacheKey() { 128 | let opts = Util.getSortedObject(this.options); 129 | 130 | opts.__originalSrc = this.src; 131 | 132 | if(this.isRemoteUrl) { 133 | opts.sourceUrl = this.src; // the source url 134 | } else if(Buffer.isBuffer(this.src)) { 135 | opts.sourceUrl = this.src.toString(); 136 | opts.__originalSize = this.src.length; 137 | } else { 138 | // Important: do not cache this 139 | opts.__originalSize = fs.statSync(this.src).size; 140 | } 141 | 142 | return JSON.stringify(opts, function(key, value) { 143 | // allows `transform` functions to be truthy for in-memory key 144 | if (typeof value === "function") { 145 | return "" + (value.name || ""); 146 | } 147 | return value; 148 | }); 149 | } 150 | 151 | getFileContents(overrideLocalFilePath) { 152 | if(!overrideLocalFilePath && this.isRemoteUrl) { 153 | return false; 154 | } 155 | 156 | let src = overrideLocalFilePath || this.src; 157 | 158 | if(!this.#contents[src]) { 159 | // perf: check to make sure it’s not a string first 160 | if(typeof src !== "string" && Buffer.isBuffer(src)) { 161 | this.#contents[src] = src; 162 | } else { 163 | debugAssets("[11ty/eleventy-img] Reading %o", src); 164 | this.#contents[src] = fs.readFileSync(src); 165 | } 166 | } 167 | 168 | // Always 169 | return this.#contents[src]; 170 | } 171 | 172 | static getValidWidths(originalWidth, widths = [], allowUpscale = false, minimumThreshold = 1) { 173 | // replace any falsy values with the original width 174 | let valid = widths.map(width => !width || width === 'auto' ? originalWidth : width); 175 | 176 | // Convert strings to numbers, "400" (floats are not allowed in sharp) 177 | valid = valid.map(width => parseInt(width, 10)); 178 | 179 | // Replace any larger-than-original widths with the original width if upscaling is not allowed. 180 | // This ensures that if a larger width has been requested, we're at least providing the closest 181 | // non-upscaled image that we can. 182 | if (!allowUpscale) { 183 | let lastWidthWasBigEnough = true; // first one is always valid 184 | valid = valid.sort((a, b) => a - b).map(width => { 185 | if(width > originalWidth) { 186 | if(lastWidthWasBigEnough) { 187 | return originalWidth; 188 | } 189 | return -1; 190 | } 191 | 192 | lastWidthWasBigEnough = originalWidth > Math.floor(width * minimumThreshold); 193 | 194 | return width; 195 | }).filter(width => width > 0); 196 | } 197 | 198 | // Remove duplicates (e.g., if null happens to coincide with an explicit width 199 | // or a user passes in multiple duplicate values, or multiple larger-than-original 200 | // widths have resulted in the original width being included multiple times) 201 | valid = [...new Set(valid)]; 202 | 203 | // sort ascending 204 | return valid.sort((a, b) => a - b); 205 | } 206 | 207 | static getFormatsArray(formats, autoFormat, svgShortCircuit, isAnimated, hasTransparency) { 208 | if(formats && formats.length) { 209 | if(typeof formats === "string") { 210 | formats = formats.split(","); 211 | } 212 | 213 | formats = formats.map(format => { 214 | if(autoFormat) { 215 | if((!format || format === "auto")) { 216 | format = autoFormat; 217 | } 218 | } 219 | 220 | if(FORMAT_ALIASES[format]) { 221 | return FORMAT_ALIASES[format]; 222 | } 223 | return format; 224 | }); 225 | 226 | if(svgShortCircuit !== "size") { 227 | // svg must come first for possible short circuiting 228 | formats.sort((a, b) => { 229 | if(a === "svg") { 230 | return -1; 231 | } else if(b === "svg") { 232 | return 1; 233 | } 234 | return 0; 235 | }); 236 | } 237 | 238 | if(isAnimated) { 239 | let validAnimatedFormats = formats.filter(f => ANIMATED_TYPES.includes(f)); 240 | // override formats if a valid animated format is found, otherwise leave as-is 241 | if(validAnimatedFormats.length > 0) { 242 | debug("Filtering non-animated formats from output: from %o to %o", formats, validAnimatedFormats); 243 | formats = validAnimatedFormats; 244 | } else { 245 | debug("No animated output formats found for animated image, using original formats (may be a static image): %o", formats); 246 | } 247 | } 248 | 249 | if(hasTransparency) { 250 | let minimumValidTransparencyFormats = formats.filter(f => MINIMUM_TRANSPARENCY_TYPES.includes(f)); 251 | // override formats if a valid animated format is found, otherwise leave as-is 252 | if(minimumValidTransparencyFormats.length > 0) { 253 | let validTransparencyFormats = formats.filter(f => TRANSPARENCY_TYPES.includes(f)); 254 | debug("Filtering non-transparency-friendly formats from output: from %o to %o", formats, validTransparencyFormats); 255 | formats = validTransparencyFormats; 256 | } else { 257 | debug("At least one transparency-friendly output format of %o must be included if the source image has an alpha channel, skipping formatFiltering and using original formats: %o", MINIMUM_TRANSPARENCY_TYPES, formats); 258 | } 259 | } 260 | 261 | // Remove duplicates (e.g., if null happens to coincide with an explicit format 262 | // or a user passes in multiple duplicate values) 263 | formats = [...new Set(formats)]; 264 | 265 | return formats; 266 | } 267 | 268 | return []; 269 | } 270 | 271 | #transformRawFiles(files = []) { 272 | let byType = {}; 273 | for(let file of files) { 274 | if(!byType[file.format]) { 275 | byType[file.format] = []; 276 | } 277 | byType[file.format].push(file); 278 | } 279 | for(let type in byType) { 280 | // sort by width, ascending (for `srcset`) 281 | byType[type].sort((a, b) => { 282 | return a.width - b.width; 283 | }); 284 | } 285 | 286 | let filterLargeRasterImages = this.options.svgShortCircuit === "size"; 287 | let svgEntry = byType.svg; 288 | let svgSize = svgEntry && svgEntry.length && svgEntry[0].size; 289 | 290 | if(filterLargeRasterImages && svgSize) { 291 | for(let type of Object.keys(byType)) { 292 | if(type === "svg") { 293 | continue; 294 | } 295 | 296 | let svgAdded = false; 297 | let originalFormatKept = false; 298 | byType[type] = byType[type].map(entry => { 299 | if(entry.size > svgSize) { 300 | if(!svgAdded) { 301 | svgAdded = true; 302 | // need at least one raster smaller than SVG to do this trick 303 | if(originalFormatKept) { 304 | return svgEntry[0]; 305 | } 306 | // all rasters are bigger 307 | return false; 308 | } 309 | 310 | return false; 311 | } 312 | 313 | originalFormatKept = true; 314 | return entry; 315 | }).filter(entry => entry); 316 | } 317 | } 318 | 319 | return byType; 320 | } 321 | 322 | #finalizeResults(results = {}) { 323 | // used when results are passed to generate HTML, we maintain some internal metadata about the options used. 324 | let imgAttributes = this.options.htmlOptions?.imgAttributes || {}; 325 | imgAttributes.src = this.src; 326 | 327 | Object.defineProperty(results, "eleventyImage", { 328 | enumerable: false, 329 | writable: false, 330 | value: { 331 | htmlOptions: { 332 | whitespaceMode: this.options.htmlOptions?.whitespaceMode, 333 | imgAttributes, 334 | pictureAttributes: this.options.htmlOptions?.pictureAttributes, 335 | fallback: this.options.htmlOptions?.fallback, 336 | }, 337 | } 338 | }); 339 | 340 | // renamed `return` to `returnType` to match Fetch API in v6.0.0-beta.3 341 | if(this.options.returnType === "html" || this.options.return === "html") { 342 | return generateHTML(results); 343 | } 344 | 345 | return results; 346 | } 347 | 348 | getSharpOptionsForFormat(format) { 349 | if(format === "webp") { 350 | return this.options.sharpWebpOptions; 351 | } else if(format === "jpeg") { 352 | return this.options.sharpJpegOptions; 353 | } else if(format === "png") { 354 | return this.options.sharpPngOptions; 355 | } else if(format === "avif") { 356 | return this.options.sharpAvifOptions; 357 | } 358 | return {}; 359 | } 360 | 361 | async getInput() { 362 | // internal cache 363 | if(!this.#input) { 364 | if(this.isRemoteUrl) { 365 | // fetch remote image Buffer 366 | this.#input = this.assetCache.queue(); 367 | } else { 368 | // not actually a promise, this is sync 369 | this.#input = this.getFileContents(); 370 | } 371 | } 372 | 373 | return this.#input; 374 | } 375 | 376 | getHash() { 377 | if (this.#computedHash) { 378 | return this.#computedHash; 379 | } 380 | 381 | // debug("Creating hash for %o", this.src); 382 | let hashContents = []; 383 | 384 | if(existsCache.exists(this.src)) { 385 | let fileContents = this.getFileContents(); 386 | 387 | // If the file starts with whitespace or the '<' character, it might be SVG. 388 | // Otherwise, skip the expensive buffer.toString() call 389 | // (no point in unicode encoding a binary file) 390 | let fileContentsPrefix = fileContents?.slice(0, 1)?.toString()?.trim(); 391 | if (!fileContentsPrefix || fileContentsPrefix[0] == "<") { 392 | // remove all newlines for hashing for better cross-OS hash compatibility (Issue #122) 393 | let fileContentsStr = fileContents.toString(); 394 | let firstFour = fileContentsStr.trim().slice(0, 5); 395 | if(firstFour === "8 as 1 (normal) but check anyways 486 | return orientation >= 5 && orientation <= 8; 487 | } 488 | 489 | isAnimated(metadata) { 490 | // sharp options have animated image support enabled 491 | if(!this.options?.sharpOptions?.animated) { 492 | return false; 493 | } 494 | 495 | let isAnimationFriendlyFormat = ANIMATED_TYPES.includes(metadata.format); 496 | if(!isAnimationFriendlyFormat) { 497 | return false; 498 | } 499 | 500 | if(metadata?.pages) { 501 | // input has multiple pages: https://sharp.pixelplumbing.com/api-input#metadata 502 | // this is *unknown* when not called from `resize` (limited metadata available) 503 | return metadata?.pages > 1; 504 | } 505 | 506 | // Best guess 507 | return isAnimationFriendlyFormat; 508 | } 509 | 510 | getEntryFormat(metadata) { 511 | return metadata.format || this.options.overrideInputFormat; 512 | } 513 | 514 | // metadata so far: width, height, format 515 | // src is used to calculate the output file names 516 | getFullStats(metadata) { 517 | let results = []; 518 | let isImageAnimated = this.isAnimated(metadata) && Array.isArray(this.options.formatFiltering) && this.options.formatFiltering.includes("animated"); 519 | let hasAlpha = metadata.hasAlpha && Array.isArray(this.options.formatFiltering) && this.options.formatFiltering.includes("transparent"); 520 | let entryFormat = this.getEntryFormat(metadata); 521 | let outputFormats = Image.getFormatsArray(this.options.formats, entryFormat, this.options.svgShortCircuit, isImageAnimated, hasAlpha); 522 | 523 | if (this.needsRotation(metadata.orientation)) { 524 | [metadata.height, metadata.width] = [metadata.width, metadata.height]; 525 | } 526 | 527 | if(metadata.pageHeight) { 528 | // When the { animated: true } option is provided to sharp, animated 529 | // image formats like gifs or webp will have an inaccurate `height` value 530 | // in their metadata which is actually the height of every single frame added together. 531 | // In these cases, the metadata will contain an additional `pageHeight` property which 532 | // is the height that the image should be displayed at. 533 | metadata.height = metadata.pageHeight; 534 | } 535 | 536 | for(let outputFormat of outputFormats) { 537 | if(!outputFormat || outputFormat === "auto") { 538 | throw new Error("When using statsSync or statsByDimensionsSync, `formats: [null | 'auto']` to use the native image format is not supported."); 539 | } 540 | 541 | if(outputFormat === "svg") { 542 | if(entryFormat === "svg") { 543 | let svgStats = this.getStat("svg", metadata.width, metadata.height); 544 | 545 | // SVG metadata.size is only available with Buffer input (remote urls) 546 | if(metadata.size) { 547 | // Note this is unfair for comparison with raster formats because its uncompressed (no GZIP, etc) 548 | svgStats.size = metadata.size; 549 | } 550 | results.push(svgStats); 551 | 552 | if(this.options.svgShortCircuit === true) { 553 | break; 554 | } else { 555 | continue; 556 | } 557 | } else { 558 | debug("Skipping SVG output for %o: received raster input.", this.src); 559 | continue; 560 | } 561 | } else { // not outputting SVG (might still be SVG input though!) 562 | let widths = Image.getValidWidths(metadata.width, this.options.widths, metadata.format === "svg" && this.options.svgAllowUpscale, this.options.minimumThreshold); 563 | for(let width of widths) { 564 | let height = Image.getAspectRatioHeight(metadata, width); 565 | 566 | results.push(this.getStat(outputFormat, width, height)); 567 | } 568 | } 569 | } 570 | 571 | return this.#transformRawFiles(results); 572 | } 573 | 574 | static getDimensionsFromSharp(sharpInstance, stat) { 575 | let dims = {}; 576 | if(sharpInstance.options.width > -1) { 577 | dims.width = sharpInstance.options.width; 578 | dims.resized = true; 579 | } 580 | if(sharpInstance.options.height > -1) { 581 | dims.height = sharpInstance.options.height; 582 | dims.resized = true; 583 | } 584 | 585 | if(dims.width || dims.height) { 586 | if(!dims.width) { 587 | dims.width = Image.getAspectRatioWidth(stat, dims.height); 588 | } 589 | if(!dims.height) { 590 | dims.height = Image.getAspectRatioHeight(stat, dims.width); 591 | } 592 | } 593 | 594 | return dims; 595 | } 596 | 597 | static getAspectRatioWidth(originalDimensions, newHeight) { 598 | return Math.floor(newHeight * originalDimensions.width / originalDimensions.height); 599 | } 600 | 601 | static getAspectRatioHeight(originalDimensions, newWidth) { 602 | // Warning: if this is a guess via statsByDimensionsSync and that guess is wrong 603 | // The aspect ratio will be wrong and any height/widths returned will be wrong! 604 | return Math.floor(newWidth * originalDimensions.height / originalDimensions.width); 605 | } 606 | 607 | getOutputSize(contents, filePath) { 608 | if(contents) { 609 | if(this.options.svgCompressionSize === "br") { 610 | return brotliSize(contents); 611 | } 612 | 613 | if("length" in contents) { 614 | return contents.length; 615 | } 616 | } 617 | 618 | // fallback to looking on local file system 619 | if(!filePath) { 620 | throw new Error("`filePath` expected."); 621 | } 622 | 623 | return fs.statSync(filePath).size; 624 | } 625 | 626 | isOutputCached(targetFile, sourceInput) { 627 | if(!this.options.useCache) { 628 | return false; 629 | } 630 | 631 | // last cache was a miss, so we must write to disk 632 | if(this.assetCache && !this.assetCache.wasLastFetchCacheHit()) { 633 | return false; 634 | } 635 | 636 | if(!diskCache.isCached(targetFile, sourceInput, !Util.isRequested(this.options.generatedVia))) { 637 | return false; 638 | } 639 | 640 | return true; 641 | } 642 | 643 | // src should be a file path to an image or a buffer 644 | async resize(input) { 645 | let sharpInputImage = sharp(input, Object.assign({ 646 | // Deprecated by sharp, use `failOn` option instead 647 | // https://github.com/lovell/sharp/blob/1533bf995acda779313fc178d2b9d46791349961/lib/index.d.ts#L915 648 | failOnError: false, 649 | }, this.options.sharpOptions)); 650 | 651 | // Must find the image format from the metadata 652 | // File extensions lie or may not be present in the src url! 653 | let sharpMetadata = await sharpInputImage.metadata(); 654 | 655 | let outputFilePromises = []; 656 | 657 | let fullStats = this.getFullStats(sharpMetadata); 658 | 659 | for(let outputFormat in fullStats) { 660 | for(let stat of fullStats[outputFormat]) { 661 | if(this.isOutputCached(stat.outputPath, input)) { 662 | // Cached images already exist in output 663 | let outputFileContents; 664 | 665 | if(this.options.dryRun || outputFormat === "svg" && this.options.svgCompressionSize === "br") { 666 | outputFileContents = this.getFileContents(stat.outputPath); 667 | } 668 | 669 | if(this.options.dryRun) { 670 | stat.buffer = outputFileContents; 671 | } 672 | 673 | stat.size = this.getOutputSize(outputFileContents, stat.outputPath); 674 | 675 | outputFilePromises.push(Promise.resolve(stat)); 676 | continue; 677 | } 678 | 679 | let sharpInstance = sharpInputImage.clone(); 680 | let transform = this.options.transform; 681 | let isTransformResize = false; 682 | 683 | if(transform) { 684 | if(typeof transform !== "function") { 685 | throw new Error("Expected `function` type in `transform` option. Received: " + transform); 686 | } 687 | 688 | await transform(sharpInstance); 689 | 690 | // Resized in a transform (maybe for a crop) 691 | let dims = Image.getDimensionsFromSharp(sharpInstance, stat); 692 | if(dims.resized) { 693 | isTransformResize = true; 694 | 695 | // Overwrite current `stat` object with new sizes and file names 696 | stat = this.getStat(stat.format, dims.width, dims.height); 697 | } 698 | } 699 | 700 | // https://github.com/11ty/eleventy-img/issues/244 701 | sharpInstance.keepIccProfile(); 702 | 703 | // Output images do not include orientation metadata (https://github.com/11ty/eleventy-img/issues/52) 704 | // Use sharp.rotate to bake orientation into the image (https://github.com/lovell/sharp/blob/v0.32.6/docs/api-operation.md#rotate): 705 | // > If no angle is provided, it is determined from the EXIF data. Mirroring is supported and may infer the use of a flip operation. 706 | // > The use of rotate without an angle will remove the EXIF Orientation tag, if any. 707 | if(this.options.fixOrientation || this.needsRotation(sharpMetadata.orientation)) { 708 | sharpInstance.rotate(); 709 | } 710 | 711 | if(!isTransformResize) { 712 | if(stat.width < sharpMetadata.width || (this.options.svgAllowUpscale && sharpMetadata.format === "svg")) { 713 | let resizeOptions = { 714 | width: stat.width 715 | }; 716 | 717 | if(sharpMetadata.format !== "svg" || !this.options.svgAllowUpscale) { 718 | resizeOptions.withoutEnlargement = true; 719 | } 720 | 721 | sharpInstance.resize(resizeOptions); 722 | } 723 | } 724 | 725 | // Format hooks take priority over Sharp processing. 726 | // format hooks are only used for SVG out of the box 727 | if(this.options.formatHooks && this.options.formatHooks[outputFormat]) { 728 | let hookResult = await this.options.formatHooks[outputFormat].call(stat, sharpInstance); 729 | if(hookResult) { 730 | stat.size = this.getOutputSize(hookResult); 731 | 732 | if(this.options.dryRun) { 733 | stat.buffer = Buffer.from(hookResult); 734 | 735 | outputFilePromises.push(Promise.resolve(stat)); 736 | } else { 737 | this.directoryManager.createFromFile(stat.outputPath); 738 | 739 | debugAssets("[11ty/eleventy-img] Writing %o", stat.outputPath); 740 | 741 | outputFilePromises.push(fsp.writeFile(stat.outputPath, hookResult).then(() => stat)); 742 | } 743 | } 744 | } else { // not a format hook 745 | let sharpFormatOptions = this.getSharpOptionsForFormat(outputFormat); 746 | let hasFormatOptions = Object.keys(sharpFormatOptions).length > 0; 747 | if(hasFormatOptions || outputFormat && sharpMetadata.format !== outputFormat) { 748 | // https://github.com/lovell/sharp/issues/3680 749 | // Fix heic regression in sharp 0.33 750 | if(outputFormat === "heic" && !sharpFormatOptions.compression) { 751 | sharpFormatOptions.compression = "av1"; 752 | } 753 | sharpInstance.toFormat(outputFormat, sharpFormatOptions); 754 | } 755 | 756 | if(!this.options.dryRun && stat.outputPath) { 757 | // Should never write when dryRun is true 758 | this.directoryManager.createFromFile(stat.outputPath); 759 | 760 | debugAssets("[11ty/eleventy-img] Writing %o", stat.outputPath); 761 | 762 | outputFilePromises.push( 763 | sharpInstance.toFile(stat.outputPath) 764 | .then(info => { 765 | stat.size = info.size; 766 | return stat; 767 | }) 768 | ); 769 | } else { 770 | outputFilePromises.push(sharpInstance.toBuffer({ resolveWithObject: true }).then(({ data, info }) => { 771 | stat.buffer = data; 772 | stat.size = info.size; 773 | return stat; 774 | })); 775 | } 776 | } 777 | 778 | if(stat.outputPath) { 779 | if(this.options.dryRun) { 780 | debug( "Generated %o", stat.url ); 781 | } else { 782 | debug( "Wrote %o", stat.outputPath ); 783 | } 784 | } 785 | } 786 | } 787 | 788 | return Promise.all(outputFilePromises).then(files => this.#finalizeResults(this.#transformRawFiles(files))); 789 | } 790 | 791 | async getStatsOnly() { 792 | if(typeof this.src !== "string" || !this.options.statsOnly) { 793 | return; 794 | } 795 | 796 | let input; 797 | if(Util.isRemoteUrl(this.src)) { 798 | if(this.rawOptions.remoteImageMetadata?.width && this.rawOptions.remoteImageMetadata?.height) { 799 | return this.getFullStats({ 800 | width: this.rawOptions.remoteImageMetadata.width, 801 | height: this.rawOptions.remoteImageMetadata.height, 802 | format: this.rawOptions.remoteImageMetadata.format, // only required if you want to use the "auto" format 803 | guess: true, 804 | }); 805 | } 806 | 807 | // Fetch remote image to operate on it 808 | // `remoteImageMetadata` is no longer required for statsOnly on remote images 809 | input = await this.getInput(); 810 | } 811 | 812 | // Local images 813 | try { 814 | // Related to https://github.com/11ty/eleventy-img/issues/295 815 | let { width, height, type } = getImageSize(input || this.src); 816 | 817 | return this.getFullStats({ 818 | width, 819 | height, 820 | format: type // only required if you want to use the "auto" format 821 | }); 822 | } catch(e) { 823 | throw new Error(`Eleventy Image error (statsOnly): \`image-size\` on "${this.src}" failed. Original error: ${e.message}`); 824 | } 825 | } 826 | 827 | // returns raw Promise 828 | queue() { 829 | if(!this.#queue) { 830 | return Promise.reject(new Error("Missing #queue.")); 831 | } 832 | 833 | if(this.#queuePromise) { 834 | return this.#queuePromise; 835 | } 836 | 837 | debug("Processing %o (in-memory cache miss), options: %o", this.src, this.options); 838 | 839 | this.#queuePromise = this.#queue.add(async () => { 840 | try { 841 | if(typeof this.src === "string" && this.options.statsOnly) { 842 | return this.getStatsOnly(); 843 | } 844 | 845 | this.buildLogger.log(`Processing ${this.buildLogger.getFriendlyImageSource(this.src)}`, this.options); 846 | 847 | let input = await this.getInput(); 848 | 849 | return this.resize(input); 850 | } catch(e) { 851 | this.buildLogger.error(`Error: ${e.message} (via ${this.buildLogger.getFriendlyImageSource(this.src)})`, this.options); 852 | 853 | if(this.options.failOnError) { 854 | throw e; 855 | } 856 | } 857 | }); 858 | 859 | return this.#queuePromise; 860 | } 861 | 862 | // Factory to return from cache if available 863 | static create(src, options = {}) { 864 | let img = new Image(src, options); 865 | 866 | // use resolved options for this 867 | if(!img.options.useCache) { 868 | return img; 869 | } 870 | 871 | let key = img.getInMemoryCacheKey(); 872 | let cached = memCache.get(key, !options.transformOnRequest && !Util.isRequested(options.generatedVia)); 873 | if(cached) { 874 | return cached; 875 | } 876 | 877 | memCache.add(key, img); 878 | 879 | return img; 880 | } 881 | 882 | /* `statsSync` doesn’t generate any files, but will tell you where 883 | * the asynchronously generated files will end up! This is useful 884 | * in synchronous-only template environments where you need the 885 | * image URLs synchronously but can’t rely on the files being in 886 | * the correct location yet. 887 | * 888 | * `options.dryRun` is still asynchronous but also doesn’t generate 889 | * any files. 890 | */ 891 | statsSync() { 892 | if(this.isRemoteUrl) { 893 | throw new Error("`statsSync` is not supported with remote sources. Use `statsByDimensionsSync(src, width, height, options)` instead."); 894 | } 895 | 896 | let dimensions = getImageSize(this.src); 897 | 898 | return this.getFullStats({ 899 | width: dimensions.width, 900 | height: dimensions.height, 901 | format: dimensions.type, 902 | }); 903 | } 904 | 905 | static statsSync(src, opts) { 906 | if(typeof src === "string" && Util.isRemoteUrl(src)) { 907 | throw new Error("`statsSync` is not supported with remote sources. Use `statsByDimensionsSync(src, width, height, options)` instead."); 908 | } 909 | 910 | let img = Image.create(src, opts); 911 | return img.statsSync(); 912 | } 913 | 914 | statsByDimensionsSync(width, height) { 915 | let dimensions = { 916 | width, 917 | height, 918 | guess: true 919 | }; 920 | return this.getFullStats(dimensions); 921 | } 922 | 923 | static statsByDimensionsSync(src, width, height, opts) { 924 | let img = Image.create(src, opts); 925 | return img.statsByDimensionsSync(width, height); 926 | } 927 | } 928 | 929 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import fs from "node:fs"; 3 | import os from "node:os"; 4 | import { URL } from "node:url"; 5 | 6 | import test from "ava"; 7 | import sharp from "sharp"; 8 | import pixelmatch from 'pixelmatch'; 9 | 10 | import eleventyImage, { Image, Util } from "../img.js"; 11 | 12 | // Remember that any outputPath tests must use path.join to work on Windows 13 | 14 | test("getFormats", t => { 15 | let formats = eleventyImage.getFormats("webp,png"); 16 | t.is(formats.length, 2); 17 | t.is(formats[0], "webp"); 18 | t.is(formats[1], "png"); 19 | }); 20 | 21 | test("getFormats (three) with svg reorder", t => { 22 | let formats = eleventyImage.getFormats("webp,png,svg"); 23 | t.is(formats.length, 3); 24 | // svg should be first 25 | t.is(formats[0], "svg"); 26 | t.is(formats[1], "webp"); 27 | t.is(formats[2], "png"); 28 | }); 29 | 30 | test("getFormats (three) with svg reorder 2", t => { 31 | let formats = eleventyImage.getFormats("webp,svg,png"); 32 | t.is(formats.length, 3); 33 | // svg should be first 34 | t.is(formats[0], "svg"); 35 | t.is(formats[1], "webp"); 36 | t.is(formats[2], "png"); 37 | }); 38 | 39 | test("getFormats (three) with svg no reorder", t => { 40 | let formats = eleventyImage.getFormats("svg,webp,png"); 41 | t.is(formats.length, 3); 42 | // svg should be first 43 | t.is(formats[0], "svg"); 44 | t.is(formats[1], "webp"); 45 | t.is(formats[2], "png"); 46 | }); 47 | 48 | test("getFormats removes duplicates", t => { 49 | let formats = eleventyImage.getFormats("svg,webp,png,webp,svg"); 50 | t.is(formats.length, 3); 51 | // svg should be first 52 | t.is(formats[0], "svg"); 53 | t.is(formats[1], "webp"); 54 | t.is(formats[2], "png"); 55 | }); 56 | 57 | test("Sync with jpeg input", t => { 58 | let stats = eleventyImage.statsSync("./test/bio-2017.jpg"); 59 | t.is(stats.webp.length, 1); 60 | t.is(stats.jpeg.length, 1); 61 | }); 62 | 63 | test("Sync by dimension with jpeg input", t => { 64 | let stats = eleventyImage.statsByDimensionsSync("./test/bio-2017.jpg", 1280, 853); 65 | t.is(stats.webp.length, 1); 66 | t.is(stats.jpeg.length, 1); 67 | }); 68 | 69 | test("Sync with widths", t => { 70 | let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { 71 | widths: [300] 72 | }); 73 | t.is(stats.webp.length, 1); 74 | t.is(stats.webp[0].width, 300); 75 | t.is(stats.jpeg.length, 1); 76 | t.is(stats.jpeg[0].width, 300); 77 | }); 78 | 79 | test("Sync by dimension with widths", t => { 80 | let stats = eleventyImage.statsByDimensionsSync("./test/bio-2017.jpg", 1280, 853, { 81 | widths: [300] 82 | }); 83 | t.is(stats.webp.length, 1); 84 | t.is(stats.webp[0].width, 300); 85 | t.is(stats.jpeg.length, 1); 86 | t.is(stats.jpeg[0].width, 300); 87 | }); 88 | 89 | 90 | test("Sync with two widths", t => { 91 | let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { 92 | widths: [300, 500] 93 | }); 94 | t.is(stats.webp.length, 2); 95 | t.is(stats.webp[0].width, 300); 96 | t.is(stats.webp[1].width, 500); 97 | t.is(stats.jpeg.length, 2); 98 | t.is(stats.jpeg[0].width, 300); 99 | t.is(stats.jpeg[1].width, 500); 100 | }); 101 | 102 | test("Sync by dimension with two widths", t => { 103 | let stats = eleventyImage.statsByDimensionsSync("./test/bio-2017.jpg", 1280, 853, { 104 | widths: [300, 500] 105 | }); 106 | t.is(stats.webp.length, 2); 107 | t.is(stats.webp[0].width, 300); 108 | t.is(stats.webp[1].width, 500); 109 | t.is(stats.jpeg.length, 2); 110 | t.is(stats.jpeg[0].width, 300); 111 | t.is(stats.jpeg[1].width, 500); 112 | }); 113 | 114 | 115 | test("Sync with null width", t => { 116 | let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { 117 | widths: [300, null] 118 | }); 119 | t.is(stats.webp.length, 2); 120 | t.is(stats.webp[0].width, 300); 121 | t.is(stats.webp[0].height, 199); 122 | t.is(stats.webp[1].width, 1280); 123 | t.is(stats.webp[1].height, 853); 124 | t.is(stats.jpeg.length, 2); 125 | t.is(stats.jpeg[0].width, 300); 126 | t.is(stats.jpeg[0].height, 199); 127 | t.is(stats.jpeg[1].width, 1280); 128 | t.is(stats.jpeg[1].height, 853); 129 | }); 130 | 131 | test("Sync with 'auto' width", t => { 132 | let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { 133 | widths: [300, 'auto'] 134 | }); 135 | t.is(stats.webp.length, 2); 136 | t.is(stats.webp[0].width, 300); 137 | t.is(stats.webp[0].height, 199); 138 | t.is(stats.webp[1].width, 1280); 139 | t.is(stats.webp[1].height, 853); 140 | t.is(stats.jpeg.length, 2); 141 | t.is(stats.jpeg[0].width, 300); 142 | t.is(stats.jpeg[0].height, 199); 143 | t.is(stats.jpeg[1].width, 1280); 144 | t.is(stats.jpeg[1].height, 853); 145 | }); 146 | 147 | test("Sync by dimension with null width", t => { 148 | let stats = eleventyImage.statsByDimensionsSync("./test/bio-2017.jpg", 1280, 853, { 149 | widths: [300, null] 150 | }); 151 | t.is(stats.webp.length, 2); 152 | t.is(stats.webp[0].width, 300); 153 | t.is(stats.webp[0].height, 199); 154 | t.is(stats.webp[1].width, 1280); 155 | t.is(stats.webp[1].height, 853); 156 | t.is(stats.jpeg.length, 2); 157 | t.is(stats.jpeg[0].width, 300); 158 | t.is(stats.jpeg[0].height, 199); 159 | t.is(stats.jpeg[1].width, 1280); 160 | t.is(stats.jpeg[1].height, 853); 161 | }); 162 | 163 | test("Sync by dimension with 'auto' width", t => { 164 | let stats = eleventyImage.statsByDimensionsSync("./test/bio-2017.jpg", 1280, 853, { 165 | widths: [300, 'auto'] 166 | }); 167 | t.is(stats.webp.length, 2); 168 | t.is(stats.webp[0].width, 300); 169 | t.is(stats.webp[0].height, 199); 170 | t.is(stats.webp[1].width, 1280); 171 | t.is(stats.webp[1].height, 853); 172 | t.is(stats.jpeg.length, 2); 173 | t.is(stats.jpeg[0].width, 300); 174 | t.is(stats.jpeg[0].height, 199); 175 | t.is(stats.jpeg[1].width, 1280); 176 | t.is(stats.jpeg[1].height, 853); 177 | }); 178 | 179 | test("Use 'auto' format as original", async t => { 180 | let stats = await eleventyImage("./test/bio-2017.jpg", { 181 | widths: [null], 182 | formats: ['auto'], 183 | outputDir: "./test/img/" 184 | }); 185 | 186 | t.is(stats.auto, undefined); 187 | t.is(stats.jpeg.length, 1); 188 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1280.jpeg")); 189 | t.is(stats.jpeg[0].width, 1280); 190 | }); 191 | 192 | test("Try to use a width larger than original", async t => { 193 | let stats = await eleventyImage("./test/bio-2017.jpg", { 194 | widths: [1500], 195 | formats: ["jpeg"], 196 | outputDir: "./test/img/" 197 | }); 198 | t.is(stats.jpeg.length, 1); 199 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1280.jpeg")); 200 | t.is(stats.jpeg[0].width, 1280); 201 | }); 202 | 203 | test("Try to use a width larger than original (two sizes)", async t => { 204 | let stats = await eleventyImage("./test/bio-2017.jpg", { 205 | widths: [1500, 2000], 206 | formats: ["jpeg"], 207 | outputDir: "./test/img/" 208 | }); 209 | t.is(stats.jpeg.length, 1); 210 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1280.jpeg")); 211 | t.is(stats.jpeg[0].width, 1280); 212 | }); 213 | 214 | test("Try to use a width larger than original (with a null in there)", async t => { 215 | let stats = await eleventyImage("./test/bio-2017.jpg", { 216 | widths: [1500, null], 217 | formats: ["jpeg"], 218 | outputDir: "./test/img/" 219 | }); 220 | t.is(stats.jpeg.length, 1); 221 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1280.jpeg")); 222 | t.is(stats.jpeg[0].width, 1280); 223 | }); 224 | 225 | test("Minimum width threshold (valid)", async t => { 226 | // original is 1280 227 | let stats = await eleventyImage("./test/bio-2017.jpg", { 228 | widths: [400, 1300], 229 | formats: ["jpeg"], 230 | outputDir: "./test/img/", 231 | dryRun: true, 232 | }); 233 | t.is(stats.jpeg.length, 2); 234 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-400.jpeg")); 235 | t.is(stats.jpeg[0].width, 400); 236 | t.is(stats.jpeg[1].outputPath, path.join("test/img/KkPMmHd3hP-1280.jpeg")); 237 | t.is(stats.jpeg[1].width, 1280); 238 | }); 239 | 240 | test("Minimum width threshold (one width larger that source)", async t => { 241 | // original is 1280 242 | let stats = await eleventyImage("./test/bio-2017.jpg", { 243 | widths: [1800], 244 | formats: ["jpeg"], 245 | outputDir: "./test/img/", 246 | dryRun: true, 247 | }); 248 | t.is(stats.jpeg.length, 1); 249 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1280.jpeg")); 250 | t.is(stats.jpeg[0].width, 1280); 251 | }); 252 | 253 | test("Minimum width threshold (one gets rejected)", async t => { 254 | // original is 1280 255 | let stats = await eleventyImage("./test/bio-2017.jpg", { 256 | widths: [1200, 1300], 257 | formats: ["jpeg"], 258 | outputDir: "./test/img/", 259 | dryRun: true, 260 | }); 261 | t.is(stats.jpeg.length, 1); 262 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1200.jpeg")); 263 | t.is(stats.jpeg[0].width, 1200); 264 | }); 265 | 266 | test("Minimum width threshold (one gets rejected, higher max)", async t => { 267 | // original is 1280 268 | let stats = await eleventyImage("./test/bio-2017.jpg", { 269 | widths: [1200, 1500], 270 | formats: ["jpeg"], 271 | outputDir: "./test/img/", 272 | dryRun: true, 273 | }); 274 | t.is(stats.jpeg.length, 1); 275 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1200.jpeg")); 276 | t.is(stats.jpeg[0].width, 1200); 277 | }); 278 | 279 | test("Minimum width threshold (one gets rejected, lower min)", async t => { 280 | // original is 1280 281 | let stats = await eleventyImage("./test/bio-2017.jpg", { 282 | widths: [1100, 1500], 283 | formats: ["jpeg"], 284 | outputDir: "./test/img/", 285 | dryRun: true, 286 | }); 287 | t.is(stats.jpeg.length, 1); 288 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1100.jpeg")); 289 | t.is(stats.jpeg[0].width, 1100); 290 | }); 291 | 292 | test("Just falsy width", async t => { 293 | let stats = await eleventyImage("./test/bio-2017.jpg", { 294 | widths: [null], 295 | formats: ["jpeg"], 296 | outputDir: "./test/img/" 297 | }); 298 | t.is(stats.jpeg.length, 1); 299 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1280.jpeg")); 300 | t.is(stats.jpeg[0].width, 1280); 301 | }); 302 | 303 | test("Use exact same width as original", async t => { 304 | let stats = await eleventyImage("./test/bio-2017.jpg", { 305 | widths: [1280], 306 | formats: ["jpeg"], 307 | outputDir: "./test/img/" 308 | }); 309 | t.is(stats.jpeg.length, 1); 310 | // breaking change in 0.5: always use width in filename 311 | t.is(stats.jpeg[0].outputPath, path.join("test/img/KkPMmHd3hP-1280.jpeg")); 312 | t.is(stats.jpeg[0].width, 1280); 313 | }); 314 | 315 | test("Try to use a width larger than original (statsSync)", t => { 316 | let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { 317 | widths: [1500], 318 | formats: ["jpeg"] 319 | }); 320 | 321 | t.is(stats.jpeg.length, 1); 322 | t.is(stats.jpeg[0].url, "/img/KkPMmHd3hP-1280.jpeg"); 323 | t.is(stats.jpeg[0].width, 1280); 324 | }); 325 | 326 | test("Use exact same width as original (statsSync)", t => { 327 | let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { 328 | widths: [1280], 329 | formats: ["jpeg"] 330 | }); 331 | 332 | t.is(stats.jpeg.length, 1); 333 | t.is(stats.jpeg[0].url, "/img/KkPMmHd3hP-1280.jpeg"); 334 | t.is(stats.jpeg[0].width, 1280); 335 | }); 336 | 337 | test("Use custom function to define file names", async (t) => { 338 | let stats = await eleventyImage("./test/bio-2017.jpg", { 339 | widths: [600, 1280], 340 | formats: ["jpeg"], 341 | outputDir: "./test/img/", 342 | filenameFormat: function (id, src, width, format) { // and options 343 | const ext = path.extname(src); 344 | const name = path.basename(src, ext); 345 | 346 | if (width) { 347 | return `${name}-${id}-${width}.${format}`; 348 | } 349 | 350 | return `${name}-${id}.${format}`; 351 | } 352 | }); 353 | 354 | t.is(stats.jpeg.length, 2); 355 | t.is(stats.jpeg[0].outputPath, path.join("test/img/bio-2017-KkPMmHd3hP-600.jpeg")); 356 | t.is(stats.jpeg[0].url, "/img/bio-2017-KkPMmHd3hP-600.jpeg"); 357 | t.is(stats.jpeg[0].srcset, "/img/bio-2017-KkPMmHd3hP-600.jpeg 600w"); 358 | t.is(stats.jpeg[0].width, 600); 359 | t.is(stats.jpeg[1].outputPath, path.join("test/img/bio-2017-KkPMmHd3hP-1280.jpeg")); 360 | t.is(stats.jpeg[1].url, "/img/bio-2017-KkPMmHd3hP-1280.jpeg"); 361 | t.is(stats.jpeg[1].srcset, "/img/bio-2017-KkPMmHd3hP-1280.jpeg 1280w"); 362 | t.is(stats.jpeg[1].width, 1280); 363 | }); 364 | 365 | test("Unavatar test", t => { 366 | let stats = eleventyImage.statsByDimensionsSync("https://unavatar.now.sh/twitter/zachleat?fallback=false", 400, 400, { 367 | widths: [75], 368 | remoteAssetContent: 'remote asset content' 369 | }); 370 | 371 | t.is(stats.webp.length, 1); 372 | t.is(stats.webp[0].width, 75); 373 | t.is(stats.webp[0].height, 75); 374 | t.is(stats.jpeg.length, 1); 375 | t.is(stats.jpeg[0].width, 75); 376 | t.is(stats.jpeg[0].height, 75); 377 | }); 378 | 379 | test("Ask for svg output from a raster image (skipped)", async t => { 380 | let stats = await eleventyImage("./test/bio-2017.jpg", { 381 | widths: [null], 382 | formats: ["svg"], 383 | outputDir: "./test/img/" 384 | }); 385 | 386 | t.deepEqual(stats, {}); 387 | }); 388 | 389 | test("Upscale an SVG, Issue #32", async t => { 390 | let stats = await eleventyImage("./test/logo.svg", { 391 | widths: [3000], 392 | formats: ["png"], 393 | outputDir: "./test/img/" 394 | }); 395 | 396 | t.is(stats.png.length, 1); 397 | t.is(stats.png[0].filename.slice(-9), "-3000.png"); // should include width in filename 398 | t.is(stats.png[0].width, 3000); 399 | t.is(stats.png[0].height, 4179); 400 | }); 401 | 402 | test("Upscale an SVG (disallowed in option), Issue #32", async t => { 403 | let stats = await eleventyImage("./test/logo.svg", { 404 | widths: [3000], 405 | formats: ["png"], 406 | outputDir: "./test/img/", 407 | svgAllowUpscale: false 408 | }); 409 | 410 | t.is(stats.png.length, 1); 411 | t.not(stats.png[0].filename.slice(-9), "-3000.png"); // should not include width in filename 412 | t.is(stats.png[0].width, 1569); 413 | t.is(stats.png[0].height, 2186); 414 | }); 415 | 416 | test("svgShortCircuit", async t => { 417 | let stats = await eleventyImage("./test/logo.svg", { 418 | widths: [null], 419 | formats: ["svg", "png", "webp"], 420 | outputDir: "./test/img/", 421 | svgShortCircuit: true, 422 | }); 423 | 424 | t.deepEqual(Object.keys(stats), ["svg"]); 425 | t.is(stats.svg.length, 1); 426 | t.is(stats.svg[0].size, 1936); 427 | }); 428 | 429 | test("svgShortCircuit (on a raster source) #242", async t => { 430 | let stats = await eleventyImage("./test/bio-2017.jpg", { 431 | widths: ["auto"], 432 | formats: ["svg", "png"], 433 | svgShortCircuit: true, 434 | useCache: false, 435 | dryRun: true, 436 | }); 437 | 438 | t.deepEqual(Object.keys(stats), ["png"]); 439 | t.is(stats.png.length, 1); 440 | t.is(stats.png[0].size, 2511518); 441 | }); 442 | 443 | 444 | test("getWidths", t => { 445 | t.deepEqual(eleventyImage.getWidths(300, [null]), [300]); // want original 446 | t.deepEqual(eleventyImage.getWidths(300, ['auto']), [300]); // want original 447 | t.deepEqual(eleventyImage.getWidths(300, [600]), [300]); // want larger 448 | t.deepEqual(eleventyImage.getWidths(300, [150]), [150]); // want smaller 449 | 450 | t.deepEqual(eleventyImage.getWidths(300, [600, null]), [300]); 451 | t.deepEqual(eleventyImage.getWidths(300, [null, 600]), [300]); 452 | t.deepEqual(eleventyImage.getWidths(300, [600, 'auto']), [300]); 453 | t.deepEqual(eleventyImage.getWidths(300, ['auto', 600]), [300]); 454 | t.deepEqual(eleventyImage.getWidths(300, [150, null]), [150,300]); 455 | t.deepEqual(eleventyImage.getWidths(300, [null, 150]), [150,300]); 456 | t.deepEqual(eleventyImage.getWidths(300, [150, 'auto']), [150,300]); 457 | t.deepEqual(eleventyImage.getWidths(300, ['auto', 150]), [150,300]); 458 | }); 459 | 460 | test("getWidths removes duplicates", t => { 461 | t.deepEqual(eleventyImage.getWidths(300, [null, 300]), [300]); 462 | t.deepEqual(eleventyImage.getWidths(300, [300, 300]), [300]); 463 | t.deepEqual(eleventyImage.getWidths(600, [300, 400, 300, 500, 400]), [300, 400, 500]); 464 | }); 465 | 466 | test("getWidths allow upscaling", t => { 467 | t.deepEqual(eleventyImage.getWidths(300, [null], true), [300]); // want original 468 | t.deepEqual(eleventyImage.getWidths(300, ['auto'], true), [300]); // want original 469 | t.deepEqual(eleventyImage.getWidths(300, [600], true), [600]); // want larger 470 | t.deepEqual(eleventyImage.getWidths(300, [150], true), [150]); // want smaller 471 | 472 | t.deepEqual(eleventyImage.getWidths(300, [600, null], true), [300, 600]); 473 | t.deepEqual(eleventyImage.getWidths(300, [null, 600], true), [300, 600]); 474 | t.deepEqual(eleventyImage.getWidths(300, [600, 'auto'], true), [300, 600]); 475 | t.deepEqual(eleventyImage.getWidths(300, ['auto', 600], true), [300, 600]); 476 | t.deepEqual(eleventyImage.getWidths(300, [150, null], true), [150,300]); 477 | t.deepEqual(eleventyImage.getWidths(300, [null, 150], true), [150,300]); 478 | t.deepEqual(eleventyImage.getWidths(300, [150, 'auto'], true), [150,300]); 479 | t.deepEqual(eleventyImage.getWidths(300, ['auto', 150], true), [150,300]); 480 | }); 481 | 482 | test("Sync by dimension with jpeg input (wrong dimensions, supplied are smaller than real)", t => { 483 | let stats = eleventyImage.statsByDimensionsSync("./test/bio-2017.jpg", 164, 164, { 484 | widths: [164, 328], 485 | formats: ["jpeg"], 486 | }); 487 | 488 | // 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 489 | t.is(stats.jpeg.length, 1); 490 | t.is(stats.jpeg[0].outputPath, path.join("img/KkPMmHd3hP-164.jpeg")); 491 | }); 492 | 493 | test("Sync by dimension with jpeg input (wrong dimensions, supplied are larger than real)", t => { 494 | let stats = eleventyImage.statsByDimensionsSync("./test/bio-2017.jpg", 1500, 1500, { 495 | widths: [164, 328], 496 | formats: ["jpeg"], 497 | }); 498 | 499 | t.is(stats.jpeg.length, 2); 500 | t.is(stats.jpeg[0].outputPath, path.join("img/KkPMmHd3hP-164.jpeg")); 501 | t.is(stats.jpeg[1].outputPath, path.join("img/KkPMmHd3hP-328.jpeg")); 502 | }); 503 | 504 | test("Keep a cache, reuse with same file names and options", async t => { 505 | let promise1 = eleventyImage("./test/bio-2017.jpg", { dryRun: true }); 506 | let promise2 = eleventyImage("./test/bio-2017.jpg", { dryRun: true }); 507 | t.is(promise1, promise2); 508 | 509 | let stats1 = await promise1; 510 | let stats2 = await promise2; 511 | t.deepEqual(stats1, stats2); 512 | }); 513 | 514 | test("Keep a cache, reuse with same remote url and options", async t => { 515 | let promise1 = eleventyImage("https://www.zachleat.com/img/avatar-2017-big.png", { dryRun: true }); 516 | let promise2 = eleventyImage("https://www.zachleat.com/img/avatar-2017-big.png", { dryRun: true }); 517 | t.is(promise1, promise2); 518 | 519 | let stats1 = await promise1; 520 | let stats2 = await promise2; 521 | t.deepEqual(stats1, stats2); 522 | }); 523 | 524 | test("Keep a cache, don’t reuse with same file names and different options", async t => { 525 | let promise1 = eleventyImage("./test/bio-2017.jpg", { 526 | widths: [null], 527 | dryRun: true, 528 | }); 529 | let promise2 = eleventyImage("./test/bio-2017.jpg", { 530 | widths: [300], 531 | dryRun: true, 532 | }); 533 | t.not(promise1, promise2); 534 | 535 | let stats1 = await promise1; 536 | let stats2 = await promise2; 537 | t.notDeepEqual(stats1, stats2); 538 | 539 | t.is(stats1.jpeg.length, 1); 540 | t.is(stats2.jpeg.length, 1); 541 | }); 542 | 543 | test("Keep a cache, don’t reuse with if the image changes, check promise equality", async t => { 544 | fs.copyFileSync("./test/modify-bio-original.jpg", "./test/generated-modify-bio.jpg"); 545 | 546 | let promise1 = eleventyImage("./test/generated-modify-bio.jpg", { 547 | outputDir: "./test/img/", 548 | }); 549 | 550 | fs.copyFileSync("./test/modify-bio-grayscale.jpg", "./test/generated-modify-bio.jpg"); 551 | 552 | let promise2 = eleventyImage("./test/generated-modify-bio.jpg", { 553 | outputDir: "./test/img/", 554 | }); 555 | 556 | t.not(promise1, promise2); 557 | }); 558 | 559 | let method = os.platform() === "win32" && process.env.GITHUB_ACTIONS ? test.skip : test; 560 | method("Keep a cache, don’t reuse with if the image changes, check output", async t => { 561 | let outputPathTemp = "./test/generated-modify2-bio.jpg"; 562 | 563 | fs.copyFileSync("./test/modify2-bio-original.jpg", outputPathTemp); 564 | 565 | let stats1 = await eleventyImage(outputPathTemp, { 566 | outputDir: "./test/img/", 567 | }); 568 | 569 | fs.copyFileSync("./test/modify2-bio-grayscale.jpg", outputPathTemp); 570 | 571 | let stats2 = await eleventyImage(outputPathTemp, { 572 | outputDir: "./test/img/", 573 | }); 574 | 575 | t.notDeepEqual(stats1, stats2); 576 | 577 | t.is(stats1.jpeg.length, 1); 578 | t.is(stats2.jpeg.length, 1); 579 | }); 580 | 581 | test("SVG to Buffer input! Issue #40", async t => { 582 | let svgContent = ``; 583 | let output = await eleventyImage(Buffer.from(svgContent), { 584 | outputDir: "./test/img/" 585 | }); 586 | 587 | t.is(output.jpeg.length, 1); 588 | t.is(output.webp.length, 1); 589 | 590 | t.is(output.jpeg[0].width, 1569); 591 | t.is(output.webp[0].width, 1569); 592 | }); 593 | 594 | test("Dryrun should include the buffer instance", async t => { 595 | let result = await eleventyImage("./test/bio-2017.jpg", { dryRun: true }); 596 | 597 | t.truthy(result.jpeg[0].buffer); 598 | t.truthy(result.webp[0].buffer); 599 | }); 600 | 601 | // TODO dryrun buffer with a remote image? 602 | 603 | 604 | test("Test with a string width", async t => { 605 | let image = await eleventyImage("./test/bio-2017.jpg", { 606 | widths: ["340"], 607 | formats: [null], 608 | dryRun: true, 609 | }); 610 | 611 | t.deepEqual(image.jpeg[0].width, 340); 612 | }); 613 | 614 | test("Test with a string px width", async t => { 615 | let image = await eleventyImage("./test/bio-2017.jpg", { 616 | widths: ["340px"], 617 | formats: [null], 618 | dryRun: true, 619 | }); 620 | 621 | t.deepEqual(image.jpeg[0].width, 340); 622 | }); 623 | 624 | test("Test with a string float width", async t => { 625 | let image = await eleventyImage("./test/bio-2017.jpg", { 626 | widths: ["340.9"], 627 | formats: [null], 628 | dryRun: true, 629 | }); 630 | 631 | t.deepEqual(image.jpeg[0].width, 340); 632 | }); 633 | 634 | test("Test with 'auto' width", async t => { 635 | let image = await eleventyImage("./test/bio-2017.jpg", { 636 | widths: ['auto'], 637 | formats: [null], 638 | dryRun: true, 639 | }); 640 | 641 | t.deepEqual(image.jpeg[0].width, 1280); 642 | t.deepEqual(image.jpeg[0].height, 853); 643 | }); 644 | 645 | test("Using `jpg` in formats Issue #64", async t => { 646 | let stats = await eleventyImage("./test/bio-2017.jpg", { 647 | formats: ["jpg"], 648 | dryRun: true, 649 | }); 650 | delete stats.jpeg[0].buffer; 651 | t.deepEqual(stats, { 652 | jpeg: [ 653 | { 654 | filename: 'KkPMmHd3hP-1280.jpeg', 655 | format: 'jpeg', 656 | height: 853, 657 | outputPath: path.join('img/KkPMmHd3hP-1280.jpeg'), 658 | size: 276231, 659 | sourceType: "image/jpeg", 660 | srcset: '/img/KkPMmHd3hP-1280.jpeg 1280w', 661 | url: '/img/KkPMmHd3hP-1280.jpeg', 662 | width: 1280, 663 | }, 664 | ] 665 | }); 666 | }); 667 | 668 | test("SVG files and dryRun: Issue #72", async t => { 669 | let stats = await eleventyImage("./test/Ghostscript_Tiger.svg", { 670 | formats: ["svg"], 671 | dryRun: true, 672 | }); 673 | t.false(fs.existsSync("./img/8b4d670b-900.svg")); 674 | t.truthy(stats.svg[0]); 675 | }); 676 | 677 | test("Sorted object keys", async t => { 678 | t.deepEqual(Util.getSortedObject({ 679 | c: 3, 680 | b: 2, 681 | a: 1 682 | }), { 683 | a: 1, 684 | b: 2, 685 | c: 3 686 | }); 687 | 688 | t.deepEqual(Util.getSortedObject({ 689 | b: 2, 690 | a: 1, 691 | 1: 3, 692 | }), { 693 | 1: 3, 694 | a: 1, 695 | b: 2, 696 | }); 697 | }); 698 | 699 | test("widths array should be ignored in hashing", t => { 700 | let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { 701 | widths: [1280] 702 | }); 703 | 704 | let stats2 = eleventyImage.statsSync("./test/bio-2017.jpg", { 705 | widths: [300, 600] 706 | }); 707 | 708 | t.is(stats.jpeg[0].url, "/img/KkPMmHd3hP-1280.jpeg"); 709 | t.is(stats2.jpeg[0].url, "/img/KkPMmHd3hP-300.jpeg"); 710 | t.is(stats2.jpeg[1].url, "/img/KkPMmHd3hP-600.jpeg"); 711 | }); 712 | 713 | test("statsSync and eleventyImage output comparison", async t => { 714 | let statsSync = eleventyImage.statsSync("./test/bio-2017.jpg", { 715 | widths: [399], 716 | formats: ["jpeg"] 717 | }); 718 | let statsByDimensionsSync = eleventyImage.statsByDimensionsSync("./test/bio-2017.jpg", 1280, 853, { 719 | widths: [399], 720 | formats: ["jpeg"] 721 | }); 722 | let stats = await eleventyImage("./test/bio-2017.jpg", { 723 | widths: [399], 724 | formats: ["jpeg"], 725 | dryRun: true 726 | }); 727 | 728 | // these aren’t expected in the statsSync method 729 | delete stats.jpeg[0].buffer; 730 | delete stats.jpeg[0].size; 731 | 732 | t.deepEqual(statsSync, stats); 733 | t.deepEqual(statsByDimensionsSync, stats); 734 | t.deepEqual(statsSync, statsByDimensionsSync); 735 | }); 736 | 737 | test("urlFormat using local image", async t => { 738 | let stats = await eleventyImage("./test/bio-2017.jpg", { 739 | formats: ["auto"], 740 | urlFormat: function({ src }) { 741 | let u = new URL(src, "https://www.zachleat.com/"); 742 | return `https://v1.image.11ty.dev/${encodeURIComponent(u)}/`; 743 | } 744 | }); 745 | 746 | t.truthy(stats); 747 | t.truthy(stats.jpeg.length); 748 | t.truthy(stats.jpeg[0].buffer); 749 | t.truthy(stats.jpeg[0].size); 750 | 751 | t.is(stats.jpeg[0].width, 1280); 752 | t.is(stats.jpeg[0].height, 853); 753 | t.is(stats.jpeg[0].url, "https://v1.image.11ty.dev/https%3A%2F%2Fwww.zachleat.com%2Ftest%2Fbio-2017.jpg/"); 754 | }); 755 | 756 | test("urlFormat using remote image", async t => { 757 | let stats = await eleventyImage("https://www.zachleat.com/img/avatar-2017.png", { 758 | formats: ["auto"], 759 | urlFormat: function({ src }) { 760 | return `https://v1.image.11ty.dev/${encodeURIComponent(src)}/`; 761 | } 762 | }); 763 | t.truthy(stats); 764 | t.truthy(stats.png.length); 765 | t.truthy(stats.png[0].buffer); 766 | t.truthy(stats.png[0].size); 767 | 768 | t.is(stats.png[0].width, 160); 769 | t.is(stats.png[0].height, 160); 770 | t.is(stats.png[0].url, "https://v1.image.11ty.dev/https%3A%2F%2Fwww.zachleat.com%2Fimg%2Favatar-2017.png/"); 771 | }); 772 | 773 | 774 | test("statsOnly using local image", async t => { 775 | let stats = await eleventyImage("./test/bio-2017.jpg", { 776 | statsOnly: true, 777 | formats: ["auto"], 778 | urlFormat: function({ src }) { 779 | let u = new URL(src, "https://www.zachleat.com/"); 780 | return `https://v1.image.11ty.dev/${encodeURIComponent(u)}/`; 781 | } 782 | }); 783 | 784 | t.deepEqual(stats, { 785 | jpeg: [ 786 | { 787 | format: 'jpeg', 788 | height: 853, 789 | sourceType: 'image/jpeg', 790 | srcset: 'https://v1.image.11ty.dev/https%3A%2F%2Fwww.zachleat.com%2Ftest%2Fbio-2017.jpg/ 1280w', 791 | url: 'https://v1.image.11ty.dev/https%3A%2F%2Fwww.zachleat.com%2Ftest%2Fbio-2017.jpg/', 792 | width: 1280, 793 | }, 794 | ], 795 | }); 796 | }); 797 | 798 | test("statsOnly using remote image", async t => { 799 | let stats = await eleventyImage("https://www.zachleat.com/img/avatar-2017.png", { 800 | statsOnly: true, 801 | remoteImageMetadata: { 802 | width: 160, 803 | height: 160, 804 | format: "png" 805 | }, 806 | formats: ["auto"], 807 | urlFormat: function({ src }) { 808 | return `https://v1.image.11ty.dev/${encodeURIComponent(src)}/`; 809 | } 810 | }); 811 | t.deepEqual(stats, { 812 | png: [ 813 | { 814 | format: 'png', 815 | height: 160, 816 | sourceType: 'image/png', 817 | srcset: 'https://v1.image.11ty.dev/https%3A%2F%2Fwww.zachleat.com%2Fimg%2Favatar-2017.png/ 160w', 818 | url: 'https://v1.image.11ty.dev/https%3A%2F%2Fwww.zachleat.com%2Fimg%2Favatar-2017.png/', 819 | width: 160, 820 | }, 821 | ], 822 | }); 823 | }); 824 | 825 | 826 | test("statsOnly using local image, no urlFormat", async t => { 827 | let stats = await eleventyImage("./test/bio-2017.jpg", { 828 | statsOnly: true, 829 | formats: ["auto"], 830 | filenameFormat(hash, src, width, format) { 831 | return "this-should-not-exist." + format; 832 | } 833 | }); 834 | 835 | // No buffer 836 | t.truthy(stats.jpeg[0]); 837 | t.falsy(stats.jpeg[0].buffer); 838 | 839 | // make sure it doesn’t exist. 840 | t.true(!fs.existsSync(stats.jpeg[0].outputPath)); 841 | 842 | t.deepEqual(stats, { 843 | jpeg: [ 844 | { 845 | format: 'jpeg', 846 | height: 853, 847 | sourceType: 'image/jpeg', 848 | filename: "this-should-not-exist.jpeg", 849 | outputPath: path.join("img", "this-should-not-exist.jpeg"), 850 | srcset: '/img/this-should-not-exist.jpeg 1280w', 851 | url: '/img/this-should-not-exist.jpeg', 852 | width: 1280, 853 | }, 854 | ], 855 | }); 856 | }); 857 | 858 | test("statsOnly using remote image, no urlFormat", async t => { 859 | let stats = await eleventyImage("https://www.zachleat.com/img/avatar-2017.png", { 860 | statsOnly: true, 861 | remoteImageMetadata: { 862 | width: 160, 863 | height: 160, 864 | format: "png" 865 | }, 866 | formats: ["auto"], 867 | filenameFormat(hash, src, width, format) { 868 | return "this-should-not-exist." + format; 869 | }, 870 | }); 871 | 872 | // No buffer 873 | t.truthy(stats.png[0]); 874 | t.falsy(stats.png[0].buffer); 875 | 876 | // make sure it doesn’t exist. 877 | t.true(!fs.existsSync(stats.png[0].outputPath)); 878 | 879 | t.deepEqual(stats, { 880 | png: [ 881 | { 882 | format: 'png', 883 | height: 160, 884 | sourceType: 'image/png', 885 | filename: "this-should-not-exist.png", 886 | outputPath: path.join("img", "this-should-not-exist.png"), 887 | srcset: '/img/this-should-not-exist.png 160w', 888 | url: '/img/this-should-not-exist.png', 889 | width: 160, 890 | }, 891 | ], 892 | }); 893 | }); 894 | 895 | test("src is recognized as local when using absolute path on Windows", t => { 896 | let image = new Image("C:\\image.jpg"); 897 | 898 | t.is(image.isRemoteUrl, false); 899 | }); 900 | 901 | test("src is recognized as local when using absolute path on POSIX", t => { 902 | let image = new Image("/home/user/image.jpg"); 903 | 904 | t.is(image.isRemoteUrl, false); 905 | }); 906 | 907 | test("src is recognized as remote when using https scheme", t => { 908 | let image = new Image("https://example.com/image.jpg"); 909 | 910 | t.is(image.isRemoteUrl, true); 911 | }); 912 | 913 | test("src is recognized as remote when using http scheme", t => { 914 | let image = new Image("http://example.com/image.jpg"); 915 | 916 | t.is(image.isRemoteUrl, true); 917 | }); 918 | 919 | test("Maintains orientation #132", async t => { 920 | let stats = await eleventyImage("./test/orientation.jpg", { 921 | // upscaling rules apply: 922 | // even though the image is 76px wide and has exif width: 151, 923 | // any number above 76 will return a 76px width image 924 | widths: [151], 925 | formats: ["jpeg"], 926 | outputDir: "./test/img/", 927 | dryRun: true, 928 | }); 929 | 930 | t.is(stats.jpeg.length, 1); 931 | t.is(stats.jpeg[0].width, 76); 932 | t.is(stats.jpeg[0].height, 151); 933 | }); 934 | 935 | // Broken test cases from https://github.com/recurser/exif-orientation-examples 936 | test("#158: Test EXIF orientation data landscape (3) with fixOrientation", async t => { 937 | let stats = await eleventyImage("./test/exif-Landscape_3.jpg", { 938 | widths: [200, "auto"], 939 | formats: ['auto'], 940 | useCache: false, 941 | dryRun: true, 942 | fixOrientation: true, 943 | }); 944 | 945 | t.is(stats.jpeg.length, 2); 946 | t.is(stats.jpeg[0].width, 200); 947 | t.is(stats.jpeg[1].width, 1800); 948 | t.is(Math.floor(stats.jpeg[0].height), 133); 949 | t.is(stats.jpeg[1].height, 1200); 950 | 951 | // This orientation (180º rotation) preserves image dimensions and requires an image diff 952 | const readToRaw = async input => { 953 | // pixelmatch requires 4 bytes/pixel, hence alpha 954 | return sharp(input).ensureAlpha().toFormat(sharp.format.raw).toBuffer(); 955 | }; 956 | for (const [inSrc, outStat] of [ 957 | ["./test/exif-Landscape_3-bakedOrientation-200.jpg", stats.jpeg[0]], 958 | ["./test/exif-Landscape_3-bakedOrientation.jpg", stats.jpeg[1]]]) { 959 | const inRaw = await readToRaw(inSrc); 960 | const outRaw = await readToRaw(outStat.buffer); 961 | t.is(pixelmatch(inRaw, outRaw, null, outStat.width, outStat.height, { threshold: 0.15 }), 0); 962 | } 963 | }); 964 | 965 | test("#158: Test EXIF orientation data landscape (3) without fixOrientation", async t => { 966 | let stats = await eleventyImage("./test/exif-Landscape_3.jpg", { 967 | widths: [200, "auto"], 968 | formats: ['auto'], 969 | useCache: false, 970 | dryRun: true, 971 | fixOrientation: false, 972 | }); 973 | 974 | // This orientation (180º rotation) preserves image dimensions and requires an image diff 975 | const readToRaw = async input => { 976 | // pixelmatch requires 4 bytes/pixel, hence alpha 977 | return sharp(input).ensureAlpha().toFormat(sharp.format.raw).toBuffer(); 978 | }; 979 | for (const [inSrc, outStat] of [ 980 | ["./test/exif-Landscape_3-bakedOrientation-200.jpg", stats.jpeg[0]], 981 | ["./test/exif-Landscape_3-bakedOrientation.jpg", stats.jpeg[1]]]) { 982 | const inRaw = await readToRaw(inSrc); 983 | const outRaw = await readToRaw(outStat.buffer); 984 | 985 | // rotation did not happen and the images are different 986 | // when/if fixOrientation is defaulted to true this test will have to be === 0 987 | t.true(pixelmatch(inRaw, outRaw, null, outStat.width, outStat.height, { threshold: 0.15 }) > 0); 988 | } 989 | }); 990 | 991 | test("#132: Test EXIF orientation data landscape (5)", async t => { 992 | let stats = await eleventyImage("./test/exif-Landscape_5.jpg", { 993 | widths: [400, "auto"], 994 | formats: ['auto'], 995 | outputDir: "./test/img/", 996 | dryRun: true, 997 | }); 998 | 999 | t.is(stats.jpeg.length, 2); 1000 | t.is(stats.jpeg[0].width, 400); 1001 | t.is(stats.jpeg[1].width, 1800); 1002 | t.is(Math.floor(stats.jpeg[0].height), 266); 1003 | t.is(stats.jpeg[1].height, 1200); 1004 | }); 1005 | 1006 | test("#132: Test EXIF orientation data landscape (6)", async t => { 1007 | let stats = await eleventyImage("./test/exif-Landscape_6.jpg", { 1008 | widths: [400], 1009 | formats: ['auto'], 1010 | outputDir: "./test/img/", 1011 | dryRun: true, 1012 | }); 1013 | 1014 | t.is(stats.jpeg.length, 1); 1015 | t.is(stats.jpeg[0].width, 400); 1016 | t.is(Math.floor(stats.jpeg[0].height), 266); 1017 | }); 1018 | 1019 | test("#132: Test EXIF orientation data landscape (7)", async t => { 1020 | let stats = await eleventyImage("./test/exif-Landscape_7.jpg", { 1021 | widths: [400], 1022 | formats: ['auto'], 1023 | outputDir: "./test/img/", 1024 | dryRun: true, 1025 | }); 1026 | 1027 | t.is(stats.jpeg.length, 1); 1028 | t.is(stats.jpeg[0].width, 400); 1029 | t.is(Math.floor(stats.jpeg[0].height), 266); 1030 | }); 1031 | 1032 | test("#132: Test EXIF orientation data landscape (8)", async t => { 1033 | let stats = await eleventyImage("./test/exif-Landscape_8.jpg", { 1034 | widths: [400], 1035 | formats: ['auto'], 1036 | outputDir: "./test/img/", 1037 | dryRun: true, 1038 | }); 1039 | 1040 | t.is(stats.jpeg.length, 1); 1041 | t.is(stats.jpeg[0].width, 400); 1042 | t.is(Math.floor(stats.jpeg[0].height), 266); 1043 | }); 1044 | 1045 | test("#158: Test EXIF orientation data landscape (15) without fixOrientation", async t => { 1046 | let stats = await eleventyImage("./test/exif-Landscape_15.jpg", { 1047 | widths: [400], 1048 | formats: ['auto'], 1049 | outputDir: "./test/img/", 1050 | dryRun: true, 1051 | }); 1052 | 1053 | t.is(stats.jpeg.length, 1); 1054 | t.is(stats.jpeg[0].width, 400); 1055 | t.is(Math.floor(stats.jpeg[0].height), 266); 1056 | }); 1057 | 1058 | 1059 | test("#158: Test EXIF orientation data landscape (15) with fixOrientation", async t => { 1060 | let stats = await eleventyImage("./test/exif-Landscape_15.jpg", { 1061 | widths: [400], 1062 | formats: ['auto'], 1063 | outputDir: "./test/img/", 1064 | dryRun: true, 1065 | fixOrientation: true, 1066 | }); 1067 | 1068 | t.is(stats.jpeg.length, 1); 1069 | t.is(stats.jpeg[0].width, 400); 1070 | t.is(Math.floor(stats.jpeg[0].height), 266); 1071 | }); 1072 | 1073 | test("Animated gif", async t => { 1074 | let stats = await eleventyImage("./test/earth-animated.gif", { 1075 | dryRun: true, 1076 | formats: ["auto"], 1077 | sharpOptions: { 1078 | animated: true 1079 | }, 1080 | useCache: false, 1081 | outputDir: "./test/img/", 1082 | }); 1083 | 1084 | t.is(stats.gif.length, 1); 1085 | t.is(stats.gif[0].width, 400); 1086 | t.is(stats.gif[0].height, 400); 1087 | // it’s a big boi 1088 | t.true( stats.gif[0].size > 1000*999 ); 1089 | }); 1090 | 1091 | test("Animated gif format filtering (no good ones)", async t => { 1092 | let stats = await eleventyImage("./test/earth-animated.gif", { 1093 | dryRun: true, 1094 | formats: ["jpeg"], 1095 | sharpOptions: { 1096 | animated: true 1097 | }, 1098 | useCache: false, 1099 | }); 1100 | 1101 | t.deepEqual(Object.keys(stats), ["jpeg"]); 1102 | t.is(stats.jpeg.length, 1); 1103 | t.is(stats.jpeg[0].width, 400); 1104 | t.is(stats.jpeg[0].height, 400); 1105 | // it’s a big boi 1106 | t.true( stats.jpeg[0].size < 1000*999, `${stats.jpeg[0].size} size is too big, should be smaller than ${1000*999}.` ); 1107 | }); 1108 | 1109 | test("Animated gif format filtering (one valid one)", async t => { 1110 | let stats = await eleventyImage("./test/earth-animated.gif", { 1111 | dryRun: true, 1112 | formats: ["jpeg", "gif"], 1113 | sharpOptions: { 1114 | animated: true 1115 | }, 1116 | useCache: false, 1117 | }); 1118 | 1119 | t.deepEqual(Object.keys(stats), ["gif"]); 1120 | t.is(stats.gif.length, 1); 1121 | t.is(stats.gif[0].width, 400); 1122 | t.is(stats.gif[0].height, 400); 1123 | // it’s a big boi 1124 | t.true( stats.gif[0].size > 1000*999 ); 1125 | }); 1126 | 1127 | test("Change hashLength", async t => { 1128 | let stats = await eleventyImage("./test/bio-2017.jpg", { 1129 | widths: [null], 1130 | hashLength: 6, 1131 | formats: ['auto'], 1132 | dryRun: true, 1133 | }); 1134 | 1135 | t.is(stats.jpeg.length, 1); 1136 | t.is(stats.jpeg[0].outputPath, path.join("img/KkPMmH-1280.jpeg")); 1137 | }); 1138 | 1139 | test("Remote image with dryRun should have a buffer property", async t => { 1140 | let stats = await eleventyImage("https://www.zachleat.com/img/avatar-2017.png", { 1141 | dryRun: true, 1142 | widths: ["auto"], 1143 | formats: ["auto"], 1144 | }); 1145 | 1146 | t.truthy(stats.png[0].buffer); 1147 | }); 1148 | 1149 | test("Remote image with dryRun should have a buffer property, useCache: false", async t => { 1150 | let stats = await eleventyImage("https://www.zachleat.com/img/avatar-2017.png", { 1151 | dryRun: true, 1152 | useCache: false, 1153 | widths: ["auto"], 1154 | formats: ["auto"], 1155 | }); 1156 | 1157 | t.truthy(stats.png[0].buffer); 1158 | }); 1159 | 1160 | test("SVG files svgShortCircuit based on file size", async t => { 1161 | let stats = await eleventyImage("./test/Ghostscript_Tiger.svg", { 1162 | formats: ["svg", "webp"], 1163 | widths: [100, 1000, 1100], 1164 | dryRun: true, 1165 | svgShortCircuit: "size", 1166 | }); 1167 | 1168 | t.deepEqual(Object.keys(stats), ["svg", "webp"]); 1169 | 1170 | t.is(stats.svg.length, 1); 1171 | 1172 | t.is(stats.webp.length, 2); 1173 | t.is(stats.webp.filter(entry => entry.format === "svg").length, 1); 1174 | 1175 | t.is(stats.webp[0].format, "webp"); 1176 | t.is(stats.webp[0].width, 100); 1177 | t.truthy(stats.webp[0].size < 20000); 1178 | 1179 | t.is(stats.webp[1].format, "svg"); 1180 | t.is(stats.webp[1].width, 900); 1181 | }); 1182 | 1183 | test("SVG files svgShortCircuit based on file size (small SVG, exclusively SVG output)", async t => { 1184 | let stats = await eleventyImage("./test/logo.svg", { 1185 | formats: ["svg", "webp"], 1186 | widths: [500], 1187 | dryRun: true, 1188 | svgShortCircuit: "size", 1189 | }); 1190 | 1191 | t.deepEqual(Object.keys(stats), ["svg", "webp"]); 1192 | 1193 | t.is(stats.svg.length, 1); 1194 | t.is(stats.webp.length, 0); 1195 | }); 1196 | 1197 | 1198 | test("SVG files svgShortCircuit based on file size (brotli compression)", async t => { 1199 | let stats = await eleventyImage("./test/Ghostscript_Tiger.svg", { 1200 | formats: ["svg", "webp"], 1201 | widths: [100, 1000, 1100], 1202 | dryRun: true, 1203 | svgShortCircuit: "size", 1204 | svgCompressionSize: "br", 1205 | }); 1206 | 1207 | t.deepEqual(Object.keys(stats), ["svg", "webp"]); 1208 | 1209 | t.is(stats.svg.length, 1); 1210 | t.true(stats.svg[0].size < 30000); // original was ~68000, br compression was applied. 1211 | 1212 | t.is(stats.webp.length, 2); 1213 | t.is(stats.webp.filter(entry => entry.format === "svg").length, 1); 1214 | 1215 | t.is(stats.webp[0].format, "webp"); 1216 | t.is(stats.webp[0].width, 100); 1217 | t.truthy(stats.webp[0].size < 20000); 1218 | 1219 | t.is(stats.webp[1].format, "svg"); 1220 | t.is(stats.webp[1].width, 900); 1221 | }); 1222 | 1223 | test("#184: Ensure original size is included if any widths are larger", async t => { 1224 | // Test image is 1280px wide; before PR for 184, asking for [1500, 900] would 1225 | // result in only the 900px image. Now, it should result in 900px *and* 1280px 1226 | // images. 1227 | let stats = await eleventyImage("./test/bio-2017.jpg", { 1228 | widths: [1500, 900], 1229 | formats: ['jpeg'], 1230 | dryRun: true, 1231 | }); 1232 | 1233 | t.is(stats.jpeg.length, 2); 1234 | t.is(stats.jpeg[0].width, 900); 1235 | t.is(stats.jpeg[1].width, 1280); 1236 | }); 1237 | 1238 | // https://github.com/lovell/sharp/blob/main/test/fixtures/prophoto.png 1239 | test("Keep ICC Profiles by default #244 test image from Sharp repo", async t => { 1240 | let inputMetadata = await sharp("./test/issue-244-sharp.png").metadata(); 1241 | 1242 | let stats = await eleventyImage("./test/issue-244-sharp.png", { 1243 | widths: ["auto"], 1244 | formats: ["auto"], 1245 | outputDir: "./test/img/", 1246 | dryRun: true 1247 | }); 1248 | 1249 | // output buffer has icc profile 1250 | let outputMetadata = await sharp(stats.png[0].buffer).metadata(); 1251 | 1252 | t.true(Buffer.isBuffer(inputMetadata.icc)); 1253 | t.true(inputMetadata.hasProfile); 1254 | 1255 | t.is(stats.png[0].outputPath, path.join("test/img/KmVobkU8Sj-1.png")); 1256 | t.true(Buffer.isBuffer(outputMetadata.icc)); 1257 | t.true(outputMetadata.hasProfile); 1258 | }); 1259 | 1260 | test("#105 Transparent format output filtering", async t => { 1261 | let stats = await eleventyImage("./test/david-mascot.png", { 1262 | dryRun: true, 1263 | formats: ["png", "avif", "jpeg"], 1264 | useCache: false, 1265 | }); 1266 | 1267 | t.deepEqual(Object.keys(stats), ["png", "avif"]); 1268 | }); 1269 | 1270 | test("#105 Transparent format output filtering (no minimum transparency formats found)", async t => { 1271 | let stats = await eleventyImage("./test/david-mascot.png", { 1272 | dryRun: true, 1273 | useCache: false, 1274 | }); 1275 | 1276 | // even though webp is transparency-friendly, we still use the full originally formats because webp is not a sufficiently minimum format for transparency 1277 | // must include one of: svg, png, or gif 1278 | t.deepEqual(Object.keys(stats), ["webp", "jpeg"]); 1279 | }); 1280 | --------------------------------------------------------------------------------