Pular para o conteúdo

ADR-0031 Internacionalização

Padrão da empresa para i18n das apps — Paraglide JS, compilado para funções de mensagem tipadas, com o locale no URL e resolvido por pedido no servidor.

Decisão
Paraglide JSConstruir a app
Estado
Aceite
Data
Âmbito
Predefinição da empresa para todas as novas apps

As apps têm de poder sair em mais do que uma língua; português (pt-PT) e inglês são o par habitual.

  • Renderizamos no servidor e fazemos streaming (ADR-0002): o locale tem de ser conhecido por pedido, e o servidor e o browser têm de concordar, senão a hidratação falha
  • Um processo serve muitos utilizadores (ADR-0012): um locale guardado numa variável ao nível do módulo passa de um pedido para outro
  • TypeScript em todo o lado: uma mensagem ou um parâmetro em falta deve fazer falhar o build, não mostrar uma chave a um utilizador
  • Também se produz texto fora do React: os emails (ADR-0029) e os jobs (ADR-0028) não têm um pedido de onde ler o locale

O que está em aberto: a biblioteca de i18n, de onde vem o locale e como se escrevem as mensagens.

Um compilador: os ficheiros de mensagens passam a funções tipadas e tree-shakable.

Prós

  • m.greeting({ name }) é uma chamada de função — uma mensagem ou um parâmetro em falta é um erro de tipos, e as mensagens não usadas não são enviadas
  • Um plugin Vite e um exemplo para TanStack Start no próprio repositório: middleware de servidor mais o rewrite do TanStack Router
  • O middleware de servidor guarda o locale do pedido em AsyncLocalStorage, pelo que pedidos concorrentes não se misturam
  • Uma mensagem pode ser forçada para um locale em cada chamada — o que os emails e os jobs precisam

Contras

  • A versão 3.0 saiu em 2026-11-03; a maioria dos exemplos online ainda mostra a v2
  • Formato de mensagens próprio, não ICU MessageFormat
  • Um ecossistema mais pequeno do que o do i18next

Prós: o maior ecossistema; as ferramentas e os tradutores conhecem-no. Contras: as mensagens são procuradas por chave em string em runtime; as chaves tipadas exigem configuração extra; uma instância por pedido no servidor fica por nossa conta; namespaces inteiros vão para o browser se não os dividirmos.

Prós: ICU MessageFormat, um padrão que os tradutores conhecem; maduro. Contras: IDs em string; um parser em runtime ou um passo extra de compilação; mais boilerplate por mensagem.

Paraglide JS é o padrão da empresa para a internacionalização das apps.

Em concreto:

  • Pacote: @inlang/paraglide-js 3.x, fixado numa versão exata (3.2.2 à data de escrita) enquanto a major for tão recente; atualizar deliberadamente
  • Plugin Vite: paraglideVitePlugin({ project: "./project.inlang", outdir: "./src/paraglide", strategy: ["url", "cookie", "preferredLanguage", "baseLocale"] }). O src/paraglide/ é output gerado: nunca se edita, e não é uma das pastas de camadas do ADR-0026
  • Os locales são por produto, declarados em project.inlang/settings.json. As mensagens vivem em messages/{locale}.json, um ficheiro por locale
  • O URL transporta o locale. Os ficheiros de rotas mantêm-se canónicos (/about); o rewrite do TanStack Router mapeia os URLs públicos com deLocalizeUrl e localizeUrl
  • Servidor: o src/server.ts envolve o handler do Start com paraglideMiddleware(request, () => handler(request)). Passar o pedido original — o pedido reescrito pelo middleware causa um ciclo de redirecionamentos quando o router também reescreve
  • Documento: a rota raiz renderiza <html lang={getLocale()} dir={getTextDirection()}>
  • Mudar de língua usa o setLocale() com a navegação de página inteira predefinida, para que o URL, a renderização no servidor e o runtime fiquem alinhados
  • No código: importar o m do src/paraglide/messages.js gerado e chamar m.some_message({ … }). Sem chaves em string, sem concatenação de strings; os plurais e outras variantes são entradas match no ficheiro de mensagens
  • Fora de um pedido (emails, jobs), passar o locale explicitamente: m.some_message({ … }, { locale })
  • Datas, números e moeda formatam-se com Intl usando o getLocale() — não é trabalho do Paraglide
  • Não usamos: experimentalPerLocaleBuild nem outras opções experimentais

Os fatores decisivos: mensagens com verificação de tipos e tree-shaking, um locale com âmbito de pedido no servidor, uma configuração documentada para TanStack Start e locales por chamada para emails e jobs. Aceitamos uma major com poucos dias e um ecossistema mais pequeno.

Mais fácil:

  • Uma chave de tradução ou um parâmetro em falta faz falhar o bun run check, não a produção
  • Só as mensagens que uma página usa chegam ao browser
  • Os emails e os jobs renderizam na língua do destinatário com os mesmos ficheiros de mensagens

Mais difícil:

  • A versão 3 é nova: contar com correções e com exemplos que já não se aplicam
  • As regras de URL exigem cuidado: o /api/* (ADR-0036) e os endpoints de health não podem ser redirecionados para um prefixo de locale
  • Os tradutores trabalham em ficheiros JSON ou nas ferramentas do inlang, não numa plataforma baseada em ICU

Do que abdicamos: do ICU MessageFormat e do ecossistema do i18next. Aceite — a segurança em tempo de compilação e os bundles mais pequenos importam-nos mais do que a amplitude das ferramentas.