Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
chrome/
public/images/blog/responsive/
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"ignoreDependencies": ["postcss"]
"ignoreDependencies": ["postcss", "sharp"]
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
"scripts": {
"dev": "next dev --webpack",
"build": "next build",
"prebuild": "npx -y tsx@4 scripts/generate-image-variants.ts",
"start": "next start",
"lint": "eslint",
"knip": "knip",
"test": "jest"
"test": "jest",
"generate:image-variants": "npx -y tsx@4 scripts/generate-image-variants.ts"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
Expand Down
66 changes: 66 additions & 0 deletions scripts/generate-image-variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
import { RESPONSIVE_WIDTHS } from "../src/lib/image-config";

const SRC_DIR = path.join(process.cwd(), "public/images/blog");
const OUT_DIR = path.join(SRC_DIR, "responsive");
const MANIFEST_PATH = path.join(process.cwd(), "src/data/blog-image-variants.json");

async function main(): Promise<void> {
if (!fs.existsSync(OUT_DIR)) {
fs.mkdirSync(OUT_DIR, { recursive: true });
}

const files = fs
.readdirSync(SRC_DIR)
.filter((f) => f.endsWith(".webp") && !fs.statSync(path.join(SRC_DIR, f)).isDirectory());

const manifest: Record<string, number[]> = {};
let generated = 0;
let skipped = 0;

for (const file of files) {
const srcPath = path.join(SRC_DIR, file);
const baseName = path.basename(file, ".webp");
let srcWidth = 0;
try {
const meta = await sharp(srcPath).metadata();
srcWidth = meta.width ?? 0;
} catch (err) {
console.warn(`skip ${file}: ${(err as Error).message}`);
continue;
}

const widthsForFile: number[] = [];
for (const width of RESPONSIVE_WIDTHS) {
if (width >= srcWidth) continue;
widthsForFile.push(width);
for (const format of ["webp", "avif"] as const) {
const outPath = path.join(OUT_DIR, `${baseName}-${width}w.${format}`);
if (fs.existsSync(outPath)) {
skipped++;
continue;
}
await sharp(srcPath)
.resize({ width, withoutEnlargement: true })
[format]({ quality: format === "avif" ? 50 : 75 })
.toFile(outPath);
generated++;
}
}
if (widthsForFile.length > 0) {
manifest[baseName] = widthsForFile;
}
}

fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + "\n");
console.log(
`Generated ${generated} variants, skipped ${skipped} existing. Manifest: ${Object.keys(manifest).length} entries.`,
);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
91 changes: 91 additions & 0 deletions src/__tests__/components/ui/ResponsiveImage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { render } from "@testing-library/react";
import ResponsiveImage from "@/components/ui/ResponsiveImage";

describe("ResponsiveImage", () => {
it("emits a <picture> with avif + webp sources only for widths in the manifest", () => {
const { container } = render(
<ResponsiveImage
src="/images/blog/afup-day-2025.webp"
alt="AFUP 2025"
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 33vw"
/>,
);
const picture = container.querySelector("picture");
expect(picture).not.toBeNull();
const sources = container.querySelectorAll("source");
expect(sources).toHaveLength(2);
expect(sources[0].getAttribute("type")).toBe("image/avif");
expect(sources[0].getAttribute("srcset")).toBe(
"/images/blog/responsive/afup-day-2025-400w.avif 400w, /images/blog/responsive/afup-day-2025-800w.avif 800w, /images/blog/responsive/afup-day-2025-1200w.avif 1200w",
);
expect(sources[1].getAttribute("type")).toBe("image/webp");
});

it("limits the srcset to widths actually generated for the source", () => {
const { container } = render(
<ResponsiveImage
src="/images/blog/afup-day-2023.webp"
alt="AFUP 2023"
width={400}
height={220}
sizes="100vw"
/>,
);
const sources = container.querySelectorAll("source");
expect(sources[0].getAttribute("srcset")).toBe(
"/images/blog/responsive/afup-day-2023-400w.avif 400w",
);
expect(sources[1].getAttribute("srcset")).toBe(
"/images/blog/responsive/afup-day-2023-400w.webp 400w",
);
});

it("falls back to a plain <img> for blog images without manifest entry", () => {
const { container } = render(
<ResponsiveImage
src="/images/blog/this-image-does-not-exist-anywhere.webp"
alt="Missing"
width={400}
height={220}
sizes="100vw"
/>,
);
expect(container.querySelector("picture")).toBeNull();
expect(container.querySelector("img")).not.toBeNull();
});

it("falls back to a plain <img> for non-blog sources", () => {
const { container } = render(
<ResponsiveImage
src="/images/illustrations/foo.svg"
alt="Illustration"
width={200}
height={200}
sizes="200px"
/>,
);
expect(container.querySelector("picture")).toBeNull();
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe("/images/illustrations/foo.svg");
expect(img?.getAttribute("sizes")).toBe("200px");
});

it("honors loading=eager and fetchPriority=high when provided", () => {
const { container } = render(
<ResponsiveImage
src="/images/blog/afup-day-2025.webp"
alt="Foo"
width={1200}
height={630}
sizes="100vw"
loading="eager"
fetchPriority="high"
/>,
);
const img = container.querySelector("img");
expect(img?.getAttribute("loading")).toBe("eager");
expect(img?.getAttribute("fetchpriority")).toBe("high");
});
});
7 changes: 4 additions & 3 deletions src/app/article/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation";
import Image from "next/image";
import ResponsiveImage from "@/components/ui/ResponsiveImage";
import Link from "next/link";
import { getAllPosts, getPostBySlug, getCategorySlug, getPostsByCategory, extractHeadings, isSymfonyAuditCategory, isTechCategory, readingTime } from "@/lib/blog";
import Container from "@/components/ui/Container";
Expand Down Expand Up @@ -206,13 +206,14 @@ export default async function ArticlePage({ params }: ArticlePageProps) {
</div>
{post.image && (
<div className="mt-6 shrink-0 self-center xl:mt-0 xl:ml-8 xl:self-start">
<Image
<ResponsiveImage
src={post.image}
alt={post.title}
width={720}
height={405}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 28rem, (max-width: 1280px) 32rem, 36rem"
className="h-auto w-full max-w-full rounded-md object-contain sm:max-w-md lg:max-w-lg xl:max-w-xl"
priority
loading="eager"
fetchPriority="high"
/>
</div>
Expand Down
8 changes: 5 additions & 3 deletions src/components/cards/BlogCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Image from "next/image";
import Link from "next/link";
import ResponsiveImage from "@/components/ui/ResponsiveImage";
import { BlogPost } from "@/types/blog";
import { JSX } from "react";

Expand All @@ -16,13 +16,15 @@ export default function BlogCard({ post, headingLevel = 3, priorityImage = false
<article className="overflow-hidden rounded-lg border-t-2 border-transparent bg-white shadow-md transition-all duration-300 hover:-translate-y-1 hover:border-primary hover:shadow-lg dark:bg-light-gray dark:shadow-black/30">
{post.image && (
<Link href={`/article/${post.slug}`}>
<Image
<ResponsiveImage
src={post.image}
alt={post.title}
width={400}
height={220}
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="h-48 w-full object-cover"
{...(priorityImage && { priority: true })}
loading={priorityImage ? "eager" : "lazy"}
fetchPriority={priorityImage ? "high" : undefined}
/>
</Link>
)}
Expand Down
66 changes: 66 additions & 0 deletions src/components/ui/ResponsiveImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import variantsManifest from "@/data/blog-image-variants.json";

interface ResponsiveImageProps {
src: string;
alt: string;
width: number;
height: number;
sizes: string;
className?: string;
loading?: "lazy" | "eager";
fetchPriority?: "high" | "low" | "auto";
}

const manifest: Record<string, number[]> = variantsManifest;

export default function ResponsiveImage({
src,
alt,
width,
height,
sizes,
className,
loading = "lazy",
fetchPriority,
}: Readonly<ResponsiveImageProps>) {
const blogMatch = src.match(/^\/images\/blog\/(.+)\.webp$/);
const widths = blogMatch ? manifest[blogMatch[1]] ?? [] : [];

if (widths.length === 0) {
return (
<img

Check warning on line 31 in src/components/ui/ResponsiveImage.tsx

View workflow job for this annotation

GitHub Actions / check

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={src}
alt={alt}
width={width}
height={height}
sizes={sizes}
className={className}
loading={loading}
decoding="async"
fetchPriority={fetchPriority}
/>
);
}

const baseName = blogMatch![1];
const srcset = (format: "webp" | "avif") =>
widths.map((w) => `/images/blog/responsive/${baseName}-${w}w.${format} ${w}w`).join(", ");

return (
<picture>
<source type="image/avif" srcSet={srcset("avif")} sizes={sizes} />
<source type="image/webp" srcSet={srcset("webp")} sizes={sizes} />
<img
src={src}
alt={alt}
width={width}
height={height}
sizes={sizes}
className={className}
loading={loading}
decoding="async"
fetchPriority={fetchPriority}
/>
</picture>
);
}
Loading
Loading