Profile picture Schedule a Meeting
c a n d l a n d . n e t

Eleventy: Responsive Images

Dusty Candland | | 11ty, eleventy, netlify, github, route285

Route285 is a site of Colorado product companies. I have images of logos & products from those sites, but need to download and display them locally, in case an image changes later. Also, since eleventy-img supports resizing and different formats I wanted to get that stuff working too.

I ran into a couple of snags. First ICO isn't supported, that was pretty easy to work around, but I still wanted a local version of the file. The useOriginalImage function handles that. Plus it's helpful for errors and SVG files.

Also, since it's an addNunjucksAsyncShortcode in 11ty, I needed to change my for blocks to asyncEach blocks. That took a while to figure out as it just results in those templates returning nothing.

The Shortcode

This is largely take from the eleventy-img README page and some code from the plugin. I also added some breakpoint support so the Picture element can use the correct media queries.

Icon (ICO) files are just downloaded locally as are SVG files.

Errors in resizing will result in just using the original source image, but the local version.

const Image = require("@11ty/eleventy-img");
const path = require("path")
const fs = require("fs-extra")
const { URL } = require("url");
var crypto = require('crypto');
const CacheAsset = require("@11ty/eleventy-cache-assets");
...

module.exports = function (eleventyConfig) {

  let outputDir = "_site"
  if (process.env.ELEVENTY_ENV == "production") {
    outputDir = "build"
  }

  const IMAGE_OUT_DIR = `./${outputDir}/public/images/`
  const IMAGE_URL_PATH = "/public/images/"

  const BREAK_POINTS = {
    xs: "max-width: 320px",
    sm: "max-width: 640px",
    md: "max-width: 768px",
    lg: "max-width: 1024px",
    xl: "min-width: 1025px"
  }

  eleventyConfig.addNunjucksAsyncShortcode("responsiveImage", async function(src, alt, breakWidths, classes = "", style = "", debug = false) {
    if (alt === undefined) {
      throw new Error(`Missing \`alt\` on responsiveImage from: ${src}`);
    }

    const copts = {
      duration: "30d",
      directory: ".cache",
      removeUrlQueryParams: false,
    }

    let local
    try {
      // Cache all
      local = await useOriginalImage(src, copts, IMAGE_OUT_DIR, IMAGE_URL_PATH)

      if (src.toLowerCase().endsWith(".svg")) {
        // TODO maybe figure out svg width/height for aspect ratio?
        return `<img src="${local}" alt="${alt}" class="${classes}" style="${style}">`;
      }
      if (src.toLowerCase().endsWith(".ico")) {
        return `<img src="${local}" width="32" height="32" alt="${alt}">`;
      }

      const widths = Object.values(breakWidths)
      const breaks = Object.keys(breakWidths).map(key => BREAK_POINTS[key])

      const url = new URL(src)
      let originalOutputFormat = path.extname(url.pathname).slice(1).toLowerCase()
      if (originalOutputFormat === "") { // if no ext, assume jpg
        originalOutputFormat = "jpg"
      }

      // returns Promise
      let stats = await Image(src, {
        widths: widths,
        formats: ["webp", originalOutputFormat],
        urlPath: IMAGE_URL_PATH,
        outputDir: IMAGE_OUT_DIR,
        cacheOptions: copts
      })

      if (debug) {
        console.log(src)
        console.log(stats)
      }

      let defaultImage = stats[originalOutputFormat].pop().url

      let imageTag = `<picture>
        ${Object.values(stats).flatMap(imageFormat => {
          return imageFormat.map((entry, idx) => {
            let media = ""
            if (breaks[idx] != null) {
              media = `media="(${breaks[idx]})" `
            }
            if (entry.format != originalOutputFormat && idx == (imageFormat.length - 1)) {
              media = ""
            }
            return `  <source ${media}type="image/${entry.format}" srcset="${entry.url}">`;
          })
        }).join("\n")}
        <img src="${defaultImage}" alt="${alt}" class="${classes}" style="${style}">
      </picture>`

      if (debug) {
        console.log(imageTag)
      }

      return imageTag
    } catch (err) {
      if (err.message === "Input buffer contains unsupported image format") {
        console.log("Wrong format... return nothing / default for: " + src)
        return ""
      }
      console.log(`${err.name}: ${err.message}: ${src}`)
      if (local) {
        return `<img src="${local}" alt="${alt}" class="${classes}" style="${style}">`
      }
      return ""
    }
  });

  // download & cache original file & write to output
  // returns url path to image
  async function useOriginalImage(src, copts, outputDir, urlDir) {
    let buffer = await await CacheAsset(src, Object.assign({
      type: "buffer"
    }, copts));

    await fs.ensureDir(outputDir)

    const url = new URL(src)
    const ext = path.extname(url.pathname)
    const md5src = crypto.createHash('md5').update(src).digest('hex');
    const fileName = md5src + ext
    const to = path.join(outputDir, fileName)

    fs.writeFileSync(to, buffer)

    return path.join(urlDir, fileName)
  }

  ...

  return {
    dir: {
      input: 'src'
    },
    markdownTemplateEngine: 'njk',
    templateFormats: [
      'html',
      'md',
      'njk'
    ],
    passthroughFileCopy: true
  }
}

