Optimize CMS images during builds
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { extname, resolve } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
allowedUploadExtensions,
|
||||
maxBuiltImageBytes,
|
||||
maxBuiltImageDimension,
|
||||
} from './image-policy.mjs';
|
||||
|
||||
const key = process.env.SITE_KEY;
|
||||
if (!key || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(key)) throw new Error('SITE_KEY is required to optimize built media.');
|
||||
|
||||
const uploadRoot = resolve(`dist/${key}/images/uploads`);
|
||||
const entries = await readdir(uploadRoot, { withFileTypes: true });
|
||||
const qualitySteps = [82, 76, 70, 64, 58, 52, 46];
|
||||
const dimensionSteps = [1920, 1600, 1440, 1280, 1024, 800];
|
||||
|
||||
function encode(pipeline, extension, quality) {
|
||||
if (extension === '.jpg' || extension === '.jpeg') {
|
||||
return pipeline.jpeg({ quality, progressive: true, chromaSubsampling: '4:2:0' });
|
||||
}
|
||||
if (extension === '.png') {
|
||||
return pipeline.png({ compressionLevel: 9, effort: 7, palette: true, quality });
|
||||
}
|
||||
if (extension === '.webp') return pipeline.webp({ quality, effort: 6 });
|
||||
if (extension === '.avif') return pipeline.avif({ quality, effort: 5, chromaSubsampling: '4:2:0' });
|
||||
throw new Error(`Unsupported CMS image extension: ${extension}`);
|
||||
}
|
||||
|
||||
async function optimize(source, extension) {
|
||||
let smallest;
|
||||
for (const dimension of dimensionSteps) {
|
||||
for (const quality of qualitySteps) {
|
||||
const pipeline = sharp(source, { failOn: 'warning', limitInputPixels: 100_000_000 })
|
||||
.rotate()
|
||||
.resize({
|
||||
width: Math.min(dimension, maxBuiltImageDimension),
|
||||
height: Math.min(dimension, maxBuiltImageDimension),
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
const output = await encode(pipeline, extension, quality).toBuffer();
|
||||
if (!smallest || output.length < smallest.length) smallest = output;
|
||||
if (output.length <= maxBuiltImageBytes) return output;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to optimize an image below ${maxBuiltImageBytes} bytes; smallest result was ${smallest?.length ?? 0} bytes.`);
|
||||
}
|
||||
|
||||
let inputBytes = 0;
|
||||
let outputBytes = 0;
|
||||
let optimizedCount = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) throw new Error(`Nested or special CMS media paths are not allowed: ${entry.name}`);
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (!allowedUploadExtensions.has(extension)) throw new Error(`CMS media is not an allowed raster extension: ${entry.name}`);
|
||||
const path = resolve(uploadRoot, entry.name);
|
||||
const source = await readFile(path);
|
||||
const output = await optimize(source, extension);
|
||||
await writeFile(path, output);
|
||||
|
||||
const metadata = await sharp(output).metadata();
|
||||
if ((metadata.width ?? 0) > maxBuiltImageDimension || (metadata.height ?? 0) > maxBuiltImageDimension) {
|
||||
throw new Error(`Optimized image dimensions exceed ${maxBuiltImageDimension}px: ${entry.name}`);
|
||||
}
|
||||
if (metadata.exif || metadata.icc || metadata.iptc || metadata.xmp) {
|
||||
throw new Error(`Optimized image retained metadata: ${entry.name}`);
|
||||
}
|
||||
|
||||
inputBytes += source.length;
|
||||
outputBytes += output.length;
|
||||
optimizedCount += 1;
|
||||
}
|
||||
|
||||
console.log(`Optimized ${optimizedCount} built CMS images from ${inputBytes} to ${outputBytes} bytes; metadata removed.`);
|
||||
Reference in New Issue
Block a user