background blurbackground mobile blur

1/1/1970

Como Implementei i18n em 20 Idiomas em 3 Dias

E aí! Acabei de finalizar uma tarefa gigantesca onde traduzi o Foony para 20 idiomas diferentes. Foi uma empreitada enorme que envolveu mexer em quase todos os arquivos da base de código, mas consegui terminar tudo em apenas 3 dias.

Abaixo eu detalho como fiz, os números específicos por trás da mudança e por que decidi criar minha própria biblioteca de tradução (mais uma vez) em vez de usar o padrão da indústria.

Por que não i18next?

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

Em vez disso, decidi otimizar para manutenibilidade por IA. O i18next é poderoso, mas a variedade de sua API pode fazer com que LLMs alucinem ou escrevam código inconsistente. Ao restringir a biblioteca a um simples t() e interpolate(), garanti que mais de 10 agentes paralelos pudessem escrever código 100% type-safe com quase nenhuma intervenção humana.

Também fiquei receoso de comprar um ecossistema gigante que pudesse introduzir mudanças incompatíveis depois. Tendo me queimado com migrações dolorosas como React Router v5 e MUI v4 → v5, sei que quebras rápidas de retrocompatibilidade são extremamente comuns no mundo JavaScript. O custo de adicionar funcionalidades de pluralização depois é menor do que o custo de migrar manualmente 139 mil linhas de código agora.

Eu queria algo simplíssimo, extremamente leve e feito sob medida para as necessidades do meu time.

Então escrevi a minha própria.

Construí um subconjunto restrito de 3 KB projetado especificamente para permitir refatoração autônoma por IA com alta precisão. Isso me permitiu agir como um único engenheiro entregando o trabalho de 3 semanas de uma equipe de 5 pessoas em apenas 3 dias.

A Implementação Personalizada

Criei uma biblioteca i18n minimalista que pesa cerca de 3 KB gzipped. Ela expõe duas funções principais: getTranslation() para contextos fora do React e um hook useTranslation() para componentes.

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

As chaves seguem uma notação "barra-ponto" (barras para o caminho do arquivo de localização, pontos para objetos aninhados dentro do arquivo). Para garantir unicidade, chaves de tradução em um arquivo não podem conter barras.

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

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();

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

O núcleo de toda a biblioteca tem apenas cerca de 580 linhas de código. Ela cuida de:

  • Carregamento preguiçoso dos arquivos de tradução para não enviarmos os 20 idiomas para cada usuário.
  • Divisão de código das traduções por "namespace" (ex.: common, misc, games/{gameId}).
  • Um locale "debug" que mostra as chaves brutas para que eu possa verificar se tudo está conectado corretamente.

Para garantir que o sistema continue fácil de manter, também adicionei documentação completa em shared/src/i18n/README.md, cobrindo desde a estrutura de arquivos até exemplos de uso para cliente e servidor. Como não estou usando uma biblioteca padrão, ter essa referência é fundamental para integrar novos membros do time (ou só para lembrar a mim mesmo no futuro, ou às LLMs, como funciona).

Os Números

Para te dar uma noção da escala dessa atualização, aqui está o que mudou na base de código:

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

Orquestrando com Agentes

Fazer isso manualmente teria levado meses de trabalho mecânico e exaustivo. Em vez disso, orquestrei mais de uma dúzia de agentes Cursor simultaneamente para fazer o trabalho pesado.

Comecei dividindo a base de código em "seções" baseadas em pastas. Cada jogo no Foony recebeu 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 Cursor simultaneamente. Atribuí a cada agente uma seção específica, como "converter o jogo de Xadrez para usar traduções", e ele percorria arquivo por arquivo, encontrando strings visíveis ao usuário e substituindo-as por t('games/chess/some.key').

O agente então adicionava aquela chave ao arquivo de localização em inglês apropriado com um comentário JSDoc explicando o "o quê" e o "onde" da string. Esse contexto é importante ao gerar as traduções para outros idiomas, pois ajuda a LLM a entender se "Save" significa "Salvar Configuração do Jogo" ou "Salvar Seu Desenho de Desenhar & Adivinhar".

