a futuristic background, 7 image icons next to each other, the image icons are getting smaller and smaller

Automatic Responsive Image Generation with an Astro Hook

Published on:

Resized, compressed, formatted pictures. Use an Astro hook to automatically format images. I use typescript in this post. The full code written in this post can be found at GitHub Gist: ImageCompressorIntegration.ts.

Introduction

Get the most out of the images on your site. I optimized the thumbnails on my personal blog, this is the image generation part of it.

The different methods I used to format the images:

To actually generate the methods, I made an Astro hook that runs at build.

Implementing the Methods

Setup

Let’s use a class for this. Name it however you want, I used ImageGenerator.

class ImageGenerator {}

One special custom type is required. Image related sizes will be used frequently.

type imageSize = { width: number, height: number };

To resize the images a naming convention must be created, because multiple images will be generated. The typical that is used is: [NAME]-[WIDTH]-[HEIGHT].[EXTENSION]. For example if it resizes image.png to 600 width and 315 height and changes its format to WebP the name of the image will be image-600-315.webp. This name generation will be used frequently, so let’s create a method for that. The NAME, WIDTH and HEIGHT, EXTENSION are parameters. Because at the end it will only use wepb, instead of EXTENSION I will use a boolean that indicates if the result file extension is webp or not.

import path from 'path';

class ImageGenerator {
  static generateName(filePath: string, size: imageSize, isWebp: boolean = false) {
    const extension = (isWebp ? ".webp" : path.extname(filePath));
    const justName = path.join(path.dirname(filePath), path.basename(filePath, path.extname(filePath))); // /directory/file.txt -> /directory/file
    return [justName, String(size.width), String(size.height)].join("-") + extension; // join the 3 parts and add extension
  }
}

I am going to predefine the sizes of my thumbnails.

class ImageGenerator {
  public static readonly thumbnailMetaSizes = [
    { width: 1200, height: 630 },
    { width: 600, height: 315 },
    { width: 360, height: 189 },
    { width: 736, height: 414 },
  ];
}

To actually edit the images, we are going to use sharp. Sharp is a high-performance image processing library. You can do all the cool things we want to do with it. Install the library then import it with import sharp from 'sharp'.

Process

Let’s edit the images.

First, load in the image with sharp.

class ImageGenerator {
  static processImage(filepath: string) { // without a filepath we can't open it :D
    const image = sharp(filepath);
  }
}

We need to resize the image to multiple sizes and edit each of them. That is why a imageSize[] parameter is needed. It is very easy to resize images with sharp, it also allows to select the object-fit of the image. Object fit tells how the image should look like if the resized ratio is not the original one. With object fit, I can define that it will always show the center part of the image. We have to make sure to clone the image before doing anything with it, so the resize will always happen with the original one.

class ImageGenerator {
  static processImage(filepath: string, sizes: imageSize[]) {
    const image = sharp(filepath);
    for (const size of sizes) {
      const resizedImage = image.clone().resize(size.width, size.height, { fit: 'cover', withoutEnlargement: true });
      // compress, remove metadata, save to png and webp
    }
  }
}

Next, format the file to WebP. I want to leave this optional, so needWebp argument will be added to our method definition. toFormat method is used for converting between formats.

class ImageGenerator {
  static async processImage(filepath: string, sizes: imageSize[], needWebp: boolean = false) {
    const image = //...
    for (const size of sizes) {
      const resizedImage = //...
      for (const isWebp of (needWebp ? [true, false] : [false])) { // we are going to run compress and metadata modification for each format
        await resizedImage.toFormat((isWebp ? "webp" : path.extname(filepath).slice(1) as any));
      }
    }
  }
}

To compress, add {quality: 100} as a parameter to toFormat method. To remove metadata call withMetadata after the formatting is done.

await resizedImage.toFormat((isWebp ? "webp" : path.extname(filepath).slice(1) as any), { quality: 100 }).withMetadata();

The last thing is to save the new images. We already implemented generateName, we just have to use it.

class ImageGenerator {
  static async processImage(filepath: string, sizes: imageSize[], needWebp: boolean = false) {
    const image = //...
    const basePath = path.dirname(filepath) + path.sep;
    for (const size of sizes) {
      //...
      for (const isWebp of (needWebp ? [true, false] : [false])) {
        const outPath = ImageCompressorIntegration.generateName(filepath, size, isWebp);
        await resizedImage.toFormat((isWebp ? "webp" : path.extname(filepath).slice(1) as any), { quality: 100 }).withMetadata().toFile(outPath);
      }
    }
  }
}

I don’t need the original image, so I added an option to delete it automatically.

import fs from 'fs';

class ImageGenerator {
  static async processImage(filepath: string, sizes: imageSize[], needWebp: boolean = false, removeOriginal: boolean = false) {
    //...
    if (removeOriginal) {
      fs.rm(filepath, () => { }); // rm to remove
    }
  }
}

We are done with our ImageGenerator class.

Astro Hook

To automatically generate the images a custom Astro integration is required.

There are 3 build related hooks: start, setup, generated. We are going to use astro:build:generated, so it will run after Astro is fully done with the build.

Inside the same TypeScript file as our ImageGenerator class export a function that will return the integration’s settings.

import type { AstroIntegration } from "astro";

export default function createPlugin(): AstroIntegration {
  return {
    name: "ImageCompressorIntegration",
    hooks: {
      "astro:build:generated": async (options: { dir: URL }) => {} // make it async because our processImage is async too
    }
  };
}

All we have to do is call processImage for all images we want to process. I want to process all files in the thumbnail directory. The built project’s path can be accessed from the options dir argument.

export default function createPlugin(): AstroIntegration {
  return {
    name: "ImageCompressorIntegration",
    hooks: {
      "astro:build:generated": async (options: { dir: URL }) => {
        const distPath = options.dir.pathname;
        const allFiles = readDirectoryRecursive(path.join(distPath, "thumbnail"));
        for (const [i, imgPath] of allFiles.entries()) {
          await ImageGenerator.processImage(imgPath, ImageGenerator.thumbnailMetaSizes, true, true);
          console.log(`${path.basename(imgPath)} image sizes generated (${i+1}/${allFiles.length})`);
        }
      }
    }
  };
}

readDirectoryRecursive function’s code can be found at GitHub Gist: ImageCompressorIntegration.ts

To finish it, use your integration in astro.config.mjs.


In the next part I will show how I use the images on the site.

Read more

a galaxy background, lots of image icons laying around, in the center there is a rocket launching up

I Boosted the Site's Performance with Better Images

card list icon, zoom in icon, clock icon, copy icon, with a nice mountain view in the background

Series Feature, Zoomable Images