├── 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 |
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 | 
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 | [](https://www.npmjs.com/package/@11ty/eleventy-img) [](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(`${tag}>`);
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, `
`);
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, `
`);
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 | }), `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", `
`);
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), `
`);
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", `
`);
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), `
`);
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", `
`);
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, `
`);
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", `
`);
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", `
`);
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), `
`);
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", `
`);
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), `
`);
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", ``);
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(), `
`);
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", `
`);
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), `
`);
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", `
`);
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), `
`);
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", `
`);
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), `
`);
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", `
`);
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), `
`);
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", `
`);
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), `
`);
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", `
`);
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), `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", `
`);
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, `
`);
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", ``, {
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(), `
`);
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", `
`);
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, `
`);
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 === "