// 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)}`);