# Astro i18n with Localized Slugs

Pattern pour faire un site multilingue où **chaque locale a ses propres slugs** (URL, catégorie, titre de catégorie). Pas juste un préfixe `/fr/` devant les mêmes slugs anglais.

**Exemple cible** :
```
/guides/technical-seo-checklist/         (EN, default, no prefix)
/fr/guides/checklist-seo-technique/       (FR — slug post traduit)
/fr/tutoriels/patterns-hreflang/          (FR — catégorie ET slug post traduits)
/es/guias/checklist-seo-tecnico/          (ES — futur)
/de/leitfaden/technische-seo-checkliste/  (DE — futur)
```

Validé sur **astro6-seo-template**.

---

## Principe : 3 axes à séparer

| Axe | Stable (clé canonique) | Localisé (slug URL) | Source |
|-----|------------------------|---------------------|--------|
| Locale | `"en"`, `"fr"`, `"es"`, ... | préfixe URL `""`, `/fr`, `/es` | `siteConfig.defaultLocale` + `supportedLocales` |
| Catégorie | `"tutorials"` | `/tutorials/`, `/tutoriels/`, `/guias/` | `siteConfig.categorySlugs[cat][locale]` |
| Post | id du fichier `posts/fr/checklist-seo-technique.mdx` | filename = slug | content collection |
| Article translation | `translationId: "technical-seo-checklist"` (partagé) | différent par locale | frontmatter |

**Règle d'or** : ne JAMAIS utiliser une clé canonique dans une URL. Toujours passer par un resolver `categorySlug(canonical, locale)`.

---

## Schema content collection

```ts
// src/content.config.ts
const posts = defineCollection({
  schema: z.object({
    title: z.string(),
    locale: z.enum(["en", "fr"]),
    category: z.enum(["guides", "tutorials", "case-studies", "news"]),
    /**
     * Shared identifier across all translations of the same logical post.
     * Convention: use the EN slug (e.g. "technical-seo-checklist").
     */
    translationId: z.string(),
    // ...
  }),
});
```

**Frontmatter** (4 fichiers liés deux à deux) :
```yaml
# posts/en/technical-seo-checklist.mdx
locale: en
category: guides
translationId: technical-seo-checklist
```
```yaml
# posts/fr/checklist-seo-technique.mdx
locale: fr
category: guides
translationId: technical-seo-checklist  # même ID = même article
```

---

## Config site.ts — Map des slugs traduits

```ts
// src/config/site.ts
export const siteConfig = {
  defaultLocale: "en" as const,
  supportedLocales: ["en", "fr"] as const,
  categories: ["guides", "tutorials", "case-studies", "news"] as const,
  categorySlugs: {
    guides:         { en: "guides",        fr: "guides" },
    tutorials:      { en: "tutorials",     fr: "tutoriels" },
    "case-studies": { en: "case-studies",  fr: "etudes-de-cas" },
    news:           { en: "news",          fr: "actualites" },
  },
};
```

**Ajouter une locale = N lignes** où N = nombre de catégories. Si on oublie une locale, le resolver throw au build (voir helper).

---

## Helpers content.ts

```ts
// src/lib/content.ts

export function categorySlug(category: CategorySlug, locale: SupportedLocale): string {
  const slug = siteConfig.categorySlugs[category][locale];
  if (!slug) {
    throw new Error(`No ${locale} URL slug for category "${category}". Add it to siteConfig.categorySlugs.`);
  }
  return slug;
}

export function localePrefix(locale: SupportedLocale): string {
  return locale === siteConfig.defaultLocale ? "" : `/${locale}`;
}

export function postUrl(post: Post): string {
  return `${localePrefix(post.data.locale)}/${categorySlug(post.data.category, post.data.locale)}/${postSlug(post)}/`;
}

/**
 * Returns one Post per locale (or null if no translation exists).
 * Used by the LanguageSwitcher to land on the correct translated URL.
 */
export async function getPostTranslations(post: Post): Promise<Record<SupportedLocale, Post | null>> {
  const all = await getCollection("posts",
    ({ data }) => data.translationId === post.data.translationId && !data.draft,
  );
  const map = {} as Record<SupportedLocale, Post | null>;
  for (const locale of siteConfig.supportedLocales) {
    map[locale] = all.find((t) => t.data.locale === locale) ?? null;
  }
  return map;
}
```

---

## Routing — getStaticPaths émet le slug LOCALISÉ

Pour `/fr/tutoriels/patterns-hreflang/`, le param `[category]` doit valoir `"tutoriels"` (pas `"tutorials"`). On émet l'URL avec le slug localisé, et on passe la clé canonique en `props` pour le runtime type-safe.

```ts
// src/pages/fr/[category]/[...slug].astro
export async function getStaticPaths() {
  const posts = await getAllPosts("fr");
  return posts.map((post) => ({
    params: {
      category: categorySlug(post.data.category, "fr"),  // localisé
      slug: postSlug(post),
    },
    props: { post },
  }));
}
```

Pour les category hubs :
```ts
// src/pages/fr/[category]/index.astro
export async function getStaticPaths() {
  const categories = await getAllCategories();
  return categories.map((cat) => ({
    params: { category: categorySlug(cat.data.slug, "fr") },
    props: { canonicalCategory: cat.data.slug },
  }));
}
```

---

## LanguageSwitcher translation-aware

