Sitemap hreflang corrigé pour URLs traduites et routes dynamiques

Remplace l'option i18n du plugin sitemap (qui supposait des chemins identiques entre langues) par un serialize custom basé sur getAlternateUrls(). Ajoute le support des routes dynamiques (albums photo, blog posts) via des patterns regex, et l'entrée statique /photo/blog. Toutes les pages traduites ont désormais des hreflang fr/en/ar + x-default corrects dans le sitemap et le HTML.
This commit is contained in:
Jalil Arfaoui 2026-02-23 01:04:02 +01:00
parent 758b48521e
commit a7362730ba
2 changed files with 65 additions and 8 deletions

View file

@ -2,6 +2,7 @@ import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
import tailwind from "@astrojs/tailwind";
import { getAlternateUrls } from "./src/utils/page-translations.ts";
// https://astro.build/config
export default defineConfig({
@ -10,13 +11,21 @@ export default defineConfig({
integrations: [
tailwind(),
sitemap({
i18n: {
defaultLocale: "fr",
locales: {
fr: "fr",
en: "en",
ar: "ar",
},
serialize(item) {
const url = new URL(item.url);
const pathname = decodeURIComponent(url.pathname).replace(/\/$/, "") || "/";
const alternates = getAlternateUrls(pathname);
if (alternates) {
const fullUrl = (path) =>
path === "/" ? `${url.origin}/` : `${url.origin}${path}/`;
item.links = [
{ lang: "fr", url: fullUrl(alternates.fr) },
{ lang: "en", url: fullUrl(alternates.en) },
{ lang: "ar", url: fullUrl(alternates.ar) },
{ lang: "x-default", url: fullUrl(alternates.fr) },
];
}
return item;
},
}),
],

View file

@ -1,4 +1,5 @@
import type { Locale } from "./i18n";
import { getPhotoAlbumsPath, getPhotoBlogPath } from "./i18n";
/**
* Groupes de pages traduites.
@ -14,6 +15,7 @@ const translationGroups: Record<Locale, string>[] = [
{ fr: "/code/competences", en: "/en/code/skills", ar: "/ar/برمجة/مهارات" },
{ fr: "/theatre", en: "/en/acting", ar: "/ar/مسرح" },
{ fr: "/photo", en: "/en/photo", ar: "/ar/تصوير" },
{ fr: "/photo/blog", en: "/en/photo/blog", ar: "/ar/تصوير/مدونة" },
];
/** Index inversé : pathname → groupe de traduction */
@ -24,13 +26,59 @@ for (const group of translationGroups) {
}
}
/** Pattern dynamique pour les routes paramétrées */
interface DynamicPattern {
regex: RegExp;
buildUrls: (match: RegExpMatchArray) => Record<Locale, string>;
}
const dynamicPatterns: DynamicPattern[] = [
{
// Photo albums: /photo/albums/{cat}, /en/photo/albums/{cat}, /ar/تصوير/ألبومات/{cat}
regex: /^(?:\/photo\/albums|\/en\/photo\/albums|\/ar\/تصوير\/ألبومات)\/([^/]+)$/,
buildUrls: (match) => {
const category = match[1];
return {
fr: `${getPhotoAlbumsPath("fr")}/${category}`,
en: `${getPhotoAlbumsPath("en")}/${category}`,
ar: `${getPhotoAlbumsPath("ar")}/${category}`,
};
},
},
{
// Photo blog posts: /photo/blog/{year}/{slug}, /en/photo/blog/{year}/{slug}, /ar/تصوير/مدونة/{year}/{slug}
regex: /^(?:\/photo\/blog|\/en\/photo\/blog|\/ar\/تصوير\/مدونة)\/(\d{4})\/([^/]+)$/,
buildUrls: (match) => {
const [, year, slug] = match;
return {
fr: `${getPhotoBlogPath("fr")}/${year}/${slug}`,
en: `${getPhotoBlogPath("en")}/${year}/${slug}`,
ar: `${getPhotoBlogPath("ar")}/${year}/${slug}`,
};
},
},
];
function matchDynamicPattern(
pathname: string,
): Record<Locale, string> | undefined {
for (const pattern of dynamicPatterns) {
const match = pathname.match(pattern.regex);
if (match) {
return pattern.buildUrls(match);
}
}
return undefined;
}
/**
* Retourne les URLs alternatives pour un pathname donné.
* Les trailing slashes sont normalisés.
* Essaie d'abord le lookup statique, puis le matching dynamique.
*/
export function getAlternateUrls(
pathname: string,
): Record<Locale, string> | undefined {
const normalized = pathname.replace(/\/$/, "") || "/";
return pathIndex.get(normalized);
return pathIndex.get(normalized) ?? matchDynamicPattern(normalized);
}