Controle de Qualidade

Revisei rapidamente todo o código que foi gerado. Os agentes foram surpreendentemente bons, mas cometiam erros ocasionais, como colocar o hook useTranslation depois de uma instrução return antecipada.

Traduções fortemente tipadas ajudaram imensamente. Isso garantiu que todas as traduções de cada locale tivessem todas as chaves corretas (e nenhuma errada). Também garantiu que chamadas a t() e interpolate() usassem strings de tradução reais que de fato 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 fornece autocompletar perfeito do TypeScript, e qualquer erro de digitação em uma chave de tradução é capturado em tempo de compilação. Os agentes não conseguem cometer erros como t('games/ches/name') porque o TypeScript sinaliza imediatamente.

Localização

Uma vez que a conversão para o inglês estava feita, dividi as tarefas restantes de localização. Atribuí a cada agente a responsabilidade de converter um único arquivo de localização em inglês para um idioma específico.

Por exemplo, dei aos agentes um prompt como este:

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.

Pensei em pedir ao Cursor para criar um script que alimentasse cada um desses arquivos em uma LLM e gerasse as coisas, mas queria economizar um pouco no custo de LLM. Usar um script para atualizar somente as traduções faltantes foi a melhor abordagem, e provavelmente vou usar uma solução parecida no futuro. Gostaria de rastrear quais strings precisam de atualização ou tradução, mas mantendo as coisas simples. Talvez eu mova o trabalho de tradução para um banco de dados ou algo do tipo.

Também adicionei um locale "debug" que só está disponível em desenvolvimento. Isso me permite ver todas as strings substituídas para verificar se as coisas estão funcionando (além de eu achar bem legal). Quando você usa o locale debug, t() retorna a chave envolvida em colchetes:

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

Então em vez de ver "Bem-vindo ao Foony!", você veria ⟦welcome⟧, facilitando identificar quaisquer traduções ausentes.

