Saltar al contenido

Seis decisiones que mantienen manejable una pequeña capa de i18n en Electron

Un diseño práctico de i18n para Electron con preferencias de idioma, catálogos tipados, comportamiento de respaldo, formato con Intl y sincronización entre el renderer y el proceso principal.

13 min de lecturaGuíasPor Temposhot Team

Una app de escritorio con entre cinco y diez idiomas rara vez necesita un gran framework de i18n. Sí necesita unas pocas reglas claras, porque una app de Electron tiene dos procesos que muestran texto: el renderer dibuja la interfaz, y el proceso principal gestiona los menús, los diálogos y las notificaciones. Las seis decisiones siguientes mantienen correcta una capa de i18n pequeña y escrita a mano a medida que crecen la app y sus catálogos.

1. Guarda una preferencia, no solo un idioma

Guardar "de" cuando el usuario elige alemán parece suficiente hasta que alguien quiere que la app siga el idioma del sistema operativo. Guarda en su lugar la intención del usuario, y resuelve el idioma real en tiempo de ejecución:

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";
}

En el proceso principal, app.getPreferredSystemLanguages() devuelve la lista ordenada que facilita el sistema operativo. Con "system" guardado, un usuario que cambia el idioma del sistema recibe el nuevo idioma en el siguiente inicio, sin tocar los ajustes de la app.

El resolver recorre toda la lista de preferencias, no solo la primera entrada. Un usuario con "portugués, después inglés" recibe inglés porque es su segunda opción, no porque sea el valor predeterminado. La coincidencia por prefijo asigna de-CH al alemán y zh-Hans-CN al chino simplificado. zh-TW no coincide con ninguno, así que pasa a la siguiente preferencia en lugar de convertirse en silencio en chino simplificado.

Orden de resolución

La función devuelve el primer resultado que coincide. El orden de las comprobaciones importa, porque un idioma guardado nunca debe quedar anulado por la lista del sistema.

Cinco pasos numerados para resolver un idioma: devolver directamente el idioma guardado, leer la lista del sistema en orden, aceptar coincidencias exactas o por prefijo, comprobar la tabla de alias y usar inglés como respaldo.

Cuando dos entradas de la lista del sistema están soportadas, gana la primera. macOS mantiene la lista en el orden que el usuario configuró en Idioma y región, así que la primera entrada soportada es la primera opción soportada del usuario.

Tres preferencias guardadas en la práctica

La tabla siguiente muestra cómo trata la misma función a tres usuarios. Cada fila es una llamada a resolveLocale con un valor guardado y una lista del sistema operativo.

Comparación de tres usuarios: una preferencia del sistema con fr-FR, de-DE y en-US se resuelve en alemán, una preferencia guardada en japonés se mantiene en japonés y una preferencia del sistema con zh-TW y en-GB se resuelve en inglés.

El usuario 1 habla francés en primer lugar, pero la app no incluye francés. El bucle omite fr-FR, coincide con de-DE en la segunda entrada y devuelve alemán. El usuario 3 recibe inglés por otro motivo: zh-TW no es un alias y no coincide por prefijo con el chino simplificado, así que el bucle llega a en-GB y devuelve inglés. El usuario 2 nunca lee la lista del sistema, porque el valor guardado es un idioma y no "system".

Valores guardados tras eliminar un idioma

Una versión posterior puede eliminar un idioma. El valor guardado deja entonces de aparecer en SUPPORTED, y la búsqueda en el catálogo no encuentra ningún archivo coincidente. Convierte los valores guardados desconocidos en "system" al cargar los ajustes, y escribe el valor corregido una sola vez, para que el archivo guardado coincida con el código. El usuario conserva un idioma que funciona en el primer inicio tras la actualización.

2. Mantén legibles las opciones de idioma tras una selección equivocada

Si un usuario elige japonés por error y todos los nombres de idioma aparecen ahora en japonés, es difícil volver a encontrar "English". Muestra cada idioma en su propio idioma (su endónimo), para que la lista se lea igual con independencia del idioma actual de la interfaz:

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

