import { mkdirSync, statSync, writeFileSync } from "node:fs"; import { basename, extname, join, resolve } from "node:path"; import { normalizeImageWidths, type ImageFormat } from "./index.ts"; export interface ImageProcessorResult { data: Uint8Array; width: number; height: number; } export interface ImageProcessor { transform( input: string, options: { width: number; format: Exclude; quality: number }, ): Promise; } export interface OptimizeImageOptions { outputDir: string; widths: number[]; formats?: Array>; quality?: number; maxVariants?: number; processor?: ImageProcessor; } export interface OptimizedImageVariant { path: string; width: number; height: number; format: Exclude; bytes: number; } export interface OptimizedImageManifest { source: string; variants: OptimizedImageVariant[]; } async function sharpProcessor(): Promise { const packageName = "sharp"; let sharp: any; try { const module = (await import(packageName)) as { default?: any }; sharp = module.default ?? module; } catch (error) { throw new Error( "WRN-IMAGE-SHARP-MISSING: install the optional `sharp` peer or provide an ImageProcessor", { cause: error }, ); } return { async transform(input, options) { const pipeline = sharp(input) .rotate() .resize({ width: options.width, withoutEnlargement: true }); const { data, info } = await pipeline .toFormat(options.format, { quality: options.quality }) .toBuffer({ resolveWithObject: true }); return { data: new Uint8Array(data), width: info.width, height: info.height }; }, }; } export async function optimizeImage( input: string, options: OptimizeImageOptions, ): Promise { const widths = normalizeImageWidths(options.widths); const formats = [...new Set(options.formats ?? ["avif", "webp"])] as Array< Exclude >; if (!widths.length) throw new RangeError("Image optimization requires at least one valid width"); if ( !formats.length || formats.some((format) => !["avif", "webp", "jpeg", "png"].includes(format)) ) throw new RangeError("Image optimization has an unsupported output format"); const maxVariants = options.maxVariants ?? 32; if (!Number.isInteger(maxVariants) || maxVariants < 1) throw new RangeError("Image maxVariants must be positive"); if (widths.length * formats.length > maxVariants) throw new RangeError(`Image optimization exceeds ${maxVariants} variants`); const quality = Math.max(1, Math.min(100, Math.round(options.quality ?? 80))); const processor = options.processor ?? (await sharpProcessor()); const outputDir = resolve(options.outputDir); mkdirSync(outputDir, { recursive: true }); const stem = basename(input, extname(input)).replace(/[^A-Za-z0-9._-]+/g, "-") || "image"; const variants: OptimizedImageVariant[] = []; for (const format of formats) { for (const width of widths) { const result = await processor.transform(resolve(input), { width, format, quality }); if ( !Number.isInteger(result.width) || result.width < 1 || !Number.isInteger(result.height) || result.height < 1 ) throw new Error("Image processor returned invalid dimensions"); const path = join(outputDir, `${stem}-${result.width}.${format === "jpeg" ? "jpg" : format}`); writeFileSync(path, result.data); variants.push({ path, width: result.width, height: result.height, format, bytes: statSync(path).size, }); } } return { source: resolve(input), variants }; }