Skip to content

🌐 Système i18n (Internationalisation)

Vue d'ensemble

J'ai créé un système de traduction custom léger basé sur des fichiers JSON statiques. Contrairement à des librairies comme astro-i18next, mon système est minimaliste et adapté à mes besoins.

Avantages :

  • 🪶 Léger (~100 lignes de code)
  • 📦 Pas de dépendance externe
  • 🎯 Parfaitement adapté à mes besoins
  • 🚀 Performance optimale

Architecture

Fichiers de Traduction

public/locales/
├── en/
│   ├── common.json      # Textes généraux (theme, locale, keyboard)
│   ├── components.json  # Composants UI
│   └── sections.json    # Sections CV
└── fr/
    ├── common.json
    ├── components.json
    └── sections.json

Fonction Principale : t()

Fichier : src/utils/i18n.ts

Signature :

typescript
function t(
  key: string,              // Format: "namespace:path.to.key"
  options: {
    lng: Locale;            // "fr" | "en"
    [key: string]: any      // Variables pour interpolation
  }
): string

Utilisation

Import

astro
---
import { t } from "@/utils/i18n";
---

Traduction Simple

astro
---
const title = t("sections:about.title", { lng: "fr" });
// → "À propos"
---

<h2>{title}</h2>

Traduction avec Interpolation

astro
---
const name = "Simon";
const phone = "+33 X XX XX XX XX";

const text = t("sections:hero.phoneTitle", {
  lng: "fr",
  name,
  phone
});
// → "Contacter Simon au +33 X XX XX XX XX"
---

<a href={`tel:${phone}`} title={text}>
  {phone}
</a>

Format des Clés

Syntaxe : namespace:path.to.key

Namespace = Nom du fichier JSON (sans .json) Path = Chemin dans l'objet JSON (séparé par des points)

Exemples

Fichier : public/locales/fr/sections.json

json
{
  "hero": {
    "showProfile": "Voir le profil de {{name}} sur"
  },
  "experience": {
    "title": "Expérience professionnelle",
    "now": "Actuel"
  }
}

Utilisation :

typescript
t("sections:hero.showProfile", { lng: "fr", name: "Simon" })
// → "Voir le profil de Simon sur"

t("sections:experience.title", { lng: "fr" })
// → "Expérience professionnelle"

t("sections:experience.now", { lng: "fr" })
// → "Actuel"

Interpolation de Variables

Syntaxe :

Dans les fichiers JSON, j'utilise pour indiquer où insérer des données dynamiques.

Exemple :

json
{
  "hero": {
    "showProfile": "Voir le profil de {{name}} sur",
    "phoneTitle": "Contacter {{name}} au {{phone}}"
  }
}

Code :

typescript
t("sections:hero.showProfile", {
  lng: "fr",
  name: "Simon"
});
// → "Voir le profil de Simon sur"

t("sections:hero.phoneTitle", {
  lng: "fr",
  name: "Simon",
  phone: "+33 6 12 34 56 78"
});
// → "Contacter Simon au +33 6 12 34 56 78"

Fonctionnement Interne

typescript
// Regex pour trouver toutes les occurrences de {{variable}}
const regex = /\{\{(\w+)\}\}/g;

// Remplacer chaque {{variable}} par sa valeur
translation = translation.replace(regex, (match, varName) => {
  return options[varName] ?? match;
});

Exemple :

Input:  "Voir le profil de {{name}} sur {{network}}"
Options: { name: "Simon", network: "GitHub" }
Output: "Voir le profil de Simon sur GitHub"

Fonctionnement Interne

Étapes de Traduction

  1. Parse la clé : "sections:hero.showProfile"

    • Namespace = sections
    • Path = hero.showProfile
  2. Charge le fichier JSON :

    typescript
    const translation = await import(
      `/public/locales/${lng}/${namespace}.json`
    );
  3. Navigate dans l'objet :

    typescript
    let result = translation;
    for (const key of path.split(".")) {
      result = result[key];
    }
    // result = "Voir le profil de {{name}} sur"
  4. Interpole les variables :

    typescript
    result = result.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
      return options[varName] ?? match;
    });
    // result = "Voir le profil de Simon sur"
  5. Retourne la string traduite


Code Source Complet

Fichier : src/utils/i18n.ts

typescript
export type Locale = "en" | "fr";

interface TranslationOptions {
  lng: Locale;
  [key: string]: any;
}

// Cache pour éviter de recharger les mêmes fichiers
const translationsCache: Record<string, any> = {};

export async function t(
  key: string,
  options: TranslationOptions
): Promise<string> {
  const { lng, ...variables } = options;

  // Parse la clé (format: "namespace:path.to.key")
  const [namespace, path] = key.split(":");

  if (!namespace || !path) {
    if (import.meta.env.DEV) {
      console.warn(`Clé de traduction invalide: ${key}`);
    }
    return key;
  }

  // Vérifie que la locale est valide
  if (!["en", "fr"].includes(lng)) {
    if (import.meta.env.DEV) {
      console.warn(`Locale invalide: ${lng}`);
    }
    return key;
  }

  // Charge le fichier de traduction (avec cache)
  const cacheKey = `${lng}-${namespace}`;

  if (!translationsCache[cacheKey]) {
    try {
      const module = await import(
        `/public/locales/${lng}/${namespace}.json`
      );
      translationsCache[cacheKey] = module.default || module;
    } catch (error) {
      if (import.meta.env.DEV) {
        console.warn(
          `Fichier de traduction introuvable: ${lng}/${namespace}.json`
        );
      }
      return key;
    }
  }

  // Navigate dans l'objet JSON avec le path
  let translation = translationsCache[cacheKey];

  for (const segment of path.split(".")) {
    translation = translation?.[segment];

    if (translation === undefined) {
      if (import.meta.env.DEV) {
        console.warn(`Traduction introuvable: ${key}`);
      }
      return key;
    }
  }

  // Interpole les variables
  if (typeof translation === "string") {
    translation = translation.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
      return variables[varName] ?? match;
    });
  }

  return translation;
}