Intl.DisplayNames puede generar estos nombres, pero su salida para las variantes de escritura cambia entre motores y versiones. Una tabla fija de seis cadenas es más fácil de revisar. Solo se traduce la opción "System default", porque es una frase y no un nombre de idioma.

La misma regla en Temposhot

La app de Mac Temposhot usa el mismo patrón de endónimos para su menú de idioma. El menú está en Ajustes, en la pestaña General, y tiene siete entradas. La guía indica que cada nombre de idioma se escribe en ese idioma.

Panel de ajustes del menú de idioma de Temposhot: ubicación en Settings General, siete entradas con System default primero, etiquetas con endónimos, una elección que se conserva tras reiniciar y textos de interfaz de seis idiomas que todavía están en curso.

La última fila importa para quien lee esta publicación. La app guarda la elección y cambia el idioma en ese momento. Parte del texto de la interfaz sigue apareciendo en inglés tras un cambio, hasta que se publiquen las traducciones de los otros seis idiomas.

Orden del menú

El menú sigue el orden de SUPPORTED, con el inglés primero. Un orden fijo es fácil de probar, y no cambia cuando una persona traductora edita un nombre. Ordenar por endónimo colocaría las entradas en un orden que depende de las reglas de ordenación de cada sistema de escritura, lo que es más difícil de revisar en una solicitud de cambios.

Añadir un idioma significa añadirlo al final de SUPPORTED. El tipo Record<Locale, string> de LANGUAGE_NAMES hace fallar entonces la compilación hasta que la nueva entrada tenga un nombre, así que un idioma no puede aparecer en el resolver sin una etiqueta visible en el menú.

Revisa el menú antes de una versión

Un menú de idiomas falla de formas pequeñas que una prueba unitaria no ve: un nombre mostrado con la escritura equivocada, una entrada que falta o una entrada que no se conserva. Una revisión manual breve antes de cada versión los detecta.

Lista de seis elementos para el menú de idioma: endónimos en cada escritura, System default como primera entrada, seis entradas de idioma, etiquetas que se actualizan al cambiar, la elección que sobrevive a un reinicio y chino solo como chino simplificado.

Ejecuta la revisión en un Mac cuyo idioma del sistema no sea el inglés. Una revisión en un sistema en inglés solo demuestra que funciona la ruta del inglés.

3. Usa el catálogo en inglés como contrato de TypeScript

Convierte el inglés en la fuente de verdad y deriva el tipo a partir de él:

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 llamada a t("settings.language") la comprueba ahora el compilador. Una errata o una clave eliminada del inglés hace fallar la compilación. Las personas traductoras trabajan en los otros catálogos, pero nadie puede añadir una clave que el inglés no tenga, así que el archivo en inglés siempre enumera todo lo que usa la interfaz.

Las claves planas con puntos mantienen el tipo sencillo y hacen que los diffs se lean con facilidad. Los objetos anidados también funcionan, pero necesitan tipos recursivos y hacen más difícil seguir los informes de claves que faltan.

Quién edita cada archivo

Las personas ingenieras editan en.ts. Las personas traductoras editan un archivo por idioma, y no modifican en.ts. Una solicitud de cambios que añade una clave solo a en.ts sigue pasando el compilador, porque los otros catálogos están tipados como parciales. La prueba de completitud enumera entonces las claves que faltan en cada idioma, lo que da al equipo un único lugar donde ver las traducciones pendientes para una versión.

Añadir una clave

Una clave nueva afecta a cuatro lugares. El orden siguiente mantiene útil el compilador desde el primer paso.

Cinco pasos numerados para añadir una clave de mensaje: añadir la clave y el texto en inglés, ejecutar el compilador, añadir las traducciones, ejecutar las pruebas de completitud y de marcadores, y revisar la maquetación en cada idioma.

El último paso es el que los equipos suelen saltarse. Las cadenas en alemán y en finlandés suelen ser más largas que en inglés, así que una etiqueta de botón que cabe en inglés puede ajustarse en varias líneas o cortarse. El compilador y las pruebas no lo ven, así que sigue siendo necesaria una revisión visual de las cadenas más largas.

Estructura de la carpeta de catálogos

La carpeta contiene un archivo por idioma, además de los auxiliares que los leen. Una estructura plana mantiene los imports cortos y deja claro qué archivo edita cada persona traductora.

