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 sitemap from "@astrojs/sitemap";
import tailwind from "@astrojs/tailwind"; import tailwind from "@astrojs/tailwind";
import { getAlternateUrls } from "./src/utils/page-translations.ts";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
@ -10,13 +11,21 @@ export default defineConfig({
integrations: [ integrations: [
tailwind(), tailwind(),
sitemap({ sitemap({
i18n: { serialize(item) {
defaultLocale: "fr", const url = new URL(item.url);
locales: { const pathname = decodeURIComponent(url.pathname).replace(/\/$/, "") || "/";
fr: "fr", const alternates = getAlternateUrls(pathname);
en: "en", if (alternates) {
ar: "ar", 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 type { Locale } from "./i18n";
import { getPhotoAlbumsPath, getPhotoBlogPath } from "./i18n";
/** /**
* Groupes de pages traduites. * Groupes de pages traduites.
@ -14,6 +15,7 @@ const translationGroups: Record<Locale, string>[] = [
{ fr: "/code/competences", en: "/en/code/skills", ar: "/ar/برمجة/مهارات" }, { fr: "/code/competences", en: "/en/code/skills", ar: "/ar/برمجة/مهارات" },
{ fr: "/theatre", en: "/en/acting", ar: "/ar/مسرح" }, { fr: "/theatre", en: "/en/acting", ar: "/ar/مسرح" },
{ fr: "/photo", en: "/en/photo", ar: "/ar/تصوير" }, { fr: "/photo", en: "/en/photo", ar: "/ar/تصوير" },
{ fr: "/photo/blog", en: "/en/photo/blog", ar: "/ar/تصوير/مدونة" },
]; ];
/** Index inversé : pathname → groupe de traduction */ /** 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é. * Retourne les URLs alternatives pour un pathname donné.
* Les trailing slashes sont normalisés. * Les trailing slashes sont normalisés.
* Essaie d'abord le lookup statique, puis le matching dynamique.
*/ */
export function getAlternateUrls( export function getAlternateUrls(
pathname: string, pathname: string,
): Record<Locale, string> | undefined { ): Record<Locale, string> | undefined {
const normalized = pathname.replace(/\/$/, "") || "/"; const normalized = pathname.replace(/\/$/, "") || "/";
return pathIndex.get(normalized); return pathIndex.get(normalized) ?? matchDynamicPattern(normalized);
} }