Por fim, outro agente implementou o roteamento /{locale}/** para que coisas como /ja/games/chess direcionassem para o idioma correto (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 criar e gerenciar ainda mais agentes para traduzir todos os meus posts.

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

Aqui está como ele funciona:

  1. Ele varre o diretório client/src/posts/en em busca de arquivos MDX em inglês.
  2. Ele verifica traduções faltantes nas outras pastas de locales (ex.: posts/ja, posts/es).
  3. Se uma tradução estiver faltando, lê o conteúdo em inglês e o alimenta no Gemini 3 Pro Preview com um prompt específico para traduzir o conteúdo preservando a formatação Markdown.
  4. Ele salva o novo arquivo no local correto.

No frontend, uso import.meta.glob para importar dinamicamente todos esses arquivos MDX. Meu componente PostPage então simplesmente verifica o locale atual do usuário e carrega preguiçosamente o arquivo MDX correto. Se uma tradução estiver faltando (porque ainda não rodei o script), ele faz fallback elegante para o inglês.

Dia 4: Geração Automatizada de Traduções

Eu sabia que a solução original não ia escalar. Então, agora que o i18n estava no ar, era hora de robustecer um pouco com uma abordagem orientada por banco de dados.

Em resumo: quando o texto em inglês ou os comentários JSDoc mudavam, as traduções precisavam ser regeradas. O rastreamento manual do que precisava ser atualizado seria propenso a erros e desperdício de tempo de desenvolvimento.

Então construí a solução que tinha originalmente planejado: um sistema de geração de traduções com PostgreSQL.

O Esquema do Banco de Dados

Adicionei uma tabela translations ao nosso banco PostgreSQL com a seguinte estrutura:

  • key: A chave de tradução em notação "barra-ponto" (ex.: "games/yacht/nested.name", "config.timeLimit.label").
  • en_value: O valor de origem em inglês
  • target_locale: O código do locale de destino (ex.: "es", "fr", "zh")
  • target_value: O valor traduzido
  • context: Um campo JSONB contendo o JSDoc para essa chave e todas as chaves ancestrais
  • created_at e updated_at: Timestamps para rastreamento

O índice único é em (key, target_locale, en_value, context). Isso é crucial: ao incluir context na restrição única, conseguimos detectar automaticamente quando comentários JSDoc mudam e regerar as traduções. Traduções antigas são mantidas para referência histórica.

O Script de Geração

Criei scripts/src/generateLocalizations.ts que automatiza todo o fluxo de tradução:

  1. Extrai chaves em inglês: Usa parsing de AST (ts-morph) para extrair todas as chaves de tradução dos arquivos shared/src/i18n/locales/en/**, processando apenas exports default
  2. Extrai contexto JSDoc: Faz parsing dos comentários JSDoc para cada chave e todas as chaves ancestrais (objetos pais) para fornecer contexto rico
  3. Consulta o banco de dados: Verifica traduções existentes no PostgreSQL, fazendo correspondência por key, target_locale, en_value E context. Se qualquer um deles mudar, a tradução é regerada.
  4. Identifica chaves faltantes/alteradas: Encontra chaves que precisam de tradução ou que tiveram valores/comentários em inglês alterados
  5. Agrupa traduções em lotes: Agrupa por locale e prefixo de namespace para chamadas de LLM mais eficientes (também torna as traduções mais rápidas). Se o lote for grande demais, porém, a qualidade da tradução piora.
  6. Gera traduções: Usa GPT 5.1 com contexto abrangente (JSDoc, idioma+região, tom, glossário, exemplos). Li que o 5.1 é melhor que o 5.2 para escrita (não soa sem graça), mas ainda não confirmei.
  7. Verificações de QA: Valida preservação de placeholders, ex.: {{name}}, integridade da chave, formato JSON
  8. Armazena no banco: Salva traduções com contexto completo (JSDoc + JSDoc ancestral)
  9. Gera arquivos de locale: Lê do banco e escreve arquivos de locale TypeScript devidamente formatados com tipos RecursivePartial

Principais Benefícios

Essa abordagem nos dá várias melhorias de DevEx:

  • Regeração automática: Quando o texto em inglês OU os comentários JSDoc mudam, as traduções são regeradas automaticamente. Então, se alguém disser que uma tradução está ruim, é muito fácil regerá-la fornecendo mais contexto como comentário.
  • Contexto rico: Comentários JSDoc fornecem contexto de tradução (ex.: "Mensagem de erro mostrada aos jogadores, máximo 15 caracteres"), ajudando a LLM a produzir traduções mais precisas
  • Contexto ancestral: O JSDoc do objeto pai fornece contexto de namespace (ex.: "Conquista por estar em uma partida onde todos os ovos são destruídos"), dando um pouco mais de clareza
  • Rastreamento histórico: Traduções antigas são salvas no banco. Não ocupam muito espaço, então não vejo muito motivo para deletá-las por enquanto, e é legal ver o histórico.

Detalhes Técnicos

A implementação usa várias técnicas para garantir confiabilidade e eficiência:

  • Extração baseada em AST para garantir que pego os comentários corretos
  • Processamento paralelo usando Semaphore para tradução concorrente em lote
  • Lógica de retentativa com backoff exponencial para falhas de API. Chamadas de LLM são notoriamente instáveis.

O script pode ser executado com npm run generate-localizations a partir do diretório scripts. Ele se conecta ao PostgreSQL e processa todas as traduções faltantes ou alteradas para todos os locales suportados quando executado.

Conclusão

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

Foram 3 dias loucos, mas o resultado é um site totalmente localizado que parece (em grande parte) nativo para usuários ao redor do mundo. Ao construir uma biblioteca personalizada e leve e aproveitar agentes de IA para o trabalho tedioso de refatoração, consegui o que teria sido impossível há apenas um ano: i18n completo em 3 dias para um site complexo, feito por 1 engenheiro. O futuro da programação não é sobre escrever código rápido. É sobre orquestrar agentes de IA e ter o conhecimento profundo de domínio para verificar a saída deles.

8 Ball Pool online multiplayer billiards icon