Árbol de archivos de la carpeta i18n: un archivo de catálogo para inglés, alemán, italiano, japonés, coreano y chino simplificado, más los archivos auxiliares index, resolve y format.

Solo en.ts exporta el objeto as const. Los demás archivos de catálogo importan Partial<Catalog> desde index.ts, así que un archivo de traducción nunca puede definir una clave que el inglés no tenga.

Nombrar las claves

Antepón a cada clave la pantalla o el área que la usa. settings.language y capture.count indican dónde vive una cadena sin abrir el código, y una búsqueda de settings. enumera todas las cadenas de esa pantalla. Evita las claves que son frases en inglés. Una clave como "Language" hace que una reformulación posterior cambie la clave además del texto.

Lista de hacer y no hacer para las claves de mensaje: nombrar las claves por pantalla y elemento, usar una frase completa por clave y marcadores para los valores; no unir claves en el código y no usar texto en inglés como clave.

Una clave por significado

Una clave sigue el significado de una frase, no el número de lugares que la muestran. La app de ejemplo muestra un recuento de capturas en la cabecera de resultados, en la barra de estado y en el cuadro de exportación. Las tres usan capture.count, con {count} rellenado por quien llama. Así, una persona traductora corrige la redacción una sola vez para los tres lugares, y una segunda clave con el mismo texto en inglés se separaría con el tiempo.

4. Mantén el respaldo en tiempo de ejecución y la comprobación de completitud

