- 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
69 lines
3.2 KiB
JavaScript
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)}`);
|