DEV Community

Cover image for Automatic Responsive Image Generation with an Astro Hook
Tömő Viktor
Tömő Viktor

Posted on • Originally published at tomoviktor.com

Automatic Responsive Image Generation with an Astro Hook

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:

  • Resize to different sizes
  • Convert to WebP format
  • Compress
  • Remove all metadata

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 {}
Enter fullscreen mode Exit fullscreen mode

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

type imageSize = { width: number, height: number };
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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 },
  ];
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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})`);
        }
      }
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)