Pular para o conteúdo

Seis decisões que mantiveram uma camada pequena de i18n no Electron fácil de manter

Um design prático de i18n para Electron: preferências de idioma, catálogos tipados, comportamento de fallback, formatação com Intl e sincronização do renderer com o processo main.

12 min de leituraGuiasPor Temposhot Team

Um app de desktop com cinco a dez idiomas raramente precisa de um framework grande de i18n. Ele precisa de algumas regras claras, porque um app Electron tem dois processos que exibem texto: o renderer desenha a interface, e o processo main controla menus, diálogos e notificações. As seis decisões abaixo mantêm uma camada pequena de i18n escrita à mão correta conforme o app e os catálogos crescem.

1. Salve uma preferência, não apenas um locale

Salvar "de" quando o usuário escolhe alemão parece suficiente até que alguém queira que o app siga o idioma do sistema operacional. Salve a intenção do usuário e resolva o locale efetivo em tempo de execução:

export const SUPPORTED = ["en", "de", "it", "ja", "ko", "zh-Hans"] as const;
export type Locale = (typeof SUPPORTED)[number];
export type LanguagePreference = "system" | Locale;

const ALIASES: Record<string, Locale> = { "zh-cn": "zh-Hans", "zh-sg": "zh-Hans" };

export function resolveLocale(pref: LanguagePreference, systemLanguages: string[]): Locale {
  if (pref !== "system") return pref;
  for (const tag of systemLanguages) {
    const lower = tag.toLowerCase();
    const exact = SUPPORTED.find((l) => lower === l.toLowerCase() || lower.startsWith(l.toLowerCase() + "-"));
    if (exact) return exact;
    if (ALIASES[lower]) return ALIASES[lower];
  }
  return "en";
}

No processo main, app.getPreferredSystemLanguages() retorna a lista ordenada de idiomas do sistema operacional. Com "system" salvo, um usuário que altera o idioma do macOS recebe o novo idioma no próximo lançamento, sem mexer nas configurações do app.

O resolvedor percorre a lista inteira de preferências, e não apenas a primeira entrada. Um usuário com "Português, depois Inglês" recebe inglês porque essa é a segunda escolha dele, e não porque é o padrão. A correspondência por prefixo mapeia de-CH para alemão e zh-Hans-CN para chinês simplificado. zh-TW não corresponde a nenhum idioma, então a busca passa para a próxima preferência em vez de virar chinês simplificado sem aviso.

Ordem das verificações

A função retorna na primeira correspondência. A ordem das verificações importa, porque a lista do sistema nunca pode substituir um locale salvo.

Cinco passos numerados para resolver um locale: retornar o locale salvo diretamente, ler a lista de idiomas do sistema em ordem, aceitar correspondências exatas ou por prefixo, consultar a tabela de aliases e usar inglês como fallback.

Quando duas entradas da lista do sistema são suportadas, a entrada anterior vence. O macOS mantém a lista na ordem que o usuário definiu em Language & Region, então a primeira entrada suportada é a primeira escolha suportada do usuário.

Três preferências salvas na prática

A tabela abaixo mostra como a mesma função trata três usuários. Cada linha é uma chamada a resolveLocale com um valor salvo e uma lista do sistema operacional.

Comparação de três usuários: a preferência system com fr-FR, de-DE e en-US resolve para alemão, uma preferência ja salva continua em japonês, e a preferência system com zh-TW e en-GB resolve para inglês.

O usuário 1 fala francês primeiro, mas o app não traz francês. O laço ignora fr-FR, encontra de-DE na segunda entrada e retorna alemão. O usuário 3 recebe inglês por outro motivo: zh-TW não é um alias e não corresponde por prefixo ao chinês simplificado, então o laço chega a en-GB e retorna inglês. O usuário 2 nunca lê a lista do sistema, porque o valor salvo é um locale e não "system".

Valores salvos depois da remoção de um idioma

Uma versão posterior pode remover um idioma. O valor salvo então deixa de aparecer em SUPPORTED, e a busca no catálogo não encontra um arquivo correspondente. Converta os valores desconhecidos para "system" ao carregar as configurações e grave o valor corrigido uma vez, para que o arquivo salvo corresponda ao código. O usuário mantém um idioma funcional no primeiro lançamento após a atualização.

2. Mantenha os nomes dos idiomas legíveis após uma seleção errada

