Relatório de laboratório

i18n end-to-end: registry de conteúdo em vez de arquivos de tradução soltos

Como o site passou a traduzir EN/PT de ponta a ponta sem duplicar rotas: um registry tipado por idioma, a armadilha das funções no UiCopy com o Next.js 16, e a paridade garantida pelo compilador.

Relatório preliminar. O universo pode ter mudado desde a coleta dos dados.

i18n end-to-end: registry de conteúdo em vez de arquivos de tradução soltos

Este site falava um idioma e tinha o outro de enfeite: o conteúdo inteiro vivia em src/content/pt-BR, e cada página fazia import fixo desse barrel. O botão de idioma traduzia o chrome — nada mais. Em três commits isso mudou para tradução de ponta a ponta: o mesmo conteúdo, em EN e PT-BR, servido pela mesma rota. Este texto registra o porquê do desenho, a armadilha concreta que o Next.js 16 nos pregou no meio do caminho, e como a paridade virou obrigação do compilador em vez de disciplina de equipe.

Por que um registry, e não arquivos de tradução soltos

A alternativa clássica — arquivos JSON de tradução por namespace, resolvidos por chave em runtime — resolve o problema de lookup e cria outro: a chave que não existe. A tradução some em silêncio, ou cai num fallback que ninguém percebe até um usuário ver a string em inglês no meio do texto em português. Não há garantia estrutural de que os dois idiomas cubram o mesmo conjunto de strings.

O tneemo.com foi pelo caminho oposto: o conteúdo é um barrel tipado por idioma, e os dois barrels satisfazem a mesma interface (ContentModule), exportada de src/content/index.ts. Um Record<Locale, ContentModule> liga cada idioma ao seu barrel. Se o barrel EN esquece um campo, tsc falha antes do build — a paridade é um erro de tipo, não uma promessa. O registry é a fonte única de verdade: páginas, projetos, arsenal, linha do tempo, entradas do laboratório e siteConfig (que carrega o pages com todo texto de UI) vivem no mesmo contrato tipado.

Isso custa mais no início (definir o contrato, tipar os dois lados) e paga em toda mudança: adicionar uma string ao PagesCopy obriga os dois idiomas a responderem, na mesma compilação.

A armadilha do Next 16: funções não cruzam a fronteira

Aqui está o erro que quase virou lenda interna. As strings de UI viviam em UiCopy, em src/lib/ui.ts, e o layout servidor passava esse objeto como prop para componentes client. O problema: UiCopy continha funções:

langSwitchAria: (target: string) => string;
countRecords: (n: number) => string;

O SiteHeader é um client component ("use client"). Todo objeto que cruza a fronteira servidor→cliente é serializado — e função não é serializável. Sintoma: erro de build do Next.js 16 apontando que a prop não pode ser transmitida a um client component. Diagnóstico: o objeto de copy parecia dado, mas carregava closures. Correção (commit 1e196c8): funções viraram dados — langSwitchLabel (string pronta) e templates com placeholder countRecordsTpl: "{n} public records", interpolados no ponto de uso. Dado cruza fronteira; código não.

A regra que ficou: copy é dado, não código. Se uma string precisa de interpolação, ela é um template; se precisa de lógica, a lógica mora no componente.

Paridade sem duplicar rotas

Antes, cada página era uma rota com texto fixo. Agora a rota é uma só — /sobre, /linha-do-tempo, qualquer uma — e o conteúdo é decidido na hora do request:

  • getContent() em src/lib/locale.ts lê o cookie tneemo:lang, normaliza para "pt-BR" ou "en" e devolve o barrel inteiro daquele idioma;
  • páginas e generateMetadata chamam await getContent() e tiram dali siteConfig, labEntries, navigation etc.;
  • o botão de idioma escreve o cookie e chama router.refresh() — sem trocar de rota, sem duplicar a árvore de rotas.

siteConfig.pages (tipo PagesCopy) concentra todo texto de UI por rota — hero, labels, CTA, category labels — populado nos dois idiomas. Como os dois barrels satisfazem o mesmo tipo, uma string nova não traduzida em EN não compila. O cookie escolhe o idioma; o tipo garante que ambos existem.

O resultado: o mesmo conteúdo em dois idiomas, uma rota por página, e a paridade mantida por tsc, não por checklist. Se a fronteira servidor→cliente reaparecer em algum componente novo, a regra é a mesma: dado atravessa, função não.