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/knip.json b/knip.json index b372695c..b43886dc 100644 --- a/knip.json +++ b/knip.json @@ -1,4 +1,4 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "ignoreDependencies": ["postcss"] + "ignoreDependencies": ["postcss", "sharp"] } diff --git a/package.json b/package.json index ee1713e4..7082a24f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-image-variants.ts b/scripts/generate-image-variants.ts new file mode 100644 index 00000000..e9666888 --- /dev/null +++ b/scripts/generate-image-variants.ts @@ -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 { + 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 = {}; + 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); +}); diff --git a/src/__tests__/components/ui/ResponsiveImage.test.tsx b/src/__tests__/components/ui/ResponsiveImage.test.tsx new file mode 100644 index 00000000..ee98ffb8 --- /dev/null +++ b/src/__tests__/components/ui/ResponsiveImage.test.tsx @@ -0,0 +1,91 @@ +import { render } from "@testing-library/react"; +import ResponsiveImage from "@/components/ui/ResponsiveImage"; + +describe("ResponsiveImage", () => { + it("emits a with avif + webp sources only for widths in the manifest", () => { + 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")).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( + , + ); + 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", () => { + const { container } = render( + , + ); + 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( + , + ); + 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 833a3cb5..c8a82dcf 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"; @@ -206,13 +206,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 1461f79e..424f8571 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..6aa8bce8 --- /dev/null +++ b/src/components/ui/ResponsiveImage.tsx @@ -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 = variantsManifest; + +export default function ResponsiveImage({ + src, + alt, + width, + height, + sizes, + className, + loading = "lazy", + fetchPriority, +}: Readonly) { + const blogMatch = src.match(/^\/images\/blog\/(.+)\.webp$/); + const widths = blogMatch ? manifest[blogMatch[1]] ?? [] : []; + + if (widths.length === 0) { + 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} + + ); +} 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;