background blurbackground mobile blur

1/1/1970

Como implementei i18n em 20 idiomas em 3 dias

E aí! Acabei de terminar uma tarefa gigantesca em que traduzi Foony para 20 idiomas diferentes. Foi um trabalhão que envolveu mexer em quase todos os arquivos da base de código, mas consegui deixar tudo pronto em só 3 dias.

Aqui embaixo vou explicar como fiz isso, os números por trás da mudança e por que resolvi criar minha própria biblioteca de tradução (de novo) em vez de usar o padrão da indústria.

Por que não i18next?

Quando comecei a ver como adicionar traduções, considerei o padrão da indústria: i18next e react-i18next.

Em vez disso, resolvi otimizar para manutenibilidade por IA. i18next é poderoso, mas a variedade de APIs pode fazer LLMs alucinarem ou escreverem código inconsistente. Restringindo a biblioteca a um t() e interpolate() bem simples, garanti que mais de 10 agentes em paralelo pudessem escrever código 100% type-safe com quase zero intervenção humana.

Também fiquei com o pé atrás de entrar de cabeça em um ecossistema grande que pode trazer breaking changes depois. Já me queimei com migrações dolorosas como React Router v5 e MUI v4 → v5, então sei que quebrar compatibilidade pra trás rápido demais é bem comum no mundinho JavaScript. O custo de adicionar recursos de pluralização mais tarde é menor do que o de migrar manualmente 139 mil linhas de código agora.

Eu queria algo bem simples, extremamente leve e feito sob medida pras necessidades do meu time.

Então escrevi a minha própria.

Montei um subconjunto enxuto de 3 KB, pensado especificamente para permitir refatoração autônoma por IA com alta precisão. Isso me permitiu agir como um único engenheiro fazendo, em 3 dias, o trabalho de 3 semanas de um time de 5 pessoas.

A implementação personalizada

Criei uma biblioteca de i18n mínima, com cerca de 3 KB gzipped. Ela expõe duas funções principais: getTranslation() para contextos fora do React e o hook useTranslation() para componentes.

Essas funções retornam t() para substituição simples de strings e interpolate() para quando preciso injetar componentes React dentro de uma string de tradução (tipo um link ou um ícone). As duas funções suportam substituição de variáveis, por exemplo "Hello {{thing}}", {thing: 'World'}.

Aqui está a função t() principal:

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 o hook do 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]);
}

O núcleo da biblioteca inteira tem só cerca de 580 linhas de código. Ele cuida de:

  • Fazer lazy-loading dos arquivos de tradução, assim não enviamos todos os 20 idiomas para todo mundo.
  • Fazer code-splitting das traduções por "namespace" (por exemplo common, misc, games/{gameId}).
  • Ter um locale de "debug" que mostra as chaves cruas, pra eu conseguir verificar se tudo está ligado direitinho.

Pra garantir que o sistema continue fácil de manter, eu também escrevi uma documentação bem completa em shared/src/i18n/README.md, cobrindo desde a estrutura de arquivos até exemplos de uso tanto no client quanto no server. Como não estou usando uma biblioteca padrão, ter essa referência é essencial pra receber gente nova no time (ou só pra lembrar meu eu do futuro, ou os LLMs, de como tudo funciona).

Em números

Pra você ter uma noção da escala dessa atualização, aqui está o que mudou na base de código:

  • 20 idiomas suportados (mais um locale de debug para desenvolvimento).
  • 360 arquivos de locale criados.
  • 139.031 linhas de código de tradução.
  • 3.938 chamadas a t() adicionadas no client.
  • 728 arquivos fonte modificados.
  • 18 arquivos fonte em inglês que servem como fonte da verdade (16 jogos + common + misc).

Orquestrando com agentes

Fazer isso tudo manualmente teria levado meses de trabalho mecânico e repetitivo. Em vez disso, orquestrei mais de uma dúzia de agentes do Cursor ao mesmo tempo pra fazer o grosso.

Comecei dividindo a base de código em "seções" por pasta. Cada jogo em Foony ganhou sua própria pasta e seu próprio namespace de tradução. Isso mantém o tamanho do carregamento inicial pequeno, já que você só carrega as traduções do jogo que está jogando.

Rodei vários agentes do Cursor em paralelo. Atribuí uma seção específica pra cada agente, tipo "converter o jogo de Xadrez pra usar traduções", e ele ia arquivo por arquivo, achando as strings visíveis pro usuário e trocando por t('games/chess/some.key').

