diff --git a/.gitignore b/.gitignore index 430a58d..cce71d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build output dist/ .astro/ +.lighthouseci/ # Dependencies node_modules/ diff --git a/package.json b/package.json index 1841b82..6099466 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:watch": "vitest", "lint:html": "html-validate \"dist/**/*.html\"", "lint:links": "linkinator dist --recurse --silent", + "optimize:images": "node scripts/optimize-product-images.mjs", "lh": "lhci autorun" }, "dependencies": { diff --git a/public/product/error-detail-1280.webp b/public/product/error-detail-1280.webp new file mode 100644 index 0000000..3d6d76a Binary files /dev/null and b/public/product/error-detail-1280.webp differ diff --git a/public/product/error-detail-1920.webp b/public/product/error-detail-1920.webp new file mode 100644 index 0000000..68ce2a1 Binary files /dev/null and b/public/product/error-detail-1920.webp differ diff --git a/public/product/exchange-detail-1280.webp b/public/product/exchange-detail-1280.webp new file mode 100644 index 0000000..57198ed Binary files /dev/null and b/public/product/exchange-detail-1280.webp differ diff --git a/public/product/exchange-detail-1920.webp b/public/product/exchange-detail-1920.webp new file mode 100644 index 0000000..70bc874 Binary files /dev/null and b/public/product/exchange-detail-1920.webp differ diff --git a/scripts/optimize-product-images.mjs b/scripts/optimize-product-images.mjs new file mode 100644 index 0000000..4d7f487 --- /dev/null +++ b/scripts/optimize-product-images.mjs @@ -0,0 +1,48 @@ +// Generate WebP variants of source PNGs in public/product/. +// Run after replacing/adding a source PNG; outputs are committed. +// +// For each .png we emit: +// -1280.webp (q=82, used as inline srcset for desktop ≤ ~1280 px) +// -1920.webp (q=80, used as inline srcset for retina/wide viewports +// and as the lightbox-modal full-size source) +// +// The original .png is kept as a fallback for the rare browser +// without WebP support (~2 % globally). + +import { readdir, stat } from 'node:fs/promises'; +import { join, parse } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const SRC_DIR = fileURLToPath(new URL('../public/product/', import.meta.url)); + +const VARIANTS = [ + { width: 1280, quality: 82, suffix: '-1280' }, + { width: 1920, quality: 80, suffix: '-1920' }, +]; + +const entries = await readdir(SRC_DIR); +const pngs = entries.filter((f) => f.toLowerCase().endsWith('.png')); + +if (pngs.length === 0) { + console.error(`No PNGs found in ${SRC_DIR}`); + process.exit(1); +} + +for (const file of pngs) { + const { name } = parse(file); + const inputPath = join(SRC_DIR, file); + const inputBytes = (await stat(inputPath)).size; + console.log(`\n${file} (${(inputBytes / 1024).toFixed(0)} KiB)`); + + for (const v of VARIANTS) { + const outName = `${name}${v.suffix}.webp`; + const outPath = join(SRC_DIR, outName); + const info = await sharp(inputPath) + .resize({ width: v.width, withoutEnlargement: true }) + .webp({ quality: v.quality, effort: 6 }) + .toFile(outPath); + const pct = ((1 - info.size / inputBytes) * 100).toFixed(0); + console.log(` → ${outName} ${(info.size / 1024).toFixed(0)} KiB (-${pct}%)`); + } +} diff --git a/src/components/Lightbox.astro b/src/components/Lightbox.astro index 6f53c9f..51b7463 100644 --- a/src/components/Lightbox.astro +++ b/src/components/Lightbox.astro @@ -5,9 +5,14 @@ interface Props { width: number; height: number; loading?: 'eager' | 'lazy'; + fetchpriority?: 'high' | 'low' | 'auto'; caption?: string; triggerClass?: string; imgClass?: string; + /** Set to false for sources without sibling .webp variants. */ + optimized?: boolean; + /** CSS sizes attribute for the WebP srcset. Defaults to a mobile-first guess. */ + sizes?: string; } const { @@ -16,11 +21,20 @@ const { width, height, loading = 'lazy', + fetchpriority, caption, triggerClass = '', imgClass = 'block w-full h-auto', + optimized = true, + sizes = '(min-width: 1024px) 56vw, 100vw', } = Astro.props; +// Auto-derive WebP variant paths from the PNG src. The optimize-product-images +// script emits -1280.webp and -1920.webp next to each .png. +const webpBase = optimized && /\.png$/i.test(src) ? src.replace(/\.png$/i, '') : null; +const webpSrcset = webpBase ? `${webpBase}-1280.webp 1280w, ${webpBase}-1920.webp 1920w` : null; +const webpDialogSrc = webpBase ? `${webpBase}-1920.webp` : src; + // Unique per-instance id so multiple lightboxes on a page do not collide. const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`; --- @@ -30,15 +44,32 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`; data-lightbox-open={dialogId} aria-label={`Enlarge: ${alt}`} > - {alt} + {webpSrcset ? ( + + + {alt} + + ) : ( + {alt} + )}