// Version synchrone pour les cas où on a déjà chargé les traductions
export function tSync(key: string, options: TranslationOptions): string {
  // Même logique mais sans async/await
  // Utilisé uniquement si les traductions sont déjà en cache
}

Mode DEV vs Production

Console Warnings

Les warnings ne s'affichent qu'en mode développement :

typescript
if (import.meta.env.DEV) {
  console.warn("Message de debug");
}

En DEV (pnpm dev) :

  • ✅ Warnings visibles dans la console
  • 🐛 Facilite le debugging

En PROD (pnpm build) :

  • ✅ Aucun warning dans la console
  • 🚀 Console propre pour l'utilisateur

Types de Warnings

  1. Clé invalide :

    Clé de traduction invalide: invalidKey
  2. Locale invalide :

    Locale invalide: de
  3. Fichier introuvable :

    Fichier de traduction introuvable: fr/missing.json
  4. Traduction introuvable :

    Traduction introuvable: sections:nonexistent.key

Cache des Traductions

Pourquoi un Cache ?

Pour éviter de recharger les mêmes fichiers JSON à chaque appel de t().

typescript
const translationsCache: Record<string, any> = {};

Clé de Cache

Format : ${locale}-${namespace}

Exemples :

  • fr-common
  • en-sections
  • fr-components

Fonctionnement

typescript
const cacheKey = `${lng}-${namespace}`;

if (!translationsCache[cacheKey]) {
  // Charge le fichier (1ère fois seulement)
  const module = await import(`/public/locales/${lng}/${namespace}.json`);
  translationsCache[cacheKey] = module.default || module;
}

// Utilise le cache pour les appels suivants
let translation = translationsCache[cacheKey];

Bénéfice : Performance optimale, chaque fichier n'est chargé qu'une seule fois.


Ajouter une Nouvelle Traduction

1. Identifier le Namespace

  • common : Textes généraux (theme, locale, keyboard)
  • components : Composants UI (dialog, buttons)
  • sections : Sections CV (hero, about, experience, etc.)

2. Ajouter dans les 2 Langues

Français (public/locales/fr/sections.json) :

json
{
  "myNewSection": {
    "title": "Mon Nouveau Titre",
    "description": "Description en français"
  }
}

Anglais (public/locales/en/sections.json) :

json
{
  "myNewSection": {
    "title": "My New Title",
    "description": "Description in English"
  }
}

3. Utiliser dans un Composant

astro
---
import { t } from "@/utils/i18n";

const { locale } = Astro.props;

const title = t("sections:myNewSection.title", { lng: locale });
const description = t("sections:myNewSection.description", { lng: locale });
---

<h2>{title}</h2>
<p>{description}</p>

Bonnes Pratiques

✅ Toujours définir dans les 2 langues

json
// ✅ Bon : FR et EN synchronisés
// fr/sections.json
{ "hero": { "title": "Héros" } }

// en/sections.json
{ "hero": { "title": "Hero" } }
json
// ❌ Mauvais : Manque EN
// fr/sections.json
{ "hero": { "title": "Héros" } }

// en/sections.json
{ }  // Vide !

✅ Utiliser des clés descriptives

json
// ✅ Bon : Clés explicites
{
  "experience": {
    "showMore": "Voir plus",
    "showLess": "Voir moins"
  }
}

// ❌ Mauvais : Clés génériques
{
  "btn1": "Voir plus",
  "btn2": "Voir moins"
}

✅ Grouper logiquement

json
// ✅ Bon : Regroupement cohérent
{
  "hero": {
    "showProfile": "...",
    "phoneTitle": "...",
    "mailTitle": "..."
  }
}

// ❌ Mauvais : Tout au même niveau
{
  "heroShowProfile": "...",
  "heroPhoneTitle": "...",
  "heroMailTitle": "..."
}

✅ Nommer explicitement les variables

json
// ✅ Bon : Variables explicites
"phoneTitle": "Contacter {{name}} au {{phone}}"

// ❌ Mauvais : Variables génériques
"phoneTitle": "Contacter {{var1}} au {{var2}}"

Debugging

Tester une Traduction

astro
---
const test = t("sections:hero.showProfile", {
  lng: "fr",
  name: "Simon"
});

console.log(test);
// → "Voir le profil de Simon sur" (si tout va bien)
// → "sections:hero.showProfile" (si erreur)
---

Vérifier les Fichiers JSON

bash
# Valider la syntaxe
cat public/locales/fr/sections.json | jq .

# Afficher le contenu
jq . public/locales/fr/sections.json

Vérifier le Cache

En DEV, le cache peut poser problème si on modifie les JSON sans recharger.

Solution : Recharger la page complètement (Cmd+R).