Skip to content
Open
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
95 changes: 95 additions & 0 deletions src/__tests__/app/sitemap-images-route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getAllPosts>;
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('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"');
expect(xml).toContain("<image:image>");
expect(xml).toContain(`<loc>${BASE_URL}/article/article-1</loc>`);
expect(xml).toContain(`<image:loc>${BASE_URL}/images/blog/article-1.webp</image:loc>`);
expect(xml).toContain("<image:caption>Caption 1</image:caption>");
expect(xml).toContain("<image:geo_location>Paris, France</image:geo_location>");
expect(xml).toContain("<image:title>Article 2 &amp; test</image:title>");
expect(xml).toContain("<image:loc>https://cdn.example.com/article-2.webp</image:loc>");
const article2Block = xml.match(
new RegExp(`<loc>${BASE_URL}/article/article-2</loc>[\\s\\S]*?<\\/url>`),
)?.[0];
expect(article2Block).toBeDefined();
expect(article2Block).not.toContain("<image:caption>");
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");
});
});
2 changes: 1 addition & 1 deletion src/__tests__/components/ui/ResponsiveImage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/lib/blog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/app/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
};
}
158 changes: 158 additions & 0 deletions src/app/sitemap-images.xml/route.ts
Original file line number Diff line number Diff line change
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}

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
? ` <image:caption>${escapeXml(image.caption)}</image:caption>\n`
: "";
const geoLocation = image.geoLocation
? ` <image:geo_location>${escapeXml(image.geoLocation)}</image:geo_location>\n`
: "";

return [
" <image:image>",
` <image:loc>${escapeXml(image.loc)}</image:loc>`,
` <image:title>${escapeXml(image.title)}</image:title>`,
caption.trimEnd(),
geoLocation.trimEnd(),
" </image:image>",
]
.filter(Boolean)
.join("\n");
})
.join("\n");

return ` <url>\n <loc>${escapeXml(url.loc)}</loc>\n${imagesXml}\n </url>`;
})
.join("\n");

return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${body}\n</urlset>`;
}

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",
},
});
}
45 changes: 45 additions & 0 deletions src/app/sitemap-news.xml/route.ts
Original file line number Diff line number Diff line change
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}

export function GET() {
const posts = getAllPosts();
const xmlItems = posts
.map(
(post) => ` <url>
<loc>${BASE_URL}/article/${post.slug}</loc>
<news:news>
<news:publication>
<news:name>Efficience IT</news:name>
<news:language>fr</news:language>
</news:publication>
<news:publication_date>${post.date}</news:publication_date>
<news:title>${escapeXml(post.title)}</news:title>
</news:news>
</url>`,
)
.join("\n");

const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
${xmlItems}
</urlset>`;

return new NextResponse(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
},
});
}
9 changes: 5 additions & 4 deletions src/components/ui/ResponsiveImage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import variantsManifest from "@/data/blog-image-variants.json";
import Image from "next/image";

interface ResponsiveImageProps {
src: string;
Expand Down Expand Up @@ -28,16 +29,16 @@ export default function ResponsiveImage({

if (widths.length === 0) {
return (
<img
<Image
src={src}
alt={alt}
width={width}
height={height}
sizes={sizes}
className={className}
loading={loading}
decoding="async"
fetchPriority={fetchPriority}
unoptimized
/>
);
}
Expand All @@ -50,16 +51,16 @@ export default function ResponsiveImage({
<picture>
<source type="image/avif" srcSet={srcset("avif")} sizes={sizes} />
<source type="image/webp" srcSet={srcset("webp")} sizes={sizes} />
<img
<Image
src={src}
alt={alt}
width={width}
height={height}
sizes={sizes}
className={className}
loading={loading}
decoding="async"
fetchPriority={fetchPriority}
unoptimized
/>
</picture>
);
Expand Down
Loading
Loading