Se um usuário escolhe japonês por engano e todos os nomes de idioma passam a aparecer em japonês, fica difícil encontrar "English" de novo. Mostre cada idioma no próprio idioma (endônimo), para que a lista seja igual qualquer que seja o idioma atual da interface:

export const LANGUAGE_NAMES: Record<Locale, string> = {
  en: "English",
  de: "Deutsch",
  it: "Italiano",
  ja: "日本語",
  ko: "한국어",
  "zh-Hans": "简体中文",
};

Intl.DisplayNames pode gerar esses nomes, mas a saída para variantes de script difere entre motores e versões. Uma tabela fixa com seis strings é mais fácil de revisar. Apenas a opção "System default" é traduzida, já que é uma expressão e não um nome de idioma.

A mesma regra no Temposhot

O app Mac Temposhot usa o mesmo padrão de endônimo no menu Language. O menu fica em Settings, na aba General, e tem sete opções. O guia informa que o nome de cada idioma é escrito no próprio idioma.

Painel de configurações do menu Language do Temposhot: localização em Settings, General, sete opções com System default em primeiro, rótulos em endônimo, escolha mantida após reiniciar e textos da interface ainda em tradução.

A última linha é importante para o leitor deste post. O app salva a escolha e troca o locale imediatamente. Alguns textos da interface continuam em inglês após a troca, até que as traduções dos outros seis idiomas sejam publicadas.

Ordem do menu

O menu segue a ordem de SUPPORTED, com inglês primeiro. Uma ordem fixa é fácil de testar e não muda quando um tradutor edita um nome. Ordenar pelo endônimo colocaria as entradas em uma ordem que depende das regras de collation de cada script, o que é mais difícil de revisar em um pull request.

Adicionar um locale significa acrescentá-lo a SUPPORTED. O tipo Record<Locale, string> em LANGUAGE_NAMES faz o build falhar até que a nova entrada tenha um nome. Assim, um locale não pode aparecer no resolvedor sem um rótulo visível no menu.

Verifique o menu antes de uma release

Um menu de idiomas quebra de formas pequenas que um teste unitário não detecta: um nome renderizado no script errado, uma entrada ausente ou uma entrada que não persiste. Uma verificação manual curta antes de cada release detecta esses problemas.

Checklist de seis itens para o menu de idiomas: endônimos em cada script, System default como primeira entrada, seis entradas de idioma, rótulos atualizados após a troca, escolha mantida após reiniciar e apenas chinês simplificado para chinês.

Faça a verificação em um Mac cujo idioma do sistema não seja inglês. Uma verificação em um sistema em inglês comprova apenas que o caminho em inglês funciona.

3. Use o catálogo em inglês como contrato TypeScript

Torne o inglês a fonte da verdade e derive o tipo a partir dele:

export const en = {
  "settings.language": "Language",
  "settings.language.system": "System default",
  "capture.count": "{count} screenshots",
} as const;

export type MessageKey = keyof typeof en;
export type Catalog = Record<MessageKey, string>;

Cada chamada t("settings.language") agora é verificada pelo compilador. Um erro de digitação ou uma chave removida do inglês faz o build falhar. Os tradutores trabalham nos outros catálogos, mas ninguém consegue adicionar uma chave que o inglês não tem, então o arquivo em inglês sempre lista tudo o que a interface usa.

Chaves planas com pontos mantêm o tipo simples e tornam os diffs fáceis de ler. Objetos aninhados também funcionam, mas exigem tipos recursivos e dificultam o rastreamento de chaves ausentes.

Quem edita cada arquivo

Engenheiros editam en.ts. Tradutores editam um arquivo de locale cada um e não alteram en.ts. Um pull request que adiciona uma chave apenas em en.ts ainda passa no compilador, porque os outros catálogos são tipados como parciais. O teste de completude então lista as chaves ausentes em cada locale, o que dá à equipe um único lugar para ver as traduções pendentes de uma release.

Adicionando uma chave

Uma nova chave afeta quatro lugares. A ordem abaixo mantém o compilador útil desde o primeiro passo.

Cinco passos numerados para adicionar uma chave de mensagem: adicionar a chave e o texto em inglês, executar o compilador, adicionar as traduções, executar os testes de completude e de placeholders, e verificar o layout em cada locale.

O último passo é o que as equipes costumam pular. Textos em alemão e finlandês costumam ser mais longos que em inglês, então um rótulo de botão que cabe em inglês pode quebrar linha ou ser cortado. O compilador e os testes não detectam isso, então uma verificação visual das strings mais longas continua necessária.

Organização da pasta de catálogos

