From b84161e442824a52f94691b8f072dfcf944921f7 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Tue, 28 Apr 2026 16:49:06 +0200 Subject: [PATCH 1/3] perf --- .gitignore | 1 + package.json | 4 +- scripts/generate-image-variants.ts | 54 +++++++++++++++ .../components/ui/ResponsiveImage.test.tsx | 65 +++++++++++++++++++ src/app/article/[slug]/page.tsx | 7 +- src/components/cards/BlogCard.tsx | 8 ++- src/components/ui/ResponsiveImage.tsx | 62 ++++++++++++++++++ 7 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 scripts/generate-image-variants.ts create mode 100644 src/__tests__/components/ui/ResponsiveImage.test.tsx create mode 100644 src/components/ui/ResponsiveImage.tsx diff --git a/.gitignore b/.gitignore index 015e90ec..be10fc61 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts chrome/ +public/images/blog/responsive/ diff --git a/package.json b/package.json index 4cabf0d5..bdde3f07 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,11 @@ "scripts": { "dev": "next dev", "build": "next build", + "prebuild": "tsx scripts/generate-image-variants.ts", "start": "next start", "lint": "eslint", - "test": "jest" + "test": "jest", + "generate:image-variants": "tsx scripts/generate-image-variants.ts" }, "dependencies": { "@tailwindcss/typography": "^0.5.19", diff --git a/scripts/generate-image-variants.ts b/scripts/generate-image-variants.ts new file mode 100644 index 00000000..908d4658 --- /dev/null +++ b/scripts/generate-image-variants.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; + +const SRC_DIR = path.join(process.cwd(), "public/images/blog"); +const OUT_DIR = path.join(SRC_DIR, "responsive"); +const WIDTHS = [400, 800, 1200]; + +async function main(): Promise { + 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()); + + 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; + } + + for (const width of WIDTHS) { + if (width >= srcWidth) continue; + 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++; + } + } + } + console.log(`Generated ${generated} variants, skipped ${skipped} existing.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/__tests__/components/ui/ResponsiveImage.test.tsx b/src/__tests__/components/ui/ResponsiveImage.test.tsx new file mode 100644 index 00000000..4b94f64c --- /dev/null +++ b/src/__tests__/components/ui/ResponsiveImage.test.tsx @@ -0,0 +1,65 @@ +import { render } from "@testing-library/react"; +import ResponsiveImage from "@/components/ui/ResponsiveImage"; + +describe("ResponsiveImage", () => { + it("emits a with avif + webp sources for blog images", () => { + const { container } = render( + , + ); + 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")).toContain( + "/images/blog/responsive/test-article-400w.avif 400w", + ); + expect(sources[0].getAttribute("srcset")).toContain( + "/images/blog/responsive/test-article-1200w.avif 1200w", + ); + expect(sources[1].getAttribute("type")).toBe("image/webp"); + const img = container.querySelector("img"); + expect(img?.getAttribute("loading")).toBe("lazy"); + expect(img?.getAttribute("decoding")).toBe("async"); + }); + + it("falls back to a plain for non-blog sources", () => { + const { container } = render( + , + ); + expect(container.querySelector("picture")).toBeNull(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + 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( + , + ); + const img = container.querySelector("img"); + expect(img?.getAttribute("loading")).toBe("eager"); + expect(img?.getAttribute("fetchpriority")).toBe("high"); + }); +}); diff --git a/src/app/article/[slug]/page.tsx b/src/app/article/[slug]/page.tsx index 8672cec6..7d50fe44 100644 --- a/src/app/article/[slug]/page.tsx +++ b/src/app/article/[slug]/page.tsx @@ -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"; @@ -190,13 +190,14 @@ export default async function ArticlePage({ params }: ArticlePageProps) { {post.image && (
- {post.title}
diff --git a/src/components/cards/BlogCard.tsx b/src/components/cards/BlogCard.tsx index 96e88b5f..1360e001 100644 --- a/src/components/cards/BlogCard.tsx +++ b/src/components/cards/BlogCard.tsx @@ -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"; @@ -16,13 +16,15 @@ export default function BlogCard({ post, headingLevel = 3, priorityImage = false
{post.image && ( - {post.title} )} diff --git a/src/components/ui/ResponsiveImage.tsx b/src/components/ui/ResponsiveImage.tsx new file mode 100644 index 00000000..e2436f52 --- /dev/null +++ b/src/components/ui/ResponsiveImage.tsx @@ -0,0 +1,62 @@ +interface ResponsiveImageProps { + src: string; + alt: string; + width: number; + height: number; + sizes: string; + className?: string; + loading?: "lazy" | "eager"; + fetchPriority?: "high" | "low" | "auto"; +} + +const WIDTHS = [400, 800, 1200]; + +export default function ResponsiveImage({ + src, + alt, + width, + height, + sizes, + className, + loading = "lazy", + fetchPriority, +}: Readonly) { + const blogMatch = src.match(/^\/images\/blog\/(.+)\.webp$/); + if (!blogMatch) { + return ( + {alt} + ); + } + + const baseName = blogMatch[1]; + const srcset = (format: "webp" | "avif") => + WIDTHS.map((w) => `/images/blog/responsive/${baseName}-${w}w.${format} ${w}w`).join(", "); + + return ( + + + + {alt} + + ); +} From abf0c27e1526a5f2554e023c5de1d448b11dd4f9 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Tue, 28 Apr 2026 16:51:30 +0200 Subject: [PATCH 2/3] fix --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bdde3f07..c35f99fb 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,11 @@ "scripts": { "dev": "next dev", "build": "next build", - "prebuild": "tsx scripts/generate-image-variants.ts", + "prebuild": "npx -y tsx@4 scripts/generate-image-variants.ts", "start": "next start", "lint": "eslint", "test": "jest", - "generate:image-variants": "tsx scripts/generate-image-variants.ts" + "generate:image-variants": "npx -y tsx@4 scripts/generate-image-variants.ts" }, "dependencies": { "@tailwindcss/typography": "^0.5.19", From 2a153e0e3cb82c706f05daeb5850fac644b13e71 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Tue, 28 Apr 2026 17:06:18 +0200 Subject: [PATCH 3/3] fix --- scripts/generate-image-variants.ts | 18 +- .../components/ui/ResponsiveImage.test.tsx | 60 +++- src/components/ui/ResponsiveImage.tsx | 12 +- src/data/blog-image-variants.json | 334 ++++++++++++++++++ src/lib/image-config.ts | 1 + 5 files changed, 401 insertions(+), 24 deletions(-) create mode 100644 src/data/blog-image-variants.json create mode 100644 src/lib/image-config.ts diff --git a/scripts/generate-image-variants.ts b/scripts/generate-image-variants.ts index 908d4658..e9666888 100644 --- a/scripts/generate-image-variants.ts +++ b/scripts/generate-image-variants.ts @@ -1,10 +1,11 @@ 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 WIDTHS = [400, 800, 1200]; +const MANIFEST_PATH = path.join(process.cwd(), "src/data/blog-image-variants.json"); async function main(): Promise { if (!fs.existsSync(OUT_DIR)) { @@ -15,8 +16,10 @@ async function main(): Promise { .readdirSync(SRC_DIR) .filter((f) => f.endsWith(".webp") && !fs.statSync(path.join(SRC_DIR, f)).isDirectory()); + const manifest: Record = {}; let generated = 0; let skipped = 0; + for (const file of files) { const srcPath = path.join(SRC_DIR, file); const baseName = path.basename(file, ".webp"); @@ -29,8 +32,10 @@ async function main(): Promise { continue; } - for (const width of WIDTHS) { + 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)) { @@ -44,8 +49,15 @@ async function main(): Promise { generated++; } } + if (widthsForFile.length > 0) { + manifest[baseName] = widthsForFile; + } } - console.log(`Generated ${generated} variants, skipped ${skipped} existing.`); + + 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) => { diff --git a/src/__tests__/components/ui/ResponsiveImage.test.tsx b/src/__tests__/components/ui/ResponsiveImage.test.tsx index 4b94f64c..ee98ffb8 100644 --- a/src/__tests__/components/ui/ResponsiveImage.test.tsx +++ b/src/__tests__/components/ui/ResponsiveImage.test.tsx @@ -2,13 +2,13 @@ import { render } from "@testing-library/react"; import ResponsiveImage from "@/components/ui/ResponsiveImage"; describe("ResponsiveImage", () => { - it("emits a with avif + webp sources for blog images", () => { + it("emits a with avif + webp sources only for widths in the manifest", () => { const { container } = render( , ); @@ -17,16 +17,43 @@ describe("ResponsiveImage", () => { const sources = container.querySelectorAll("source"); expect(sources).toHaveLength(2); expect(sources[0].getAttribute("type")).toBe("image/avif"); - expect(sources[0].getAttribute("srcset")).toContain( - "/images/blog/responsive/test-article-400w.avif 400w", - ); - expect(sources[0].getAttribute("srcset")).toContain( - "/images/blog/responsive/test-article-1200w.avif 1200w", + 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"); - const img = container.querySelector("img"); - expect(img?.getAttribute("loading")).toBe("lazy"); - expect(img?.getAttribute("decoding")).toBe("async"); + }); + + it("limits the srcset to widths actually generated for the source", () => { + const { container } = render( + , + ); + 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 for blog images without manifest entry", () => { + const { container } = render( + , + ); + expect(container.querySelector("picture")).toBeNull(); + expect(container.querySelector("img")).not.toBeNull(); }); it("falls back to a plain for non-blog sources", () => { @@ -41,7 +68,6 @@ describe("ResponsiveImage", () => { ); expect(container.querySelector("picture")).toBeNull(); const img = container.querySelector("img"); - expect(img).not.toBeNull(); expect(img?.getAttribute("src")).toBe("/images/illustrations/foo.svg"); expect(img?.getAttribute("sizes")).toBe("200px"); }); @@ -49,10 +75,10 @@ describe("ResponsiveImage", () => { it("honors loading=eager and fetchPriority=high when provided", () => { const { container } = render( = variantsManifest; export default function ResponsiveImage({ src, @@ -22,7 +24,9 @@ export default function ResponsiveImage({ fetchPriority, }: Readonly) { const blogMatch = src.match(/^\/images\/blog\/(.+)\.webp$/); - if (!blogMatch) { + const widths = blogMatch ? manifest[blogMatch[1]] ?? [] : []; + + if (widths.length === 0) { return ( - WIDTHS.map((w) => `/images/blog/responsive/${baseName}-${w}w.${format} ${w}w`).join(", "); + widths.map((w) => `/images/blog/responsive/${baseName}-${w}w.${format} ${w}w`).join(", "); return ( diff --git a/src/data/blog-image-variants.json b/src/data/blog-image-variants.json new file mode 100644 index 00000000..ad4984c8 --- /dev/null +++ b/src/data/blog-image-variants.json @@ -0,0 +1,334 @@ +{ + "afup-day-2023": [ + 400 + ], + "afup-day-2024": [ + 400, + 800 + ], + "afup-day-2025": [ + 400, + 800, + 1200 + ], + "alice-faker": [ + 400 + ], + "api-rest": [ + 400 + ], + "archi-hexagonale": [ + 400, + 800 + ], + "audit-efficience-it": [ + 400 + ], + "bruno": [ + 400, + 800 + ], + "bundles-symfony": [ + 400 + ], + "cahier-des-charges-application-web": [ + 400, + 800 + ], + "cahier-des-charges": [ + 400, + 800 + ], + "canard-plastique": [ + 400 + ], + "certifications-symfony": [ + 400, + 800 + ], + "chocoblast": [ + 400 + ], + "choisir-prestataire-symfony": [ + 400, + 800 + ], + "claude-symfony": [ + 400, + 800 + ], + "code-mort": [ + 400, + 800 + ], + "commandes-invocables-symfony": [ + 400, + 800 + ], + "composer-symfony": [ + 400 + ], + "contributions-open-source": [ + 400 + ], + "conventions-codage": [ + 400, + 800, + 1200 + ], + "copilot-vs-chatgpt": [ + 400, + 800 + ], + "core-team": [ + 400, + 800 + ], + "cve": [ + 400, + 800 + ], + "dbtoolsbundle": [ + 400 + ], + "deployer-nuxtjs": [ + 400, + 800, + 1200 + ], + "dette-technique": [ + 400, + 800, + 1200 + ], + "diataxis-symfony": [ + 400 + ], + "doctavis": [ + 400, + 800, + 1200 + ], + "doctrine-orm-3": [ + 400 + ], + "documentation-technique": [ + 400 + ], + "domain-symfony": [ + 400, + 800 + ], + "easyadmin-vs-forestadmin": [ + 400 + ], + "eco-conception": [ + 400, + 800 + ], + "ecosia": [ + 400, + 800, + 1200 + ], + "editeur-code": [ + 400, + 800 + ], + "elasticsearch-algolia": [ + 400 + ], + "evenements-2025": [ + 400, + 800 + ], + "fondation-php": [ + 400 + ], + "forum-php-2024": [ + 400, + 800 + ], + "framework-javascript": [ + 400 + ], + "frankenphp": [ + 400 + ], + "ia-generatives": [ + 400, + 800 + ], + "int-uuid-ulid": [ + 400, + 800, + 1200 + ], + "json-streamer": [ + 400, + 800 + ], + "louis-arnaud-catoire": [ + 400, + 800 + ], + "maintenance-applicative": [ + 400 + ], + "manifeste-applications-web": [ + 400 + ], + "messenger-vs-enqueue": [ + 400 + ], + "microservices-monolithe": [ + 400 + ], + "migration-mysql-postgresql": [ + 400, + 800, + 1200 + ], + "migration-symfony": [ + 400, + 800, + 1200 + ], + "mise-en-cache": [ + 400 + ], + "moderniser-application-php-legacy": [ + 400, + 800 + ], + "monofony": [ + 400, + 800, + 1200 + ], + "monter-competences-symfony": [ + 400, + 800 + ], + "monter-en-competence-claude-code": [ + 400, + 800 + ], + "mysql-max-id": [ + 400, + 800, + 1200 + ], + "nodejs-frameworks": [ + 400, + 800 + ], + "normes-rgaa": [ + 400, + 800, + 1200 + ], + "pexels-tech": [ + 400 + ], + "php-9": [ + 400 + ], + "php-vs-nodejs": [ + 400, + 800 + ], + "phpstan-2": [ + 400 + ], + "phpstan-symfony": [ + 400, + 800 + ], + "playwright": [ + 400, + 800 + ], + "postman-newman": [ + 400, + 800 + ], + "pourquoi-symfony": [ + 400, + 800, + 1200 + ], + "progiciel": [ + 400 + ], + "psr-php": [ + 400, + 800 + ], + "rag-symfony": [ + 400, + 800 + ], + "rector": [ + 400 + ], + "rejoindre-efficience-it": [ + 400 + ], + "securite-informatique": [ + 400 + ], + "seo-ai": [ + 400 + ], + "serveurs-mcp-symfony": [ + 400, + 800 + ], + "skills-claude-code-equipe-symfony": [ + 400, + 800 + ], + "sylius-ecommerce": [ + 400, + 800 + ], + "sylius-vs-prestashop": [ + 400, + 800 + ], + "symfony-ai": [ + 400, + 800 + ], + "symfony-insight": [ + 400 + ], + "symfony-messenger": [ + 400, + 800 + ], + "symfony-moldus": [ + 400 + ], + "symfony-vs-laravel": [ + 400, + 800 + ], + "twig-4": [ + 400 + ], + "unsplash-tech": [ + 400, + 800, + 1200 + ], + "vivatech-2025": [ + 400, + 800 + ], + "vocabulaire-dev-web": [ + 400, + 800 + ] +} diff --git a/src/lib/image-config.ts b/src/lib/image-config.ts new file mode 100644 index 00000000..1962a2a6 --- /dev/null +++ b/src/lib/image-config.ts @@ -0,0 +1 @@ +export const RESPONSIVE_WIDTHS = [400, 800, 1200] as const;