Internacionalización (i18n)

EmberKit incluye un núcleo i18n ligero y tree-shakeable — sin runtime de terceros. Usa catálogos TypeScript o archivos JSON de traducción.

Resumen

El i18n de EmberKit se basa en cuatro ideas:

  • Catálogos de mensajes — mapas clave/valor planos o anidados por locale
  • Interpolación {placeholder}t('greeting', { name: 'Ada' })
  • Sintaxis plural con pipeone {count} item | other {count} items
  • Formateadores Intl — fechas, números y tiempo relativo sin coste extra de bundle

El locale fluye por contexto (createI18nContext) y puede resolverse por petición en el servidor.

Estructura del proyecto (JSON — recomendado)

src/
├── lib/
│   └── i18n.ts              # createI18nFromGlob + export del provider
├── locales/
│   ├── en.json
│   └── es.json
└── routes/
    ├── [locale]/
    │   ├── _layout.tsx      # I18nProvider + locale desde loader
    │   └── index.tsx
    └── index.tsx            # redirección opcional al locale por defecto
public/
└── locales/                 # solo al cargar en diferido vía fetch
    ├── en.json
    └── es.json

src/locales/en.json

{
  "greeting": "Hello {name}",
  "nav": {
    "home": "Home",
    "about": "About"
  },
  "items": "one {count} item | other {count} items"
}

Los objetos anidados se aplanan a claves con punto — nav.home en código.

src/lib/i18n.ts

import { createI18nFromGlob, createI18nContext, resolveLocaleFromRequest } from '@emberkit/core';

const modules = import.meta.glob('../locales/*.json', { eager: true });

export const i18n = createI18nFromGlob(modules, {
  locales: ['en', 'es'] as const,
  defaultLocale: 'en',
  fallbackLocale: 'en',
});

export const { Provider: I18nProvider, useI18n } = createI18nContext();

export function resolveRequestLocale(request: Request): string {
  return resolveLocaleFromRequest(request, {
    locales: i18n.locales,
    defaultLocale: i18n.defaultLocale,
    strategy: ['path-prefix', 'header', 'cookie'],
  });
}

Los nombres de archivo son códigos de locale: en.jsonen, es-MX.jsones-MX.

Carga de archivos de traducción

MétodoAPICuándo usarlo
Vite globcreateI18nFromGlobPor defecto — empaqueta todos los locales en build
Import estáticocreateI18nFromJsonUn solo locale o imports explícitos
Fetch en runtimecreateI18nFromUrlsCarga diferida desde public/locales/
Sistema de archivos NodecreateI18nFromDirectoryScripts SSR, CLI, herramientas solo Node

Import estático

import en from './locales/en.json';
import es from './locales/es.json';
import { createI18nFromJson } from '@emberkit/core';

export const i18n = createI18nFromJson({
  locales: ['en', 'es'] as const,
  defaultLocale: 'en',
  messages: { en, es },
});

Vite y TypeScript resuelven imports JSON cuando resolveJsonModule está activo en tsconfig.json.

Carga diferida en runtime (fetch)

import { createI18nFromUrls } from '@emberkit/core';

export const i18n = await createI18nFromUrls(
  {
    en: '/locales/en.json',
    es: '/locales/es.json',
  },
  { locales: ['en', 'es'] as const, defaultLocale: 'en' },
);

Coloca JSON bajo public/locales/ para que el servidor de desarrollo y producción los sirvan como estáticos.

Node / SSR (sistema de archivos)

import { createI18nFromDirectory } from '@emberkit/core/i18n/node';

export const i18n = createI18nFromDirectory('./src/locales', {
  locales: ['en', 'es'] as const,
  defaultLocale: 'en',
});

También disponible desde @emberkit/core/i18n/node:

  • readLocaleCatalog(filePath) — leer y parsear un archivo JSON
  • loadLocalesFromDirectory(dir) — cargar todos los *.json de una carpeta

Catálogos TypeScript

Usa defineMessages cuando quieras catálogos colocados en TypeScript con inferencia completa de claves:

import { createI18n, defineMessages } from '@emberkit/core';

const en = defineMessages({
  'nav.home': 'Home',
  greeting: 'Hello {name}',
  items: 'one {count} item | other {count} items',
});

const es = defineMessages({
  'nav.home': 'Inicio',
  greeting: 'Hola {name}',
  items: 'one {count} artículo | other {count} artículos',
});

