i18n

tauri-plugin-i18n-vsk#

Lee los catálogos de idioma de una carpeta y responde el texto de una clave en el idioma activo. Un composable de Vue mantiene la interfaz al día y cambia de idioma sin recargar la ventana.

  • Crate: tauri-plugin-i18n-vsk
  • Paquete JS: @vasakgroup/tauri-plugin-i18n
  • Plataforma: cualquiera (Linux en las aplicaciones de VasakOS)

El crate se publica como tauri-plugin-i18n-vsk —el sufijo evita el nombre ya tomado en crates.io—, pero el plugin se llama i18n: de ahí salen los permisos (i18n:…) y los comandos que la interfaz invoca (plugin:i18n|…).

Instalación#

1
2
3
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-i18n-vsk = "2.3.1"
1
bun add @vasakgroup/tauri-plugin-i18n
1
2
3
4
5
6
7
// src-tauri/src/lib.rs
fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_i18n_vsk::init(None))
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

init recibe el idioma inicial (Option<String>); con None arranca en en. El plugin no lee el idioma de la sesión: eso lo decide la aplicación. Las de VasakOS miran LC_ALL, LC_MESSAGES y LANG, toman la primera parte (es_AR.UTF-8 → es) y pasan es como idioma inicial, con en como única excepción.

Con init_with_path(idioma, Some(ruta)) se indica la carpeta de traducciones:

1
2
3
4
.plugin(tauri_plugin_i18n_vsk::init_with_path(
    Some("es".to_string()),
    Some("/usr/share/mi-app/locales".to_string()),
))

Sin ruta, el plugin la busca solo: locales/ y src-tauri/locales/ bajo el directorio de trabajo (sólo si el binario no está instalado), ../locales y ../../locales respecto del ejecutable, /usr/share/<nombre-del-binario>/locales —el caso del binario instalado, que es el que antes fallaba— y la carpeta Bundle/Resources/locales de macOS. Se queda con la primera que exista.

Catálogos de idioma#

Un archivo por idioma, nombrado con el idioma (es.yml, en.yml, pt-BR.json); el idioma se lee del último punto del nombre, así que es.yml es es. Se aceptan .yml, .yaml, .json y .toml.

El formato habitual es un catálogo anidado, todo de un idioma:

1
2
3
4
5
# es.yml
vistas:
  inicio:
    titulo: Inicio
    subtitulo: Todo lo que tienes a mano

Y las claves se piden aplanadas con puntos: vistas.inicio.titulo.

El otro formato reparte los idiomas dentro del mismo archivo y se declara con _version: 2. En ese caso el nombre del archivo no importa:

1
2
3
4
5
# strings.yml
_version: 2
vistas.inicio.titulo:
  es: Inicio
  en: Home

Las claves que falten en el disco siguen viniendo de las traducciones empaquetadas dentro del binario: el cargador del plugin trae un bundled_locales.rs vacío por omisión, y cada aplicación puede reemplazarlo por el suyo antes de compilar. En las aplicaciones de VasakOS los textos vienen del disco, y el catálogo del disco gana clave por clave, así que se puede corregir una cadena en /usr/share sin recompilar.

Permisos#

i18n:default, en el archivo de capacidades:

1
2
3
4
// src-tauri/capabilities/default.json
{
  "permissions": ["i18n:default"]
}

El permiso por omisión habilita los cinco comandos. También existen por separado: i18n:allow-load-translations, i18n:allow-translate, i18n:allow-get-locale, i18n:allow-set-locale, i18n:allow-get-available-locales y sus deny- correspondientes.

Una aplicación que traduce en JavaScript —el uso normal— necesita cuatro: i18n:allow-load-translations, i18n:allow-get-locale, i18n:allow-set-locale y, sólo si llama a I18n.getAvailableLocales(), i18n:allow-get-available-locales. i18n:allow-translate es para quien pregunta el texto al backend en vez de traducirlo en el WebView.

API de JavaScript#

useI18n()#

El composable de Vue. Devuelve el estado y las funciones que usan los componentes:

  • t(clave): string — El texto de la clave en el idioma activo. Si la clave no está, devuelve la clave tal cual (views.home.title), que es lo que hace que alguien la agregue al catálogo.
  • locale — El idioma activo, reactivo.
  • setLocale(idioma): Promise<void> — Cambia el idioma.
  • availableLocales — Los idiomas con catálogo cargado.
  • isLoaded — Si los catálogos ya llegaron.
  • reload(): Promise<void> — Vuelve a leer los catálogos del disco.

Se puede llamar desde fuera de un componente (main.ts, antes de montar), que es el uso recomendado.

I18n.getInstance(): I18n#

La clase que guarda los catálogos y el idioma, en una sola instancia por ventana. Es el único lugar donde vive ese estado: el composable lo refleja, no lo copia.

load(): Promise<void>#

Carga los catálogos y se suscribe al cambio de idioma. Se puede llamar todas las veces que haga falta: la primera hace el trabajo y las demás esperan a esa misma.

