my website built with vue, plus lexicon definitions for moe.wlo.gallery.* vt3e.cat
at develop 5.6 kB view raw
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}