Optimize CMS images during builds
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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}`);
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user