reload(): Promise<void>#

Vuelve a pedir los catálogos aunque ya estén cargados. Sirve cuando los archivos de idioma cambiaron en el disco; no vuelve a suscribirse al cambio de idioma.

translate(clave): string#

El texto de la clave, leído del estado local —sin ir al backend—. Igual que t(), devuelve la clave si no la encuentra.

bind(elemento, clave): void#

Escribe el texto en un elemento del DOM y lo deja anotado para volver a escribirlo cuando cambie el idioma.

subscribe(oyente): () => void#

Avisa cuando cambian los catálogos o el idioma. Devuelve cómo darse de baja; es lo que usa el composable para reflejar este estado.

destroy(): void#

Cancela la suscripción al cambio de idioma y olvida la carga, de modo que un load() posterior la vuelva a hacer. Los oyentes registrados con subscribe() se conservan.

I18n.setLocale(idioma): Promise<void>#

Cambia el idioma y avisa a la interfaz, incluso antes de la primera carga. Un idioma sin catálogo deja las claves crudas.

I18n.getLocale(): Promise<string>#

El idioma activo, preguntado al backend.

I18n.getAvailableLocales(): Promise<string[]>#

Todos los idiomas con catálogo.

Comandos Rust#

  • load_translations() — Todos los catálogos, como { idioma: { clave: texto } }.
  • translate(key) — El texto de la clave en el idioma activo; None si no está.
  • set_locale(locale) — Cambia el idioma y emite el aviso al frontend.
  • get_locale() — El idioma activo.
  • get_available_locales() — Los idiomas con catálogo.

Eventos#

EventoCuándo se emiteCarga
i18n:locale_changedCambia el idioma de la aplicaciónEl idioma nuevo (string)
1
2
3
4
5
import { listen } from "@tauri-apps/api/event";

await listen<string>("i18n:locale_changed", (event) => {
  console.log("idioma nuevo:", event.payload);
});

El evento llega a todas las ventanas: un cambio de idioma pedido en una se ve en las demás. La clase I18n ya lo escucha, así que una interfaz que use useI18n() o subscribe() no necesita suscribirse por su cuenta.

Ejemplo#

Conviene cargar los catálogos antes de montar la aplicación, para que la primera pantalla no muestre las claves crudas:

1
2
3
4
5
6
7
8
9
// src/main.ts
import I18n from "@vasakgroup/tauri-plugin-i18n";
import { createApp } from "vue";
import App from "./App.vue";

const app = createApp(App);

await I18n.getInstance().load();
app.mount("#app");
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<script setup lang="ts">
import { useI18n } from "@vasakgroup/tauri-plugin-i18n";

const { t, locale, setLocale, availableLocales } = useI18n();
</script>

<template>
  <p>{{ t("vistas.inicio.titulo") }}</p>
  <button
    v-for="idioma in availableLocales"
    :key="idioma"
    :disabled="idioma === locale"
    @click="setLocale(idioma)"
  >
    {{ idioma }}
  </button>
</template>

Detalles de implementación#

  • Un solo estado. La clase I18n es un singleton y el composable se suscribe a ella. Cargar antes de montar deja los textos puestos para todos los componentes.
  • Una sola carga. Una aplicación con cincuenta componentes que usan useI18n() hace una sola llamada al backend y una sola suscripción al evento de cambio de idioma, no cincuenta de cada una.
  • Una sola suscripción, reintentable. Si suscribirse al cambio de idioma falla, no queda marcado como hecho: la carga siguiente lo vuelve a intentar.
  • Manda la última carga pedida, no la última que contesta: un reload() encima de una carga lenta no deja los catálogos viejos, y un cambio de idioma en medio de una carga no se deshace.
  • Un catálogo roto no tumba nada. Un archivo ilegible, que no sea UTF-8 o mal formado se saltea y el resto se carga igual; la aplicación abre con las claves a la vista en lugar de quedarse sin ventana.
  • Los arreglos no se guardan como texto: la clave falta y se ve cruda, en lugar de aparecer un hueco. Un valor null es una cadena vacía a propósito, y los números y los booleanos se muestran como texto.
  • Si no se encuentra ninguna carpeta de catálogos, la aplicación abre igual y las claves se ven crudas; el motivo se escribe en stderr, con las rutas que se probaron.
  • Un binario en /usr, /opt o /snap se considera instalado: no mira las rutas relativas al directorio de trabajo, que es de quien lo lanzó y no de la aplicación. Sin esa guarda, una aplicación abierta desde el menú cargaba los catálogos de otra y mostraba toda la interfaz en crudo.
  • Con RUST_I18N_DEBUG=1 el cargador imprime en la salida de cargo qué rutas y qué archivos leyó.
  • El comando set_locale emite el aviso y el estado del plugin lo emite otra vez, así que un solo cambio de idioma dispara el evento dos veces. Recibir el mismo idioma dos veces no cambia nada.