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
This commit is contained in:
1 parent
002845c880
commit
59caa8e838
14 files changed
+225
-17
No files matched your search
@@ -0,0 +1,68 @@
|
||||
// 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)}`);
|
||||
Reference in new issue
Block a user