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:
- 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 {}
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.
Previous Part
I Boosted the Site's Performance with Better Images
Next Part