background blurbackground mobile blur

1/1/1970

Come ho implementato l'i18n in 20 lingue in 3 giorni

Ciao! Ho appena finito un lavoro enorme in cui ho tradotto Foony in 20 lingue diverse. È stato un lavoro gigantesco che mi ha costretto a mettere mano praticamente a ogni file del codebase, ma sono comunque riuscito a fare tutto in soli 3 giorni.

Qui sotto ti racconto come l'ho fatto, i numeri precisi del cambiamento e perché ho deciso di scrivermi un'ennesima libreria di traduzione su misura invece di usare lo standard del settore.

Perché non i18next?

Quando ho iniziato a pensare di aggiungere le traduzioni, ho ovviamente guardato allo standard del settore: i18next e react-i18next.

Alla fine però ho deciso di ottimizzare per la manutenibilità gestita dall'AI. i18next è potente, ma la varietà della sua API può spingere gli LLM a fantasticare o a scrivere codice incoerente. Limitando la libreria a una semplice t() e interpolate(), mi sono assicurato che più di 10 agenti in parallelo potessero scrivere codice 100% type-safe con quasi zero intervento umano.

Ero anche un po' diffidente all'idea di legarmi a un grande ecosistema che magari un domani introduce breaking change pesanti. Dopo essermi già scottato con migrazioni dolorose come React Router v5 e MUI v4 → v5, so bene che nel mondo JavaScript rompere la retrocompatibilità in modo aggressivo è fin troppo comune. Il costo di aggiungere il supporto al plurale più avanti è più basso del costo di migrare a mano adesso 139k righe di codice.

Volevo qualcosa di estremamente semplice, super leggero e cucito esattamente sui bisogni del mio team.

Così me la sono scritta da solo.

Ho costruito un sottoinsieme limitato da 3 KB pensato apposta per permettere refactoring autonomi di alta precisione da parte dell'AI. Questo mi ha permesso, da solo, di portare a termine in 3 giorni il carico di lavoro di un team di 5 persone in 3 settimane.

L'implementazione personalizzata

Ho messo insieme una libreria i18n minimale che pesa circa 3 KB gzippata. Espone due funzioni principali: getTranslation() per i contesti non React e una hook useTranslation() per i componenti.

Queste restituiscono t() per la semplice sostituzione di stringhe e interpolate() quando devo inserire componenti React dentro una stringa di traduzione (per esempio un link o un'icona). Entrambe le funzioni supportano la sostituzione di variabili, ad esempio "Hello {{thing}}", {thing: 'World'}.

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 la 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]);
}

Il cuore di tutta la libreria è di circa 580 righe di codice. Gestisce:

  • Il lazy loading dei file di traduzione, così non inviamo tutte e 20 le lingue a ogni utente.
  • Lo splitting del codice per le traduzioni in base al "namespace" (per esempio common, misc, games/{gameId}).
  • Una locale "debug" che mostra le chiavi grezze così posso verificare che tutto sia collegato correttamente.

Per assicurarmi che il sistema resti facile da mantenere, ho anche aggiunto documentazione completa in shared/src/i18n/README.md, che copre tutto: dalla struttura delle cartelle agli esempi d'uso lato client e lato server. Visto che non sto usando una libreria standard, avere questo riferimento è fondamentale per fare onboarding a nuovi membri del team (o semplicemente per ricordare al me del futuro o agli LLM come funziona tutto).

I numeri

Per darti un'idea della scala di questo aggiornamento, ecco cosa è cambiato nel codebase:

  • 20 lingue supportate (più una locale di debug per lo sviluppo).
  • 360 file di locale creati.
  • 139.031 righe di codice di traduzione.
  • 3.938 chiamate a t() aggiunte lato client.
  • 728 file sorgente modificati.
  • 18 file sorgente inglesi che fanno da fonte di verità (16 giochi + common + misc).

Orchestrare con gli agenti

Far tutto questo a mano avrebbe richiesto mesi di lavoro meccanico e noiosissimo. Invece ho orchestrato in parallelo più di una dozzina di agenti Cursor che hanno fatto il lavoro pesante.

Ho iniziato spezzando il codebase in "sezioni" basate sulle cartelle. Ogni gioco su Foony ha avuto la sua cartella e il suo namespace di traduzione. Questo mantiene piccolo il peso del primo caricamento, perché carichi solo le traduzioni del gioco a cui stai giocando.

