12 KiB
ling
Librería de internacionalización (i18n) para TypeScript con rutas type-safe, pluralización, referencias internas e inyección de logger.
Arquitectura
src/ling/
├── types.ts # Tipos e interfaces — la fuente de verdad del sistema
├── consts.ts # Constantes globales (prefijo de referencia, locale por defecto…)
├── guards.ts # Type guards: isIDLing, isLingRecord
├── errors.ts # Mensajes de error centralizados
├── engine.ts # createLing() + helper p() — el núcleo
├── translations.ts # Schema de traducciones del proyecto
└── instance.ts # Instancia exportada lista para usar
Flujo de resolución
Cuando llamas a ling.t('some.key', params) el engine sigue este orden:
t(path, params)
│
├─ resolvePath() → busca el valor bruto en el schema
│
├─ resolveValue() → desanida referencias (#?) y ejecuta funciones
│ ├─ isIDLing? → sigue el puntero (recursivo, límite: maxResolveDeep)
│ ├─ función? → la ejecuta con params y obtiene un LingRecord
│ └─ LingRecord → lo devuelve tal cual
│
└─ tsRecord() → elige el idioma activo, interpola {{variables}}, aplica fallback
resolveValue() es la única función que maneja referencias y profundidad. t() solo orquesta: busca → resuelve → traduce. No hay lógica duplicada.
Conceptos clave
LingRecord
La unidad mínima de una traducción. Un objeto con el locale por defecto (es) obligatorio y el resto opcionales.
const greeting: LingRecord = {
es: 'Hola',
en: 'Hello',
fr: 'Bonjour',
};
LingNode
El tipo recursivo que describe el schema completo. Puede ser un LingRecord, una función que devuelve un LingRecord, una referencia IDLing, o un objeto que contiene más LingNode.
LingFn
Función de traducción con parámetros tipados. TypeScript infiere los parámetros requeridos y los exige en t().
const totalLabel: LingFn<{ amount: number; currency: string }> = (params) => ({
es: `Total: ${params.amount}${params.currency}`,
en: `Total: ${params.currency}${params.amount}`,
});
LingPluralFn
Función de pluralización. Siempre lleva { count: number } más cualquier parámetro extra. Se construye con el helper p().
IDLing
Referencia interna con el prefijo #?. Permite que una clave apunte a otra sin duplicar la traducción.
const schema = {
actions: {
confirm: { es: 'Confirmar', en: 'Confirm' },
submit: '#?actions.confirm', // alias — mismo texto
}
};
Configuración del schema
El schema se define en translations.ts sin anotar el tipo en la declaración de la variable. Solo se usa satisfies LingNode para que TypeScript valide la estructura pero conserve el tipo inferido exacto. Esto es lo que permite que LeafPaths<TranslationSchema> derive rutas concretas como "checkout.total".
// ✅ Correcto — TypeScript infiere el tipo exacto
export const translations = {
checkout: {
pay: { es: 'Pagar', en: 'Pay' },
total: (params: { amount: number; currency: string }) => ({
es: `Total: ${params.amount}${params.currency}`,
en: `Total: ${params.currency}${params.amount}`,
}),
},
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' },
}),
},
} satisfies LingNode;
export type TranslationSchema = typeof translations;
// ❌ Incorrecto — borra la información de rutas, t() pierde type-safety
export const translations: LingNode = { ... };
Uso básico
Traducción simple
import { ling } from '@/ling';
ling.t('checkout.pay'); // → 'Pagar' (locale: es)
ling.t('common.cancel'); // → 'Cancelar'
Traducción con parámetros
TypeScript exige los parámetros correctos en tiempo de compilación.
ling.t('checkout.total', { amount: 99, currency: '€' }); // → 'Total: 99€'
ling.t('errors.generic', { code: 404 }); // → 'Ha ocurrido un error (404)'
// TS2345 si faltan parámetros o el tipo es incorrecto:
ling.t('checkout.total'); // ❌ Error de compilación
ling.t('checkout.total', { amount: '99', currency: '€' }); // ❌ amount debe ser number
Interpolación con {{variables}}
Para cadenas pluralizadas o cualquier LingRecord, las variables se interpolan con la sintaxis {{nombre}}.
ling.t('messages.unread', { count: 3 }); // → '3 mensajes sin leer'
ling.t('messages.unread', { count: 1 }); // → '1 mensaje sin leer'
Cambio de locale
ling.setLocale('en');
ling.t('checkout.pay'); // → 'Pay'
ling.getLocale(); // → 'en'
Traducción para un locale puntual sin cambiar el activo
ling.tForLocale('common.ok', 'fr'); // → 'OK' (sin cambiar currentLocale)
Traducir un LingString suelto
ts() sirve para traducir valores que vienen de datos externos (bases de datos, APIs) y pueden ser un string fijo, un LingRecord o una referencia.
const description: LingString = { es: 'Descripción', en: 'Description' };
ling.ts(description); // → 'Descripción'
Escuchar cambios de locale
const unsub = ling.onLocaleChange((locale) => {
console.log('Nuevo locale:', locale);
});
// Para desuscribirse:
unsub();
Pluralización con p()
p() es un helper puro que no pertenece a la instancia — se usa en tiempo de definición del schema, antes de que la instancia exista. Recibe la configuración de formas plurales por locale y devuelve una LingPluralFn.
import { p } from '@/ling/engine';
const unread = p({
es: { one: '{{count}} mensaje', other: '{{count}} mensajes' },
en: { one: '{{count}} message', other: '{{count}} messages' },
fr: { one: '{{count}} message', other: '{{count}} messages' },
});
// En el schema:
const translations = {
messages: { unread }
} satisfies LingNode;
// En uso:
ling.t('messages.unread', { count: 1 }); // → '1 mensaje'
ling.t('messages.unread', { count: 5 }); // → '5 mensajes'
Las formas plurales siguen el estándar Unicode CLDR (zero, one, two, few, many, other). Solo other es obligatorio.
Referencias internas (#?)
Permiten que una clave reutilice la traducción de otra sin duplicarla. El engine las resuelve de forma recursiva con un límite de profundidad (maxResolveDeep) para evitar bucles infinitos — si se supera, lanza Error("Circular reference in ling").
const translations = {
actions: {
confirm: { es: 'Confirmar', en: 'Confirm' },
accept: '#?actions.confirm', // apunta a confirm
},
ui: {
button: '#?actions.accept', // apunta a accept → confirm
}
} satisfies LingNode;
ling.t('ui.button'); // → 'Confirmar'
Carga de módulos
Hay dos estrategias según si los módulos se conocen en build time o se cargan en runtime.
Eager — todo conocido en build time
La opción más simple. Los módulos se fusionan en el schema base y TypeScript infiere el tipo completo. Todas las rutas están tipadas desde el arranque.
// translations.ts
import { shopTranslations } from '@/shop/translations';
import { adminTranslations } from '@/admin/translations';
export const translations = {
...core,
shop: shopTranslations,
admin: adminTranslations,
} satisfies LingNode;
export type TranslationSchema = typeof translations;
// instance.ts
export const ling = createLing<TranslationSchema>(translations, 'es');
ling.t('shop.product'); // ✅ tipado
ling.t('shop.total', { amount: 99, currency: '€' }); // ✅ parámetros tipados
ling.t('admin.users'); // ✅ tipado
Lazy — módulos cargados en runtime con extend()
extend() muta la instancia global en runtime. Las rutas del módulo lazy no están tipadas — t() acepta cualquier string para cubrirlas.
// instance.ts — solo el schema base
export const ling = createLing<TranslationSchema>(translations, 'es');
// En el router, cuando el módulo se carga
const { shopTranslations } = await import('@/shop/translations');
ling.extend('shop', shopTranslations);
ling.t('shop.product'); // ✅ funciona en runtime
ling.t('shop.total', { amount: 99, currency: '€' }); // ✅ funciona en runtime
ling.t('checkout.pay'); // ✅ tipado — pertenece al schema base
Si el módulo no se ha cargado aún, t() devuelve el path y loguea el error en desarrollo — degradación controlada, sin excepciones.
register() — nueva instancia tipada
Devuelve una nueva instancia con el tipo actualizado sin modificar la instancia original. Útil para tests o contextos aislados.
const base = createLing(coreSchema, 'es');
const lingShop = base.register('shop', shopTranslations);
lingShop.t('shop.product'); // ✅ tipado — tipo inferido al momento
lingShop.t('common.ok'); // ✅ schema original preservado
base.t('shop.product'); // ❌ base no conoce 'shop' — error de compilación
El tipo de retorno es LingInstance<CoreSchema & { shop: typeof shopTranslations }> — TypeScript conoce ambas partes sin declaración extra.
extend() |
register() |
|
|---|---|---|
| Instancia | Muta la actual | Nueva instancia |
| Type-safety en rutas nuevas | No — acepta string |
Sí — inferido al momento |
| Caso de uso | Global + lazy loading | Tests, contextos aislados |
Inyección de logger
Por defecto ling usa console.warn / console.error solo en NODE_ENV === 'development'. Una vez que tu sistema de logging propio está listo, puedes inyectarlo con setLogger(). Solo puede llamarse una vez — es inmutable tras la primera inyección.
import { ling } from '@/ling';
import { logr } from '@/logr';
ling.setLogger(logr);
Cualquier objeto que implemente la interfaz LingLogger es válido:
interface LingLogger {
warn : (category: string, message: string) => void;
error: (category: string, message: string) => void;
}
Este diseño rompe la dependencia cíclica ling ↔ logr: ling arranca con console, logr se inicializa usando ling, y después ling adopta logr como logger definitivo.
Type-safety: cómo funciona
El sistema de tipos se apoya en tres utilidades definidas en types.ts:
LeafPaths<S> — deriva en tiempo de compilación todas las rutas válidas del schema (solo hojas, no namespaces intermedios). Es lo que hace que t('checkout.total') compile y t('checkout') no.
GetTypeAtPath<Root, Current, P> — dado un path string, navega el árbol de tipos y devuelve el tipo exacto de ese nodo. También resuelve aliases #? saltando al nodo referenciado.
HasParams<T> + ParamsFor<T> — determinan si el nodo es una función (con o sin count) y extraen el tipo exacto de sus parámetros. Esto es lo que hace que t() exija { amount, currency } para 'checkout.total' y no pida nada para 'checkout.pay'.
t() tiene dos sobrecargas que conviven:
// Sobrecarga 1 — rutas conocidas en build time, completamente type-safe
ling.t('checkout.total', { amount: 99, currency: '€' }); // ✅ parámetros exigidos
ling.t('checkout.pay'); // ✅ sin parámetros
ling.t('checkout.total'); // ❌ faltan parámetros
// Sobrecarga 2 — cualquier string, para rutas lazy
ling.t('shop.product'); // ✅ sin error de compilación
ling.t('shop.total', { amount: 99, currency: '€' }); // ✅ params opcionales
En instance.ts el genérico explícito es imprescindible:
// ✅ TypeScript conoce el schema exacto → t() queda completamente tipado
export const ling = createLing<TranslationSchema>(translations, 'es');
// ❌ Sin genérico, S = LingNode → LeafPaths<LingNode> = never → t() no compila
export const ling = createLing(translations, 'es');
Locales soportados
| Código | Idioma |
|---|---|
es |
Español (obligatorio, locale por defecto) |
en |
Inglés |
de |
Alemán |
fr |
Francés |
it |
Italiano |
pt |
Portugués |
ca |
Catalán |
eu |
Euskera |
gl |
Gallego |
Para añadir un nuevo locale, extender SupportedLocale en types.ts.