aleksandrhovhannisyan.com icon indicating copy to clipboard operation
aleksandrhovhannisyan.com copied to clipboard

Optimizing Images with the 11ty Image Plugin

Open AleksandrHovhannisyan opened this issue 3 years ago • 39 comments

AleksandrHovhannisyan avatar Oct 31 '21 18:10 AleksandrHovhannisyan

Really nice article (and very nice web site by the way)! You just inspire me to refactor my custom and hacky solution to use Eleventy-img instead. I reused some of your concepts and some of your code (I hope you don't mind). Here's what I came up with. I had the need to be able to choose between lazy and eager mode and to work without JavaScript with the noscript tag.

const Image = require("@11ty/eleventy-img");
const outdent = require("outdent");
const path = require("path");

const placeholder = 22;

module.exports = async ({
    input,
    width = [300, 600],
    alt = "",
    baseFormat = "jpeg",
    optimalFormat = ["avif", "webp"],
    lazy = false,
    className = ["shadow-black-transparent"],
    sizes = "100vw"
}) => {
    const { dir, base } = path.parse(input);
    const inputPath = path.join(".", dir, base);

    const metadata = await Image(inputPath, {
        widths: [placeholder, ...width],
        formats: [...optimalFormat, baseFormat],
        urlPath: dir,
        outputDir: path.join("docs", dir)
    });
    
    const lowSrc = metadata[baseFormat][0];
    const highSrc = metadata[baseFormat][metadata[baseFormat].length - 1];
    
    if(lazy) {
      return outdent`
    <picture class="lazy-picture" data-lazy-state="unseen">
    ${Object.values(metadata).map(entry => {
      return `<source type="${entry[0].sourceType}" srcset="${entry[0].srcset}" data-srcset="${entry.filter(imageObject => imageObject.width !== 1).map(filtered => filtered.srcset).join(", ")}" sizes="${sizes}" class="lazy">`;
    }).join("\n")}
    <img
      src="${lowSrc.url}"
      data-src="${highSrc.url}"
      width="${highSrc.width}"
      height="${highSrc.height}"
      alt="${alt}"
      class="lazy ${className.join(" ")}"
      loading="lazy">
    </picture>
    <noscript>
    <picture>
    ${Object.values(metadata).map(entry => {
      return `<source type="${entry[0].sourceType}" srcset="${entry.filter(imageObject => imageObject.width !== 1).map(filtered => filtered.srcset).join(", ")}" sizes="${sizes}">`;
    }).join("\n")}
    <img
      src="${highSrc.url}"
      width="${highSrc.width}"
      height="${highSrc.height}"
      alt="${alt}"
      class="${className.join(" ")}">
    </picture>
    </noscript>`;

    } else if(!lazy) {
      return outdent`
      <picture>
      ${Object.values(metadata).map(entry => {
        return `<source type="${entry[0].sourceType}" srcset="${entry.filter(imageObject => imageObject.width !== 1).map(filtered => filtered.srcset).join(", ")}" sizes="${sizes}">`;
      }).join("\n")}
      <img
        src="${highSrc.url}"
        width="${highSrc.width}"
        height="${highSrc.height}"
        alt="${alt}"
        class="${className.join(" ")}"
      </picture>`;
    }
}

Thanks, really enjoy reading your blog! :+1:

solution-loisir avatar Nov 30 '21 03:11 solution-loisir

@solution-loisir Thanks, really glad to hear it! I've updated the post to mention noscript as an enhancement.

AleksandrHovhannisyan avatar Nov 30 '21 11:11 AleksandrHovhannisyan

Hi @AleksandrHovhannisyan do you have the final code for your post? Im trying to follow up on it but im getting Expected positive integer for width but received 0 of type number.

const ImageWidths = {
  ORIGINAL: null,
  PLACEHOLDER: 24,
};

const imageShortcode = async (
  relativeSrc,
  alt,
  widths = [400, 800, 1280],
  baseFormat = 'jpeg',
  optimizedFormats = ['webp', 'avif'],
  sizes = '100vw'
) => {
  const { dir: imgDir } = path.parse(relativeSrc);
  const fullSrc = path.join('src', relativeSrc);

  const imageMetadata = await Image(fullSrc, {
    widths: [ImageWidths.ORIGINAL, ImageWidths.PLACEHOLDER, ...widths],
    formats: [...optimizedFormats, baseFormat],
    outputDir: path.join('dist', imgDir),
    urlPath: imgDir,
    filenameFormat: (hash, src, width, format) => {
      const suffix = width === ImageWidths.PLACEHOLDER ? 'placeholder' : width;
      const extension = path.extname(src);
      const name = path.basename(src, extension);
      return `${name}-${hash}-${suffix}.${format}`;
    },
  });

  // Map each unique format (e.g., jpeg, webp) to its smallest and largest images
  const formatSizes = Object.entries(imageMetadata).reduce((formatSizes, [format, images]) => {
    if (!formatSizes[format]) {
      const placeholder = images.find((image) => image.width === ImageWidths.PLACEHOLDER);
      // 11ty sorts the sizes in ascending order under the hood
      const largestVariant = images[images.length - 1];

      formatSizes[format] = {
        placeholder,
        largest: largestVariant,
      };
    }
    return formatSizes;
  }, {});


  // Chain class names w/ the classNames package; optional
  // const picture = `<picture class="${classNames('lazy-picture', className)}"> //removed to use without classNames
  const picture = `<picture class="lazy-picture">
  ${Object.values(imageMetadata)
    // Map each format to the source HTML markup
    .map((formatEntries) => {
      // The first entry is representative of all the others since they each have the same shape
      const { format: formatName, sourceType } = formatEntries[0];

      const placeholderSrcset = formatSizes[formatName].placeholder.url;
      const actualSrcset = formatEntries
        // We don't need the placeholder image in the srcset
        .filter((image) => image.width !== ImageWidths.PLACEHOLDER)
        // All non-placeholder images get mapped to their srcset
        .map((image) => image.srcset)
        .join(', ');

      return `<source type="${sourceType}" srcset="${placeholderSrcset}" data-srcset="${actualSrcset}" data-sizes="${sizes}">`;
    })
    .join('\n')}
    <img
      src="${formatSizes[baseFormat].placeholder.url}"
      data-src="${formatSizes[baseFormat].largest.url}"
      width="${width}"
      height="${height}"
      alt="${alt}"
      class="lazy-img"
      loading="lazy">
  </picture>`;

  return picture;


};

bronze avatar Jan 08 '22 23:01 bronze

@bronze Looks like my post may have a typo. I believe it should be this for the image width and height attributes:

width="${formatSizes[baseFormat].largest.width}"
height="${formatSizes[baseFormat].largest.height}"

AleksandrHovhannisyan avatar Jan 09 '22 16:01 AleksandrHovhannisyan

Hey @AleksandrHovhannisyan! Your article is amazing. It got met set up and running on my local servers and on Netlify dev. I'm just having an issue which has turned out to be quite a headache -- when I'm deploying to Netlify it just doesn't want to play nice. I get this error:

10:24:28 AM: [11ty] EleventyShortcodeError: Error with Nunjucks shortcode Image (via Template render error) 10:24:28 AM: [11ty] 3. ENOENT: no such file or directory, stat 'src/images/uploads/Worldwalker_Awakening.png' (via Template render error)

I've tried everything from modifying the fullSrc object, the frontmatter values for my posts, etc... and it always works well on the local server but I can't quite crack it on the actual netlify deploy. Any ideas?

KingScroll avatar Apr 28 '22 14:04 KingScroll

Actually, I fixed it. The real problem was the fact that I was trying to run a Synchronous version of the Image shortcode. The reason for that is that I have a nunjucks macro I was trying to get images in, and the error comes from the synchronous code. The Asynchronous code works perfectly.

I need to either figure out an alternative to macros, or get the synchronous version of the code right. If you have any ideas or insights, that would be cool!

KingScroll avatar Apr 28 '22 17:04 KingScroll

@KingScroll Glad you figured it out! Unfortunately, I don't believe you can use async shortcodes in Nunjucks macros. See the issue here: https://github.com/11ty/eleventy/issues/1613. I believe you'll need to use the synchronous version. But I recall running into issues with that as well, so unfortunately, I had to use Liquid for my site.

AleksandrHovhannisyan avatar Apr 28 '22 17:04 AleksandrHovhannisyan

@KingScroll Glad you figured it out! Unfortunately, I don't believe you can use async shortcodes in Nunjucks macros. See the issue here: 11ty/eleventy#1613. I believe you'll need to use the synchronous version. But I recall running into issues with that as well, so unfortunately, I had to use Liquid for my site.

Hello again! So I got rid of the macro (it was just for one element), so no more synchronous stuff! Bad news: it just doesn't work. I'm still getting the same error, unfortunately, which means that my problem wasn't quite what I thought it was. I'm still getting this error on Netlify:

4:12:49 PM: [11ty] Problem writing Eleventy templates: (more in DEBUG output) 4:12:49 PM: [11ty] 1. Having trouble rendering njk template ./src/content/projects/projects.njk (via TemplateContentRenderError) 4:12:49 PM: [11ty] 2. (./src/content/projects/projects.njk) 4:12:49 PM: [11ty] EleventyShortcodeError: Error with Nunjucks shortcode Image (via Template render error) 4:12:49 PM: [11ty] 3. ENOENT: no such file or directory, stat 'src/images/uploads/Worldwalker_Awakening.png' (via Template render error)

I suspect it might have something to do with the path modifications in the shortcode function.

My file structure is as follows:

image

The images copy over in their optimized format to public/images/uploads/ via PassthroughCopy.

It all works in my local server, but Netlify doesn't seem to want it. Do you think you can help me with this? I really can't quite crack it

KingScroll avatar Apr 28 '22 20:04 KingScroll

Many thanks for this article, everything is explained in an easy and objective way. Unfortunately, I'm having a problem with my code and I believe is something with my outputDir and urlPath configuration (which is weird since the structure is very similar to the one exemplified in the article). The only difference is that I use /dist/ as the output directory, not /_site/.

So i just changed this line of code outputDir: path.join('_site', imgDir),

To this outputDir: path.join('dist', imgDir),

The images were correctly copied to the /dist/assets/images/ directory, but instead of the image I'm receiving a text written "undefined" on my website.

Here's my imageShortcode: const imageShortcode = async ( relativeSrc, alt, className, widths = [null, 400, 800, 1280], formats = ['jpeg', 'webp'], sizes = '100vw' ) => { const { dir: imgDir } = path.parse(relativeSrc); const fullSrc = path.join('src', relativeSrc); const imageMetadata = await Image(fullSrc, { widths, formats, outputDir: path.join('dist', imgDir), urlPath: imgDir }); };

And this is how I'm using the shortcode inside the njk file: {% image "/assets/images/image-1.jpg", "image alt text", "(min-width: 30em) 50vw, 100vw" %}

Any idea what could be happening? (I apologize in advance if this is not the right place for my question)

werls avatar May 10 '22 20:05 werls

@werls This is the right place to ask, no worries. Sounds like your shortcode maybe isn't returning anything. Either that or some sort of async issue.

AleksandrHovhannisyan avatar May 10 '22 21:05 AleksandrHovhannisyan

Oops, my bad. Actually my shortcode wasn't returning anything. Solved now. Thank you!

werls avatar May 10 '22 23:05 werls

Do you see the possibility of having this flow over classical markdown image tags instead of having a liquid shortcodes?

I just want to keep images as simple markdown

![Some alternative text](images/example.jpg]

muratcorlu avatar Jul 20 '22 20:07 muratcorlu

@muratcorlu I wish! I tried to get that to work at some point but hit some roadblocks along the way. There's an open issue here where I've provided more context on the problem: https://github.com/11ty/eleventy/issues/2428#issuecomment-1152703912. Ben Holmes created a demo here that I almost got working: https://github.com/Holben888/11ty-image-optimization-demo. The TL;DR of the issue is that if you add a custom Markdown extension via 11ty's addExtension API, you opt out of 11ty processing your Markdown files for templating, so things like shortcodes and partials won't work.

AleksandrHovhannisyan avatar Jul 20 '22 20:07 AleksandrHovhannisyan

I think it would be possible to write a markdown-it plugin similar to this one (very old), but which would use the 11ty image plugin for processing images. It could be interesting to have both a regular 11ty shortcode and a markdown plugin. I might test this over the weekend (if I find the time) just to see what's possible...

solution-loisir avatar Jul 20 '22 21:07 solution-loisir

@solution-loisir Ooh, that's a clever idea! Let me know what you figure out.

AleksandrHovhannisyan avatar Jul 20 '22 22:07 AleksandrHovhannisyan

Hi @muratcorlu and @AleksandrHovhannisyan, I wrote a markdown-it plugin which uses the synchronous version of the eleventy-img plugin. This my first markdown-it plugin so it's probably not perfect. It serves as a proof of concept for the discussion. Here's the code. I did not publish it so it's used as a local function via the regular markdown-it API like:

const markdownIt = require('markdown-it');
const markdownItEleventyImg = require("./markdown-it/markdown-it-eleventy-img");

module.exports = function(config) {
  config.setLibrary('md', markdownIt ({
    html: true,
    breaks: true,
    linkify: true
  })
  .use(markdownItEleventyImg, {
    widths: [800, 500, 300],
    lazy: false
  });
} 

I think that the shortcode is more flexible and is much easier to write and maybe to maintain. But still, I see some value in using modern image format while keeping the authoring simple and comfortable. Especially if you have standard dimension for images in markdown. This could be developed much further (adding <figure>, controlling loading, etc.) Tell me what you think. Feel free to ask questions. Thanks for the challenge!

solution-loisir avatar Jul 24 '22 00:07 solution-loisir

@solution-loisir Very cool! I wish markdown-it supported async renderers 😞 (And had better docs for how to write plugins.) I bet you could take this idea further and have the plugin take a custom image rendering function as an option. That way, users can either supply a renderer that uses the 11ty image plugin or use something else entirely.

AleksandrHovhannisyan avatar Jul 24 '22 14:07 AleksandrHovhannisyan

I bet you could take this idea further and have the plugin take a custom image rendering function as an option.

That's a very good idea, and still provide a default function. I like that, I may fiddle with this a little. If it takes shape enough, I may consider publishing eventually. Thanks for your input! ☺️

solution-loisir avatar Jul 24 '22 14:07 solution-loisir

Hey, just to let you know markdown-it-eleventy-img is now live! @AleksandrHovhannisyan, I did consider your idea of providing a callback function to the user, but decided to go a different way. The main idea here is to provide the ability to use modern image formats while keeping the simplicity and the essence of markdown. I'm pretty new to all this so, check it out, use it, let me know what you think! :-)

solution-loisir avatar Jul 28 '22 04:07 solution-loisir

@solution-loisir Nice work! I'll take this for a spin when I have some downtime 🙂 My main reasoning for not using the 11ty image plugin directly is that it would make the plugin's API simpler (you wouldn't need to forward 11ty image's options to the plugin), and it would also give users more control over how they want to render their images. For example, my custom 11ty image shortcode is a bit more involved and has some custom rendering logic. But this sounds promising for simpler use cases.

AleksandrHovhannisyan avatar Jul 28 '22 11:07 AleksandrHovhannisyan

and it would also give users more control over how they want to render their images.

Fair point. I think it could be implemented side by side for a do it your way use case. It would complete the plugin nicely.

solution-loisir avatar Jul 28 '22 12:07 solution-loisir

@KingScroll Glad you figured it out! Unfortunately, I don't believe you can use async shortcodes in Nunjucks macros. See the issue here: 11ty/eleventy#1613. I believe you'll need to use the synchronous version. But I recall running into issues with that as well, so unfortunately, I had to use Liquid for my site.

Hello again! So I got rid of the macro (it was just for one element), so no more synchronous stuff! Bad news: it just doesn't work. I'm still getting the same error, unfortunately, which means that my problem wasn't quite what I thought it was. I'm still getting this error on Netlify:

4:12:49 PM: [11ty] Problem writing Eleventy templates: (more in DEBUG output) 4:12:49 PM: [11ty] 1. Having trouble rendering njk template ./src/content/projects/projects.njk (via TemplateContentRenderError) 4:12:49 PM: [11ty] 2. (./src/content/projects/projects.njk) 4:12:49 PM: [11ty] EleventyShortcodeError: Error with Nunjucks shortcode Image (via Template render error) 4:12:49 PM: [11ty] 3. ENOENT: no such file or directory, stat 'src/images/uploads/Worldwalker_Awakening.png' (via Template render error)

I suspect it might have something to do with the path modifications in the shortcode function.

My file structure is as follows:

image

The images copy over in their optimized format to public/images/uploads/ via PassthroughCopy.

It all works in my local server, but Netlify doesn't seem to want it. Do you think you can help me with this? I really can't quite crack it

Hi @KingScroll

I get the exact same error on Netlify.

But I only get it when it is images with transparency (png) I try to convert.

Everything works perfectly on my local machine. But when I try to build on Netlify it fails with the exact same error as you get.

If I the use the image (still png) but without transparency - it works like a charm on Netlify.

Do you know - @AleksandrHovhannisyan - if something related to images with a transparent background could be the cause of trouble?

MarkBuskbjerg avatar Oct 28 '22 22:10 MarkBuskbjerg

@MarkBuskbjerg Wish I could help, but it's hard to say without seeing the code for your site. My guess is that this is still a Nunjucks async issue in disguise, although if you say non-transparent PNGs work, that might not be the issue.

AleksandrHovhannisyan avatar Oct 29 '22 13:10 AleksandrHovhannisyan

I'm having a hard time wrapping my head around how to pass different widths in the shortcode than the defaults. If I'm using your defaults and would rather the img be 200px and 480px what would the shortcode look like?

miklb avatar Nov 27 '22 20:11 miklb

@miklb Since Nunjucks supports array expressions natively, you could do:

{% image 'src', 'alt', [100, 200, etc.] %}

Or, if you're using an object argument:

{% image src: 'src', alt: 'alt', widths: [100, 200, etc.] %}

In Liquid, things are unfortunately not as easy because it doesn't support array expressions out of the box; you have to split strings on a delimiter, like this:

{% assign widths = "100,200,300" | split: "," %}

That's a bit of a problem in situations like this where you want to have an array of numbers, not an array of strings. On my site, what I do is create an intermediate include that assembles my arguments as JSON and forwards them to my image shortcode:

https://github.com/AleksandrHovhannisyan/aleksandrhovhannisyan.com/blob/7ed63df3300ce0e1fc09d0f1219a8d38dad9c6ea/src/_includes/image.html#L1-L24

Allowing me to do this in Liquid:

{% include image.html src: "src", alt: "alt", widths: "[100, 200, 300]" %}

Such that the string of arrays, when JSON-parsed, becomes an array of numbers. A bit convoluted, but I don't know of any other workarounds. If you find one, do let me know!

AleksandrHovhannisyan avatar Nov 27 '22 21:11 AleksandrHovhannisyan

Thanks. Seems a little too convoluted for my needs. I may opt for two different shortcodes—one for full content width images and one for floated images.

miklb avatar Nov 29 '22 01:11 miklb

@miklb That makes a lot more sense! Good call.

AleksandrHovhannisyan avatar Nov 29 '22 01:11 AleksandrHovhannisyan

@AleksandrHovhannisyan just wanted to say I re-read your post and realized you already covered my question and after reading https://www.aleksandrhovhannisyan.com/blog/passing-object-arguments-to-liquid-shortcodes-in-11ty/ I better understand your include. I hated the idea of duplicating code for one argument. Cheers.

miklb avatar Nov 30 '22 04:11 miklb

@miklb Fwiw, I think your proposed solution would've also worked. This is what I imagined:

const specialImage = async (args) => {
  const image = await imageShortcode({ ...args, widths: [100, 200, etc.] });
  return image;
}

And then you could register that as its own shortcode and use it:

{% specialImage 'src', 'alt' %}

Either way works, though! The include approach is a little more flexible in Liquid in case you need to vary other arguments as well and want to use named arguments.

AleksandrHovhannisyan avatar Nov 30 '22 19:11 AleksandrHovhannisyan

Hello, so I'm using image.liquid not image.html in my _includes to create an intermediate include but I'm running into the following issue:

Screen Shot 2022-12-19 at 9 49 17 AM

The include in the index looks like this:

{% include 'image', src: 'assets/image-01.jpg', alt: 'this is s test' %}

And the JS shortcode looks like this as following your guide

const imageShortcode = async ( src, alt, className = undefined, widths = [400, 800, 1280], formats = ['webp', 'jpeg'], sizes = '100vw' ) => { const imageMetadata = await Image(src, { widths: [...widths, null], formats: [...formats, null], outputDir: '_site/assets/images', urlPath: '/assets', }) const imageAttributes = { alt, sizes, loading: 'lazy', decoding: 'async', } return Image.generateHTML(imageMetadata, imageAttributes) }

Along with the global filter

eleventyConfig.addFilter('fromJson', JSON.parse)

Am I missing something?

truleighsyd avatar Dec 19 '22 15:12 truleighsyd