

1/1/1970
Comment j'ai mis en place l'i18n dans 20 langues en 3 jours
Salut ! Je viens de terminer une tâche énorme : j'ai traduit Foony en 20 langues différentes. Ça m'a obligé à toucher à presque tous les fichiers du code, mais j'ai réussi à tout boucler en seulement 3 jours.
Plus bas, je vais t'expliquer comment j'ai fait, te donner quelques chiffres précis, et pourquoi j'ai encore une fois choisi de coder ma propre librairie de traduction au lieu d'utiliser le standard du marché.
Pourquoi pas i18next ?
Quand j'ai commencé à réfléchir à ajouter des traductions, j'ai naturellement pensé au standard du moment : i18next et react-i18next.
Finalement, j'ai préféré optimiser pour la maintenabilité par une IA. i18next est puissant, mais la variété de son API peut pousser les LLM à halluciner ou à écrire du code incohérent. En limitant la librairie à un simple t() et interpolate(), j'ai garanti que plus de 10 agents en parallèle puissent écrire du code 100 % type-safe avec presque aucune intervention humaine.
J'étais aussi méfiant à l'idée de m'enfermer dans un gros écosystème qui pourrait introduire des breaking changes plus tard. Après m'être déjà fait avoir par des migrations douloureuses comme React Router v5 et MUI v4 → v5, je sais que la rétrocompatibilité qui casse du jour au lendemain est bien trop fréquente dans le monde JavaScript. Le coût d'ajouter des fonctionnalités de pluralisation plus tard est bien plus faible que celui de migrer manuellement 139 000 lignes de code maintenant.
Je voulais quelque chose d'ultra simple, extrêmement léger et taillé exactement pour les besoins de mon équipe.
Alors j'ai écrit le mien.
J'ai créé un sous-ensemble limité de 3 Ko, conçu spécialement pour permettre un refactoring autonome par IA avec une grande précision. Ça m'a permis, en tant qu'unique développeur, d'abattre en 3 jours le travail de 3 semaines d'une équipe de 5 personnes.
L'implémentation maison
J'ai imaginé une petite librairie d'i18n minimaliste qui fait environ 3 Ko une fois gzippée. Elle expose deux fonctions principales : getTranslation() pour les contextes hors React et un hook useTranslation() pour les composants.
Ces fonctions renvoient t() pour les remplacements de chaînes simples, et interpolate() quand j'ai besoin d'injecter des composants React dans une chaîne traduite (par exemple un lien ou une icône). Les deux gèrent aussi le remplacement de variables, par exemple "Hello {{thing}}", {thing: 'World'}.
Voici la fonction t() de base :
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Vérifie si la clé contient '/' ce qui indique un namespace
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
Et le hook React :
export function useTranslation() {
const [language] = useLanguage();
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
Le cœur de toute la librairie fait environ 580 lignes de code. Il gère :
- le lazy-loading des fichiers de traduction pour éviter d'envoyer les 20 langues à chaque utilisateur ;
- le code-splitting des traductions par « namespace » (par exemple
common,misc,games/{gameId}) ; - une locale « debug » qui affiche les clés brutes pour que je puisse vérifier que tout est bien branché.
Pour que le système reste facile à maintenir, j'ai aussi ajouté une documentation complète dans shared/src/i18n/README.md, qui couvre tout, de la structure des fichiers aux exemples d'utilisation côté client et côté serveur. Comme je n'utilise pas de librairie standard, avoir cette référence est essentiel pour accueillir de nouveaux membres dans l'équipe (ou simplement pour rappeler à mon futur moi, ou aux LLM, comment tout fonctionne).
En chiffres
Pour te donner une idée de l'ampleur de cette mise à jour, voilà ce qui a changé dans la base de code :
- 20 langues prises en charge (plus une locale de debug pour le dev).
- 360 fichiers de locale créés.
- 139 031 lignes de code de traduction.
- 3 938 appels à
t()ajoutés côté client. - 728 fichiers source modifiés.
- 18 fichiers source anglais qui servent de référence (16 jeux +
common+misc).
Orchestrer tout ça avec des agents
Faire tout ça à la main m'aurait pris des mois de travail répétitif et pénible. À la place, j'ai orchestré en parallèle plus d'une douzaine d'agents Cursor pour faire le gros du boulot.
J'ai commencé par découper la base de code en « sections » basées sur les dossiers. Chaque jeu sur Foony avait son propre dossier et son propre namespace de traduction. Ça permet de garder une taille de chargement initiale réduite, puisque tu ne charges que les traductions du jeu auquel tu joues.
Je faisais tourner plusieurs agents Cursor en même temps. J'assignais à chaque agent une section précise, par exemple « convertir le jeu d'échecs pour qu'il utilise les traductions », et il parcourait les fichiers un par un, trouvait les chaînes visibles par l'utilisateur et les remplaçait par t('games/chess/some.key').
L'agent ajoutait ensuite cette clé dans le fichier de locale anglais approprié, avec un commentaire JSDoc qui expliquait le « quoi » et le « où » de la chaîne. Ce contexte est important pour générer les traductions dans les autres langues, car il aide le LLM à comprendre si « Save » veut dire « Enregistrer la configuration de la partie » ou « Enregistrer ton dessin dans Draw & Guess ».
Contrôle qualité
J'ai ensuite passé en revue rapidement tout le code généré. Les agents étaient étonnamment bons, mais ils commettaient parfois des erreurs, comme placer le hook useTranslation après un return précoce.
Des traductions fortement typées m'ont énormément aidé. Ça garantissait que toutes les traductions de chaque locale avaient bien toutes les bonnes clés (et aucune clé en trop). Ça garantissait aussi que les appels à t() et interpolate() utilisaient bien des chaînes de traduction réellement présentes.
Le système de types extrait toutes les clés de traduction possibles à partir des fichiers source anglais :
/**
* Extrait tous les chemins possibles à partir d'un type d'objet imbriqué, en créant des clés en notation par points.
* Exemple : {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... and so on for all games
Ça donne un autocomplete TypeScript parfait, et la moindre faute de frappe dans une clé de traduction est repérée à la compilation. Les agents ne peuvent pas faire des erreurs du genre t('games/ches/name') puisque TypeScript les signale immédiatement.
Localisation
Une fois la conversion anglaise terminée, j'ai découpé le reste du travail sur les locales. J'ai rendu chaque agent responsable de la conversion d'un seul fichier de locale anglais vers une langue donnée.
Par exemple, je donnais aux agents une consigne de ce genre :
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
J'ai envisagé de laisser Cursor générer un script qui enverrait chacun de ces fichiers à un LLM pour qu'il produise les traductions, mais je voulais économiser un peu sur le coût des LLM. Utiliser un script uniquement pour mettre à jour les traductions manquantes était une meilleure approche, et je vais probablement réutiliser une solution similaire plus tard. J'aimerais suivre quelles chaînes ont besoin d'être mises à jour ou traduites, mais je veux garder les choses simples. Je déplacerai peut-être le travail de traduction dans une base de données ou autre.
J'ai aussi ajouté une locale « debug » qui n'est disponible qu'en développement. Ça me permet de voir toutes les chaînes remplacées pour vérifier que tout fonctionne (et en plus je trouve ça stylé). Quand tu utilises la locale de debug, t() renvoie la clé entourée de crochets :
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Donc au lieu de voir « Welcome to Foony! », tu verrais ⟦welcome⟧, ce qui rend très simple la détection des traductions manquantes.
Enfin, un autre agent a implémenté le routage /{locale}/** pour que des chemins comme /ja/games/chess arrivent sur la bonne langue (dans ce cas le japonais).
Traduire le blog
Traduire les chaînes de l'interface, c'était une chose, mais qu'en était-il des articles de blog ? Je n'avais pas envie de lancer et gérer encore plus d'agents juste pour traduire tous mes articles.
J'ai résolu ça en demandant à un agent de créer un script (scripts/src/generateBlogTranslations.ts) qui automatise tout le processus.
Voilà comment il fonctionne :
- Il scanne le dossier
client/src/posts/enà la recherche de fichiers MDX en anglais. - Il vérifie les traductions manquantes dans les autres dossiers de locales (par exemple
posts/ja,posts/es). - Si une traduction manque, il lit le contenu anglais et l'envoie à Gemini 3 Pro Preview avec un prompt spécifique pour traduire le contenu tout en préservant la mise en forme Markdown.
- Il enregistre le nouveau fichier au bon endroit.
Côté frontend, j'utilise import.meta.glob pour importer dynamiquement tous ces fichiers MDX. Mon composant PostPage vérifie ensuite simplement la locale actuelle de l'utilisateur et fait un lazy-load du bon fichier MDX. Si une traduction manque (parce que je n'ai pas encore lancé le script), il bascule proprement sur l'anglais.
Conclusion
À ce stade, j'avais un site entièrement fonctionnel traduit dans les 20 locales !
Ces 3 jours ont été un peu fous, mais au bout du compte le résultat est un site entièrement localisé qui paraît (presque) natif pour des utilisateurs partout dans le monde. En construisant une librairie maison légère et en m'appuyant sur des agents d'IA pour tout le refactoring pénible, j'ai réussi quelque chose qui aurait été impossible il y a encore un an : une i18n complète en 3 jours pour un site complexe, réalisée par un seul développeur. Le futur de la programmation ne consiste pas à écrire du code toujours plus vite. Il consiste à orchestrer des agents d'IA et à avoir l'expertise métier profonde nécessaire pour valider leur travail.