

1/1/1970
Come ho implementato l'i18n in 20 lingue in 3 giorni
Ehilà! Ho appena portato a termine un'impresa colossale: ho tradotto Foony in 20 lingue diverse. È stato un lavoro mastodontico che ha toccato quasi ogni file del codebase, ma sono riuscito a completare tutto in soli 3 giorni.
Qui sotto vi spiego come ci sono riuscito, i numeri precisi dietro questo cambiamento e perché ho deciso di costruire (ancora una volta) la mia libreria di traduzioni invece di affidarmi allo standard di settore.
Perché non i18next?
Quando ho iniziato a pensare all'aggiunta delle traduzioni, ho preso in considerazione lo standard del settore: i18next e react-i18next.
Alla fine, però, ho deciso di ottimizzare per la manutenibilità da parte dell'IA. i18next è potente, ma la varietà della sua API può portare gli LLM ad avere allucinazioni o a scrivere codice incoerente. Limitando la libreria a un semplice t() e interpolate(), mi sono assicurato che oltre 10 agenti in parallelo potessero scrivere codice 100% type-safe con un intervento umano quasi nullo.
Ero anche diffidente all'idea di legarmi a un grande ecosistema che potrebbe introdurre breaking change in futuro. Avendo già subito migrazioni dolorose come quelle di React Router v5 e MUI v4 → v5, so che le rotture di compatibilità all'indietro sono fin troppo comuni nel mondo JavaScript. Il costo di aggiungere funzionalità di pluralizzazione in seguito è inferiore a quello di migrare manualmente 139k righe di codice ora.
Volevo qualcosa di estremamente semplice, leggerissimo e cucito su misura per le esigenze del mio team.
Così l'ho scritta da zero.
Ho costruito un sottoinsieme limitato di 3 KB pensato appositamente per consentire un refactoring autonomo e di alta precisione da parte dell'IA. Questo mi ha permesso di agire come un singolo ingegnere portando a termine il carico di lavoro di un team di 5 persone su 3 settimane in soli 3 giorni.
L'implementazione personalizzata
Ho realizzato una libreria i18n minimale che si attesta intorno ai 3 KB gzippati. Espone due funzioni principali: getTranslation() per i contesti non-React e un hook useTranslation() per i componenti.
Queste restituiscono t() per la semplice sostituzione di stringhe e interpolate() per quando ho bisogno di iniettare componenti React in una stringa di traduzione (come un link o un'icona). Entrambe le funzioni supportano la sostituzione di variabili, ad esempio "Hello {{thing}}", {thing: 'World'}.
Le chiavi seguono una notazione "slash-dot" (slash per il percorso del file di localizzazione, punti per gli oggetti annidati nel file). Per garantire l'unicità, le chiavi di traduzione in un file non possono contenere slash.
Ecco la funzione 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;
}
E l'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]);
}
Il cuore dell'intera libreria è di sole 580 righe di codice circa. Si occupa di:
- Caricamento lazy dei file di traduzione, in modo da non spedire tutte e 20 le lingue a ogni utente.
- Code-splitting delle traduzioni per "namespace" (ad esempio
common,misc,games/{gameId}). - Una locale di "debug" che mostra le chiavi grezze, così posso verificare che tutto sia collegato correttamente.
Per assicurarmi che il sistema rimanga facile da mantenere, ho anche aggiunto una documentazione completa in shared/src/i18n/README.md, che copre tutto, dalla struttura dei file agli esempi d'uso sia per il client che per il server. Dato che non sto usando una libreria standard, avere questo punto di riferimento è fondamentale per l'onboarding di nuovi membri del team (o semplicemente per ricordare al me stesso del futuro o agli LLM come funziona).
I numeri
Per darvi un'idea della portata di questo aggiornamento, ecco cosa è cambiato nel codebase:
- 20 lingue supportate (più una locale di debug per lo sviluppo).
- 360 file di localizzazione creati.
- 139.031 righe di codice di traduzione.
- 3.938 chiamate a
t()aggiunte in tutto il client. - 728 file sorgente modificati.
- 18 file sorgente in inglese che fungono da fonte di verità (16 giochi + common + misc).
Orchestrare con gli agenti
Farlo a mano avrebbe richiesto mesi di lavoro meccanico e abbrutente. Invece, ho orchestrato simultaneamente più di una dozzina di agenti Cursor per fare il lavoro pesante.
Ho iniziato suddividendo il codebase in "sezioni" basate sulle cartelle. Ogni gioco di Foony ha la sua cartella e il suo namespace di traduzione. Questo mantiene piccola la dimensione del caricamento iniziale, dato che si caricano solo le traduzioni del gioco a cui si sta giocando.
Ho fatto girare più agenti Cursor contemporaneamente. Ho assegnato a ciascun agente una sezione specifica, ad esempio "converti il gioco degli Scacchi per usare le traduzioni", e l'agente passava in rassegna file per file, individuando le stringhe rivolte all'utente e sostituendole con t('games/chess/some.key').
L'agente aggiungeva poi quella chiave nell'apposito file di localizzazione inglese con un commento JSDoc che spiegava il "cosa" e il "dove" della stringa. Questo contesto è importante quando si generano le traduzioni per le altre lingue, perché aiuta l'LLM a capire se "Save" significa "Salva configurazione partita" o "Salva il tuo disegno di Draw & Guess".
Controllo qualità
Ho rivisto rapidamente tutto il codice generato. Gli agenti sono stati sorprendentemente bravi, ma di tanto in tanto facevano qualche errore, come mettere l'hook useTranslation dopo un return anticipato.
Le traduzioni con tipizzazione forte sono state di enorme aiuto. Questo ha garantito che tutte le traduzioni di ogni locale avessero tutte le chiavi corrette (e nessuna chiave sbagliata). Inoltre, ha garantito che le chiamate a t() e interpolate() usassero stringhe di traduzione reali ed esistenti.
Il sistema di tipi estrae tutte le possibili chiavi di traduzione dai file sorgente in inglese:
/**
* 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
Questo offre un autocompletamento TypeScript perfetto, e qualsiasi refuso in una chiave di traduzione viene catturato in fase di compilazione. Gli agenti non possono commettere errori come t('games/ches/name') perché TypeScript lo segnala immediatamente.
Localizzazione
Una volta completata la conversione in inglese, ho suddiviso le rimanenti attività di localizzazione. Ho reso ogni agente responsabile della conversione di un singolo file di localizzazione inglese in una lingua specifica.
Per esempio, ho dato agli agenti un prompt come questo:
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.
Avevo pensato di far creare a Cursor uno script per dare in pasto ognuno di questi file a un LLM e fargli generare le cose, ma volevo risparmiare un po' sui costi degli LLM. Usare uno script per aggiornare solo le traduzioni mancanti è stato l'approccio migliore, e probabilmente userò una soluzione simile in futuro. Mi piacerebbe tracciare quali stringhe necessitano di aggiornamento o traduzione, mantenendo però le cose semplici. Potrei spostare il lavoro di traduzione su un database o qualcosa del genere.
Ho anche aggiunto una locale di "debug" disponibile solo in sviluppo. Questa mi permette di visualizzare tutte le stringhe sostituite per verificare che tutto funzioni (e poi penso che sia figa). Quando si usa la locale di debug, t() restituisce la chiave racchiusa tra parentesi:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Quindi, invece di vedere "Welcome to Foony!", vedreste ⟦welcome⟧, rendendo facile individuare eventuali traduzioni mancanti.
Infine, un altro agente ha implementato il routing /{locale}/**, in modo che cose come /ja/games/chess vengano instradate alla lingua corretta (in questo caso il giapponese).
Tradurre il blog
Tradurre le stringhe dell'interfaccia era una cosa, ma e i post del blog? Non avevo voglia di avviare e gestire ancora più agenti per tradurre tutti i miei post.
Ho risolto facendo creare a un agente uno script (scripts/src/generateBlogTranslations.ts) che automatizza l'intero processo.
Ecco come funziona:
- Scansiona la directory
client/src/posts/encercando i file MDX in inglese. - Verifica le traduzioni mancanti nelle altre cartelle delle locali (ad esempio
posts/ja,posts/es). - Se manca una traduzione, legge il contenuto inglese e lo passa a Gemini 3 Pro Preview con un prompt specifico per tradurre il contenuto preservando la formattazione Markdown.
- Salva il nuovo file nella posizione corretta.
Sul frontend, uso import.meta.glob per importare dinamicamente tutti questi file MDX. Il mio componente PostPage controlla semplicemente la locale corrente dell'utente e carica in modalità lazy il file MDX corretto. Se manca una traduzione (perché non ho ancora eseguito lo script), ricade in modo elegante sull'inglese.
Giorno 4: Generazione automatica delle traduzioni
Sapevo che la soluzione iniziale non avrebbe scalato. Quindi, ora che avevo l'i18n in piedi, era il momento di renderla più robusta con un approccio basato su database.
In breve: quando il testo inglese o i commenti JSDoc cambiavano, le traduzioni dovevano essere rigenerate. Tracciare manualmente cosa avesse bisogno di un aggiornamento sarebbe stato soggetto a errori e uno spreco di tempo per gli sviluppatori.
Così ho costruito la soluzione che avevo originariamente pianificato: un sistema di generazione delle traduzioni basato su PostgreSQL.
Lo schema del database
Ho aggiunto una tabella translations al nostro database PostgreSQL con la seguente struttura:
key: La chiave di traduzione in notazione "slash-dot" (ad esempio"games/yacht/nested.name","config.timeLimit.label").en_value: Il valore sorgente in inglesetarget_locale: Il codice della locale di destinazione (ad esempio"es","fr","zh")target_value: Il valore tradottocontext: Un campo JSONB contenente il JSDoc per questa chiave e per tutte le chiavi antenatecreated_ateupdated_at: Timestamp per il tracciamento
L'indice univoco è su (key, target_locale, en_value, context). Questo è cruciale: includendo context nel vincolo di unicità, possiamo rilevare automaticamente quando i commenti JSDoc cambiano e rigenerare le traduzioni. Le vecchie traduzioni vengono mantenute come riferimento storico.
Lo script di generazione
Ho creato scripts/src/generateLocalizations.ts che automatizza l'intero workflow di traduzione:
- Estrae le chiavi inglesi: Usa il parsing AST (ts-morph) per estrarre tutte le chiavi di traduzione dai file
shared/src/i18n/locales/en/**, processando solo i default export - Estrae il contesto JSDoc: Effettua il parsing dei commenti JSDoc per ogni chiave e per tutte le chiavi antenate (oggetti genitori) per fornire un contesto ricco
- Interroga il database: Verifica le traduzioni esistenti in PostgreSQL, facendo il match su
key,target_locale,en_valueEcontext. Se una qualsiasi di queste cambia, la traduzione viene rigenerata. - Identifica le chiavi mancanti/modificate: Trova le chiavi che necessitano di traduzione o che hanno valori/commenti inglesi modificati
- Raggruppa le traduzioni in batch: Raggruppa per locale e prefisso di namespace per chiamate LLM più efficienti (rendendo anche le traduzioni più veloci). Tuttavia, se il batch è troppo grande, la qualità della traduzione peggiorerà.
- Genera le traduzioni: Usa GPT 5.1 con un contesto completo (JSDoc, lingua+regione, tono, glossario, esempi). Ho letto che 5.1 è migliore di 5.2 per la scrittura (non suona piatto), ma non l'ho confermato.
- Controlli QA: Convalida la conservazione dei placeholder, ad esempio
{{name}}, l'integrità delle chiavi, il formato JSON - Memorizza nel database: Salva le traduzioni con il contesto completo (JSDoc + JSDoc antenato)
- Genera i file di localizzazione: Legge dal database e scrive file di localizzazione TypeScript correttamente formattati con tipi
RecursivePartial
Vantaggi principali
Questo approccio ci offre diversi miglioramenti per la DevEx:
- Rigenerazione automatica: Quando il testo inglese O i commenti JSDoc cambiano, le traduzioni vengono rigenerate automaticamente. Quindi, se qualcuno dice che una traduzione non è buona, è davvero facile rigenerarla fornendo più contesto come commento.
- Contesto ricco: I commenti JSDoc forniscono il contesto della traduzione (ad esempio, "Messaggio di errore mostrato ai giocatori, max 15 caratteri"), aiutando l'LLM a produrre traduzioni più accurate
- Contesto degli antenati: Il JSDoc dell'oggetto genitore fornisce il contesto del namespace (ad esempio, "Achievement per essere in una partita in cui tutte le uova sono state distrutte"), dando un po' più di chiarezza
- Tracciamento storico: Le vecchie traduzioni vengono salvate nel database. Non occupano molto spazio, quindi per ora non vedo molti motivi per eliminarle, ed è figo vedere la cronologia.
Dettagli tecnici
L'implementazione utilizza diverse tecniche per garantire affidabilità ed efficienza:
- Estrazione basata su AST per assicurarmi di ottenere i commenti corretti
- Elaborazione parallela tramite Semaforo per la traduzione di batch concorrenti
- Logica di retry con backoff esponenziale per i fallimenti dell'API. Le chiamate LLM sono notoriamente instabili.
Lo script può essere eseguito con npm run generate-localizations dalla directory scripts. Quando viene eseguito, si connette a PostgreSQL ed elabora tutte le traduzioni mancanti o modificate per tutte le locali supportate.
Conclusione
A questo punto, avevo un sito perfettamente funzionante tradotto in tutte e 20 le locali!
Sono stati 3 giorni folli, ma il risultato è un sito completamente localizzato che risulta (per lo più) nativo per gli utenti di tutto il mondo. Costruendo una libreria personalizzata e leggera e sfruttando gli agenti IA per il tedioso lavoro di refactoring, sono riuscito a fare ciò che sarebbe stato impossibile solo un anno fa: l'i18n completo in 3 giorni per un sito web complesso, da parte di 1 ingegnere. Il futuro della programmazione non riguarda lo scrivere codice velocemente. Riguarda l'orchestrare gli agenti IA e il possedere quella profonda competenza di dominio necessaria per verificarne l'output.