You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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.

Powered by TurnKey Linux.