Using the Shortcode

Here is where I got hung up for a while, and after looking through a bunch of GitHub issues that seemed related, I found the asyncEach function in the Nunjucks docs

Since the shortcode is async, any where it's used needs to also support async code. Here's an example of that.

{% asyncEach product in maker.products | toProducts(maker.name or maker.site_name, maker.url, maker.icon) %}
  <li class="w-full md:w-1/2 lg:w-1/3 p-4 mb-8 flex">
    {% include "product_snip.njk" %}
  </li>
{% endeach %}

Actually using the shortcode is straight forward.

  • Pass the image URL.
  • Pass the alt attribute value.
  • Pass the breakpoints and sizes, if you only need one size, the breakpoint doesn't matter. I used xs, extra small, where I needed just one size.
  • Optional class attribute value.
  • Optional style attribute value.
  • Optional debug flag that will print some details to the console.
{% responsiveImage product.image, ("Product image of " + product.title), {sm: 608, lg: 744, xl: 936}, "w-full mb-4" %}

Deployment

I'm deploying to Netlify, but using GitHub Actions to do the builds. Since I'm caching the remote images, I need the build server to have a cache. Both Netlify & GitHub offer solutions, but since I'm on GitHub, I stuck with that.

Here's the two additional tasks I added. One for node_modules and the other for .cache (where I'm caching downloads). They're both using the action/cache action.

# GitHub Workflow file

...

    - name: setup node_modules cache
      uses: actions/cache@v2
      with:
        path: '**/node_modules'
        key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}

    - name: setup dot cache dir
      uses: actions/cache@v2
      with:
        path: './.cache'
        key: ${{ runner.os }}-dot-cache

...

The first task came from the How to cache node_modules in GitHub Actions with Yarn article.

The Result

The output picture element with source's and the image element. If the browser supports WEBP, it will use those at the given breakpoints. Otherwise it will use the more standard format. All images are served from the Route285 website.

<picture>
  <source media="(max-width: 640px)" type="image/webp" srcset="/public/images/614c409d-608.webp">
  <source media="(max-width: 1024px)" type="image/webp" srcset="/public/images/614c409d-744.webp">
  <source type="image/webp" srcset="/public/images/614c409d-936.webp">
  <source media="(max-width: 640px)" type="image/jpg" srcset="/public/images/614c409d-608.jpg">
  <source media="(max-width: 1024px)" type="image/jpg" srcset="/public/images/614c409d-744.jpg">
  <img src="/public/images/614c409d-936.jpg" alt="Product image of 10-Year Jersey" class="w-full mb-4" style="">
</picture>

Check it out at Route285

Webmentions

These are webmentions via the IndieWeb and webmention.io. Mention this post from your site: