Automatic Responsive Image Generation with an Astro Hook
Tags: webdev, astro, performance, typescript
Published: 2023-05-12 16:01:49

Resized, compressed, formatted pictures. Use an [Astro](https://astro.build/) 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](https://gist.github.com/11Firefox11/b371c3f6ad3acc8213772260fd8973e9).

## Introduction
Get the most out of the images on your site. I [optimized the thumbnails on my personal blog](https://tomoviktor.com//posts/image-performance-beginning), this is the image generation part of it.

The different methods I used to format the images:
- Resize to different sizes
- Convert to [WebP](https://en.wikipedia.org/wiki/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`.
```ts
class ImageGenerator {}
```

One special custom type is required. Image related sizes will be used frequently.
```ts
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.
```ts
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.
```ts
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`](https://sharp.pixelplumbing.com/). Sharp is a high-performance image processing library. You can do all the cool things we want to do with it. [Install the library](https://sharp.pixelplumbing.com/install) then import it with `import sharp from 'sharp'`.

### Process
Let's edit the images.

First, load in the image with sharp.
```ts
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](https://sharp.pixelplumbing.com/api-resize), it also allows to select the [object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/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.
```ts
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](https://sharp.pixelplumbing.com/api-output#toformat) is used for converting between formats.
```ts
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.
```ts
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.
```ts
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.
```ts
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](https://docs.astro.build/en/reference/integrations-reference/#hooks) [Astro integration](https://docs.astro.build/en/guides/integrations-guide/#using-integrations) is required.

There are 3 build related hooks: `start`, `setup`, `generated`. We are going to use [`astro:build:generated`](https://docs.astro.build/en/reference/integrations-reference/#astrobuildgenerated), 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.
```ts
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.
```ts
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](https://gist.github.com/11Firefox11/b371c3f6ad3acc8213772260fd8973e9#file-imagecompressorintegration-ts-L24-L35)

To finish it, [use your integration in `astro.config.mjs`](https://docs.astro.build/en/guides/integrations-guide/#using-integrations).


<br/>


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