A pasta tem um arquivo por locale e os auxiliares que os leem. Uma estrutura plana mantém os imports curtos e deixa claro qual arquivo um tradutor edita.

Árvore de arquivos da pasta i18n: um arquivo de catálogo para inglês, alemão, italiano, japonês, coreano e chinês simplificado, além dos arquivos auxiliares index, resolve e format.

Somente en.ts exporta o objeto as const. Os outros arquivos de catálogo importam Partial<Catalog> de index.ts, então um arquivo de tradução nunca pode definir uma chave que o inglês não tem.

Nomeando chaves

Use como prefixo a tela ou a área que usa a chave. settings.language e capture.count mostram onde a string está sem abrir o código, e uma busca por settings. lista todas as strings daquela tela. Evite chaves que são frases em inglês. Uma chave como "Language" faz com que uma reformulação posterior altere a chave e também o texto.

Lista de faça e não faça para chaves de mensagem: nomear as chaves por tela e elemento, uma frase por chave e placeholders para os valores; não montar chaves no código e não usar frases em inglês como chave.

Uma chave por significado

A chave segue o significado de uma frase, e não o número de lugares que a exibem. O app de exemplo mostra a contagem de capturas no cabeçalho de resultados, na barra de status e no diálogo de exportação. Os três usam capture.count, com {count} preenchido por quem chama. Assim, um tradutor corrige a redação uma única vez para os três lugares. Uma segunda chave com o mesmo texto em inglês se afastaria com o tempo.

4. Mantenha o fallback em tempo de execução e a verificação de completude

Outros catálogos costumam chegar em JSON de uma ferramenta de tradução, então o compilador não consegue garantir o conteúdo deles. Tipe-os como Partial<Catalog> e use o inglês como fallback por chave em tempo de execução:

let current: Partial<Catalog> = {};

export function setCatalog(next: Partial<Catalog>) {
  current = next;
}

export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
  const template = current[key] ?? en[key];
  return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? `{${name}}`));
}

O fallback mantém uma release utilizável quando uma string atrasa. Ele também esconde a lacuna, então adicione um teste que falhe quando um locale distribuído estiver incompleto. Os dois são necessários: o fallback protege o usuário, e o teste protege a equipe de distribuir fallbacks sem perceber. A seção de testes abaixo mostra essa verificação.

Como fica uma string ausente

O exemplo abaixo usa um catálogo em alemão que foi exportado antes de o tradutor terminar a tela de configurações. Os números vêm do exemplo, e não de um build distribuído.

Quatro estatísticas do exemplo de fallback: 3 chaves ausentes em de.ts no corte da release, 3 chaves exibidas em inglês em tempo de execução, 0 chaves exibidas com o nome bruto, já que o inglês sempre tem a chave, e 1 teste falhando que bloqueia a release.

O usuário vê inglês em três rótulos e alemão no restante. Uma chave bruta como settings.language aparecendo na tela seria um resultado pior, e o fallback impede isso porque o inglês sempre define todas as chaves.

Variáveis ausentes continuam visíveis

A função t mantém {name} na saída quando vars não tem valor para ela. O app continua funcionando, e quem revisa vê o placeholder não preenchido em uma captura de tela ou em uma falha de teste. O placeholder é mais fácil de encontrar do que uma string vazia silenciosa, e não impede o restante da tela de ser renderizado.

Teste o caminho do fallback

Um fallback que nunca é executado em um teste pode quebrar sem que ninguém perceba. Um teste curto com um catálogo vazio cobre esse caminho:

it("falls back to English for a missing key", () => {
  setCatalog({});
  expect(t("settings.language")).toBe("Language");
});

Chame setCatalog com o catálogo real nos outros testes, para que cada teste comece de um estado conhecido.

O teste de completude deve ler a lista de locales de SUPPORTED, e não os arquivos no disco. Um arquivo presente mas não listado não pode ser distribuído por acidente, e um locale listado sem arquivo falha o teste imediatamente.

O fallback funciona por chave

O fallback se aplica a cada chave, e não a uma tela inteira. Um catálogo em alemão com 40 de 42 chaves de configurações traduzidas mostra inglês nos dois rótulos ausentes e alemão nos outros 40. Um fallback por tela mostraria 40 strings prontas em inglês por causa de duas ausentes, um resultado pior para o usuário.

5. Formate datas e números fora do catálogo de mensagens

Catálogos devem conter frases, não regras de formatação. Datas, números, tamanhos de arquivo e plurais vêm do Intl, usando o locale resolvido:

const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
const number = new Intl.NumberFormat(locale);
const plural = new Intl.PluralRules(locale);

Uma string do catálogo então contém um placeholder, como {date} ou {count}, e quem chama formata o valor antes de passá-lo. Isso mantém os tradutores longe dos padrões de data. Um usuário de inglês dos EUA vê Mar 2, 2026, e um usuário alemão vê 02.03.2026, sem que ninguém escreva nenhum dos dois padrões. As formas de plural variam em quantidade conforme o idioma, então escolha a variante da mensagem com Intl.PluralRules em vez de count === 1.

As categorias de plural mudam por idioma

Intl.PluralRules retorna uma de até seis categorias: zero, one, two, few, many e other. O inglês usa duas delas, e o russo usa quatro. Uma verificação count === 1 cobre o inglês e falha no russo.

Comparação de escolhas de plural: o inglês usa one para a contagem 1 e other para 2 e 5, enquanto o russo usa one para 1, few para 2 e many para 5.

O catálogo guarda uma variante por categoria que o locale usa. O auxiliar lê plural.select(count) e busca a chave correspondente, como capture.count.one ou capture.count.few. Uma categoria ausente usa other como fallback, o que mantém um plural parcialmente traduzido utilizável.

Unidades pertencem ao valor

Intl.NumberFormat cuida do agrupamento e dos separadores decimais. Assim, 1.2 MB em inglês e 1,2 MB em alemão saem do mesmo caminho de código. A unidade faz parte do valor formatado, e não do catálogo. Uma entrada como "{size} saved" recebe o tamanho formatado de quem chama, e o tradutor altera apenas a frase ao redor do valor.

A saída exata de datas depende do runtime

A saída de Intl.DateTimeFormat segue os dados ICU do runtime, então a string exata pode mudar entre versões do Electron. Um teste que fixa "Mar 2, 2026, 9:05 AM" falha após uma atualização por um motivo não relacionado ao app. Compare a saída com o resultado da mesma chamada Intl dentro do teste, e fixe o fuso horário para que o teste passe em qualquer máquina.

6. Sincronize a escolha do renderer com o processo main

A tela de configurações fica no renderer, mas menus nativos, diálogos, a bandeja e as notificações são criados no processo main. Faça do processo main o único responsável pela preferência:

// preload.ts
contextBridge.exposeInMainWorld("i18n", {
  setPreference: (pref: LanguagePreference) => ipcRenderer.invoke("i18n:set", pref),
  onChange: (cb: (locale: Locale) => void) =>
    ipcRenderer.on("i18n:changed", (_e, locale: Locale) => cb(locale)),
});

// main.ts
ipcMain.handle("i18n:set", (_e, pref: unknown) => {
  if (!isPreference(pref)) throw new Error("invalid language preference");
  store.set("language", pref);
  const locale = resolveLocale(pref, app.getPreferredSystemLanguages());
  rebuildMenu(locale);
  for (const win of BrowserWindow.getAllWindows()) win.webContents.send("i18n:changed", locale);
  return locale;
});

O renderer pede, o processo main decide, persiste e transmite. Toda janela, inclusive as abertas depois, lê o mesmo locale resolvido. O handler valida o valor recebido, já que a entrada de IPC não é confiável.

Salvar a escolha no localStorage do renderer é um atalho comum, porque a tela de configurações roda ali. O valor então fica no armazenamento de uma única janela, e o processo main não consegue lê-lo antes de uma janela abrir. O menu e a bandeja são criados antes da primeira janela, então precisam do valor do armazenamento do processo main.

O caminho de uma troca de idioma

Uma única troca atravessa os dois processos. A sequência abaixo mostra qual lado é responsável por cada passo.

Fluxo de cinco caixas: o seletor do renderer chama setPreference, o preload encaminha o valor no canal i18n:set, o main valida e salva o valor, o main resolve o locale e reconstrói o menu nativo, e o main envia i18n:changed a todas as janelas.

O renderer não resolve "system" por conta própria. Se cada processo resolvesse a preferência separadamente, uma janela poderia exibir um idioma diferente do menu depois que o idioma do sistema operacional mudasse. Um único resolvedor no processo main mantém os dois idênticos.

Valide o valor no handler

O script de preload tipa o argumento, mas um renderer pode enviar qualquer valor por ipcRenderer.invoke. O processo main verifica o valor contra a mesma lista da qual o tipo é derivado:

