Eleventy: Responsive Images
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](https://github.com/11ty/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.
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: