Automated Image Compression: A Vite Plugin Using Sharp
Tags: webdev, javascript, vite, sharp
Published: 2023-08-17 07:27:04

**Optimizing Images with Vite and the Sharp Library**

I'd like to discuss a method that leverages the sharp library to create a simple Vite plugin. The purpose of this plugin is to:
1. Compress original images.
2. Generate featherweight versions in `.webp` and `.avif` formats.
3. Provide a minimum of manual work during development.

**TL;DR**:
- Github Repository: [Link](https://github.com/MrWaip/optimize-image/tree/master)

<figure>
  <img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/89ulmgz982mw8thr204e.png"><figcaption>Compression Details: Original image (~3.1 MB) -> After compression (~746 KB), webp (~92 kB), .avif (~66 kB).</figcaption>
</figure>

**Why Opt for This?**
- Modern image formats like `.webp` and `.avif` offer a significant reduction in size while maintaining transparency.
- Oftentimes, provided images in projects aren't compressed, leading to unnecessary bloat.
- Manual image compression, using tools like tinypng, is tedious. Automating this process is the way forward.

Using the `sharp` library will be pivotal to this endeavor.
- Sharp Documentation: [Link](https://sharp.pixelplumbing.com/)

**The Concept**:
When we import our image as `import Image from './images/hero.png?optimized';`, the plugin should not merely return an image link. Instead, it should return an object containing multiple links. This object can then be fed into a `<picture>` HTML tag to be rendered accordingly.

Here's a Svelte component (`Picture.svelte`) to demonstrate this:

```typescript
<script lang="ts">
 type OptimizedSrc = {
  avif?: string;
  webp?: string;
  fallback: string;
 };

 export let src: OptimizedSrc | string;

 function getSource(src: OptimizedSrc | string): OptimizedSrc {
  if (typeof src === 'string') return { fallback: src };
  return src;
 }

 $: sources = getSource(src);
</script>

<picture>
 {#if sources.avif}
  <source srcset={sources.avif} type="image/avif" />
 {/if}
 {#if sources.webp}
  <source srcset={sources.webp} type="image/webp" />
 {/if}
 <img src={sources.fallback} alt="" />
</picture>
```

Usage:

```typescript
<script lang="ts">
 import Image from '../images/source.png?optimized';
 import Picture from './Picture.svelte';
</script>

<div>
 <Picture src={Image} />
</div>
```

**Crafting the Plugin**:
I recommend referring to the thorough documentation provided by Vite and Rollup:
- [Vite Plugin Guide](https://vitejs.dev/guide/api-plugin.html)
- [Rollup Plugin Development](https://rollupjs.org/plugin-development/)
- [Scaffolding with Vite](https://vitejs.dev/guide/#scaffolding-your-first-vite-project): `npm create vite@latest my-dir --template svelte-ts`

Upon setting up your application, ensure you register the plugin in the Vite config. It should be prioritized first.

```typescript
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { imageOptimizerPlugin } from './imageOptimizerPlugin';

export default defineConfig({
 plugins: [imageOptimizerPlugin(), sveltekit()]
});
```

Actually, the entire code of the plugin itself.

```typescript
// imageOptimizerPlugin.ts

import path, { basename, extname } from 'node:path';

import { Plugin } from 'vite';
import sharp from 'sharp';

const pattern = '?optimized';

const isProd = process.env.NODE_ENV === 'production';

function isIdForOptimization(id: string | undefined) {
 return id?.includes(pattern);
}

const forSharpPattern = /\?(webp|avif|fallback)/;

function isIdForSharp(id: string | undefined) {
 return forSharpPattern.test(id || '');
}

function resolveId(id: string, importer: string) {
 return path.resolve(path.dirname(importer), id);
}

export const imageOptimizerPlugin = (): Plugin[] => {

 return [
  {
   name: '?sharp-handler',
   enforce: 'pre',
   async resolveId(id, importer) {
    if (!isIdForSharp(id)) return;

    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    return resolveId(id, importer!);
   },
   async load(id) {
    if (!isIdForSharp(id)) return;

    const unwrappedId = id.replace(forSharpPattern, '');
    let [, extension] = id.split('?');

    let buffer: Uint8Array;

    if (extension === 'fallback') {
     buffer = await sharp(unwrappedId)
      .png({ quality: 70, effort: 7, compressionLevel: 6 })
      .toBuffer();
    } else if (extension === 'webp') {
     buffer = await sharp(unwrappedId).webp({ quality: 80 }).toBuffer();
    } else {
     buffer = await sharp(unwrappedId).avif({ quality: 60 }).toBuffer();
    }

    if (extension === 'fallback') extension = extname(unwrappedId).replace('.', '');

    const name = basename(unwrappedId, extname(unwrappedId)) + `.${extension}`;

    const referenceId = this.emitFile({
     type: 'asset',
     name: name,
     needsCodeReference: true,
     source: buffer
    });

    return `export default import.meta.ROLLUP_FILE_URL_${referenceId};`;
   }
  },
  {
   name: '?optimized-handler',
   enforce: 'pre',
   async resolveId(id, importer) {
    if (!isIdForOptimization(id)) return;

    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    return resolveId(id, importer!);
   },
   async load(id) {
    if (!isIdForOptimization(id)) return;

    const unwrappedId = id.replace(pattern, '');

    if (!isProd) {
     return {
      code: `import fallback from "${unwrappedId}";` + `export default { fallback };`,
      map: null
     };
    }

    const webp = JSON.stringify(unwrappedId + '?webp');
    const avif = JSON.stringify(unwrappedId + '?avif');
    const fallback = JSON.stringify(unwrappedId + '?fallback');

    return (
     `import webp from ${webp};` +
     `import avif from ${avif};` +
     `import fallback from ${fallback};` +
     `export default {webp, avif, fallback};`
    );
   }
  }
 ];
};
```

**Let's discuss how it works**
Your main Vite plugin is actually composed of two sub-plugins:

- **The Sharp Handler (`?sharp-handler`)**:
  - **Responsibilities**: Processes actual image formats.
  - **Resolution Logic**: When resolving image IDs, it filters out images that don't need processing through Sharp.
  - **Loading Logic**: Based on the specific image format to be generated, Sharp parameters are adjusted. Each image format (`.webp`, `.avif`, or the compressed original) is generated with its respective settings. The processed image buffer is then emitted as an asset with a reference ID.

- **The Optimized Handler (`?optimized-handler`)**:
  - **Responsibilities**: Handles custom image imports.
  - **Resolution Logic**: Targets only the images flagged for optimization.
  - **Loading Logic**: In development mode, it returns the original image for faster performance. In production mode, it processes the image to generate the optimized versions and constructs the export statement with all available formats.

**Dev vs. Prod Mode:**
The plugin behaves differently based on the environment:

- In **development mode**, image optimization is bypassed to prioritize speed, returning the original image.
  
- In **production mode**, images are fully processed, generating both `.webp` and `.avif` formats along with a compressed original.

Lastly, to prevent linting errors due to our custom import, notify TypeScript about the expected return format from our tailored module:

```typescript
// optimized-module.d.ts
declare module '*?optimized' {
 const value: {
  webp?: string;
  avif?: string;
  fallback: string;
 };
 export default value;
}
```

In conclusion, with this plugin, your images will be automatically optimized and converted. For example, in my test repo, a 3.1 MB image was efficiently reduced to 746 KB, with `.webp` and `.avif` versions weighing only 92 kB and 66 kB, respectively. 

What it will look like in the end:
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dffz4vwelxbxkp5xw0hw.png)


Thanks for reading! I trust you'll find this method handy. 😄