diff --git a/src/__tests__/app/sitemap-images-route.test.ts b/src/__tests__/app/sitemap-images-route.test.ts new file mode 100644 index 00000000..1e7a80e5 --- /dev/null +++ b/src/__tests__/app/sitemap-images-route.test.ts @@ -0,0 +1,95 @@ +import { BASE_URL } from "@/lib/metadata"; +import { getAllPosts } from "@/lib/blog"; + +jest.mock("next/server", () => ({ + NextResponse: class { + private readonly body: string; + headers: Headers; + + constructor(body: string, init?: { headers?: HeadersInit }) { + this.body = body; + this.headers = new Headers(init?.headers); + } + + async text() { + return this.body; + } + }, +})); + +jest.mock("@/lib/blog", () => ({ + getAllPosts: jest.fn(), +})); + +const getAllPostsMock = getAllPosts as jest.MockedFunction; +const routeModule = jest.requireActual("@/app/sitemap-images.xml/route"); +const { GET, dynamic } = routeModule; + +describe("GET /sitemap-images.xml", () => { + beforeEach(() => { + getAllPostsMock.mockReset(); + }); + + it("returns an image sitemap xml with static and blog images", async () => { + getAllPostsMock.mockReturnValue([ + { + slug: "article-1", + title: "Article 1", + date: "2026-01-01", + author: "Auteur", + category: "Symfony", + excerpt: "Description article 1", + image: "/images/blog/article-1.webp", + imageCaption: "Caption 1", + imageGeoLocation: "Paris, France", + content: "Contenu", + wordCount: 10, + }, + { + slug: "article-2", + title: "Article 2 & test", + date: "2026-01-02", + author: "Auteur", + category: "PHP", + excerpt: "", + image: "https://cdn.example.com/article-2.webp", + content: "Contenu", + wordCount: 10, + }, + { + slug: "article-sans-image", + title: "Sans image", + date: "2026-01-03", + author: "Auteur", + category: "PHP", + excerpt: "Description", + content: "Contenu", + wordCount: 10, + }, + ]); + + const response = GET(); + const xml = await response.text(); + + expect(response.headers.get("Content-Type")).toContain("application/xml"); + expect(xml).toContain('"); + expect(xml).toContain(`${BASE_URL}/article/article-1`); + expect(xml).toContain(`${BASE_URL}/images/blog/article-1.webp`); + expect(xml).toContain("Caption 1"); + expect(xml).toContain("Paris, France"); + expect(xml).toContain("Article 2 & test"); + expect(xml).toContain("https://cdn.example.com/article-2.webp"); + const article2Block = xml.match( + new RegExp(`${BASE_URL}/article/article-2[\\s\\S]*?<\\/url>`), + )?.[0]; + expect(article2Block).toBeDefined(); + expect(article2Block).not.toContain(""); + expect(xml).not.toContain(`${BASE_URL}/article/article-sans-image`); + expect(xml).toContain(`${BASE_URL}/images/illustrations/source-code.svg`); + }); + + it('exports "force-static" dynamic mode', () => { + expect(dynamic).toBe("force-static"); + }); +}); diff --git a/src/__tests__/components/ui/ResponsiveImage.test.tsx b/src/__tests__/components/ui/ResponsiveImage.test.tsx index ee98ffb8..14f714bc 100644 --- a/src/__tests__/components/ui/ResponsiveImage.test.tsx +++ b/src/__tests__/components/ui/ResponsiveImage.test.tsx @@ -69,7 +69,7 @@ describe("ResponsiveImage", () => { 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"); + expect(img?.getAttribute("alt")).toBe("Illustration"); }); it("honors loading=eager and fetchPriority=high when provided", () => { diff --git a/src/__tests__/lib/blog.test.ts b/src/__tests__/lib/blog.test.ts index 2be74d0a..db12f314 100644 --- a/src/__tests__/lib/blog.test.ts +++ b/src/__tests__/lib/blog.test.ts @@ -65,6 +65,25 @@ describe("getPostBySlug", () => { } }); + it("exposes image metadata fields when present in frontmatter", () => { + const existsSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true); + const readFileSpy = jest + .spyOn(fs, "readFileSync") + .mockReturnValue( + "---\nimage: /images/blog/test.webp\nimageCaption: Une image test\nimageGeoLocation: Lille, France\n---\n" as never, + ); + + try { + const post = getPostBySlug(TEMP_SLUG); + expect(post?.image).toBe("/images/blog/test.webp"); + expect(post?.imageCaption).toBe("Une image test"); + expect(post?.imageGeoLocation).toBe("Lille, France"); + } finally { + readFileSpy.mockRestore(); + existsSpy.mockRestore(); + } + }); + it("returns undefined mainTech when frontmatter has only unknown keys", () => { const existsSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true); const readFileSpy = jest diff --git a/src/app/robots.ts b/src/app/robots.ts index ce0d9e27..0b1df99b 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -18,6 +18,9 @@ export default function robots(): MetadataRoute.Robots { { userAgent: "Google-Extended", allow: "/" }, { userAgent: "CCBot", allow: "/" }, ], - sitemap: "https://www.itefficience.com/sitemap.xml", + sitemap: [ + "https://www.itefficience.com/sitemap.xml", + "https://www.itefficience.com/sitemap-images.xml", + ], }; } diff --git a/src/app/sitemap-images.xml/route.ts b/src/app/sitemap-images.xml/route.ts new file mode 100644 index 00000000..2cf1cecd --- /dev/null +++ b/src/app/sitemap-images.xml/route.ts @@ -0,0 +1,158 @@ +import { NextResponse } from "next/server"; +import { getAllPosts } from "@/lib/blog"; +import { BASE_URL } from "@/lib/metadata"; + +export const dynamic = "force-static"; + +type SitemapImage = { + loc: string; + title: string; + caption?: string; + geoLocation?: string; +}; + +type SitemapImageUrl = { + loc: string; + images: SitemapImage[]; +}; + +const STATIC_IMAGE_URLS: SitemapImageUrl[] = [ + { + loc: BASE_URL, + images: [ + { + loc: `${BASE_URL}/images/illustrations/source-code.svg`, + title: "Agence PHP et Symfony, experte en développement web", + caption: "Illustration de code source de la page d'accueil", + geoLocation: "Lille, France", + }, + ], + }, + { + loc: `${BASE_URL}/developpement-web-sur-mesure`, + images: [ + { + loc: `${BASE_URL}/images/illustrations/developpement-backend.svg`, + title: "Développement web sur mesure", + caption: "Illustration d'architecture back-end Symfony avec API et services", + geoLocation: "Lille, France", + }, + ], + }, + { + loc: `${BASE_URL}/cloud-et-devops`, + images: [ + { + loc: `${BASE_URL}/images/illustrations/online-report.svg`, + title: "Cloud et DevOps", + caption: "Illustration de supervision et d'infrastructure cloud", + geoLocation: "Lille, France", + }, + ], + }, + { + loc: `${BASE_URL}/accompagnement-et-conseil`, + images: [ + { + loc: `${BASE_URL}/images/illustrations/digital-presentation.svg`, + title: "Accompagnement et conseil", + caption: "Illustration de présentation digitale et accompagnement technique", + geoLocation: "Lille, France", + }, + ], + }, + { + loc: `${BASE_URL}/processus-collaboration`, + images: [ + { + loc: `${BASE_URL}/images/illustrations/team-work.svg`, + title: "Processus de collaboration", + caption: "Illustration de collaboration d'équipe sur des projets Symfony", + geoLocation: "Lille, France", + }, + ], + }, + { + loc: `${BASE_URL}/green-it`, + images: [ + { + loc: `${BASE_URL}/images/illustrations/greenit.svg`, + title: "Green IT", + caption: "Illustration d'eco-conception et de sobriete numerique", + geoLocation: "Lille, France", + }, + ], + }, +]; + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function absoluteImageUrl(path: string): string { + return path.startsWith("http") ? path : `${BASE_URL}${path}`; +} + +function toXml(urls: SitemapImageUrl[]): string { + const body = urls + .filter((url) => url.images.length > 0) + .map((url) => { + const imagesXml = url.images + .map((image) => { + const caption = image.caption + ? ` ${escapeXml(image.caption)}\n` + : ""; + const geoLocation = image.geoLocation + ? ` ${escapeXml(image.geoLocation)}\n` + : ""; + + return [ + " ", + ` ${escapeXml(image.loc)}`, + ` ${escapeXml(image.title)}`, + caption.trimEnd(), + geoLocation.trimEnd(), + " ", + ] + .filter(Boolean) + .join("\n"); + }) + .join("\n"); + + return ` \n ${escapeXml(url.loc)}\n${imagesXml}\n `; + }) + .join("\n"); + + return `\n\n${body}\n`; +} + +export function GET() { + const posts = getAllPosts(); + + const blogImageUrls: SitemapImageUrl[] = posts.map((post) => ({ + loc: `${BASE_URL}/article/${post.slug}`, + images: post.image + ? [ + { + loc: absoluteImageUrl(post.image), + title: post.title, + caption: post.imageCaption ?? post.excerpt, + geoLocation: post.imageGeoLocation, + }, + ] + : [], + })); + + const xml = toXml([...STATIC_IMAGE_URLS, ...blogImageUrls]); + + return new NextResponse(xml, { + headers: { + "Content-Type": "application/xml; charset=utf-8", + }, + }); +} diff --git a/src/app/sitemap-news.xml/route.ts b/src/app/sitemap-news.xml/route.ts new file mode 100644 index 00000000..d3f38d97 --- /dev/null +++ b/src/app/sitemap-news.xml/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { getAllPosts } from "@/lib/blog"; +import { BASE_URL } from "@/lib/metadata"; + +export const dynamic = "force-static"; + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function GET() { + const posts = getAllPosts(); + const xmlItems = posts + .map( + (post) => ` + ${BASE_URL}/article/${post.slug} + + + Efficience IT + fr + + ${post.date} + ${escapeXml(post.title)} + + `, + ) + .join("\n"); + + const xml = ` + +${xmlItems} +`; + + return new NextResponse(xml, { + headers: { + "Content-Type": "application/xml; charset=utf-8", + }, + }); +} diff --git a/src/components/ui/ResponsiveImage.tsx b/src/components/ui/ResponsiveImage.tsx index 6aa8bce8..0ec44182 100644 --- a/src/components/ui/ResponsiveImage.tsx +++ b/src/components/ui/ResponsiveImage.tsx @@ -1,4 +1,5 @@ import variantsManifest from "@/data/blog-image-variants.json"; +import Image from "next/image"; interface ResponsiveImageProps { src: string; @@ -28,7 +29,7 @@ export default function ResponsiveImage({ if (widths.length === 0) { return ( - {alt} ); } @@ -50,7 +51,7 @@ export default function ResponsiveImage({ - {alt} ); diff --git a/src/lib/blog.ts b/src/lib/blog.ts index 33ab904d..4eb5e16f 100644 --- a/src/lib/blog.ts +++ b/src/lib/blog.ts @@ -47,6 +47,8 @@ export function getAllPosts(): BlogPost[] { excerpt: data.excerpt ?? "", updatedAt: data.updatedAt, image: data.image, + imageCaption: data.imageCaption, + imageGeoLocation: data.imageGeoLocation, proficiencyLevel: data.proficiencyLevel, faq: data.faq, event: data.event, @@ -78,6 +80,8 @@ export function getPostBySlug(slug: string): BlogPost | undefined { excerpt: data.excerpt ?? "", updatedAt: data.updatedAt, image: data.image, + imageCaption: data.imageCaption, + imageGeoLocation: data.imageGeoLocation, proficiencyLevel: data.proficiencyLevel, faq: data.faq, event: data.event, diff --git a/src/types/blog.ts b/src/types/blog.ts index 054e500b..358c67ab 100644 --- a/src/types/blog.ts +++ b/src/types/blog.ts @@ -46,6 +46,8 @@ export interface BlogPost { excerpt: string; updatedAt?: string; image?: string; + imageCaption?: string; + imageGeoLocation?: string; proficiencyLevel?: ProficiencyLevel; faq?: FaqItem[]; event?: EventSchema;