

1/1/1970
Comment j'ai implémenté l'i18n vers 20 langues en 3 jours
Salut ! Je viens de terminer une tâche colossale : traduire Foony en 20 langues différentes. C'était un énorme chantier qui a touché presque chaque fichier du codebase, mais j'ai réussi à tout boucler en seulement 3 jours.
Ci-dessous, je vais détailler comment j'ai procédé, les chiffres précis derrière ce changement, et pourquoi j'ai décidé de coder ma propre librairie de traduction (encore une fois) au lieu d'utiliser le standard de l'industrie.
Pourquoi pas i18next ?
Quand j'ai commencé à réfléchir à l'ajout de traductions, j'ai considéré le standard de l'industrie : i18next et react-i18next.
À la place, j'ai décidé d'optimiser pour la maintenabilité par l'IA. i18next est puissant, mais la diversité de son API peut amener les LLM à halluciner ou à écrire du code incohérent. En limitant la librairie à un simple t() et interpolate(), je me suis assuré que plus de 10 agents en parallèle puissent écrire du code 100 % type-safe avec quasiment aucune intervention humaine.
J'étais aussi méfiant à l'idée de m'enfermer dans un grand écosystème qui pourrait introduire des breaking changes plus tard. Ayant été échaudé par des migrations douloureuses comme React Router v5 et MUI v4 → v5, je sais que la rupture rapide de la rétrocompatibilité est trop fréquente dans le monde JavaScript. Le coût d'ajouter des fonctionnalités de pluralisation plus tard est inférieur au coût de migrer manuellement 139 000 lignes de code maintenant.
Je voulais quelque chose de très simple, extrêmement léger, et taillé exactement aux besoins de mon équipe.
Alors j'ai écrit le mien.
J'ai construit un sous-ensemble restreint de 3 Ko spécifiquement conçu pour permettre un refactoring autonome et hautement fiable par l'IA. Cela m'a permis d'agir comme un seul ingénieur accomplissant en 3 jours la charge de travail de 3 semaines d'une équipe de 5 personnes.
L'implémentation maison
J'ai mis au point une librairie i18n minimale qui pèse environ 3 Ko gzippés. Elle expose deux fonctions principales : getTranslation() pour les contextes hors React et un hook useTranslation() pour les composants.
Ces fonctions retournent t() pour le simple remplacement de chaîne et interpolate() lorsque je dois injecter des composants React dans une chaîne de traduction (comme un lien ou une icône). Les deux fonctions supportent le remplacement de variables, par exemple "Hello {{thing}}", {thing: 'World'}.
Les clés suivent une notation "slash-point" (slashes pour le chemin de fichier vers le fichier de localisation, points pour les objets imbriqués dans le fichier). Pour garantir l'unicité, les clés de traduction dans un fichier ne peuvent pas contenir de slashes.
Voici la fonction t() principale :
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Check if key contains '/' - this indicates a 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();
// Subscribe to locale loading events to trigger re-renders when translations are loaded
const version = useSyncExternalStore(
(callback) => LocaleQueryer.onLoad(callback),
() => LocaleQueryer.getVersion(),
() => LocaleQueryer.getVersion()
);
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 ne fait qu'environ 580 lignes de code. Il gère :
- Le chargement paresseux des fichiers de traduction pour ne pas envoyer les 20 langues à chaque utilisateur.
- Le code-splitting des traductions par "namespace" (ex :
common,misc,games/{gameId}). - Une locale "debug" qui affiche les clés brutes pour que je puisse vérifier que tout est correctement branché.
Pour que le système reste facile à maintenir, j'ai aussi ajouté une documentation complète dans shared/src/i18n/README.md, couvrant tout, de la structure des fichiers aux exemples d'utilisation côté client et serveur. Comme je n'utilise pas une librairie standard, avoir cette référence est essentiel pour onboarder de nouveaux membres de l'équipe (ou simplement rappeler à mon futur moi ou aux LLM comment ça fonctionne).
En chiffres
Pour vous donner une idée de l'ampleur de cette mise à jour, voici ce qui a changé dans le codebase :
- 20 langues supportées (plus une locale debug pour le développement).
- 360 fichiers de locale créés.
- 139 031 lignes de code de traduction.
- 3 938 appels à
t()ajoutés à travers le client. - 728 fichiers source modifiés.
- 18 fichiers source en anglais qui servent de source de vérité (16 jeux + common + misc).
Orchestration avec des agents
Faire ça manuellement aurait pris des mois de travail mécanique et abrutissant. À la place, j'ai orchestré simultanément plus d'une douzaine d'agents Cursor pour faire le gros du boulot.
J'ai commencé par découper le codebase en "sections" basées sur les dossiers. Chaque jeu sur Foony a son propre dossier et son propre namespace de traduction. Cela maintient la taille du chargement initial réduite, puisque vous ne chargez que les traductions du jeu auquel vous jouez.
J'ai lancé plusieurs agents Cursor simultanément. J'ai assigné à chaque agent une section spécifique, comme "convertir le jeu d'échecs pour utiliser les traductions", et il parcourait fichier par fichier, trouvant les chaînes visibles par l'utilisateur et les remplaçant par t('games/chess/some.key').
L'agent ajoutait ensuite cette clé au fichier de locale anglais approprié avec un commentaire JSDoc expliquant le "quoi" et le "où" de la chaîne. Ce contexte est important lors de la génération des traductions pour les autres langues, car il aide le LLM à comprendre si "Save" signifie "Sauvegarder la configuration du jeu" ou "Sauvegarder votre dessin Draw & Guess".
Contrôle qualité
J'ai rapidement passé en revue tout le code généré. Les agents étaient étonnamment bons, mais ils faisaient occasionnellement des erreurs, comme placer le hook useTranslation après une instruction return précoce.
Les traductions fortement typées m'ont énormément aidé. Cela a garanti que toutes les traductions de chaque locale avaient toutes les bonnes clés (et aucune mauvaise). Cela a aussi garanti que les appels à t() et interpolate() utilisaient de vraies chaînes de traduction qui existaient.
Le système de types extrait toutes les clés de traduction possibles à partir des fichiers source en anglais :
/**
* Extracts all possible paths from a nested object type, creating dot-notation keys.
* Example: {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
Cela donne une autocomplétion TypeScript parfaite, et toute faute de frappe dans une clé de traduction est détectée à la compilation. Les agents ne peuvent pas faire d'erreurs comme t('games/ches/name') parce que TypeScript le signale immédiatement.
Localisation
Une fois la conversion en anglais terminée, j'ai découpé les tâches de locale restantes. J'ai rendu chaque agent responsable de la conversion d'un seul fichier de locale anglais vers une langue spécifiée.
Par exemple, j'ai donné aux agents un prompt comme celui-ci :
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 demander à Cursor de créer un script pour fournir chacun de ces fichiers à un LLM et lui faire générer les choses, mais je voulais économiser un peu sur le coût du LLM. Utiliser un script pour ne mettre à jour que les traductions manquantes était la meilleure approche, et j'utiliserai probablement une solution similaire à l'avenir. J'aimerais traquer quelles chaînes nécessitent une mise à jour ou une traduction, mais en gardant les choses simples. Je pourrais déplacer le travail de traduction vers une base de données ou quelque chose comme ça.
J'ai aussi ajouté une locale "debug" disponible uniquement en développement. Cela me permet de voir toutes les chaînes remplacées pour vérifier que tout fonctionne (en plus, je trouve ça cool). Quand vous utilisez la locale debug, t() retourne la clé entourée de crochets :
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Donc au lieu de voir "Welcome to Foony!", vous verriez ⟦welcome⟧, ce qui rend facile le repérage des traductions manquantes.
Enfin, un autre agent a implémenté le routing /{locale}/** pour que des choses comme /ja/games/chess soient routées vers la bonne langue (dans ce cas, le japonais).
Traduire le blog
Traduire les chaînes de l'UI était une chose, mais qu'en est-il des articles de blog ? Je ne voulais pas lancer et gérer encore plus d'agents pour traduire tous mes articles de blog.
J'ai résolu ça en demandant à un agent de créer un script (scripts/src/generateBlogTranslations.ts) qui automatise tout le processus.
Voici comment ça fonctionne :
- Il scanne le répertoire
client/src/posts/enpour les fichiers MDX en anglais. - Il vérifie les traductions manquantes dans les autres dossiers de locale (ex :
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 le formatage Markdown.
- Il sauvegarde le nouveau fichier au bon endroit.
Sur le frontend, j'utilise import.meta.glob pour importer dynamiquement tous ces fichiers MDX. Mon composant PostPage vérifie alors simplement la locale actuelle de l'utilisateur et charge paresseusement le bon fichier MDX. Si une traduction manque (parce que je n'ai pas encore exécuté le script), il retombe gracieusement sur l'anglais.
Jour 4 : Génération automatique des traductions
Je savais que la solution originale n'allait pas tenir à l'échelle. Donc, maintenant que j'avais l'i18n en place, il était temps de la solidifier un peu avec une approche pilotée par une base de données.
En bref : quand le texte anglais ou les commentaires JSDoc changeaient, les traductions devaient être régénérées. Le suivi manuel de ce qui devait être mis à jour aurait été source d'erreurs et une perte de temps de développement.
J'ai donc construit la solution que j'avais initialement prévue : un système de génération de traductions adossé à PostgreSQL.
Le schéma de base de données
J'ai ajouté une table translations à notre base PostgreSQL avec la structure suivante :
key: La clé de traduction en notation "slash-point" (ex :"games/yacht/nested.name","config.timeLimit.label").en_value: La valeur source en anglaistarget_locale: Le code de la locale cible (ex :"es","fr","zh")target_value: La valeur traduitecontext: Un champ JSONB contenant les JSDoc pour cette clé et toutes les clés ancêtrescreated_atetupdated_at: Timestamps pour le suivi
L'index unique est sur (key, target_locale, en_value, context). C'est crucial : en incluant context dans la contrainte d'unicité, on peut détecter automatiquement quand les commentaires JSDoc changent et régénérer les traductions. Les anciennes traductions sont conservées pour référence historique.
Le script de génération
J'ai créé scripts/src/generateLocalizations.ts qui automatise tout le workflow de traduction :
- Extrait les clés anglaises : Utilise le parsing AST (ts-morph) pour extraire toutes les clés de traduction depuis les fichiers
shared/src/i18n/locales/en/**, en ne traitant que les exports par défaut - Extrait le contexte JSDoc : Parse les commentaires JSDoc pour chaque clé et toutes les clés ancêtres (objets parents) pour fournir un contexte riche
- Interroge la base de données : Vérifie les traductions existantes dans PostgreSQL, en faisant correspondre
key,target_locale,en_value, ETcontext. Si l'un d'entre eux change, la traduction est régénérée. - Identifie les clés manquantes/modifiées : Trouve les clés qui nécessitent une traduction ou dont les valeurs/commentaires anglais ont changé
- Regroupe les traductions par lots : Groupe par locale et préfixe de namespace pour des appels LLM plus efficaces (rend aussi les traductions plus rapides). Si le lot est trop grand, par contre, la qualité de la traduction se dégrade.
- Génère les traductions : Utilise GPT 5.1 avec un contexte complet (JSDoc, langue+région, ton, glossaire, exemples). J'ai lu que 5.1 est meilleur que 5.2 pour l'écriture (ne sonne pas fade), mais je n'ai pas confirmé.
- Vérifications QA : Valide la préservation des placeholders, ex
{{name}}, l'intégrité des clés, le format JSON - Stocke en base de données : Sauvegarde les traductions avec le contexte complet (JSDoc + JSDoc ancêtres)
- Génère les fichiers de locale : Lit depuis la base de données et écrit des fichiers de locale TypeScript correctement formatés avec des types
RecursivePartial
Avantages clés
Cette approche nous offre plusieurs améliorations DevEx :
- Régénération automatique : Quand le texte anglais OU les commentaires JSDoc changent, les traductions sont automatiquement régénérées. Donc si quelqu'un dit qu'une traduction est mauvaise, c'est très facile de régénérer les traductions en fournissant plus de contexte sous forme de commentaire.
- Contexte riche : Les commentaires JSDoc fournissent du contexte de traduction (ex : "Message d'erreur affiché aux joueurs, max 15 caractères"), aidant le LLM à produire des traductions plus précises
- Contexte ancestral : Les JSDoc des objets parents fournissent du contexte de namespace (ex : "Succès pour avoir été dans une partie où tous les œufs sont détruits"), apportant un peu plus de clarté
- Suivi historique : Les anciennes traductions sont sauvegardées en base de données. Elles ne prennent pas beaucoup de place, donc je ne vois pas de raison de les supprimer pour l'instant, et c'est cool de voir l'historique.
Détails techniques
L'implémentation utilise plusieurs techniques pour garantir fiabilité et efficacité :
- Extraction basée sur l'AST pour s'assurer d'obtenir les bons commentaires
- Traitement parallèle utilisant un Semaphore pour la traduction par lots concurrents
- Logique de retry avec backoff exponentiel pour les échecs d'API. Les appels LLM sont notoirement instables.
Le script peut être lancé avec npm run generate-localizations depuis le répertoire scripts. Il se connecte à PostgreSQL et traite toutes les traductions manquantes ou modifiées pour toutes les locales supportées lors de son exécution.
Conclusion
À ce stade, j'avais un site pleinement fonctionnel traduit dans les 20 locales !
Ce furent 3 jours de folie, mais le résultat est un site entièrement localisé qui semble (presque) natif aux utilisateurs du monde entier. En construisant une librairie maison et légère et en exploitant les agents IA pour le travail fastidieux de refactoring, j'ai accompli ce qui aurait été impossible il y a seulement un an : une i18n complète en 3 jours pour un site web complexe par 1 ingénieur. Le futur de la programmation, ce n'est pas écrire du code rapidement. C'est orchestrer des agents IA et posséder l'expertise métier approfondie pour vérifier leur output.