export const i18n = createI18n({
  locales: ['en', 'es'] as const,
  defaultLocale: 'en',
  fallbackLocale: 'en',
  messages: { en, es },
});

Traducir

i18n.t('greeting', { name: 'World' }); // Hello World
i18n.setLocale('es');
i18n.t('greeting', { name: 'Mundo' }); // Hola Mundo

// Plural pipe syntax
i18n.tp('items', 1, { count: 1 });
i18n.tp('items', 5, { count: 5 });

// Intl helpers (respect current locale)
i18n.formatDate(new Date(), { dateStyle: 'medium' });
i18n.formatNumber(1234.5);
i18n.formatRelativeTime(-1, 'day'); // "1 day ago"

Pasa { strict: true } como segundo argumento a createI18n / createI18nFromJson para lanzar MissingTranslationError en claves faltantes (útil en CI).

Provider y hooks

import { I18nProvider, useI18n, i18n } from '../lib/i18n';

function App({ locale, children }: { locale: string; children?: unknown }) {
  return (
    <I18nProvider i18n={i18n} locale={locale}>
      {children}
    </I18nProvider>
  );
}

function Header() {
  const { t, setLocale, formatDate } = useI18n();
  return (
    <header>
      <span>{t('nav.home')}</span>
      <button type="button" onClick={() => setLocale('es')}>ES</button>
      <time>{formatDate(new Date(), { dateStyle: 'medium' })}</time>
    </header>
  );
}

Rutas con prefijo de locale

Combina i18n con un segmento dinámico [locale]:

src/routes/[locale]/index.tsx   →  /en, /es
src/routes/[locale]/about.tsx   →  /en/about, /es/about
// src/routes/[locale]/_layout.tsx
import type { RouteComponent } from '@emberkit/core';
import { I18nProvider, i18n, resolveRequestLocale } from '../../lib/i18n';

export async function loader({ request }: { request: Request }) {
  const locale = resolveRequestLocale(request);
  return { data: { locale } };
}

const LocaleLayout: RouteComponent<{ locale: string }> = ({ locale, children }) => (
  <I18nProvider i18n={i18n} locale={locale}>
    {children}
  </I18nProvider>
);

export default LocaleLayout;

Helpers de ruta para enlaces y redirecciones:

import { extractLocaleFromPath, localizePath } from '@emberkit/core';

const { locale, pathnameWithoutLocale } = extractLocaleFromPath('/es/blog/post', ['en', 'es']);
// locale: 'es', pathnameWithoutLocale: '/blog/post'

localizePath('/en/about', 'es', ['en', 'es']); // '/es/about'

En Cloudflare Workers, usa beforeAssets en createCloudflareWorker para redirigir visitantes a su prefijo de locale preferido. Consulta Despliegue en el edge.

Detección de locale en SSR

resolveLocaleFromRequest comprueba estrategias en orden y recurre a defaultLocale:

EstrategiaOrigen
path-prefixPrimer segmento de URL (/es/aboutes)
headerAccept-Language
cookieCookie locale (nombre configurable)
query?lang=es (parámetro configurable)
import { resolveLocaleFromRequest } from '@emberkit/core';

export async function loader({ request }: RouteParams) {
  const locale = resolveLocaleFromRequest(request, {
    locales: ['en', 'es'],
    defaultLocale: 'en',
    strategy: ['path-prefix', 'header', 'cookie'],
    cookieName: 'locale',
    queryParam: 'lang',
  });
  i18n.setLocale(locale);
  return { data: { locale } };
}

Parseo y validación

Helpers de bajo nivel para pipelines personalizados:

import { parseMessageCatalog, parseMessageCatalogJson } from '@emberkit/core';

const catalog = parseMessageCatalog({ nav: { home: 'Home' } });
// { 'nav.home': 'Home' }

const fromFile = parseMessageCatalogJson('{"hello":"world"}');

Catálogos inválidos lanzan InvalidMessageCatalogError (valores hoja no string, JSON mal formado).

Scaffold CLI

emberkit generate i18n app --path src/lib/i18n.ts

El archivo generado usa createI18nFromGlob y espera JSON en una carpeta locales/ junto a él.

Referencia de API

Consulta Referencia de API — Internacionalización para la lista completa de funciones.

Relacionado