Files
bcs-website/scripts/optimize-images.mjs
T
bmr_bluecap 59caa8e838 perf: responsive AVIF/WebP images; a11y contrast and link-text fixes
- scripts/optimize-images.mjs generates hashed AVIF/WebP variants at build time (sharp)
- Picture.astro + rehype plugin serve them via <picture> with srcset/sizes; hero gets fetchpriority=high
- footer CTA and active service-nav item meet WCAG AA contrast
- homepage Learn More link gets screen-reader-only context
- llms.txt: insights described as coming soon
2026-10-23 16:58:13 -04:00

69 lines
3.2 KiB
JavaScript

// Pre-generates responsive AVIF + WebP variants of the site's raster images, plus a manifest the
// components read (src/utils/images.ts, the rehype plugin in astro.config.mjs).
//
// Why not astro:assets: every image here is referenced by a /public path string — from pages,
// content-collection frontmatter, and Markdown bodies — and og:image needs those stable JPEG URLs.
// This keeps the originals untouched (fallback + social cards) and adds the modern formats beside them.
//
// Output (generated, gitignored): public/assets/opt/*.{avif,webp} and src/data/image-manifest.json.
// File names carry a content hash, so nginx's 1-year immutable cache on /assets can never serve a
// stale variant after a source image changes. Existing outputs are reused, so reruns are fast.
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import sharp from "sharp";
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const publicDir = path.join(root, "public");
const outDir = path.join(publicDir, "assets", "opt");
const manifestPath = path.join(root, "src", "data", "image-manifest.json");
// Sources: every photo in live-site/ (not icons/ — tiny PNGs, nothing to gain) and the logo.
const sources = [
...(await readdir(path.join(publicDir, "assets", "live-site")))
.filter((f) => /\.(jpe?g|png)$/i.test(f))
.map((f) => `/assets/live-site/${f}`),
"/assets/bluecap-logo.png",
];
// Candidate widths; any at or above the original's width are replaced by the original width.
const widths = [480, 800, 1200, 1600];
// The logo only ever renders at <=190px wide, so one 2x-retina size is enough. Keyed by file name:
// live-site/ also holds a copy of it.
const widthOverrides = { "bluecap-logo.png": [480] };
// Tuned 2026-10-23 on the busiest photo (img_0062): AVIF q42 is visually indistinguishable from
// a q90 JPEG at 800px and ~half the bytes of WebP; effort 2 encodes ~2x faster than 4 for ~3%
// more bytes, which keeps CI builds quick.
const formats = {
avif: { quality: 42, effort: 2 },
webp: { quality: 65 },
};
await mkdir(outDir, { recursive: true });
const manifest = {};
for (const src of sources) {
const input = await readFile(path.join(publicDir, src));
const { width, height } = await sharp(input).metadata();
const hash = createHash("sha256").update(input).digest("hex").slice(0, 8);
const base = path.basename(src).replace(/\.[^.]+$/, "");
const sizes = widthOverrides[path.basename(src)] ?? [...new Set([...widths.filter((w) => w < width), width])];
const entry = { width, height, avif: [], webp: [] };
for (const w of sizes) {
for (const [format, options] of Object.entries(formats)) {
const file = `${base}.${hash}-${w}.${format}`;
const dest = path.join(outDir, file);
if (!existsSync(dest)) {
await sharp(input).resize({ width: w }).toFormat(format, options).toFile(dest);
}
entry[format].push({ src: `/assets/opt/${file}`, width: w });
}
}
manifest[src] = entry;
}
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`optimize-images: ${sources.length} images -> ${path.relative(root, outDir)}`);