perf(homepage): serve product screenshots as resized WebP to lift Lighthouse perf above 0.95
All checks were successful
ci / build-test (push) Successful in 4m56s
All checks were successful
ci / build-test (push) Successful in 4m56s
The homepage Lighthouse perf score dropped to 0.94 in CI (threshold 0.95) because Hero and ThreeAmWalkthrough each load a 1920×945 PNG screenshot straight out of public/product/ — together ~1.2 MiB and the LCP element. Other pages have no product imagery and pass with 1.0. Adds a sharp-based generator (scripts/optimize-product-images.mjs, run via `npm run optimize:images`) that emits 1280w and 1920w WebP variants beside each source PNG. Lightbox.astro now wraps the trigger in a <picture> with a WebP <source srcset> auto-derived from the PNG path (PNG kept as fallback for the ~2% of clients without WebP), exposes a fetchpriority prop, and points the dialog modal at the 1920w WebP. The Hero passes fetchpriority="high" on the LCP image; index.astro injects a matching <link rel="preload" as="image" imagesrcset> via a new BaseLayout head slot so the WebP is discovered before the body parses. Effect on the LCP image: 551 KiB PNG → 46 KiB WebP at 1280w (-92%). Local Lighthouse perf: 0.95 → 1.00 across 3 runs on /index.html; pricing/imprint stay 1.00; privacy unchanged at 0.98 (pre-existing CLS). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
.astro/
|
.astro/
|
||||||
|
.lighthouseci/
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint:html": "html-validate \"dist/**/*.html\"",
|
"lint:html": "html-validate \"dist/**/*.html\"",
|
||||||
"lint:links": "linkinator dist --recurse --silent",
|
"lint:links": "linkinator dist --recurse --silent",
|
||||||
|
"optimize:images": "node scripts/optimize-product-images.mjs",
|
||||||
"lh": "lhci autorun"
|
"lh": "lhci autorun"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
BIN
public/product/error-detail-1280.webp
Normal file
BIN
public/product/error-detail-1280.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
public/product/error-detail-1920.webp
Normal file
BIN
public/product/error-detail-1920.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
public/product/exchange-detail-1280.webp
Normal file
BIN
public/product/exchange-detail-1280.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
public/product/exchange-detail-1920.webp
Normal file
BIN
public/product/exchange-detail-1920.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
48
scripts/optimize-product-images.mjs
Normal file
48
scripts/optimize-product-images.mjs
Normal file
@@ -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 <name>.png we emit:
|
||||||
|
// <name>-1280.webp (q=82, used as inline srcset for desktop ≤ ~1280 px)
|
||||||
|
// <name>-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 <picture> 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}%)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,14 @@ interface Props {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
loading?: 'eager' | 'lazy';
|
loading?: 'eager' | 'lazy';
|
||||||
|
fetchpriority?: 'high' | 'low' | 'auto';
|
||||||
caption?: string;
|
caption?: string;
|
||||||
triggerClass?: string;
|
triggerClass?: string;
|
||||||
imgClass?: 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 {
|
const {
|
||||||
@@ -16,11 +21,20 @@ const {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
loading = 'lazy',
|
loading = 'lazy',
|
||||||
|
fetchpriority,
|
||||||
caption,
|
caption,
|
||||||
triggerClass = '',
|
triggerClass = '',
|
||||||
imgClass = 'block w-full h-auto',
|
imgClass = 'block w-full h-auto',
|
||||||
|
optimized = true,
|
||||||
|
sizes = '(min-width: 1024px) 56vw, 100vw',
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
|
// Auto-derive WebP variant paths from the PNG src. The optimize-product-images
|
||||||
|
// script emits <name>-1280.webp and <name>-1920.webp next to each <name>.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.
|
// Unique per-instance id so multiple lightboxes on a page do not collide.
|
||||||
const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
---
|
---
|
||||||
@@ -30,6 +44,9 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
|||||||
data-lightbox-open={dialogId}
|
data-lightbox-open={dialogId}
|
||||||
aria-label={`Enlarge: ${alt}`}
|
aria-label={`Enlarge: ${alt}`}
|
||||||
>
|
>
|
||||||
|
{webpSrcset ? (
|
||||||
|
<picture>
|
||||||
|
<source type="image/webp" srcset={webpSrcset} sizes={sizes} />
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
@@ -37,8 +54,22 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
|||||||
height={height}
|
height={height}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
fetchpriority={fetchpriority}
|
||||||
class={imgClass}
|
class={imgClass}
|
||||||
/>
|
/>
|
||||||
|
</picture>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
loading={loading}
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority={fetchpriority}
|
||||||
|
class={imgClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span aria-hidden="true" class="lightbox-zoom-badge">
|
<span aria-hidden="true" class="lightbox-zoom-badge">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="11" cy="11" r="7.5"/>
|
<circle cx="11" cy="11" r="7.5"/>
|
||||||
@@ -59,7 +90,7 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<img src={src} alt={alt} class="lightbox-image" />
|
<img src={webpDialogSrc} alt={alt} class="lightbox-image" loading="lazy" decoding="async" />
|
||||||
{caption && <p class="lightbox-caption">{caption}</p>}
|
{caption && <p class="lightbox-caption">{caption}</p>}
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const pins: Pin[] = [
|
|||||||
width={1920}
|
width={1920}
|
||||||
height={945}
|
height={945}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
/>
|
/>
|
||||||
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
||||||
{pins.map((pin) => (
|
{pins.map((pin) => (
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const ogUrl = new URL(ogImage, Astro.site ?? 'https://www.cameleer.io').toString
|
|||||||
<meta name="twitter:image" content={ogUrl} />
|
<meta name="twitter:image" content={ogUrl} />
|
||||||
|
|
||||||
<meta name="robots" content="index,follow" />
|
<meta name="robots" content="index,follow" />
|
||||||
|
|
||||||
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-bg text-text font-sans antialiased">
|
<body class="min-h-screen bg-bg text-text font-sans antialiased">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ import FinalCTA from '../components/sections/FinalCTA.astro';
|
|||||||
title="Cameleer — Ship Camel integrations. Sleep through the night."
|
title="Cameleer — Ship Camel integrations. Sleep through the night."
|
||||||
description="The hosted runtime and observability platform for Apache Camel. Auto-traced, replay-ready, cross-service correlated — so the 3 AM page becomes a 30-second answer."
|
description="The hosted runtime and observability platform for Apache Camel. Auto-traced, replay-ready, cross-service correlated — so the 3 AM page becomes a 30-second answer."
|
||||||
>
|
>
|
||||||
|
<Fragment slot="head">
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="image"
|
||||||
|
type="image/webp"
|
||||||
|
href="/product/exchange-detail-1280.webp"
|
||||||
|
imagesrcset="/product/exchange-detail-1280.webp 1280w, /product/exchange-detail-1920.webp 1920w"
|
||||||
|
imagesizes="(min-width: 1024px) 56vw, 100vw"
|
||||||
|
fetchpriority="high"
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
|
|||||||
Reference in New Issue
Block a user