const isPreference = (value: unknown): value is LanguagePreference =>
  value === "system" || (typeof value === "string" && (SUPPORTED as readonly string[]).includes(value));

Um valor rejeitado lança um erro, e o store não é gravado. Apenas valores das duas listas podem chegar a store.set.

Janelas abertas após a troca

Uma janela criada depois da troca não precisa esperar uma transmissão. Ela pede o valor ao processo main ao carregar:

ipcMain.handle("i18n:get", () =>
  resolveLocale(store.get("language", "system") as LanguagePreference, app.getPreferredSystemLanguages()),
);

O renderer chama i18n:get uma vez na inicialização e usa i18n:changed depois disso. Os dois caminhos chamam o mesmo resolvedor, então uma janela nova e uma antiga não podem divergir.

Janelas fechadas durante a troca

Uma janela fechada antes da transmissão não está mais em BrowserWindow.getAllWindows(), então o laço a ignora. Uma janela que recarrega chama i18n:get de novo e recebe o valor atual. A transmissão é de melhor esforço, e o caminho de carregamento traz o valor correto.

Testes que protegem as fronteiras

  • Tabela do resolvedor. Entradas como ["de-CH", "en-US"] (alemão), ["zh-TW", "en"] (inglês) e [] (inglês) são mapeadas para os locales esperados.
  • Completude. Para cada locale distribuído, toda chave de en existe e não está vazia.
  • Paridade de placeholders. Cada tradução usa os mesmos placeholders do inglês, então {count} nunca é removido nem renomeado.
  • Formatação. A saída de datas é verificada com um fuso horário fixo, então os testes passam em qualquer máquina.
  • Handler de IPC. Uma preferência inválida é rejeitada, e uma válida dispara a reconstrução do menu e a transmissão.
for (const [locale, catalog] of Object.entries(catalogs)) {
  for (const key of Object.keys(en) as MessageKey[]) {
    expect(catalog[key], `${locale}: ${key}`).toBeTruthy();
  }
}

Cada teste cobre uma fronteira. A tabela abaixo lista o que uma falha indica.

TesteUma falha significa
Tabela do resolvedorA ordem das verificações ou a tabela de aliases mudou
CompletudeUm catálogo distribuído não tem uma chave que o inglês define
Paridade de placeholdersUma tradução renomeou ou removeu uma variável
FormataçãoA saída de data ou número depende da máquina, não do locale
Handler de IPCUma entrada inválida chegou ao store, ou uma janela não foi notificada

Execute a suíte a cada alteração em um catálogo, e não apenas antes de uma release. Uma tradução que renomeia um placeholder é mais barata de corrigir no pull request que a introduziu. O corte da release é o lugar mais caro para encontrá-la.

Uma camada pequena de i18n ainda precisa de fronteiras claras

Cada decisão traça uma linha: intenção versus locale resolvido, nomes versus rótulos traduzidos, inglês como contrato, fallback versus completude, frases versus formatação, e renderer versus processo main. Mantenha essas linhas, e uma camada escrita à mão com algumas centenas de linhas continua mais fácil de entender do que um framework configurado para fazer o mesmo.

Um framework passa a valer o custo quando os catálogos precisam de recursos que a camada não tem, como extração de mensagens do código-fonte, ferramentas para tradutores que acompanham o status de revisão, ou carregamento em tempo de execução de catálogos distribuídos separadamente do app. Até que uma dessas necessidades exista, as seis decisões acima cobrem as falhas mais comuns.

As mesmas fronteiras valem para outras superfícies que exibem texto, como uma web view ou uma folha de configurações. Cada uma lê o locale resolvido do processo main, usa o mesmo tipo de catálogo e passa no mesmo teste de completude. Uma nova superfície então não precisa de novas regras, apenas de uma nova chamada a t.

O Temposhot, o app Mac que este site documenta, é um app nativo para macOS construído com Xcode, e não um app Electron. Sua configuração de Language oferece 7 opções: System default mais seis idiomas, exibidos pelos próprios nomes.

Transforme seus cliques em um guia passo a passo claro

Para documentar uma alteração de configuração como a descrita acima, grave-a com Action Capture. O Temposhot é gratuito e pode ser baixado para Macs com Apple Silicon e macOS 14 ou posterior.

electroni18ntypescriptengineering

Transforme capturas de tela repetitivas em trabalho pronto

O Temposhot faz capturas por temporizador ou a cada clique e exporta o resultado em PDF, imagens, ZIP ou MP4.

Posts relacionados