1import fs from "node:fs/promises";
2import path from "node:path";
3import { glob } from "glob";
4import sharp from "sharp";
5import type { Plugin } from "vite";
6
7interface ImageResizeOptions {
8 sourceDir?: string;
9 outputDir?: string;
10 quality?: number;
11 formats?: Array<"jpeg" | "png" | "webp">;
12 manifestFile?: string;
13}
14
15interface ImageManifest {
16 original: { width: number; height: number };
17 altText: string;
18 sizes: {
19 width: number;
20 height: number;
21 path: string;
22 }[];
23}
24
25interface AltTextJson {
26 altText: string;
27}
28
29export function imageResize(options: ImageResizeOptions = {}): Plugin {
30 const {
31 sourceDir = "public/originals",
32 outputDir = "public",
33 quality = 85,
34 formats = ["webp"],
35 manifestFile = "src/image-manifest.ts",
36 } = options;
37
38 // generate sizes based on original dimensions
39 const generateSizes = (width: number, height: number) => {
40 const aspectRatio = width / height;
41 const maxDimension = Math.max(width, height);
42 const sizes: Array<{ width: number; height: number; suffix: string }> = [];
43
44 const breakpoints = [64, 128, 256, 320, 480, 640, 768, 1024, 1280, 1920];
45
46 // filter out breakpoints smaller than original
47 const validBreakpoints = breakpoints.filter((bp) => bp < maxDimension);
48
49 // always include some small sizes for thumbnails
50 if (!validBreakpoints.includes(64)) validBreakpoints.unshift(64);
51 if (!validBreakpoints.includes(128)) validBreakpoints.unshift(128);
52
53 for (const bp of validBreakpoints) {
54 let newWidth: number;
55 let newHeight: number;
56
57 if (width >= height) {
58 // landscape or square - constrain by width
59 newWidth = bp;
60 newHeight = Math.round(bp / aspectRatio);
61 } else {
62 // portrait - constrain by height
63 newHeight = bp;
64 newWidth = Math.round(bp * aspectRatio);
65 }
66
67 sizes.push({
68 width: newWidth,
69 height: newHeight,
70 suffix: `${newWidth}x${newHeight}`,
71 });
72 }
73
74 return sizes;
75 };
76
77 const processImage = async (
78 filePath: string,
79 allManifests: Record<string, ImageManifest>,
80 ) => {
81 try {
82 const fileName = path.basename(filePath, path.extname(filePath));
83 const baseName = path.join(path.dirname(filePath), fileName);
84
85 // get original metadata
86 const metadata = await sharp(filePath).metadata();
87 if (!metadata.width || !metadata.height) {
88 console.warn(` could not get dimensions for ${filePath}`);
89 return;
90 }
91
92 console.log(
93 ` processing ${fileName}: ${metadata.width}x${metadata.height}`,
94 );
95
96 // load alt text from json file
97 let altText = "";
98 const altTextFilePath = `${baseName}.json`;
99 try {
100 const altTextContent = await fs.readFile(altTextFilePath, "utf-8");
101 const altTextJson: AltTextJson = JSON.parse(altTextContent);
102 altText = altTextJson.altText;
103 } catch (altTextError) {
104 if (altTextError.code === "ENOENT") {
105 console.warn(
106 ` no alt text JSON found for ${fileName}. using empty string.`,
107 );
108 } else {
109 console.error(
110 ` error reading alt text JSON for ${fileName}:`,
111 altTextError,
112 );
113 }
114 }
115
116 // generate sizes & create output directory
117 const sizes = generateSizes(metadata.width, metadata.height);
118 const imageOutputDir = path.join(outputDir, fileName);
119 await fs.mkdir(imageOutputDir, { recursive: true });
120
121 for (const size of sizes) {
122 for (const format of formats) {
123 const outputFile = path.join(
124 imageOutputDir,
125 `${size.suffix}.${format}`,
126 );
127
128 let pipeline = sharp(filePath).resize(size.width, size.height, {
129 fit: "cover",
130 position: "center",
131 });
132
133 if (format in pipeline) pipeline = pipeline[format]({ quality });
134 else {
135 console.warn(
136 ` unsupported format ${format} for ${filePath}, skipping`,
137 );
138 continue;
139 }
140
141 await pipeline.toFile(outputFile);
142 console.log(` ${size.suffix}.${format}`);
143 }
144 }
145
146 // generate sizes manifest
147 const manifest = {
148 altText,
149 original: { width: metadata.width, height: metadata.height },
150 sizes: sizes.map((s) => ({
151 width: s.width,
152 height: s.height,
153 path: `/${fileName}/${s.suffix}.${formats[0]}`,
154 })),
155 };
156
157 allManifests[fileName] = manifest;
158 await fs.writeFile(
159 path.join(imageOutputDir, "manifest.json"),
160 JSON.stringify(manifest, null, 2),
161 );
162 } catch (error) {
163 console.error(` error processing ${filePath}:`, error);
164 }
165 };
166
167 const processAllImages = async () => {
168 console.log("detecting and processing images...");
169
170 const pattern = path.join(sourceDir, "**/*.{jpg,jpeg,png,webp,avif}");
171 const files = await glob(pattern);
172
173 if (files.length === 0) {
174 console.log(` no images found in ${sourceDir}`);
175 return;
176 }
177
178 const allManifests: Record<string, ImageManifest> = {};
179 for (const file of files) {
180 await processImage(file, allManifests);
181 }
182
183 await generateTypeScriptManifest(allManifests);
184 console.log("image processing completed");
185 };
186
187 const generateTypeScriptManifest = async (
188 manifests: Record<string, ImageManifest>,
189 ) => {
190 try {
191 let tsContent = `/* This file is automatically generated by the image resizer. */\n\n`;
192 tsContent += `export const imageManifests = ${JSON.stringify(manifests, null, 2)} as const;\n\n`;
193 tsContent += `export type ImageManifests = typeof imageManifests;\n`;
194
195 await fs.writeFile(manifestFile, tsContent);
196 console.log(` TypeScript manifest file created at ${manifestFile}`);
197 } catch (error) {
198 console.error(" Error writing TypeScript manifest file:", error);
199 }
200 };
201
202 return {
203 name: "vite-image-resize",
204 buildStart: async () => {
205 await processAllImages();
206 },
207 };
208}