Seis línguas, um código-base: i18n como arquitetura
vaynerov.com/de/leistungen não é uma página inglesa com um seletor de idioma aparafusado. É outro URL, outro catálogo, outro conjunto de alternativas hreflang e uma fatia de JavaScript cuidadosamente racionada. Esta é a arquitetura por baixo de seis línguas.
Nesta página
Olhe para o URL: vaynerov.com/de/leistungen. Nada nele é inglês. O segmento de idioma diz alemão e o slug também — Leistungen, serviços. Um visitante que chegue de um resultado de pesquisa alemão nunca vê uma palavra inglesa na barra de endereço, e um rastreador que indexe essa página indexa um caminho alemão, não um caminho inglês disfarçado com um ?lang=de.
Essa decisão isolada — a de que o slug faz parte da tradução — é a razão pela qual a internacionalização deste site é um problema de arquitetura e não um problema de tradução. Traduzir é uma folha de cálculo. Arquitetura é o que acontece quando uma rota canónica tem de se tornar seis URLs, seis entradas de sitemap, seis alternativas hreflang e uma árvore React partilhada, sem duplicar o código nem triplicar os bytes que chegam a um telemóvel.
- 6
- idiomas — en, ru, es, de, fr, pt
- 28
- entradas de caminhos localizados
- 1.19 MB
- catálogos de mensagens em disco
- 11
- namespaces enviados ao navegador
A forma medida da camada de i18n: o que é encaminhado, o que é guardado e a pequena fração disso que um navegador chega a transferir.
O slug faz parte da tradução
Corremos o next-intl com localePrefix: "as-needed" e o inglês como idioma por omissão. Os URLs ingleses não levam prefixo nenhum — /services é a página canónica de serviços — enquanto os outros cinco idiomas são sempre prefixados. A troca é deliberada: o as-needed custa uma decisão de encaminhamento em cada pedido e compra URLs canónicos limpos, sem prefixo, para o idioma para o qual a maioria dos links aponta.
Por cima do prefixo está o mapa de caminhos: 28 entradas, cada uma a emparelhar uma rota interna com seis slugs públicos. Serviços, trabalhos, preços, o Journal, sobre nós, carreiras, investidores, contacto, orçamento, privacidade e termos traduzem-se todos.
const pathnames = {
"/services": {
en: "/services",
ru: "/uslugi",
es: "/servicios",
de: "/leistungen",
fr: "/services",
pt: "/servicos",
},
// …27 more
};Uma rota, seis URLs. O francês calha aterrar na mesma palavra que o inglês — inofensivo, porque o prefixo de idioma desambigua: /services e /fr/services são páginas diferentes.
| Rota | ru | es | de | fr | pt |
|---|---|---|---|---|---|
| services | uslugi | servicios | leistungen | services | servicos |
| work | raboty | trabajos | projekte | realisations | projetos |
| pricing | tseny | precios | preise | tarifs | precos |
| articles | stati | articulos | artikel | articles | artigos |
Quatro das 28 entradas de caminhos. Os slugs russos são transliterações latinas, para que todos os caminhos do site se mantenham em ASCII numa linha de log, num resultado de pesquisa e num painel de analítica.
A parte interessante da tabela é o que lhe falta. Um punhado de rotas nunca se traduz: /museum, /games, /roadmap, /cookies, /nda. Duas razões diferentes, ambas dignas de serem ditas em voz alta. Os nomes de código de produto são nomes — o Reliquary chama-se Reliquary em todas as línguas, e um visitante alemão que o procure escreve o nome que lhe demos, não uma tradução dele. O texto-padrão legal é o caso oposto: essas páginas mudam raramente, são lidas mais por advogados e rastreadores do que por humanos, e um caminho estável nos seis idiomas vale mais do que um caminho localizado.
Um middleware, três desvios
Todos os pedidos encontram um encaminhador feito à mão à volta do createIntlMiddleware. A maioria segue direta para a negociação de idioma. Três prefixos não. O /admin* é entregue ao updateSession para que o cookie de autenticação do Supabase seja renovado; o /api* e o /pitch* passam para o next() intocados, porque nenhum deles tem nada que ser redirecionado para um idioma que nunca pediu. O próprio matcher exclui o _next e tudo o que tenha um ponto, por isso os recursos estáticos nem chegam a acordar o middleware.
O desvio que se paga a si próprio é o da sessão, e o raciocínio está num comentário mesmo ao lado: nenhuma página pública lê a sessão do Supabase, por isso correr o updateSession em tráfego público compraria uma ida e volta de autenticação por pedido e mais nada. É um acoplamento deliberado e não um descuido — no dia em que uma página pública precisar de um utilizador autenticado, esse comentário é a primeira coisa que tem de ser revista, e é exatamente por isso que é um comentário e não um silêncio.
1,19 MB de catálogos de mensagens, e o problema russo
Seis catálogos JSON guardam todas as cadeias de texto do site. Não têm o mesmo tamanho.
| Idioma | Bytes do catálogo | vs. inglês |
|---|---|---|
| en | 170 069 | — |
| es | 186 854 | +10% |
| pt | 187 552 | +10% |
| de | 188 041 | +11% |
| fr | 193 393 | +14% |
| ru | 260 610 | +53% |
Tamanhos dos catálogos de mensagens medidos com wc -c. Total nos seis idiomas: 1 186 519 bytes.
Quatro das cinco traduções agrupam-se dez a catorze por cento acima do inglês, que é mais ou menos a expansão que qualquer tradução românica ou germânica dá de graça. O russo fica nos +53%, e vale a pena ser preciso quanto ao porquê, porque o número favorece a conclusão errada: boa parte dessa diferença é aritmética de UTF-8. Os caracteres cirílicos custam dois bytes cada onde o latino custa um, por isso uma frase russa do mesmo comprimento aterra ao dobro dos bytes antes de se acrescentar uma única palavra. O russo é também genuinamente mais palavroso do que o inglês — mas a contagem de bytes mede codificação e prosa juntas, e fingir o contrário faria os nossos próprios gráficos mentirem-nos.
O tamanho importa por causa da forma como os catálogos são carregados: a configuração de pedido importa-os por inteiro, a cada pedido. É um bom negócio para cadeias de interface e péssimo para prosa longa, e é por isso que o artigo que está a ler não vive num catálogo de mensagens. As peças do Journal são módulos de conteúdo tipados sob src/content/articles/<slug>/<locale>.ts, importados apenas pelas rotas dos artigos. Texto longo nos catálogos taxaria todas as páginas do site por texto que aparece numa só.
Onze namespaces chegam ao navegador
Os componentes de servidor traduzem no servidor; as cadeias nunca saem da renderização. Os componentes de cliente não podem fazer isso, e a solução ingénua — entregar o catálogo inteiro ao NextIntlClientProvider — serializava para o payload RSC de todas as páginas mais de 10 KB de cadeias que nenhum componente de cliente daquela página alguma vez leria.
Por isso o provider recebe um subconjunto escolhido a dedo. Um auxiliar pickMessages resolve caminhos com pontos contra o catálogo, e uma única lista GLOBAL_CLIENT_NAMESPACES — onze entradas — decide o que um navegador tem autorização para ver. Algumas dessas entradas não são namespaces inteiros mas folhas individuais: só três folhas de pages.services seguem, porque três delas são necessárias a um componente interativo e o resto desse namespace é renderizado no servidor. O procedimento de manutenção para manter essa lista honesta é um grep, documentado no ficheiro ao lado da lista, que é a forma menos glamorosa e mais fiável de documentação que conhecemos.
Todas as cadeias que chegam ao navegador estão lá porque alguém decidiu que deviam estar. Não existe uma omissão de «enviar tudo».
A exceção é o Lab. Os componentes de jogo têm texto pesado de uma forma que as páginas de marketing não têm — o namespace games chega a cerca de 150 chaves para um único jogo — e nada disso pertence à lista de permissões global. Por isso a subárvore dos jogos monta o seu próprio provider aninhado, com o seu namespace, que é onde vive uma armadilha genuinamente traiçoeira.
Traduções que mudam sem um deploy
Os catálogos estáticos são o chão, não o teto. O texto das superfícies geridas pelo CMS pode ser sobreposto a partir da base de dados, por isso uma correção de redação em espanhol não precisa de uma compilação. A fusão é deliberadamente cuidadosa: as sobreposições são aplicadas com setNestedValue sobre um structuredClone do catálogo importado, nunca sobre o próprio objeto do módulo — esse objeto é partilhado por todos os pedidos do processo, e alterá-lo faria da sobreposição de um visitante a sobreposição de todos. A leitura fica atrás de unstable_cache com a etiqueta i18n-overrides e uma janela de cinco minutos, e as escritas de administração invalidam a etiqueta em vez de esperarem que passe.
Se a base de dados estiver inacessível, um catch vazio recai no JSON estático e a página renderiza. É o comportamento certo e produziu também o melhor erro da história deste sistema.
Chamar cookies() dentro de unstable_cache lança exceção no Next 15+. O leitor de sobreposições em cache estava a fazer exatamente isso. A exceção caía no catch vazio, o catálogo estático era servido, todas as páginas pareciam perfeitas — e a funcionalidade de sobreposições estava morta em silêncio.
As sobreposições guardavam-se bem. Ficavam em cache bem. Simplesmente nunca apareciam, e nada em lado nenhum reportava um erro, porque o mecanismo de recurso fazia precisamente aquilo para que tinha sido escrito. A correção é pequena — a leitura das sobreposições usa o cliente Supabase público e sem cookies, que não tem nada que ler cookies para começar — mas a lição sobreviveu-lhe. Passámos a tratar um recurso silencioso como um recurso por acabar: se um catch engolir uma falha que muda o que o utilizador vê, tem de deixar rasto algures, ou não é uma rede de segurança, é uma venda nos olhos.
Um mecanismo de recurso que não consegue avisar que disparou acabará por esconder uma funcionalidade morta.
Uma consequência das sobreposições chega até ao payload do cliente: o provider é alimentado a partir de getMessages() e não de uma importação direta do JSON. Importar o JSON diretamente seria marginalmente mais rápido e também contornaria a fusão por completo, por isso os componentes de cliente ficariam ali a renderizar o texto pré-sobreposição enquanto a metade renderizada no servidor da mesma página mostrava o texto corrigido. Duas fontes de verdade num ecrã é pior do que uma fonte lenta.
Recursos, hreflang e o que os rastreadores recebem
A cobertura de tradução nunca é uniforme. Uma secção do CMS pode existir em inglês e alemão mas ainda não em português, e as opções honestas são uma região em branco ou texto em inglês. Escolhemos o inglês, ao nível do conteúdo: fetchSectionWithFallback, fetchRowsWithFallback e fetchPageMeta tentam cada um o idioma pedido, repetem em inglês quando falham e curto-circuitam a repetição quando o idioma pedido é já o inglês. Uma página parcialmente traduzida degrada-se para uma página bilingue em vez de uma página partida.
O mapa de alternativas não sabe nada disso, e é de propósito. O alternates.ts declara o conjunto de URLs — seis entradas hreflang mais um x-default a apontar para o URL inglês — e mais nada. Misturar-lhe lógica de recurso significaria que o grafo de hreflang mudava de forma consoante o ponto em que ia um tradutor, o que é uma excelente maneira de ensinar a um motor de busca que a nossa estrutura de URLs é instável. A cobertura é expressa onde a cobertura pertence: o sitemap restringe as alternativas hreflang das páginas de detalhe de trabalhos e do Journal aos idiomas que têm mesmo uma entrada publicada.
Os slugs localizados têm ainda uma consequência na fronteira dos rastreadores que ninguém prevê à primeira. O fluxo de orçamento é proibido no robots.txt — é um funil, não uma página de entrada — e, como o slug é localizado, não há um caminho único para proibir. Há seis. Junte-se a isso que os grupos do robots.txt são independentes uns dos outros (um disallow escrito num grupo não se aplica ao seguinte) e que damos permissão a dezoito rastreadores de IA em dezoito grupos separados, e a lista de proibições tem de ser repetida por inteiro, todas as vezes, por todos os grupos. É gerada, não escrita à mão, por razões que devem ser óbvias.
A multiplicação aparece também no sitemap. Vinte páginas estáticas tornam-se 120 entradas antes de se contar um único jogo, caso de estudo ou peça do Journal, e cada uma dessas entradas leva o seu próprio bloco de alternativas. Um sitemap com essa forma não é coisa que alguém mantenha à mão; é uma função do mapa de caminhos, que é a mesma razão pela qual o mapa de caminhos pode ser o único sítio onde um slug é escrito. Tudo o que tenha de ser repetido em seis sítios acabará por ser repetido em cinco.
A última milha são os metadados sociais, onde seis idiomas se tornam um mapa de Open Graph — en_US, ru_RU, es_ES, de_DE, fr_FR, pt_PT — emitido por um auxiliar partilhado que reafirma sempre todos os campos. Essa última parte não é estilística: o Next.js substitui em vez de fundir em profundidade o openGraph por segmento de rota, por isso qualquer página que defina um campo próprio deita fora em silêncio os valores por omissão do layout de idioma, levando atrás o og:site_name e o og:locale. O auxiliar existe para que ninguém tenha de se lembrar disto.
Nada disto é exótico. O que faz com que tudo se aguente é que cada um dos seis idiomas é tratado como uma superfície de primeira classe com as mesmas garantias — o seu URL, o seu catálogo, os seus metadados, a sua história com os rastreadores — e não como inglês com uma tabela de consulta à frente. A medida disso não é o lançamento; é o custo da vigésima nona entrada de caminhos, que é uma linha no mapa, seis slugs, uma linha de sitemap e discussão nenhuma. Escrevemos sobre porque mantemos essa fasquia em O padrão de quem conjura; isto é o aspeto que a fasquia tem quando é uma tabela de encaminhamento.