Ho lanciato più agenti di Cursor in parallelo. A ognuno ho assegnato una sezione specifica, tipo "converti il gioco degli scacchi per usare le traduzioni", e lui andava file per file, trovava le stringhe visibili all'utente e le sostituiva con t('games/chess/some.key').

Poi l'agente aggiungeva quella chiave al file di locale inglese corrispondente con un commento JSDoc che spiegava il "cosa" e il "dove" della stringa. Questo contesto è importante quando si generano le traduzioni nelle altre lingue, perché aiuta l'LLM a capire se "Save" significa "Salva configurazione della partita" oppure "Salva il tuo disegno di Draw & Guess".

Controllo qualità

Ho rivisto velocemente tutto il codice generato. Gli agenti sono stati sorprendentemente bravi, ma ogni tanto sbagliavano, per esempio mettendo la hook useTranslation dopo un return anticipato.

Avere traduzioni fortemente tipizzate ha aiutato un sacco. Questo ha garantito che tutte le traduzioni di ogni locale avessero tutte le chiavi giuste (e nessuna chiave sbagliata). E ha anche assicurato che le chiamate a t() e interpolate() usassero davvero chiavi di traduzione esistenti.

Il sistema di tipi estrae tutte le possibili chiavi di traduzione dai file sorgente inglesi:

/**
 * 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 autocomplete perfetto in TypeScript e qualunque typo in una chiave di traduzione viene beccato in fase di compilazione. Gli agenti non possono fare errori tipo t('games/ches/name') perché TypeScript li segnala subito.

Localizzazione

Una volta completata la conversione all'inglese come fonte unica, ho suddiviso il resto del lavoro sulle locali. Ho assegnato a ogni agente il compito di convertire un singolo file di locale inglese in una lingua specifica.

Per esempio, ho dato agli agenti un prompt tipo 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.

Ho pensato di far creare a Cursor uno script che passasse ognuno di questi file a un LLM per generare le traduzioni, ma volevo risparmiare un po' sui costi degli LLM. Usare uno script solo per aggiornare le traduzioni mancanti è stato l'approccio migliore, e probabilmente userò una soluzione simile anche in futuro. Mi piacerebbe tracciare quali stringhe vanno aggiornate o tradotte, ma voglio tenere il sistema semplice. Magari sposterò il lavoro di traduzione su un database o qualcosa del genere.

Ho anche aggiunto una locale "debug" disponibile solo in sviluppo. Questo mi permette di vedere tutte le stringhe sostituite per verificare che tutto funzioni (e secondo me è anche molto carina). Quando usi la locale di debug, t() restituisce la chiave racchiusa tra parentesi speciali:

if (targetLocale === 'debug') {
  return `⟦${key}⟧`;
}

Così, invece di vedere "Benvenuto su Foony!", vedresti ⟦welcome⟧, il che rende molto facile individuare eventuali traduzioni mancanti.

Infine, un altro agente ha implementato il routing /{locale}/**, così percorsi come /ja/games/chess vengono instradati alla lingua corretta (in questo caso il giapponese).

Tradurre il blog

Tradurre le stringhe dell'interfaccia era una cosa, ma che fare con i post del blog? Non avevo nessuna voglia di avviare e gestire ancora più agenti solo per tradurre tutti i miei articoli.

Ho risolto facendo creare a un agente uno script (scripts/src/generateBlogTranslations.ts) che automatizza l'intero processo.

Funziona così:

  1. Scansiona la cartella client/src/posts/en alla ricerca dei file MDX in inglese.
  2. Controlla se mancano traduzioni nelle altre cartelle di locale (per esempio posts/ja, posts/es).
  3. Se manca una traduzione, legge il contenuto inglese e lo passa a Gemini 3 Pro Preview con un prompt specifico per tradurre il contenuto mantenendo la formattazione Markdown.
  4. 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 fa il lazy loading del file MDX corretto. Se manca una traduzione (perché magari non ho ancora eseguito lo script), fa il fallback in inglese in modo pulito.

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 sembra (quasi) nativo agli utenti di tutto il mondo. Scrivendo una libreria su misura e super leggera e sfruttando agenti AI per il lavoro di refactoring più noioso, sono riuscito a fare qualcosa che solo un anno fa sarebbe sembrato impossibile: i18n completo in 3 giorni per un sito complesso, con un solo sviluppatore. Il futuro della programmazione non è scrivere codice velocemente, ma orchestrare agenti AI e avere la competenza di dominio profonda per verificare il loro output.

8 Ball Pool online multiplayer billiards icon