Le switcher doit pointer vers la **vraie URL traduite** (slug différent), pas vers `/fr/{slug-EN}` qui 404.

```astro
---
// Post page builds an hreflangMap from translations
const translations = await getPostTranslations(post);
const hreflangMap: Partial<Record<SupportedLocale, string>> = {};
for (const [loc, t] of Object.entries(translations)) {
  hreflangMap[loc] = t ? postUrl(t) : undefined;
}
---
<BaseLayout hreflangMap={hreflangMap} ... />
```

`BaseLayout` → `Header` → `LanguageSwitcher` :
```astro
const enHref = hreflangMap?.en ?? getLocalizedPath(pathname, "en");  // fallback prefix swap
const frHref = hreflangMap?.fr ?? getLocalizedPath(pathname, "fr");
```

**Pages sans traduction explicite** (homepage, about) → fallback `getLocalizedPath()` (juste un swap de préfixe).

---

## SEO — hreflang alternates dans `<head>`

```ts
const metadata = createMetadata({
  // ...
  alternates: {
    ...hreflangMap,
    "x-default": hreflangMap.en ?? pagePath,
  },
});
```

Sortie HTML :
```html
<link rel="alternate" hreflang="en" href="https://site/tutorials/hreflang-patterns/" />
<link rel="alternate" hreflang="fr" href="https://site/fr/tutoriels/patterns-hreflang/" />
<link rel="alternate" hreflang="x-default" href="https://site/tutorials/hreflang-patterns/" />
```

Critique pour Google : sans la réciprocité parfaite des URLs traduites, Search Console flag "missing return tag".

---

## i18n DEEP — labels traduits aussi

Le titre de catégorie affiché dans les breadcrumbs / hero doit être localisé (pas la clé canonique `tutorials`).

```ts
const categoryEntry = await getCategory(post.data.category);
const categoryTitle = categoryEntry?.data.title[locale] ?? post.data.category;
```

Schema `categories.json` :
```json
{
  "slug": "tutorials",
  "title": { "en": "Tutorials", "fr": "Tutoriels" },
  "description": { "en": "...", "fr": "..." }
}
```

Même chose pour `articleSection` dans le JSON-LD (Schema.org) : on passe le titre localisé, pas la clé. `aria-label` des `<nav>` aussi traduit (`Breadcrumb` / `Fil d'Ariane`).

---

## Sitemap — émission des alternates

`@astrojs/sitemap` config :
```ts
sitemap({
  i18n: {
    defaultLocale: "en",
    locales: { en: "en", fr: "fr" },
  },
  serialize(item) {
    // Override lastmod, etc.
    return item;
  },
});
```

Sortie : chaque URL liste ses `<xhtml:link rel="alternate" hreflang="...">` entries automatiquement.

---

## RSS feeds par locale

Chaque locale a son propre `feed.xml` (`/feed.xml`, `/fr/feed.xml`). Les liens `<link>` doivent utiliser le slug catégorie localisé :

```ts
items.push({
  title: post.data.title,
  link: `/fr/${categorySlug(post.data.category, "fr")}/${slug}`,  // /fr/tutoriels/...
  // ...
});
```

Titre + description du flux : `t("rss.title")` / `t("rss.description")` (pas hardcodé EN).

---

## Pièges évités

1. **`as CategorySlug` cast d'un param d'URL** — bug silencieux. Le param URL est le slug LOCALISÉ ("tutoriels"), pas la clé canonique. Toujours passer la clé canonique via `props`.
2. **`postUrl()` qui ignore la locale** — quand on rendait `<PostCard>` dans une page FR, il pointait vers `/fr/tutorials/...` (404). Centraliser la résolution dans `postUrl(post)`.
3. **OG image URL avec clé canonique** — `og:image` sur page FR devait matcher le slug catégorie localisé pour cohérence. Route `/og/[category]/[...slug].png.ts` émet aussi avec slug localisé.
4. **Fallback silencieux** — `categorySlug()` throw si la map est incomplète. Une nouvelle locale ajoutée à `supportedLocales` sans entrée dans `categorySlugs` → build fail tout de suite, pas crash silencieux en prod.
5. **Hreflang non réciproque** — chaque URL DOIT pointer vers TOUTES les autres locales (y compris elle-même). Sans ça, Google refuse de swap l'URL indexée par locale dans les SERPs.

---

## Scaling à 5+ locales

```ts
// 1. Étendre supportedLocales
supportedLocales: ["en", "fr", "es", "de", "it"] as const,

// 2. Étendre categorySlugs (4 lignes par catégorie)
categorySlugs: {
  guides: { en: "guides", fr: "guides", es: "guias", de: "leitfaden", it: "guide" },
  tutorials: { en: "tutorials", fr: "tutoriels", es: "tutoriales", de: "tutorials", it: "tutorial" },
  // ...
},

// 3. Créer src/pages/{es,de,it}/[category]/[...slug].astro (copy-paste de la version FR)
// 4. Ajouter src/i18n/dict/{es,de,it}.json
// 5. Ajouter posts/{es,de,it}/*.mdx avec translationId matching
```

Le LanguageSwitcher boucle sur `supportedLocales` → marche automatiquement avec N locales.

---

## Liens

- Repo de référence : `astro6-seo-template`
- Astro docs i18n : https://docs.astro.build/en/guides/internationalization/
- Astro routing endpoints : https://docs.astro.build/en/guides/endpoints/
