Uma visitante de uma loja online espanhola encontra um botão de finalizar compra que não faz nada. Ela clica no pequeno ícone de bug no canto e recebe «What's on your mind?» e um botão que diz «Send». Ela consegue entender. Mas escreve três palavras em vez de três frases, porque descrever um problema numa segunda língua dá trabalho, e ninguém deve esse trabalho a você.
Até esta semana, era assim em todo site não anglófono que usava o BugDump. O widget falava inglês, e traduzi-lo significava passar você mesmo todas as 36 strings. A maioria das pessoas não fazia isso. Agora ele vem em inglês, russo, espanhol, francês e português, e escolhe um idioma sozinho. A escolha acabou sendo a parte interessante.
Atualização, 3 de outubro: o alemão chegou no SDK 0.0.42 como o sexto idioma. Ele também precisou de palavras mais curtas: o botão de anexar diz «Datei» (arquivo) e o selo de nota de voz, «Audio». A entrada do registro de alterações traz os detalhes.

Três lugares onde um widget pode descobrir o idioma
Um widget incorporado roda na página de outra pessoa. Ele pode ler três sinais, e eles nem sempre concordam.
O atributo lang da página. <html lang="es"> é o site dizendo em que idioma está o conteúdo dele. Leitores de tela o usam para escolher a pronúncia, e os navegadores olham para ele quando oferecem traduzir uma página. Ele descreve a página, não a pessoa que a lê.
A lista de idiomas do navegador. navigator.languages são as preferências do visitante em ordem, algo como ["pt-BR", "pt", "en"]. navigator.language é só o primeiro item. Ela descreve a pessoa, não a página.
O que o site diz ao widget. Uma configuração explícita no código de incorporação. O mais confiável dos três, e o que quase ninguém preenche.
Existe também o cabeçalho Accept-Language, que um servidor vê em toda requisição. Um widget carregado como script a partir de uma CDN nunca consegue lê-lo, então ele fica de fora.
Por que a página vem primeiro
Widgets de chat de suporte costumam seguir o navegador, e, numa conversa com uma pessoa, isso faz sentido: você quer falar na sua própria língua. Um widget de relatório de bug é outra coisa. Ele fica dentro da interface de outra pessoa, ao lado dos botões e dos textos dela, e trata do produto dela. Um painel em francês numa página de checkout em inglês parece, por si só, um bug.
Por isso a ordem que escolhi é:
<html lang>, se ele indicar um idioma que o widget traz.Senão, o primeiro dos idiomas do navegador do visitante que o widget traz.
Senão, inglês.
A página vem primeiro para que um site em inglês continue em inglês, mesmo para um visitante com o navegador em russo. O navegador vem em segundo para que uma página sem nenhum atributo lang ainda receba algo razoável.
O furo óbvio é uma página com o lang errado. Muitos templates iniciais vêm com <html lang="en"> e ninguém nunca mexe nisso, então um site escrito inteiro em português pode continuar dizendo que é inglês. Decidi que a correção para isso é o atributo, não um palpite mais esperto. Um lang errado já prejudica quem usa leitor de tela naquele site. Um widget que contorna o erro em silêncio esconde o problema em vez de expô-lo.
Comparar tags sem esperteza
As tags de idioma aparecem em muitos formatos: fr, fr-CA, FR, ou pt_BR vindo de um backend que nunca ouviu falar de BCP 47. O widget traz um conjunto de strings por idioma, então a comparação olha só para a parte antes do primeiro - ou _, em minúsculas:
function findLocale(tag) {
const base = tag.toLowerCase().split(/[-_]/)[0];
return SHIPPED_LOCALES.find((locale) => locale === base);
}
Isso significa que um visitante em Portugal recebe o português do Brasil. Não é perfeito, e prefiro dizer isso a fingir que é.
A busca na lista é proposital. base in LOCALES parece quase a mesma coisa, até chegar uma tag como constructor e casar com uma propriedade que todo objeto JavaScript herda. Ninguém define lang="constructor" de propósito, mas, do ponto de vista de um widget, um atributo lang é entrada vinda de um estranho.
O que quebrou no caminho
Um teste que dependia de qual laptop o rodava. As versões recentes do Node têm um navigator global, e o languages dele segue o locale da máquina. Os testes de configuração rodam sem DOM, então a primeira versão teria resolvido «auto» para russo numa máquina configurada em russo e para inglês no CI. Agora a detecção não retorna nada a menos que também exista um document, que é como um ambiente fora do navegador deveria se parecer de qualquer forma.
O russo é longo. «Прикрепить» (anexar) tem dez letras onde o inglês tem seis, e empurrou o terceiro botão da barra de ações para uma segunda linha. A correção não foi CSS, foi uma palavra mais curta: o botão em russo agora diz «Файл» (arquivo) e deixa o ícone de clipe fazer o resto. O selo de nota de voz nos anexos tinha o mesmo problema. É um bloco de 72 pixels, e «Nota de voz» termina em reticências ali, então espanhol e francês agora dizem «Audio», e português, «Áudio».
Você só encontra essas coisas olhando. Renderizei o painel em todos os idiomas antes de lançar, e todos, menos o inglês, precisaram de pelo menos uma palavra mais curta.
Strings coladas no código. «Uploading 1/3… 40%» era montada com a palavra embutida, assim como «Microphone 2» e «Powered by Bugdump». Em vez de inventar uma sintaxe de template, a parte traduzível virou só a palavra, e o código acrescenta os números e o nome depois dela. Isso funciona porque todos os idiomas que o widget traz põem a palavra primeiro. O japonês não poria, e, no dia em que eu o adicionar, essas três strings viram templates.
Leitores de tela. O widget é renderizado num shadow root na página hospedeira, então herda o lang da página. Um painel em francês numa página em inglês seria lido em voz alta com pronúncia inglesa. Agora o widget define lang no próprio elemento.
Quem decide ainda é o site
A detecção automática é um padrão, não uma regra. Um site pode indicar o idioma no código de incorporação:
<script
src="https://bugdump.com/sdk/latest.js"
async
data-api-key="your-api-key"
data-locale="fr"
></script>
translations continua substituindo qualquer string por cima do idioma escolhido, então renomear «Send feedback» para «Report a bug» funciona igual em francês e em inglês. Para um idioma que o widget não traz, locale: 'nl' mais as suas próprias strings em holandês também funciona, e tudo o que você deixar de fora cai no inglês.
Na página Widget do BugDump, a mesma escolha é uma lista suspensa, e a prévia ao vivo troca de idioma conforme você escolhe:

