En esta página

i18n

Un motor de internacionalización independiente del framework. Sigue el mismo diseño que el router: un núcleo pequeño (I18nCore) con un singleton global opcional (createI18n / useI18n) y sin acoplamiento al DOM, así que lo enlazas con la interfaz como prefieras.

Úsalo cuando necesites cambiar de idioma en tiempo de ejecución en una aplicación con ranui. Llama a createI18n una vez, luego lee las cadenas con useI18n().t(key, params) y cambia de idioma con setLocale. No depende de ningún framework ni del DOM, así que funciona en JS a secas, en cualquier framework y en SSR.

El motor se publica como su propia entrada ranui/i18n: importarlo no registra ningún elemento personalizado, así que una página que solo necesita traducir nunca arrastra la biblioteca de componentes. Las mismas exportaciones están también en el barril ranui de nivel superior.

Inicio rápido

Crea el singleton de i18n una vez al arrancar y traduce desde cualquier parte:

import { createI18n, useI18n } from 'ranui/i18n';

createI18n({
  // Cada idioma es un diccionario PLANO: las claves se buscan tal cual, no anidadas.
  messages: {
    en: { 'hero.title': 'Hi {name}', 'nav.home': 'Home' },
    zh: { 'hero.title': '你好 {name}', 'nav.home': '首页' },
  },
  fallbackLocale: 'en', // se usa cuando falta una clave en el idioma activo
  persist: true, // recuerda la elección bajo la clave de localStorage 'ran-locale'
  detectNavigator: true, // toma el idioma inicial de las preferencias del navegador
});

const i18n = useI18n();

i18n.t('hero.title', { name: 'Ada' }); // → "Hi Ada"
i18n.setLocale('zh'); // persiste y avisa a los suscriptores
i18n.t('hero.title', { name: 'Ada' }); // → "你好 Ada"

t(key) busca messages[activeLocale][key], después messages[fallbackLocale][key] y, si no hay ninguno, devuelve la propia key. Los marcadores {param} de la cadena se rellenan con el segundo argumento. Como la búsqueda es un acceso a un mapa plano, las claves son cadenas literales: escribe 'hero.title' como una sola clave, no como un objeto anidado { hero: { title } }.

Parámetros (interpolación)

Sí, los mensajes admiten parámetros en tiempo de ejecución. Pon marcadores del estilo {name} en la cadena y pasa los valores como segundo argumento de t(); cada {param} se sustituye por el valor que le corresponda:

createI18n({
  messages: {
    en: {
      'cart.summary': '{count} items · ${total}',
      greeting: 'Welcome back, {user}!',
    },
    zh: {
      'cart.summary': '{count} 件商品 · ¥{total}',
      greeting: '欢迎回来,{user}!',
    },
  },
});

const i18n = useI18n();
i18n.t('cart.summary', { count: 3, total: 59.9 }); // → "3 items · $59.9"
i18n.t('greeting', { user: 'Ada' }); // → "Welcome back, Ada!"

Detalles:

  • La sintaxis del marcador es {word} (letras, dígitos, _). Los valores pueden ser cadenas o números: los números se convierten a texto.
  • Un marcador sin clave que le corresponda se deja tal cual ({oops} se queda literalmente en la salida), lo que hace que un parámetro que falta salte a la vista en vez de quedar en blanco sin más.
  • La interpolación ocurre después del recurso al idioma de reserva, así que los mismos parámetros funcionan sea cual sea el idioma que acabó resolviendo la cadena.
  • No hay pluralización ni formato de números o fechas incorporados; compón eso con Intl.NumberFormat / Intl.PluralRules y pasa la cadena ya formateada como parámetro.

Escapar llaves literales

Una { o } suelta, o un grupo con espacios como { color: red }, no es un marcador y pasa intacto, así que el CSS, el JSON y los fragmentos de código dentro de un mensaje están a salvo por defecto. El único caso ambiguo es un {word} literal que quieras mostrar tal cual. Para escaparlo, duplica las llaves (la misma convención que format! de Rust, str.format de Python y String.Format de .NET):

const i18n = useI18n(); // se dan por registrados los mensajes de abajo