Los otros catálogos llegan a menudo como JSON desde una herramienta de traducción, así que el compilador no puede garantizarlos. Tipálos como Partial<Catalog> y recurre al inglés clave a clave en tiempo de ejecución:

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}}`));
}

El respaldo mantiene utilizable una versión cuando una cadena llega tarde. También oculta el hueco, así que añade una prueba que falle cuando un idioma publicado esté incompleto. Hacen falta las dos cosas: el respaldo protege a las personas usuarias, y la prueba protege al equipo de publicar respaldos por descuido. La sección de pruebas más abajo muestra la comprobación.

Cómo se ve una cadena que falta

El ejemplo siguiente usa un catálogo en alemán que se cortó antes de que la persona traductora terminara la pantalla de ajustes. Las cifras son del ejemplo, no de una versión publicada.

Cuatro cifras del ejemplo de respaldo: 3 claves que faltan en de.ts en el corte de la versión, 3 de esas claves se muestran en inglés en tiempo de ejecución, 0 claves se muestran como nombres sin traducir y 1 prueba fallida que bloquea la versión.

La persona usuaria ve inglés en tres etiquetas y alemán en el resto. Una clave sin traducir, como settings.language, en pantalla sería un resultado peor, y el respaldo lo evita porque el inglés siempre define todas las claves.

Las variables que faltan siguen visibles

La función t deja {name} en la salida cuando vars no tiene un valor para él. La app sigue funcionando, y quien revisa ve el marcador sin rellenar en una captura o en un fallo de prueba. Ese marcador es más fácil de encontrar que una cadena vacía silenciosa, y no impide que el resto de la pantalla se muestre.

Prueba la ruta del respaldo

Un respaldo que nunca se ejecuta en una prueba puede romperse sin que nadie lo note. Una prueba corta con un catálogo vacío cubre esa ruta:

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

Llama a setCatalog con el catálogo real en las demás pruebas, para que cada prueba empiece desde un estado conocido.

La prueba de completitud debe leer la lista de idiomas desde SUPPORTED, no desde los archivos del disco. Así, un archivo que existe pero no está en la lista no puede publicarse por accidente, y un idioma de la lista sin archivo falla la prueba de inmediato.

El respaldo funciona clave a clave

El respaldo se aplica a cada clave, no a una pantalla completa. Un catálogo alemán con 40 de 42 claves de ajustes traducidas muestra inglés en las dos etiquetas que faltan, y alemán en las otras 40. Un respaldo por pantalla mostraría 40 cadenas terminadas en inglés por culpa de dos que faltan, un resultado peor para la persona usuaria.

5. Formatea fechas y números fuera del catálogo de mensajes

Los catálogos deben contener frases, no reglas de formato. Las fechas, los números, los tamaños de archivo y los plurales vienen de Intl, usando el idioma resuelto:

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

Una cadena del catálogo contiene entonces un marcador, como {date} o {count}, y quien llama formatea el valor antes de pasarlo. Así, la persona traductora no toca los patrones de fecha, y una persona usuaria de inglés de EE. UU. ve "Mar 2, 2026" mientras que una usuaria de alemán ve "02.03.2026", sin que nadie escriba ninguno de los dos patrones. Las formas plurales varían en número según el idioma, así que elige la variante del mensaje con Intl.PluralRules en lugar de con count === 1.

Las categorías de plural cambian según el idioma

Intl.PluralRules devuelve una de hasta seis categorías: zero, one, two, few, many y other. El inglés usa dos de ellas, y el ruso usa cuatro. Una comprobación de count === 1 cubre el inglés y falla con el ruso.

Comparación de la elección de plural: el inglés usa one para el recuento 1 y other para 2 y 5, mientras que el ruso usa one para 1, few para 2 y many para 5.

El catálogo guarda una variante por cada categoría que usa el idioma. El auxiliar lee plural.select(count) y busca la clave correspondiente, como capture.count.one o capture.count.few. Una categoría que falta recurre a other, lo que mantiene utilizable un plural traducido solo en parte.

Las unidades pertenecen al valor

Intl.NumberFormat gestiona los separadores de miles y de decimales, así que 1.2 MB en inglés y 1,2 MB en alemán salen de la misma ruta de código. La unidad forma parte del valor formateado, no del catálogo. Una entrada como "{size} saved" recibe el tamaño ya formateado de quien llama, y la persona traductora cambia solo la frase que rodea al valor.

La salida exacta de las fechas depende del entorno de ejecución

La salida de Intl.DateTimeFormat sigue los datos ICU del entorno de ejecución, así que la cadena exacta puede cambiar entre versiones de Electron. Una prueba que fija "Mar 2, 2026, 9:05 AM" falla tras una actualización por un motivo ajeno a la app. Compara la salida con el resultado de la misma llamada a Intl dentro de la prueba, y fija la zona horaria para que la prueba pase en cualquier equipo.

6. Sincroniza la elección del renderer con el proceso principal

La pantalla de ajustes vive en el renderer, pero los menús nativos, los diálogos, la barra de menús y las notificaciones se crean en el proceso principal. Haz del proceso principal el único responsable de la preferencia:

// 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;
});

El renderer pide, el proceso principal decide, guarda y difunde. Cada ventana, incluidas las abiertas después, lee el mismo idioma resuelto. El manejador comprueba el valor recibido, porque la entrada de IPC no es de confianza.

Guardar la elección en el localStorage del renderer es un atajo habitual, porque la pantalla de ajustes se ejecuta allí. El valor queda entonces en el almacenamiento de una ventana, y el proceso principal no puede leerlo antes de abrir una ventana. El menú y la barra de menús se construyen antes de la primera ventana, así que necesitan el valor del almacén del proceso principal.

La ruta de un cambio de idioma

Un solo cambio atraviesa los dos procesos. La secuencia siguiente muestra qué lado es responsable de cada paso.

Flujo de cinco recuadros: el selector del renderer llama a setPreference, el preload reenvía el valor por el canal i18n:set, el proceso principal lo valida y lo guarda, el proceso principal resuelve el idioma y reconstruye el menú, y el proceso principal envía i18n:changed a cada ventana.

El renderer no resuelve "system" por su cuenta. Si cada proceso resolviera la preferencia por separado, una ventana podría mostrar un idioma distinto del menú tras cambiar el idioma del sistema operativo. Un solo resolver en el proceso principal mantiene los dos iguales.

Valida el valor en el manejador

El script de preload tipa el argumento, pero un renderer puede enviar cualquier valor a través de ipcRenderer.invoke. El proceso principal comprueba el valor contra la misma lista de la que procede el tipo:

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

Un valor rechazado lanza un error, y el almacén no se escribe. Solo los valores de las dos listas pueden llegar a store.set.

Ventanas abiertas tras el cambio

Una ventana creada después del cambio no debe esperar una difusión. Pregunta al proceso principal al cargarse:

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

El renderer llama a i18n:get una vez al arrancar y usa i18n:changed después. Las dos rutas llaman al mismo resolver, así que una ventana nueva y una antigua no pueden discrepar.

Ventanas cerradas durante un cambio

Una ventana que se cierra antes de la difusión ya no está en BrowserWindow.getAllWindows(), así que el bucle la omite. Una ventana que se recarga vuelve a llamar a i18n:get y recibe el valor actual. La difusión se hace con el mejor esfuerzo, y la ruta de carga lleva el valor correcto.

Pruebas que protegen los límites

  • Tabla del resolver. Entradas como ["de-CH", "en-US"] (alemán), ["zh-TW", "en"] (inglés) y [] (inglés) se asignan a los idiomas esperados.
  • Completitud. Para cada idioma publicado, cada clave de en existe y no está vacía.
  • Paridad de marcadores. Cada traducción usa los mismos marcadores que el inglés, así que {count} nunca se elimina ni se renombra.
  • Formato. La salida de las fechas se comprueba con una zona horaria fija, así que las pruebas pasan en cualquier equipo.
  • Manejador de IPC. Una preferencia no válida se rechaza, y una válida desencadena la reconstrucción del menú y una difusión.
for (const [locale, catalog] of Object.entries(catalogs)) {
  for (const key of Object.keys(en) as MessageKey[]) {
    expect(catalog[key], `${locale}: ${key}`).toBeTruthy();
  }
}

Cada prueba cubre un límite. La tabla siguiente indica qué señala un fallo de cada prueba.

PruebaUn fallo significa
Tabla del resolverCambiaron el orden de las comprobaciones o la tabla de alias
CompletitudUn catálogo publicado no tiene una clave que el inglés define
Paridad de marcadoresUna traducción renombró o eliminó una variable
FormatoLa salida de fechas o números depende del equipo, no del idioma
Manejador de IPCUna entrada no válida llegó al almacén, o una ventana no fue notificada

Ejecuta el conjunto de pruebas con cada cambio en un catálogo, no solo antes de una versión. Una traducción que renombra un marcador es lo más barato de corregir en la solicitud de cambios que la introdujo, y el corte de una versión es el lugar más caro para encontrarlo.

Una pequeña capa de i18n sigue necesitando límites claros

Cada decisión traza una línea: intención frente a idioma resuelto, nombres frente a etiquetas traducidas, el inglés como contrato, respaldo frente a completitud, frases frente a formato, y renderer frente a proceso principal. Si se mantienen esas líneas, una capa escrita a mano de unas pocas centenas de líneas sigue siendo más fácil de razonar que un framework configurado para hacer lo mismo.

Un framework merece su coste cuando los catálogos necesitan funciones que la capa no tiene, como la extracción de mensajes desde el código fuente, herramientas de traducción que siguen el estado de revisión, o la carga en tiempo de ejecución de catálogos que se distribuyen por separado de la app. Hasta que exista una de esas necesidades, las seis decisiones anteriores cubren los fallos habituales.

Los mismos límites se aplican a otras ventanas que muestran texto, como una vista web o una hoja de ajustes. Cada una lee el idioma resuelto desde el proceso principal, usa el mismo tipo de catálogo y supera la misma prueba de completitud. Una superficie nueva no necesita entonces reglas nuevas, solo una llamada nueva a t.

Temposhot, la app de Mac sobre la que trata este sitio, es una app nativa de macOS creada con Xcode, no una app de Electron. Su ajuste de idioma ofrece 7 opciones: System default y seis idiomas mostrados con sus propios nombres.

Convierte tus clics en una guía paso a paso clara

Para documentar un cambio de ajustes como el de arriba, grábalo con Action Capture. Temposhot se puede descargar gratis para Mac con Apple Silicon y macOS 14 o posterior.

electroni18ntypescriptengineering

Convierte capturas repetibles en trabajo terminado

Temposhot toma capturas con un temporizador o en cada clic y exporta el resultado en PDF, imágenes, ZIP o MP4.

Publicaciones relacionadas