Quanto custou
Os cinco idiomas vão empacotados no script. Os quatro extras acrescentaram cerca de 2,5 KB com gzip, e o widget agora pesa cerca de 32 KB. Pensei em carregá-los sob demanda, mas isso significa mais uma requisição e um painel que só pode ser renderizado depois que ela volta. Um estado de carregamento para economizar dois kilobytes e meio é uma troca ruim.
O que não fiz: trocar o idioma de um widget que já está na tela e layouts da direita para a esquerda. Nenhum dos idiomas que o widget traz é escrito da direita para a esquerda, e um app de página única que troca de idioma pode desmontar o widget e iniciá-lo de novo até alguém precisar de mais.
Se você está construindo um widget
Leia
<html lang>antes denavigator.languages. O seu widget faz parte da página.Compare pelo idioma base e declare o idioma que você realmente usou com um atributo
langpróprio.Deixe o site sobrescrever tudo, e faça essa sobrescrita caber num atributo da tag script.
Renderize cada string em cada idioma antes de lançar. As traduções são mais longas do que você espera.
O widget do BugDump faz tudo isso a partir do SDK 0.0.41, e quem usa a tag script já tem. Traduzir e mudar a marca do widget cobre todas as opções, e a entrada do registro de alterações traz a versão curta.
Leia também
Modo escuro em widgets incorporados: como seguir o seletor de tema do seu app — por que
prefers-color-schemeé o sinal errado para um widget incorporado e a pequena API que resolve isso.Três quartos do meu SDK eram código que quase ninguém executava — a medição, a correção e o que se revelou errado sobre o
defer, num widget que mandava 450 KB a cada visitante.Subdomínios por tenant no TanStack Start com reescrita de URL — outra história de implementação: cada portal no próprio subdomínio sem mover um arquivo de rota.