i18n.t('use {{ and }} for literal braces'); // → "use { and } for literal braces"
i18n.t('the {{count}} token'); // → "the {count} token"  (sin interpolar)
i18n.t('{{{name}}}', { name: 'Ada' }); // → "{Ada}"  (el valor entre llaves literales)
En el mensaje Salida
{{ {
}} }
{name} el parámetro name, o {name} si no está
{ name } { name } (con espacios → no es un marcador)
{ { (llave suelta)

El escapado se aplica en la misma pasada de izquierda a derecha que la interpolación y funciona pases parámetros o no, así que {{ y }} siempre significan llaves literales.

Duplicar es la misma convención de format! de Rust, str.format de Python y String.Format de .NET, así que no hace falta un carácter de escape nuevo. Si necesitas gramática real de plural, género o número, formatea con Intl.* y pasa el resultado como parámetro.

Reaccionar a los cambios de idioma

onChange se dispara después de cada setLocale; úsalo para volver a pintar las cadenas que ya dibujaste:

const i18n = useI18n();

const unsubscribe = i18n.onChange((locale) => {
  document.documentElement.lang = locale;
  repaintStrings(); // vuelve a ejecutar tus llamadas a t()
});

// más adelante, cuando la vista se desmonta
unsubscribe();

Añadir mensajes bajo demanda

Carga el diccionario de un idioma cuando haga falta (por ejemplo, dividiendo el código por idioma) y fusiónalo:

const i18n = useI18n();

const { default: fr } = await import('./locales/fr.js');
i18n.addMessages('fr', fr); // se fusiona con cualquier diccionario 'fr' existente
i18n.setLocale('fr');

Texto incorporado en los componentes

Las pocas cadenas que escribe el propio componente (los nombres accesibles de los controles del reproductor, los botones de copiar y descargar de r-markdown, r-math y r-mermaid, los mensajes de validación, el aviso de límite del medidor de tokens) siguen el idioma de quien lee. ranui usa el idioma de la instancia de i18n de la app si hay una registrada y, si no, el atributo lang más cercano (normalmente <html lang>). Trae estas cadenas en chino, japonés, coreano, español, portugués, alemán y persa; cualquier otro idioma ve el inglés.

Para cambiar una, define ranui.<key> en tu propio diccionario. Tiene prioridad sobre la tabla incorporada:

createI18n({
  messages: {
    en: { 'ranui.player.error': 'This video is unavailable' },
    zh: { 'ranui.player.error': '视频暂时无法播放' },
  },
});

Las claves de cada componente y su texto en inglés están en su propio components/<name>/text.ts del código fuente de ranui. Un atributo puesto en el elemento, como label o label-*, sigue ganando a ambos. El texto se resuelve al escribirse: cuando el elemento se conecta o cuando cambia el estado que describe. Por eso, tras cambiar de idioma en tiempo de ejecución, un elemento que ya está en la página conserva su texto hasta la próxima escritura; si necesitas el cambio al momento, vuelve a aplicar las etiquetas como se muestra más abajo.

Localizar el texto de los componentes

Toda cadena visible es también una entrada: un atributo, una propiedad, una opción o contenido en un slot. Lo que pases ahí gana al texto incorporado, así que la otra forma de localizar ranui es pasar la salida de t() allí donde la cadena ya va:

const i18n = useI18n(); // se dan por registrados los mensajes de abajo

modal.setAttribute('title', i18n.t('dialog.deleteProject.title'));
themeSwitch.setAttribute('label-dark', i18n.t('theme.dark'));

La mayoría de componentes no tienen texto propio: les llega por los slots y atributos que ya escribes. Estas son las cadenas que no tienen de dónde salir y las entradas que las sustituyen; la tabla muestra el texto en inglés:

Componente Inglés incorporado Se sobrescribe con
Modal.confirm / Modal.open título Confirm, botones OK / Cancel las opciones title, okText, cancelText
Modal.info / .success / .warning / .error títulos Info / Success / Warning / Error la opción title
<r-theme-switch> aria-labels Theme, System theme, Light theme, Dark theme label, label-system, label-light, label-dark
<r-voice-button> aria-labels Start voice input / Stop voice input; pistas Release to keep · slide up to cancel, Release to cancel label, active-label, hold-hint, cancel-hint
<r-reasoning> etiqueta de cabecera Reasoning label
<r-token-meter> etiqueta Context label
<r-colorpicker> aria-labels Choose color, Hue, Alpha opacity label, hue-label, alpha-label
<r-player> título del panel de error Playback failed, botón Retry label-error, label-retry
<r-markdown> / <r-math> / <r-mermaid> botones de la barra como Copy code y Download label-copy, label-download y demás label-*

Un patrón práctico es volver a aplicarlas desde un único sitio en cada cambio de idioma, para que el mismo código corra al arrancar y después de un cambio:

const i18n = useI18n();

const applyLabels = () => {
  document.querySelectorAll('r-voice-button').forEach((el) => {
    el.setAttribute('label', i18n.t('voice.start'));
    el.setAttribute('active-label', i18n.t('voice.stop'));
  });
};

applyLabels();
i18n.onChange(applyLabels);

Acuérdate de mantener también document.documentElement.lang al día: es lo que miran el navegador, los lectores de pantalla y los selectores :lang().

API

createI18n(config) crea y registra el singleton global (llámalo una vez); useI18n() lo devuelve, o null si createI18n todavía no se ha ejecutado.

I18nConfig

Campo Tipo Por defecto Descripción
messages LocaleMessages {} locale → { key → string }. Cada diccionario es plano.
locale string el de reserva Idioma inicial (una elección persistida tiene prioridad cuando está activada).
fallbackLocale string 'en' Idioma que se consulta cuando falta una clave en el idioma activo.
persist boolean false Guarda el idioma activo en localStorage.
storageKey string 'ran-locale' Clave de localStorage usada cuando persist está activo.
detectNavigator boolean false Toma el idioma inicial de las preferencias del navegador. Lee la lista ordenada navigator.languages completa, así que quien no tenga diccionario para su primera opción recibe la segunda en vez del idioma de reserva.

Métodos de I18nCore

Método Devuelve Descripción
t(key, params?) string Traduce; recurre al idioma de reserva y luego a la propia clave.
setLocale(locale) void Cambia de idioma; persiste (si está activado) y avisa a los suscriptores.
getLocale() string El idioma activo.
onChange(handler) () => void Se suscribe a los cambios de idioma; devuelve una función para darse de baja.
addMessages(locale, dict) void Fusiona más mensajes en un idioma.
getMessages(locale?) MessageDict Lee el diccionario de un idioma (por defecto, el activo).
availableLocales string[] Idiomas que tienen un diccionario registrado.
destroy() void Elimina todos los suscriptores.

Tipos

type MessageDict = Record<string, string>; // plano: 'hero.title' → 'Hi {name}'
type LocaleMessages = Record<string, MessageDict>; // locale → MessageDict
type TranslateParams = Record<string, string | number>;

SSR

El núcleo es seguro en SSR: los accesos a localStorage y navigator están protegidos, así que createI18n y t se ejecutan sin lanzar errores durante el renderizado en el servidor. La persistencia y la detección del navegador simplemente no hacen nada en el servidor y entran en juego cuando el código corre en el navegador.