Optimize CMS images during builds

This commit is contained in:
Kevin
2026-09-04 12:11:09 -04:00
parent 80dd0e0904
commit dd6fae06c9
11 changed files with 136 additions and 22 deletions
+3
View File
@@ -12,5 +12,8 @@ const result = spawnSync('npm', ['run', 'build', '--', '--outDir', `dist/${key}`
});
if (result.status !== 0) process.exit(result.status ?? 1);
const images = spawnSync('node', ['scripts/optimize-images.mjs'], { env: { ...process.env, SITE_KEY: key }, stdio: 'inherit' });
if (images.status !== 0) process.exit(images.status ?? 1);
const seo = spawnSync('node', ['scripts/generate-seo.mjs'], { env: { ...process.env, SITE_KEY: key }, stdio: 'inherit' });
if (seo.status !== 0) process.exit(seo.status ?? 1);
+4
View File
@@ -0,0 +1,4 @@
export const allowedUploadExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
export const maxSourceImageBytes = 25 * 1024 * 1024;
export const maxBuiltImageBytes = 500 * 1024;
export const maxBuiltImageDimension = 1920;
+75
View File
@@ -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.`);
+2 -3
View File
@@ -2,6 +2,7 @@ import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { readdir, readFile, stat } from 'node:fs/promises';
import { extname, join, relative, sep } from 'node:path';
import { allowedUploadExtensions, maxSourceImageBytes } from './image-policy.mjs';
const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'));
const ajv = new Ajv2020({ allErrors: true, strict: true });
@@ -36,7 +37,6 @@ async function filesUnder(directory) {
}
const uploadRoot = join('public', 'images', 'uploads');
const allowedUploadExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
const ascii = (value) => Buffer.from(value, 'ascii');
const startsWith = (buffer, signature) => buffer.subarray(0, signature.length).equals(signature);
const isRasterForExtension = (buffer, extension) => {
@@ -55,11 +55,10 @@ const isRasterForExtension = (buffer, extension) => {
for (const path of await filesUnder('public/images')) {
const imagePath = relative('public/images', path).split(sep).join('/');
const info = await stat(path);
if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${imagePath}`);
const uploadPath = relative(uploadRoot, path);
const isUpload = uploadPath !== '..' && !uploadPath.startsWith(`..${sep}`);
if (!isUpload) continue;
if (info.size > maxSourceImageBytes) throw new Error(`CMS image exceeds the 25 MiB source safety limit: ${imagePath}`);
if (uploadPath.includes(sep)) throw new Error(`Nested CMS media paths are not allowed: ${imagePath}`);
const extension = extname(uploadPath).toLowerCase();
if (!allowedUploadExtensions.has(extension)) throw new Error(`CMS media is not an allowed raster extension: ${imagePath}`);
+17 -2
View File
@@ -1,5 +1,7 @@
import { access, readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { access, readFile, readdir, stat } 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) throw new Error('SITE_KEY is required.');
@@ -31,6 +33,19 @@ if (!admin.includes('decap-cms@3.15.1/dist/decap-cms.js')) throw new Error('Admi
const headers = await readFile(resolve(root, '_headers'), 'utf8');
if (!/\/admin\/\*[\s\S]*X-Robots-Tag: noindex, nofollow, noarchive/.test(headers)) throw new Error('Admin response headers are missing the no-index rule.');
const builtUploads = resolve(root, 'images/uploads');
for (const entry of await readdir(builtUploads, { withFileTypes: true })) {
if (!entry.isFile()) throw new Error(`Built CMS media contains a nested or special path: ${entry.name}.`);
const extension = extname(entry.name).toLowerCase();
if (!allowedUploadExtensions.has(extension)) throw new Error(`Built CMS media has a disallowed extension: ${entry.name}.`);
const path = resolve(builtUploads, entry.name);
const info = await stat(path);
if (info.size > maxBuiltImageBytes) throw new Error(`Built CMS image exceeds ${maxBuiltImageBytes} bytes: ${entry.name}.`);
const metadata = await sharp(path).metadata();
if ((metadata.width ?? 0) > maxBuiltImageDimension || (metadata.height ?? 0) > maxBuiltImageDimension) throw new Error(`Built CMS image exceeds ${maxBuiltImageDimension}px: ${entry.name}.`);
if (metadata.exif || metadata.icc || metadata.iptc || metadata.xmp) throw new Error(`Built CMS image retained metadata: ${entry.name}.`);
}
const home = await readFile(resolve(root, 'index.html'), 'utf8');
if (key === 'www') {
for (const { file, html } of pages) if (!html.includes('href="https://blog.3031023.xyz/"')) throw new Error(`${file} is missing the Happy Tails Journal link.`);
+9 -1
View File
@@ -1,6 +1,7 @@
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import yaml from 'js-yaml';
import { maxSourceImageBytes } from './image-policy.mjs';
const fail = (message) => { throw new Error(message); };
const assert = (condition, message) => { if (!condition) fail(message); };
@@ -54,7 +55,7 @@ const optionalImageField = {
choose_url: false,
allow_multiple: false,
pattern: cmsImagePattern,
media_library: { config: { max_file_size: 512000, multiple: false } },
media_library: { config: { max_file_size: maxSourceImageBytes, multiple: false } },
};
const imageAltField = { widget: 'string', pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.'] };
const expectedCollections = {
@@ -185,6 +186,13 @@ for (const fragment of [
'featured: z.boolean().default(false)',
]) assert(schema.includes(fragment), `Astro schema changed; review the Decap contract for: ${fragment}`);
const buildScript = await readFile('scripts/build-site.mjs', 'utf8');
assert(buildScript.includes("['scripts/optimize-images.mjs']"), 'Every site build must optimize uploaded images before deployment.');
const imageOptimizer = await readFile('scripts/optimize-images.mjs', 'utf8');
for (const fragment of ['maxBuiltImageBytes', 'maxBuiltImageDimension', '.rotate()', 'metadata.exif', 'metadata.icc', 'metadata.iptc', 'metadata.xmp']) {
assert(imageOptimizer.includes(fragment), `Built-image optimization changed; review the CMS media contract for: ${fragment}`);
}
async function verifyContentPaths(directory) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {