import fs from "node:fs/promises"; import path from "node:path"; import { glob } from "glob"; import sharp from "sharp"; import type { Plugin } from "vite"; interface ImageResizeOptions { sourceDir?: string; outputDir?: string; quality?: number; formats?: Array<"jpeg" | "png" | "webp">; manifestFile?: string; } interface ImageManifest { original: { width: number; height: number }; altText: string; sizes: { width: number; height: number; path: string; }[]; } interface AltTextJson { altText: string; } export function imageResize(options: ImageResizeOptions = {}): Plugin { const { sourceDir = "public/originals", outputDir = "public", quality = 85, formats = ["webp"], manifestFile = "src/image-manifest.ts", } = options; // generate sizes based on original dimensions const generateSizes = (width: number, height: number) => { const aspectRatio = width / height; const maxDimension = Math.max(width, height); const sizes: Array<{ width: number; height: number; suffix: string }> = []; const breakpoints = [64, 128, 256, 320, 480, 640, 768, 1024, 1280, 1920]; // filter out breakpoints smaller than original const validBreakpoints = breakpoints.filter((bp) => bp < maxDimension); // always include some small sizes for thumbnails if (!validBreakpoints.includes(64)) validBreakpoints.unshift(64); if (!validBreakpoints.includes(128)) validBreakpoints.unshift(128); for (const bp of validBreakpoints) { let newWidth: number; let newHeight: number; if (width >= height) { // landscape or square - constrain by width newWidth = bp; newHeight = Math.round(bp / aspectRatio); } else { // portrait - constrain by height newHeight = bp; newWidth = Math.round(bp * aspectRatio); } sizes.push({ width: newWidth, height: newHeight, suffix: `${newWidth}x${newHeight}`, }); } return sizes; }; const processImage = async ( filePath: string, allManifests: Record, ) => { try { const fileName = path.basename(filePath, path.extname(filePath)); const baseName = path.join(path.dirname(filePath), fileName); // get original metadata const metadata = await sharp(filePath).metadata(); if (!metadata.width || !metadata.height) { console.warn(` could not get dimensions for ${filePath}`); return; } console.log( ` processing ${fileName}: ${metadata.width}x${metadata.height}`, ); // load alt text from json file let altText = ""; const altTextFilePath = `${baseName}.json`; try { const altTextContent = await fs.readFile(altTextFilePath, "utf-8"); const altTextJson: AltTextJson = JSON.parse(altTextContent); altText = altTextJson.altText; } catch (altTextError) { if (altTextError.code === "ENOENT") { console.warn( ` no alt text JSON found for ${fileName}. using empty string.`, ); } else { console.error( ` error reading alt text JSON for ${fileName}:`, altTextError, ); } } // generate sizes & create output directory const sizes = generateSizes(metadata.width, metadata.height); const imageOutputDir = path.join(outputDir, fileName); await fs.mkdir(imageOutputDir, { recursive: true }); for (const size of sizes) { for (const format of formats) { const outputFile = path.join( imageOutputDir, `${size.suffix}.${format}`, ); let pipeline = sharp(filePath).resize(size.width, size.height, { fit: "cover", position: "center", }); if (format in pipeline) pipeline = pipeline[format]({ quality }); else { console.warn( ` unsupported format ${format} for ${filePath}, skipping`, ); continue; } await pipeline.toFile(outputFile); console.log(` ${size.suffix}.${format}`); } } // generate sizes manifest const manifest = { altText, original: { width: metadata.width, height: metadata.height }, sizes: sizes.map((s) => ({ width: s.width, height: s.height, path: `/${fileName}/${s.suffix}.${formats[0]}`, })), }; allManifests[fileName] = manifest; await fs.writeFile( path.join(imageOutputDir, "manifest.json"), JSON.stringify(manifest, null, 2), ); } catch (error) { console.error(` error processing ${filePath}:`, error); } }; const processAllImages = async () => { console.log("detecting and processing images..."); const pattern = path.join(sourceDir, "**/*.{jpg,jpeg,png,webp,avif}"); const files = await glob(pattern); if (files.length === 0) { console.log(` no images found in ${sourceDir}`); return; } const allManifests: Record = {}; for (const file of files) { await processImage(file, allManifests); } await generateTypeScriptManifest(allManifests); console.log("image processing completed"); }; const generateTypeScriptManifest = async ( manifests: Record, ) => { try { let tsContent = `/* This file is automatically generated by the image resizer. */\n\n`; tsContent += `export const imageManifests = ${JSON.stringify(manifests, null, 2)} as const;\n\n`; tsContent += `export type ImageManifests = typeof imageManifests;\n`; await fs.writeFile(manifestFile, tsContent); console.log(` TypeScript manifest file created at ${manifestFile}`); } catch (error) { console.error(" Error writing TypeScript manifest file:", error); } }; return { name: "vite-image-resize", buildStart: async () => { await processAllImages(); }, }; }