O agente então adicionava aquela chave no arquivo de locale em inglês correspondente, com um comentário JSDoc explicando o "o quê" e o "onde" daquela string. Esse contexto é importante na hora de gerar as traduções para outros idiomas, porque ajuda o LLM a entender se "Save" quer dizer "Save Game Configuration" ou "Save Your Draw & Guess Drawing".

Controle de qualidade

Revisei rapidinho todo o código que foi gerado. Os agentes mandaram bem até demais, mas ainda cometiam alguns erros aqui e ali, tipo colocar o hook useTranslation depois de um return antecipado.

Ter traduções fortemente tipadas ajudou demais. Isso garantia que todas as traduções de cada locale tivessem todas as chaves certas (e nenhuma chave errada). Também garantia que as chamadas a t() e interpolate() usassem strings de tradução reais que existiam.

O sistema de tipos extrai todas as possíveis chaves de tradução dos arquivos fonte em inglês:

/**
 * 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

Isso dá um autocomplete perfeito no TypeScript, e qualquer typo em uma chave de tradução é pego em tempo de compilação. Os agentes não conseguem cometer erros como t('games/ches/name') porque o TypeScript acusa na hora.

Localização

Depois que a conversão pro inglês terminou, dividi as tarefas restantes de locale. Deixei cada agente responsável por converter um único arquivo de locale em inglês para um idioma específico.

Por exemplo, passei para os agentes um prompt mais ou menos assim:

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.

Até cogitei pedir pro Cursor criar um script que alimentasse cada um desses arquivos em um LLM e deixasse ele gerar tudo, mas eu queria economizar um pouco em custo de LLM. Usar um script só pra atualizar traduções que estavam faltando foi uma abordagem melhor, e provavelmente vou usar algo parecido no futuro. Eu gostaria de rastrear quais strings precisam de atualização / tradução, mas quero manter as coisas simples. Talvez eu mova o trabalho de tradução pra um banco de dados ou algo assim.

Também adicionei um locale de "debug" que só fica disponível em desenvolvimento. Isso me deixa ver todas as strings substituídas pra conferir se está tudo funcionando (e eu acho isso bem legal). Quando você usa o locale de debug, t() retorna a chave entre colchetes especiais:

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

Então, em vez de ver "Welcome to Foony!", você veria ⟦welcome⟧, o que deixa fácil enxergar traduções faltando.

Por fim, outro agente implementou o roteamento /{locale}/**, então coisas como /ja/games/chess caem direto no idioma certo (nesse caso, japonês).

Traduzindo o blog

Traduzir as strings da interface era uma coisa, mas e os posts do blog? Eu não queria subir e gerenciar ainda mais agentes só pra traduzir todos os meus posts.

Resolvi isso pedindo para um agente criar um script (scripts/src/generateBlogTranslations.ts) que automatiza o processo inteiro.

Funciona assim:

  1. Ele faz um scan no diretório client/src/posts/en atrás de arquivos MDX em inglês.
  2. Ele verifica se há traduções faltando nas outras pastas de locale (por exemplo posts/ja, posts/es).
  3. Se faltar uma tradução, ele lê o conteúdo em inglês e manda para o Gemini 3 Pro Preview com um prompt específico pra traduzir o conteúdo preservando a formatação de Markdown.
  4. Ele salva o novo arquivo no lugar certo.

No frontend, uso import.meta.glob pra importar dinamicamente todos esses arquivos MDX. Meu componente PostPage só verifica o locale atual do usuário e faz lazy-load do arquivo MDX correto. Se faltar uma tradução (porque ainda não rodei o script), ele volta pro inglês de forma suave.

Conclusão

Nesse ponto, eu já tinha um site totalmente funcional traduzido para todos os 20 locales!

Foram 3 dias bem insanos, mas o resultado é um site totalmente localizado que parece (quase) nativo pra gente do mundo todo. Criando uma biblioteca própria, leve e focada, e usando agentes de IA pro trabalho chato de refatoração, consegui algo que seria impossível há pouco tempo: i18n completo em 3 dias para um site complexo, feito por 1 único engenheiro.

O futuro da programação não é escrever código rápido. É saber orquestrar agentes de IA e ter conhecimento profundo do domínio pra conseguir validar o que eles produzem.

8 Ball Pool online multiplayer billiards icon