dev 7 months ago
parent 0426d9f46d
commit 04580608a2

@ -1,5 +1,4 @@
import type {LingLogger} from "@/ling/types.ts";
export const idPrefix = '#?'; export const idPrefix = '#?';
@ -9,3 +8,5 @@ export const defaultISOLocale = 'es' ;
export const maxResolveDeep = 3; export const maxResolveDeep = 3;
export const loggerCategory = 'ling'; export const loggerCategory = 'ling';

@ -1,295 +1,382 @@
# ling # ling
Sistema de internacionalización type-safe para TypeScript. Agnóstico de framework, sin dependencias externas. Librería de internacionalización (i18n) para TypeScript con rutas type-safe, pluralización, referencias internas e inyección de logger.
--- ---
## Estructura de ficheros ## Arquitectura
``` ```
ling/ src/ling/
├── index.ts # Barrel — punto de entrada público ├── types.ts # Tipos e interfaces — la fuente de verdad del sistema
├── engine.ts # Singleton global (wiring de schema base + factory) ├── consts.ts # Constantes globales (prefijo de referencia, locale por defecto…)
├── instance.ts # Factory: createLing() ├── guards.ts # Type guards: isIDLing, isLingRecord
├── types.ts # Tipos e interfaces ├── errors.ts # Mensajes de error centralizados
├── translations.ts # Traducciones base de la aplicación ├── engine.ts # createLing() + helper p() — el núcleo
└── tests/ ├── translations.ts # Schema de traducciones del proyecto
└── ling.test.ts └── instance.ts # Instancia exportada lista para usar
``` ```
Cada módulo de la aplicación define sus propias traducciones y las registra en el singleton: ### Flujo de resolución
``` Cuando llamas a `ling.t('some.key', params)` el engine sigue este orden:
auth/
└── ling.ts # Registra el namespace 'auth'
checkout/
└── ling.ts # Registra el namespace 'checkout'
``` ```
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.
--- ---
## Setup ## Conceptos clave
### 1. Define las traducciones base ### LingRecord
Solo las claves compartidas por toda la aplicación — common, errors, etc. La unidad mínima de una traducción. Un objeto con el locale por defecto (`es`) obligatorio y el resto opcionales.
```ts ```ts
// translations.ts const greeting: LingRecord = {
import type { TranslationNode } from './ling.types'; es: 'Hola',
en: 'Hello',
export const translations = { fr: 'Bonjour',
common: { };
ok : { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' },
},
errors: {
generic: (params: { code: number }) => ({
es: `Ha ocurrido un error (${params.code})`,
en: `An error occurred (${params.code})`,
}),
},
} satisfies TranslationNode;
``` ```
> `es` es obligatorio en cada hoja. El resto de locales son opcionales y hacen fallback a `es` si faltan. ### LingNode
### 2. Crea el singleton 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`.
```ts ### LingFn
// ling.engine.ts
import { createLing } from './ling.factory';
import { translations } from './translations';
export const ling = createLing(translations, 'es'); Función de traducción con parámetros tipados. TypeScript infiere los parámetros requeridos y los exige en `t()`.
```
### 3. Cada módulo registra sus traducciones
```ts ```ts
// auth/ling.ts const totalLabel: LingFn<{ amount: number; currency: string }> = (params) => ({
import { ling } from '@/ling.engine'; es: `Total: ${params.amount}${params.currency}`,
en: `Total: ${params.currency}${params.amount}`,
export const authLing = ling.register('auth', {
loginFailed : { es: 'Login fallido', en: 'Login failed' },
sessionExpired: { es: 'Sesión expirada', en: 'Session expired' },
welcome : (params: { name: string }) => ({
es: `Bienvenido, ${params.name}`,
en: `Welcome, ${params.name}`,
}),
}); });
``` ```
### 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.
```ts ```ts
// checkout/ling.ts const schema = {
import { ling } from '@/ling.engine'; actions: {
confirm: { es: 'Confirmar', en: 'Confirm' },
submit: '#?actions.confirm', // alias — mismo texto
}
};
```
export const checkoutLing = ling.register('checkout', { ---
## 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"`.
```ts
// ✅ Correcto — TypeScript infiere el tipo exacto
export const translations = {
checkout: {
pay: { es: 'Pagar', en: 'Pay' }, pay: { es: 'Pagar', en: 'Pay' },
total: (params: { amount: number; currency: string }) => ({ total: (params: { amount: number; currency: string }) => ({
es: `Total: ${params.amount}${params.currency}`, es: `Total: ${params.amount}${params.currency}`,
en: `Total: ${params.currency}${params.amount}`, 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;
### 4. Uso dentro de cada módulo export type TranslationSchema = typeof translations;
```
```ts ```ts
// auth/login.ts // ❌ Incorrecto — borra la información de rutas, t() pierde type-safety
import { authLing } from './ling'; export const translations: LingNode = { ... };
authLing.t('auth.loginFailed') // → "Login fallido"
authLing.t('auth.welcome', { name: 'Ana' }) // → "Bienvenido, Ana"
authLing.t('common.ok') // → "Aceptar" (base disponible)
``` ```
Cada módulo tiene **autocompletado y validación en compilación** solo de sus claves más las del schema base. No ve las claves de otros módulos.
--- ---
## API ## Uso básico
### `t(path, params?)`
Traduce una clave del schema al locale actual. ### Traducción simple
```ts ```ts
authLing.t('auth.loginFailed') import { ling } from '@/ling';
authLing.t('auth.welcome', { name: 'Ana' })
authLing.t('common.ok')
```
- Solo acepta rutas que terminan en una traducción real — rutas intermedias como `'auth'` dan error de tipos. ling.t('checkout.pay'); // → 'Pagar' (locale: es)
- Los params son obligatorios si la traducción los requiere, y TypeScript los infiere automáticamente. ling.t('common.cancel'); // → 'Cancelar'
```
### `ts(value)` ### Traducción con parámetros
*Translate String* — resuelve un `LingString` con el locale actual. Útil para campos de datos que pueden estar localizados o no. TypeScript exige los parámetros correctos en tiempo de compilación.
```ts ```ts
ts('texto fijo') // → "texto fijo" (pass-through) ling.t('checkout.total', { amount: 99, currency: '€' }); // → 'Total: 99€'
ts({ es: 'una descripción', en: 'a description' }) // → "una descripción" ling.t('errors.generic', { code: 404 }); // → 'Ha ocurrido un error (404)'
```
```ts
import type { LingString } from '@/ling';
interface Product {
id : string;
name: LingString;
}
const product: Product = { id: '1', name: { es: 'Silla', en: 'Chair' } }; // TS2345 si faltan parámetros o el tipo es incorrecto:
ling.ts(product.name) // → "Silla" ling.t('checkout.total'); // ❌ Error de compilación
ling.t('checkout.total', { amount: '99', currency: '€' }); // ❌ amount debe ser number
``` ```
### `tForLocale(path, locale, params?)` ### Interpolación con `{{variables}}`
Resuelve una clave en una locale específica sin cambiar el estado global. Útil para SSR o generación de emails. Para cadenas pluralizadas o cualquier `LingRecord`, las variables se interpolan con la sintaxis `{{nombre}}`.
```ts ```ts
authLing.tForLocale('auth.loginFailed', 'en') // → "Login failed" ling.t('messages.unread', { count: 3 }); // → '3 mensajes sin leer'
ling.getLocale() // → "es" (no ha cambiado) ling.t('messages.unread', { count: 1 }); // → '1 mensaje sin leer'
``` ```
### `register(namespace, module)` ### Cambio de locale
Registra las traducciones de un módulo bajo un namespace. Devuelve una nueva instancia con los tipos extendidos que comparte el mismo estado reactivo que el singleton.
```ts ```ts
export const authLing = ling.register('auth', { ... }); ling.setLocale('en');
ling.t('checkout.pay'); // → 'Pay'
ling.getLocale(); // → 'en'
``` ```
- El locale se sincroniza automáticamente con el singleton — un solo `setLocale` actualiza todos los módulos. ### Traducción para un locale puntual sin cambiar el activo
- Cada módulo ve sus claves tipadas más las del schema base.
- Encadenar `register()` acumula namespaces:
```ts ```ts
const full = ling ling.tForLocale('common.ok', 'fr'); // → 'OK' (sin cambiar currentLocale)
.register('auth', authTranslations)
.register('checkout', checkoutTranslations);
``` ```
### `setLocale(locale)` ### Traducir un `LingString` suelto
Cambia el locale del singleton y propaga el cambio a todos los módulos registrados. `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.
```ts ```ts
ling.setLocale('en') const description: LingString = { es: 'Descripción', en: 'Description' };
// authLing, checkoutLing... todos reflejan 'en' automáticamente ling.ts(description); // → 'Descripción'
``` ```
### `getLocale()` ### Escuchar cambios de locale
```ts ```ts
ling.getLocale() // → "es" const unsub = ling.onLocaleChange((locale) => {
console.log('Nuevo locale:', locale);
});
// Para desuscribirse:
unsub();
``` ```
### `onLocaleChange(fn)` ---
## Pluralización con `p()`
Registra un listener que se ejecuta cuando cambia el locale. Devuelve `unsubscribe`. `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`.
```ts ```ts
const unsubscribe = ling.onLocaleChange((locale) => { import { p } from '@/ling/engine';
console.log('Nuevo locale:', locale);
const unread = p({
es: { one: '{{count}} mensaje', other: '{{count}} mensajes' },
en: { one: '{{count}} message', other: '{{count}} messages' },
fr: { one: '{{count}} message', other: '{{count}} messages' },
}); });
unsubscribe(); // deja de escuchar // 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'
``` ```
> `tForLocale()` no dispara los listeners. Las formas plurales siguen el estándar Unicode CLDR (`zero`, `one`, `two`, `few`, `many`, `other`). Solo `other` es obligatorio.
--- ---
## Locales soportadas ## Referencias internas (`#?`)
| Código | Idioma | 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")`.
|--------|--------|
| `es` | Español *(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 una nueva locale, edita `SupportedLocale` en `ling.types.ts`:
```ts ```ts
export type SupportedLocale = DefaultLocale | 'en' | 'de' | 'fr' | ... | 'ja'; const translations = {
``` actions: {
confirm: { es: 'Confirmar', en: 'Confirm' },
accept: '#?actions.confirm', // apunta a confirm
},
ui: {
button: '#?actions.accept', // apunta a accept → confirm
}
} satisfies LingNode;
TypeScript marcará todas las hojas del schema donde falte la nueva locale. ling.t('ui.button'); // → 'Confirmar'
```
--- ---
## Tipos públicos ## Carga de módulos
| Tipo | Descripción | Hay dos estrategias según si los módulos se conocen en build time o se cargan en runtime.
|------|-------------|
| `SupportedLocale` | Unión de todas las locales soportadas |
| `DefaultLocale` | `'es'` — locale obligatoria en cada traducción |
| `LocaleRecord` | `{ es: string, en?: string, ... }` |
| `LingString` | `string \| LocaleRecord` — campos opcionalmente localizados |
| `TranslationNode` | Tipo recursivo del árbol de traducciones |
| `TranslationFn<P>` | Función de traducción con parámetros tipados |
| `LingInstance<S>` | Tipo de la instancia parametrizado por el schema |
--- ### Eager — todo conocido en build time
## Fallback 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.
```ts
// 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.
```ts
// 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
``` ```
locale actual → defaultLocale → clave como texto
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.
```ts
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
``` ```
En desarrollo (`NODE_ENV === 'development'`) se emite `console.warn` cuando se usa el fallback. En producción la degradación es silenciosa. 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 |
--- ---
## Integración con frameworks ## Inyección de logger
Conecta `setLocale` y `onLocaleChange` al sistema reactivo del framework. 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.
**Vue 3**
```ts ```ts
import { ref } from 'vue'; import { ling } from '@/ling';
import { ling } from '@/ling.engine'; import { logr } from '@/logr';
export const locale = ref(ling.getLocale()); ling.setLogger(logr);
ling.onLocaleChange(l => locale.value = l);
``` ```
**Svelte** Cualquier objeto que implemente la interfaz `LingLogger` es válido:
```ts ```ts
import { writable } from 'svelte/store'; interface LingLogger {
import { ling } from '@/ling.engine'; 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:
export const locale = writable(ling.getLocale()); ```ts
ling.onLocaleChange(l => locale.set(l)); // 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
``` ```
**React** En `instance.ts` el genérico explícito es imprescindible:
```ts ```ts
import { useSyncExternalStore } from 'react'; // ✅ TypeScript conoce el schema exacto → t() queda completamente tipado
import { ling } from '@/ling.engine'; export const ling = createLing<TranslationSchema>(translations, 'es');
export function useLocale() { // ❌ Sin genérico, S = LingNode → LeafPaths<LingNode> = never → t() no compila
return useSyncExternalStore(ling.onLocaleChange, ling.getLocale); export const ling = createLing(translations, 'es');
}
``` ```
--- ---
## Tests ## Locales soportados
```bash | Código | Idioma |
vitest |--------|------------|
``` | `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 |
Los tests usan schemas propios independientes del de producción — no hay acoplamiento entre la suite y las traducciones reales. Para añadir un nuevo locale, extender `SupportedLocale` en `types.ts`.

@ -8,10 +8,11 @@ import type {
LingRecord, LingRecord,
LingString, LingInstance, LingLogger, LingNode, PluralConfig, LingString, LingInstance, LingLogger, LingNode, PluralConfig,
} from './types.ts'; } from './types.ts';
import {LING_ERRORS} from "@/ling/errors.ts";
import { LING_ERRORS } from './errors.ts';
import {isIDLing, isLingRecord} from "@/ling/guards.ts";
import {idPrefix, loggerCategory, maxResolveDeep} from "@/ling/consts.ts"; import {idPrefix, loggerCategory, maxResolveDeep} from "@/ling/consts.ts";
import {isIDLing, isLingRecord} from "@/ling/guards.ts";
import {pluralRule} from "@/ling/plural-rules.ts";
// ============================== // ==============================
// HELPERS // HELPERS
@ -27,14 +28,6 @@ function isDev(): boolean {
// Logger por defecto — console puro, sin dependencias externas.
// Se reemplaza con setLogger() una vez logr está inicializado.
const consoleLogger: LingLogger = {
warn : (category, message) => isDev() && console.warn (message),
error: (category, message) => isDev() && console.error(message),
};
// ============================== // ==============================
// ENGINE // ENGINE
// ============================== // ==============================
@ -55,15 +48,9 @@ export function createLing<S extends LingNode>(
// Logger // Logger
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/**
* Inyecta un logger externo. Solo puede llamarse una vez.
* En desarrollo avisa si se intenta sobreescribir.
*/
function setLogger(external: LingLogger): void { function setLogger(external: LingLogger): void {
if (loggerSet) { if (loggerSet) {
if (isDev()) { if (isDev()) console.warn(LING_ERRORS.LOGGER_ALREADY_SET);
console.warn(LING_ERRORS.LOGGER_ALREADY_SET);
}
return; return;
} }
logger = external; logger = external;
@ -92,103 +79,75 @@ export function createLing<S extends LingNode>(
// Resolución interna // Resolución interna
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function tsRecord(record: LingRecord, path?: string, params?: any): string { function tsRecord(record: LingRecord, path?: string, params?: any): string {
const translationInLocale = record[currentLocale]; const translationInLocale = record[currentLocale];
const isMissing = translationInLocale === undefined; const isMissing = translationInLocale === undefined;
// 1. Resolución del valor (con fallback)
let translation = translationInLocale ?? record[defaultLocale] ?? path ?? ''; let translation = translationInLocale ?? record[defaultLocale] ?? path ?? '';
// 2. Interpolación global (aplica a la traducción final)
if (params) { if (params) {
Object.entries(params).forEach(([key, val]) => { Object.entries(params).forEach(([key, val]) => {
translation = translation.replace(new RegExp(`{{${key}}}`, 'g'), String(val)); translation = translation.replace(new RegExp(`{{${key}}}`, 'g'), String(val));
}); });
} }
// 3. LOGGING: Solo si estamos en desarrollo Y falta la traducción
if (isDev() && isMissing) { if (isDev() && isMissing) {
const msg = path const msg = path
? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale) ? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale)
: LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale); : LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale);
logger.warn(loggerCategory, msg); logger.warn(loggerCategory, msg);
} }
return translation; return translation;
} }
/** /**
* Resuelve referencias de forma recursiva con un límite de profundidad * Resuelve un valor del schema siguiendo referencias (#?) y funciones
* para evitar bucles infinitos. * de forma recursiva. Lanza si se supera el límite de profundidad.
*/ */
function resolveValue(value: any, args: any[], depth: number): any { function resolveValue(value: any, args: any[], depth: number): any {
if (depth > maxResolveDeep) { if (depth > maxResolveDeep) {
logger.error(loggerCategory, LING_ERRORS.CIRCULAR_REFERENCE(value)); logger.error(loggerCategory, LING_ERRORS.CIRCULAR_REFERENCE(String(value)));
throw new Error("Circular reference in ling"); throw new Error("Circular reference in ling");
} }
if (isIDLing(value)) { if (isIDLing(value)) {
const path = value.substring(idPrefix.length); const path = value.substring(idPrefix.length);
const resolved = resolvePath(currentSchema, path); const resolved = resolvePath(currentSchema, path);
return resolveValue(resolved, args, depth + 1); // recursión con depth+1 return resolveValue(resolved, args, depth + 1);
} }
if (typeof value === 'function') { if (typeof value === 'function') {
return value(args[0]); return value(args[0]);
} }
return value; // LingRecord, string, lo que sea return value; // LingRecord, string, etc.
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// API pública // API pública
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/**
* Función principal de traducción.
* Soporta navegación por puntos (dot-notation), pluralización e interpolación.
*/
const t: LingInstance<S>['t'] = (path: string, ...args: any[]): any => { const t: LingInstance<S>['t'] = (path: string, ...args: any[]): any => {
// 1. Buscamos el valor en el árbol de traducciones
const rawValue = resolvePath(currentSchema, path); const rawValue = resolvePath(currentSchema, path);
// 2. Si no existe nada en esa ruta, devolvemos el path y logueamos error
if (rawValue === undefined) { if (rawValue === undefined) {
if (isDev()) { if (isDev()) logger.error(loggerCategory, LING_ERRORS.KEY_NOT_FOUND(path));
logger.error(loggerCategory, LING_ERRORS.KEY_NOT_FOUND(path));
}
return path; return path;
} }
let finalValue = rawValue; // Toda la resolución de referencias y funciones vive en resolveValue.
// t() solo orquesta: busca → resuelve → traduce.
const finalValue = resolveValue(rawValue, args, 0);
finalValue = resolveValue(rawValue, args, 0);
// 4. LÓGICA DE EJECUCIÓN: ¿Es una función (plural) o un objeto (LingRecord)?
// CASO A: Es una función (ej: resultado de p())
if (typeof finalValue === 'function') { if (typeof finalValue === 'function') {
// Ejecutamos la función pasándole los parámetros (args[0]) return tsRecord(finalValue(args[0]), path, args[0]);
// Esto devuelve un LingRecord (ej: { es: '1 mensaje', en: '1 message' })
const record = finalValue(args[0]);
// Delegamos en tsRecord para elegir el idioma e interpolar {{variables}}
return tsRecord(record, path, args[0]);
} }
// CASO B: Es un LingRecord directo (objeto con idiomas { es: '...', en: '...' })
if (isLingRecord(finalValue)) { if (isLingRecord(finalValue)) {
return tsRecord(finalValue, path, args[0]); return tsRecord(finalValue, path, args[0]);
} }
// CASO C: Es un string simple o fallback
// Si por algún motivo llegamos a un valor que no es objeto ni función
return String(finalValue); return String(finalValue);
}; };
@ -196,7 +155,6 @@ export function createLing<S extends LingNode>(
function ts(value: LingString): string { function ts(value: LingString): string {
if (!value) return ''; if (!value) return '';
const finalValue = resolveValue(value, [], 0); const finalValue = resolveValue(value, [], 0);
if (isLingRecord(finalValue)) return tsRecord(finalValue); if (isLingRecord(finalValue)) return tsRecord(finalValue);
return typeof finalValue === 'string' ? finalValue : String(finalValue); return typeof finalValue === 'string' ? finalValue : String(finalValue);
} }
@ -214,37 +172,54 @@ export function createLing<S extends LingNode>(
return result; return result;
} }
/**
* Añade un módulo lazy al schema en runtime mutando la instancia actual.
* El type-safety viene de FullSchema declarado en instance.ts — no de esta función.
* extend() solo mueve el runtime para que coincida con lo que TypeScript ya sabe.
*
* @example
* const { shopTranslations } = await import('@/shop/translations');
* ling.extend('shop', shopTranslations);
*/
function extend(namespace: string, module: LingNode): void {
currentSchema = {
...(currentSchema as Record<string, any>),
[namespace]: module,
};
}
/**
* Registra un módulo y devuelve una nueva instancia con el tipo actualizado.
* Útil para contextos aislados, tests, o cuando necesitas el tipo inferido
* sin declarar FullSchema de antemano.
*
* @example
* const lingTest = createLing(baseSchema, 'es').register('shop', shopTranslations);
* lingTest.t('shop.product'); // ✅ tipado
*/
function register<NS extends string, M extends LingNode>( function register<NS extends string, M extends LingNode>(
namespace: NS, namespace: NS,
module: M module: M
): LingInstance<S & { [K in NS]: M }> { ): LingInstance<S & { [K in NS]: M }> {
currentSchema = { const newSchema = {
...(currentSchema as Record<string, any>), ...(currentSchema as Record<string, any>),
[namespace]: module, [namespace]: module,
}; } as S & { [K in NS]: M };
const extended = createLing(
currentSchema as S & { [K in NS]: M },
defaultLocale
);
const extended = createLing(newSchema, defaultLocale);
extended.setLocale(currentLocale); extended.setLocale(currentLocale);
onLocaleChange(locale => extended.setLocale(locale)); onLocaleChange(locale => extended.setLocale(locale));
return extended; return extended;
} }
return { t, tForLocale, ts, setLocale, getLocale, onLocaleChange, register, setLogger }; return { t, tForLocale, ts, setLocale, getLocale, onLocaleChange, extend, register, setLogger };
} }
/**
* Helper para generar traducciones pluralizadas.
* Mapea cada idioma a sus respectivas reglas gramaticales.
*/
/** /**
* Helper de pluralización. * Helper de pluralización.
* El tipo de retorno ahora incluye '& Record<string, any>' para permitir * El tipo de retorno incluye `& Record<string, any>` para permitir
* parámetros adicionales de interpolación (como {{name}}). * parámetros adicionales de interpolación (como {{name}}).
*/ */
export const p = (config: PluralConfig) => export const p = (config: PluralConfig) =>
@ -253,14 +228,20 @@ export const p = (config: PluralConfig) =>
for (const [locale, forms] of Object.entries(config)) { for (const [locale, forms] of Object.entries(config)) {
if (!forms) continue; if (!forms) continue;
const rule = pluralRule(locale, params.count);
// Seleccionamos la regla (one, other, etc.) según el idioma
const rule = new Intl.PluralRules(locale).select(params.count);
const typedForms = forms as PluralForms; const typedForms = forms as PluralForms;
// Si la regla específica no existe (ej: 'few'), usamos 'other'
result[locale] = typedForms[rule] || typedForms.other; result[locale] = typedForms[rule] || typedForms.other;
} }
return result as LingRecord; return result as LingRecord;
}; };
// Logger por defecto — console puro, sin dependencias externas.
// Se reemplaza con setLogger() una vez logr está inicializado.
const consoleLogger: LingLogger = {
warn : (category, message) => isDev() && console.warn (message),
error: (category, message) => isDev() && console.error(message),
};

@ -1,12 +1,30 @@
import { createLing } from './engine.ts'; import { createLing } from './engine.ts';
import type { TranslationSchema } from './translations';
import {translations} from './translations'; import {translations} from './translations';
// ─── CASO EAGER (todo conocido en build time) ────────────────────────────────
// No se necesita nada más.
export const ling = createLing<TranslationSchema>(translations, 'es');
export const ling = createLing(translations, 'es'); // ─── CASO LAZY (módulos cargados en runtime) ─────────────────────────────────
// FullSchema declara en build time los tipos de los módulos lazy.
// import type no genera código — cero coste en el bundle.
// extend() en runtime mueve el schema para que coincida con lo que TypeScript ya sabe.
//
// import type { shopTranslations } from '@/shop/translations';
// import type { adminTranslations } from '@/admin/translations';
//
// export type FullSchema = typeof translations & {
// shop : typeof shopTranslations;
// admin : typeof adminTranslations;
// };
//
// export const ling = createLing<FullSchema>(translations, 'es');
//
// // En el router, cuando el módulo se carga:
// const { shopTranslations } = await import('@/shop/translations');
// ling.extend('shop', shopTranslations);
// Desestructura si prefieres usar t() directamente
export const { t, tForLocale, setLocale, getLocale, onLocaleChange } = ling; export const { t, tForLocale, setLocale, getLocale, onLocaleChange } = ling;

@ -0,0 +1,323 @@
/**
* Reglas de pluralización CLDR para cardinales.
* Derivadas de la especificación Unicode CLDR (https://cldr.unicode.org/index/cldr-spec/plural-rules)
* y equivalentes a las generadas por make-plural (MIT License, https://github.com/eemeli/make-plural).
*
* Cero dependencias de entorno — funciona en Node, browser, edge, workers.
*
* Forma de uso:
* pluralRule('es', 1) // → 'one'
* pluralRule('es', 2) // → 'other'
* pluralRule('ar', 0) // → 'zero'
* pluralRule('ru', 3) // → 'few'
*/
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
type PluralFn = (n: number) => PluralCategory;
// ─── HELPERS ─────────────────────────────────────────────────────────────────
/** Parte entera de n */
const i = (n: number) => Math.floor(Math.abs(n));
/** Número de dígitos decimales visibles (sin trailing zeros) */
const v = (n: number) => {
const s = String(n);
const d = s.indexOf('.');
return d < 0 ? 0 : s.length - d - 1;
};
/** Dígitos decimales visibles como número entero (sin trailing zeros) */
const f = (n: number) => {
const s = String(n);
const d = s.indexOf('.');
return d < 0 ? 0 : parseInt(s.slice(d + 1).replace(/0+$/, '') || '0', 10);
};
/** n mod m */
const mod = (n: number, m: number) => n % m;
// ─── REGLAS POR LOCALE ───────────────────────────────────────────────────────
const rules: Record<string, PluralFn> = {
// ── one/other (n = 1 → one) ───────────────────────────────────────────────
// af, an, asa, az, bem, bez, bg, brx, ce, cgg, chr, ckb, dv, ee, el,
// eo, es, eu, fo, fur, gsw, ha, haw, hu, jgo, jmc, ka, kaj, kcg, kk,
// kkj, kl, ks, ksb, ku, ky, lb, lg, mas, mgo, ml, mn, mr, nah, nb,
// nd, ne, nn, nnh, no, nr, ny, nyn, om, or, os, pap, ps, rm, rof,
// rwk, saq, sd, seh, sn, so, sq, ss, ssy, st, syr, ta, te, teo,
// tig, tk, tn, tr, ts, uve, uz, ve, vo, vun, wae, xh, xog
af: n => n === 1 ? 'one' : 'other',
an: n => n === 1 ? 'one' : 'other',
az: n => n === 1 ? 'one' : 'other',
bg: n => n === 1 ? 'one' : 'other',
bn: n => i(n) === 0 || n === 1 ? 'one' : 'other',
ca: n => n === 1 && v(n) === 0 ? 'one' : 'other',
da: n => n === 1 || (n !== Math.floor(n) && [0, 1].includes(i(n))) ? 'one' : 'other',
de: n => n === 1 && v(n) === 0 ? 'one' : 'other',
el: n => n === 1 ? 'one' : 'other',
en: n => n === 1 && v(n) === 0 ? 'one' : 'other',
eo: n => n === 1 ? 'one' : 'other',
es: n => n === 1 ? 'one' : 'other',
et: n => n === 1 && v(n) === 0 ? 'one' : 'other',
eu: n => n === 1 ? 'one' : 'other',
fi: n => n === 1 && v(n) === 0 ? 'one' : 'other',
gl: n => n === 1 && v(n) === 0 ? 'one' : 'other',
gu: n => i(n) === 0 || n === 1 ? 'one' : 'other',
he: n => n === 1 && v(n) === 0 ? 'one' : n === 2 && v(n) === 0 ? 'two' : v(n) !== 0 ? 'many' : 'other',
hi: n => i(n) === 0 || n === 1 ? 'one' : 'other',
hu: n => n === 1 ? 'one' : 'other',
hy: n => i(n) === 0 || i(n) === 1 ? 'one' : 'other',
id: _ => 'other',
is: n => {
const mod10 = mod(i(n), 10);
const mod100 = mod(i(n), 100);
return (mod10 === 1 && mod100 !== 11) ? 'one' : 'other';
},
it: n => n === 1 && v(n) === 0 ? 'one' : 'other',
ja: _ => 'other',
ka: n => n === 1 ? 'one' : 'other',
km: _ => 'other',
kn: n => i(n) === 0 || n === 1 ? 'one' : 'other',
ko: _ => 'other',
lt: n => {
const n10 = mod(n, 10);
const n100 = mod(n, 100);
if (n10 === 1 && (n100 < 11 || n100 > 19)) return 'one';
if (n10 >= 2 && n10 <= 9 && (n100 < 11 || n100 > 19)) return 'few';
if (f(n) !== 0) return 'many';
return 'other';
},
lv: n => {
const n10 = mod(n, 10);
const n100 = mod(n, 100);
if (n === 0) return 'zero';
if (n10 === 1 && n100 !== 11) return 'one';
return 'other';
},
mk: n => {
const i_ = i(n);
const v_ = v(n);
if (v_ === 0 && mod(i_, 10) === 1 && mod(i_, 100) !== 11) return 'one';
if (v_ === 0 && mod(i_, 10) === 2 && mod(i_, 100) !== 12) return 'two';
if ((v_ === 0 && (mod(i_, 10) === 7 || mod(i_, 10) === 8) && mod(i_, 100) !== 17 && mod(i_, 100) !== 18) ||
(v_ !== 0 && (mod(f(n), 10) === 7 || mod(f(n), 10) === 8))) return 'many';
return 'other';
},
ml: n => n === 1 ? 'one' : 'other',
mn: n => n === 1 ? 'one' : 'other',
mr: n => n === 1 ? 'one' : 'other',
ms: _ => 'other',
my: _ => 'other',
nb: n => n === 1 ? 'one' : 'other',
ne: n => n === 1 ? 'one' : 'other',
nl: n => n === 1 && v(n) === 0 ? 'one' : 'other',
or: n => n === 1 ? 'one' : 'other',
pa: n => n === 0 || n === 1 ? 'one' : 'other',
pl: n => {
const v_ = v(n);
const i_ = i(n);
const n10 = mod(i_, 10);
const n100 = mod(i_, 100);
if (i_ === 1 && v_ === 0) return 'one';
if (v_ === 0 && n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
if (v_ === 0 && i_ !== 1 && (n10 === 0 || n10 === 1) ||
v_ === 0 && n10 >= 5 && n10 <= 9 ||
v_ === 0 && n100 >= 12 && n100 <= 14) return 'many';
return 'other';
},
pt: n => n >= 0 && n < 2 ? 'one' : 'other',
ro: n => {
const v_ = v(n);
const n100 = mod(n, 100);
if (i(n) === 1 && v_ === 0) return 'one';
if (v_ !== 0 || n === 0 || (n100 >= 2 && n100 <= 19)) return 'few';
return 'other';
},
ru: n => {
const v_ = v(n);
if (v_ !== 0) return 'other';
const n10 = mod(i(n), 10);
const n100 = mod(i(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
si: n => n === 0 || n === 1 || (i(n) === 0 && f(n) === 1) ? 'one' : 'other',
sk: n => {
const v_ = v(n);
const i_ = i(n);
if (i_ === 1 && v_ === 0) return 'one';
if (i_ >= 2 && i_ <= 4 && v_ === 0) return 'few';
if (v_ !== 0) return 'many';
return 'other';
},
sl: n => {
const v_ = v(n);
const n100 = mod(i(n), 100);
if (n100 === 1 && v_ === 0) return 'one';
if (n100 === 2 && v_ === 0) return 'two';
if ((n100 >= 3 && n100 <= 4 || v_ !== 0)) return 'few';
return 'other';
},
sq: n => n === 1 ? 'one' : 'other',
sr: n => {
const v_ = v(n);
const i_ = i(n);
const n10 = v_ === 0 ? mod(i_, 10) : mod(f(n), 10);
const n100 = v_ === 0 ? mod(i_, 100) : mod(f(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
sv: n => n === 1 && v(n) === 0 ? 'one' : 'other',
sw: n => n === 1 && v(n) === 0 ? 'one' : 'other',
ta: n => n === 1 ? 'one' : 'other',
te: n => n === 1 ? 'one' : 'other',
th: _ => 'other',
tr: n => n === 1 ? 'one' : 'other',
uk: n => {
const v_ = v(n);
if (v_ !== 0) return 'other';
const n10 = mod(i(n), 10);
const n100 = mod(i(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
ur: n => n === 1 && v(n) === 0 ? 'one' : 'other',
uz: n => n === 1 ? 'one' : 'other',
vi: _ => 'other',
zh: _ => 'other',
zu: n => i(n) === 0 || n === 1 ? 'one' : 'other',
// ── Árabe — 6 formas ──────────────────────────────────────────────────────
ar: n => {
if (n === 0) return 'zero';
if (n === 1) return 'one';
if (n === 2) return 'two';
const n100 = mod(n, 100);
if (n100 >= 3 && n100 <= 10) return 'few';
if (n100 >= 11 && n100 <= 99) return 'many';
return 'other';
},
// ── Galés — 6 formas ──────────────────────────────────────────────────────
cy: n => {
if (n === 0) return 'zero';
if (n === 1) return 'one';
if (n === 2) return 'two';
if (n === 3) return 'few';
if (n === 6) return 'many';
return 'other';
},
// ── Bretón — 5 formas ─────────────────────────────────────────────────────
br: n => {
const n10 = mod(n, 10);
const n100 = mod(n, 100);
const n1000000 = mod(n, 1000000);
if (n10 === 1 && n100 !== 11 && n100 !== 71 && n100 !== 91) return 'one';
if (n10 === 2 && n100 !== 12 && n100 !== 72 && n100 !== 92) return 'two';
if ((n10 === 3 || n10 === 4 || n10 === 9) && (n100 < 10 || n100 > 19) && (n100 < 70 || n100 > 79) && (n100 < 90 || n100 > 99)) return 'few';
if (n !== 0 && n1000000 === 0) return 'many';
return 'other';
},
// ── Francés ───────────────────────────────────────────────────────────────
fr: n => i(n) === 0 || i(n) === 1 ? 'one' : 'other',
// ── Gallego ───────────────────────────────────────────────────────────────
// (mismo que es, pt para cardinales)
// ── Irlandés — 5 formas ───────────────────────────────────────────────────
ga: n => {
if (n === 1) return 'one';
if (n === 2) return 'two';
if (n >= 3 && n <= 6) return 'few';
if (n >= 7 && n <= 10) return 'many';
return 'other';
},
// ── Escocés gaélico — 4 formas ────────────────────────────────────────────
gd: n => {
if (n === 1 || n === 11) return 'one';
if (n === 2 || n === 12) return 'two';
if ((n >= 3 && n <= 10) || (n >= 13 && n <= 19)) return 'few';
return 'other';
},
// ── Maltés — 4 formas ─────────────────────────────────────────────────────
mt: n => {
const n100 = mod(n, 100);
if (n === 1) return 'one';
if (n === 0 || (n100 >= 2 && n100 <= 10)) return 'few';
if (n100 >= 11 && n100 <= 19) return 'many';
return 'other';
},
// ── Bosnio/Croata/Serbio ──────────────────────────────────────────────────
bs: n => {
const v_ = v(n);
const i_ = i(n);
const n10 = v_ === 0 ? mod(i_, 10) : mod(f(n), 10);
const n100 = v_ === 0 ? mod(i_, 100) : mod(f(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
hr: n => {
const v_ = v(n);
const i_ = i(n);
const n10 = v_ === 0 ? mod(i_, 10) : mod(f(n), 10);
const n100 = v_ === 0 ? mod(i_, 100) : mod(f(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
// ── Bielorruso ────────────────────────────────────────────────────────────
be: n => {
const n10 = mod(n, 10);
const n100 = mod(n, 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
// ── Checo/Eslovaco ────────────────────────────────────────────────────────
cs: n => {
const v_ = v(n);
const i_ = i(n);
if (i_ === 1 && v_ === 0) return 'one';
if (i_ >= 2 && i_ <= 4 && v_ === 0) return 'few';
if (v_ !== 0) return 'many';
return 'other';
},
// ── Amhárico/Tigriña ──────────────────────────────────────────────────────
am: n => i(n) === 0 || n === 1 ? 'one' : 'other',
// ── Persa ─────────────────────────────────────────────────────────────────
fa: n => i(n) === 0 || n === 1 ? 'one' : 'other',
};
/**
* Devuelve la categoría plural CLDR para un número y locale dados.
* Si el locale no está soportado, devuelve 'other' como fallback seguro.
*
* @example
* pluralRule('es', 1) // → 'one'
* pluralRule('es', 2) // → 'other'
* pluralRule('ar', 0) // → 'zero'
* pluralRule('ru', 3) // → 'few'
* pluralRule('xx', 5) // → 'other' (locale desconocido)
*/
export function pluralRule(locale: string, n: number): PluralCategory {
// Normalizar: 'es-ES' → 'es', 'zh-Hans' → 'zh'
const base = locale.split('-')[0].split('_')[0];
const fn = rules[base];
return fn ? fn(n) : 'other';
}

@ -0,0 +1,253 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createLing } from '@/ling/engine';
import type { LingNode } from '@/ling/types';
// ─── SCHEMAS DE TEST ─────────────────────────────────────────────────────────
const coreSchema = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' },
},
} satisfies LingNode;
const shopSchema = {
product: { es: 'Producto', en: 'Product' },
cart: { es: 'Carrito', en: 'Cart' },
total: (params: { amount: number; currency: string }) => ({
es: `Total: ${params.amount}${params.currency}`,
en: `Total: ${params.currency}${params.amount}`,
}),
} satisfies LingNode;
const adminSchema = {
dashboard: { es: 'Panel de control', en: 'Dashboard' },
users: { es: 'Usuarios', en: 'Users' },
} satisfies LingNode;
// ─── TIPOS PARA LAZY ─────────────────────────────────────────────────────────
// FullSchema declara en build time los tipos de los módulos lazy.
// Se construye desde los tipos reales — nunca se desincroniza.
// En producción se usa import type para cero coste en bundle.
type CoreSchema = typeof coreSchema;
type ShopSchema = typeof shopSchema;
type AdminSchema = typeof adminSchema;
type FullSchema = CoreSchema & {
shop : ShopSchema;
admin : AdminSchema;
};
// =============================================================================
// EAGER — schema fusionado en build time
// =============================================================================
describe('Carga Eager', () => {
const eagerTranslations = {
...coreSchema,
shop: shopSchema,
admin: adminSchema,
} satisfies LingNode;
type EagerSchema = typeof eagerTranslations;
const ling = createLing<EagerSchema>(eagerTranslations, 'es');
it('resuelve claves del schema base', () => {
expect(ling.t('common.ok')).toBe('Aceptar');
expect(ling.t('common.cancel')).toBe('Cancelar');
});
it('resuelve claves del módulo shop', () => {
expect(ling.t('shop.product' as any)).toBe('Producto');
expect(ling.t('shop.cart' as any)).toBe('Carrito');
});
it('resuelve claves del módulo admin', () => {
expect(ling.t('admin.dashboard' as any)).toBe('Panel de control');
expect(ling.t('admin.users' as any)).toBe('Usuarios');
});
it('resuelve funciones con parámetros en módulos eager', () => {
expect(ling.t('shop.total' as any, { amount: 99, currency: '€' })).toBe('Total: 99€');
});
it('cambia locale y resuelve todos los módulos correctamente', () => {
ling.setLocale('en');
expect(ling.t('common.ok')).toBe('OK');
expect(ling.t('shop.product' as any)).toBe('Product');
expect(ling.t('admin.dashboard' as any)).toBe('Dashboard');
expect(ling.t('shop.total' as any, { amount: 99, currency: '€' })).toBe('Total: €99');
ling.setLocale('es');
});
it('todos los módulos están disponibles desde el arranque', () => {
// No hay ventana de tiempo en que las claves no existan
expect(ling.t('shop.product' as any)).not.toBe('shop.product');
expect(ling.t('admin.users' as any)).not.toBe('admin.users');
});
});
// =============================================================================
// LAZY — extend() sobre instancia global
// Los tests de lazy verifican comportamiento en runtime, no tipos.
// El type-safety del lazy se verifica en instance.ts con FullSchema + import type.
// Aquí usamos createLing sin genérico — t() acepta cualquier string.
// =============================================================================
describe('Carga Lazy con extend()', () => {
let ling: ReturnType<typeof createLing<typeof coreSchema>>;
beforeEach(() => {
ling = createLing(coreSchema, 'es');
});
it('resuelve claves del schema base antes de extend()', () => {
expect(ling.t('common.ok')).toBe('Aceptar');
});
it('devuelve el path si el módulo no está cargado aún', () => {
// Degradación controlada — sin excepciones
expect(ling.t('shop.product' as any)).toBe('shop.product');
expect(ling.t('admin.dashboard' as any)).toBe('admin.dashboard');
});
it('resuelve claves del módulo shop tras extend()', () => {
ling.extend('shop', shopSchema);
expect(ling.t('shop.product' as any)).toBe('Producto');
expect(ling.t('shop.cart' as any)).toBe('Carrito');
});
it('resuelve claves del módulo admin tras extend()', () => {
ling.extend('admin', adminSchema);
expect(ling.t('admin.dashboard' as any)).toBe('Panel de control');
expect(ling.t('admin.users' as any)).toBe('Usuarios');
});
it('resuelve funciones con parámetros en módulos lazy', () => {
ling.extend('shop', shopSchema);
expect(ling.t('shop.total' as any, { amount: 50, currency: '$' })).toBe('Total: 50$');
});
it('el schema base sigue intacto tras extend()', () => {
ling.extend('shop', shopSchema);
expect(ling.t('common.ok')).toBe('Aceptar');
expect(ling.t('common.cancel')).toBe('Cancelar');
});
it('múltiples extend() son acumulativos', () => {
ling.extend('shop', shopSchema);
ling.extend('admin', adminSchema);
expect(ling.t('shop.product' as any)).toBe('Producto');
expect(ling.t('admin.users' as any)).toBe('Usuarios');
expect(ling.t('common.ok')).toBe('Aceptar');
});
it('cambia locale y los módulos extendidos responden correctamente', () => {
ling.extend('shop', shopSchema);
ling.setLocale('en');
expect(ling.t('shop.product' as any)).toBe('Product');
expect(ling.t('common.ok')).toBe('OK');
ling.setLocale('es');
});
it('extend() con el mismo namespace sobreescribe el módulo anterior', () => {
ling.extend('shop', shopSchema);
ling.extend('shop', { product: { es: 'Artículo', en: 'Item' } });
expect(ling.t('shop.product' as any)).toBe('Artículo');
});
});
// =============================================================================
// register() — nueva instancia tipada
// =============================================================================
describe('register() — nueva instancia tipada', () => {
it('devuelve una nueva instancia con el módulo añadido', () => {
const base = createLing(coreSchema, 'es');
const withShop = base.register('shop', shopSchema);
expect(withShop.t('shop.product')).toBe('Producto');
expect(withShop.t('common.ok')).toBe('Aceptar');
});
it('la instancia original no se modifica', () => {
const base = createLing(coreSchema, 'es');
base.register('shop', shopSchema);
// base no conoce 'shop'
expect((base as any).t('shop.product')).toBe('shop.product');
});
it('se pueden encadenar múltiples register()', () => {
const ling = createLing(coreSchema, 'es')
.register('shop', shopSchema)
.register('admin', adminSchema);
expect(ling.t('shop.product' as any)).toBe('Producto');
expect(ling.t('admin.dashboard' as any)).toBe('Panel de control');
expect(ling.t('common.ok')).toBe('Aceptar');
});
it('resuelve funciones con parámetros en la instancia registrada', () => {
const ling = createLing(coreSchema, 'es').register('shop', shopSchema);
expect(ling.t('shop.total' as any, { amount: 10, currency: '€' })).toBe('Total: 10€');
});
it('hereda el locale activo de la instancia original', () => {
const base = createLing(coreSchema, 'es');
base.setLocale('en');
const withShop = base.register('shop', shopSchema);
expect(withShop.t('shop.product')).toBe('Product');
expect(withShop.t('common.ok')).toBe('OK');
});
it('el cambio de locale en la instancia original se propaga a la registrada', () => {
const base = createLing(coreSchema, 'es');
const withShop = base.register('shop', shopSchema);
base.setLocale('en');
expect(withShop.t('shop.product')).toBe('Product');
base.setLocale('es');
expect(withShop.t('shop.product')).toBe('Producto');
});
});
// =============================================================================
// extend() vs register() — diferencias de comportamiento
// =============================================================================
describe('extend() vs register() — contratos distintos', () => {
it('extend() muta la instancia — register() no', () => {
const base = createLing(coreSchema, 'es');
// register() — instancia nueva, base intacta
const withShop = base.register('shop', shopSchema);
expect(base.t('shop.product' as any)).toBe('shop.product'); // base no tiene shop
expect(withShop.t('shop.product')).toBe('Producto'); // withShop sí
// extend() — muta base
base.extend('admin', adminSchema);
expect(base.t('admin.dashboard' as any)).toBe('Panel de control'); // base ahora tiene admin
});
it('extend() es visible en la misma referencia sin reasignar', () => {
const ling = createLing(coreSchema, 'es');
const ref = ling; // misma referencia
ling.extend('shop', shopSchema);
expect(ref.t('shop.product' as any)).toBe('Producto'); // ref ve el cambio
});
});

@ -125,7 +125,7 @@ describe('createI18n', () => {
}); });
it('devuelve la clave si no existe en ningún locale', () => { it('devuelve la clave si no existe en ningún locale', () => {
// @ts-expect-error — clave inexistente a propósito
expect(i18n.t('this.key.does.not.exist')).toBe('this.key.does.not.exist'); expect(i18n.t('this.key.does.not.exist')).toBe('this.key.does.not.exist');
}); });

@ -1,9 +1,12 @@
import { p } from './engine.ts';
import type { LingNode } from './types.ts'; import type { LingNode } from './types.ts';
/** /**
* MEJORA: `satisfies TranslationNode` en lugar de `satisfies Record<string, any>`. * Sin `: LingNode` en la declaración — TypeScript infiere el tipo exacto.
* Ahora TypeScript valida que cada hoja sea un LocaleRecord válido o una función tipada. * `satisfies LingNode` valida la estructura sin borrar la información.
* Si añades una clave malformada (ej: { es: 123 }), obtendrás un error en tiempo de compilación. *
* Esto permite que LeafPaths<TranslationSchema> derive rutas concretas
* como "checkout.total" y que t() quede completamente type-safe.
*/ */
export const translations = { export const translations = {
checkout: { checkout: {
@ -12,6 +15,8 @@ export const translations = {
en: "Pay" en: "Pay"
}, },
// LingPluralFn no aplica aquí, pero el tipo se infiere correctamente
// porque p() devuelve (params: { count: number } & ...) => LingRecord
total: (params: { amount: number; currency: string }) => ({ total: (params: { amount: number; currency: string }) => ({
es: `Total: ${params.amount}${params.currency}`, es: `Total: ${params.amount}${params.currency}`,
en: `Total: ${params.currency}${params.amount}` en: `Total: ${params.currency}${params.amount}`
@ -38,9 +43,16 @@ export const translations = {
es: `Ha ocurrido un error (${params.code})`, es: `Ha ocurrido un error (${params.code})`,
en: `An error occurred (${params.code})` en: `An error occurred (${params.code})`
}) })
},
// Ejemplo de uso de p() con LingPluralFn — TypeScript infiere { count: number }
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; } satisfies LingNode;
export type TranslationSchema = typeof translations; export type TranslationSchema = typeof translations;

@ -2,19 +2,16 @@
// LOCALES // LOCALES
// ============================== // ==============================
import type {idPrefix} from "@/ling/consts.ts";
import {idPrefix} from "@/ling/consts.ts"; import type {PluralCategory} from "@/ling/plural-rules.ts";
/** /**
* Referencia interna: #?path.del.schema * Referencia interna: #?path.del.schema
*/ */
export type IDLing = `${typeof idPrefix}${string}`; export type IDLing = `${typeof idPrefix}${string}`;
export type DefaultLocale = 'es'; export type DefaultLocale = 'es';
export type SupportedLocale = export type SupportedLocale =
| DefaultLocale | DefaultLocale
| 'en' | 'en'
@ -27,7 +24,6 @@ export type SupportedLocale =
| 'gl'; | 'gl';
// ============================== // ==============================
// LOCALIZED TYPES // LOCALIZED TYPES
// ============================== // ==============================
@ -42,56 +38,36 @@ export type LingRecord = {
[K in DefaultLocale]: string; [K in DefaultLocale]: string;
}; };
/** /**
* Params tipado con un genérico en lugar de `any`, * Función de traducción tipada con parámetros genéricos.
* así las funciones de traducción con parámetros son completamente type-safe.
*/ */
export type LingFn<P extends Record<string, unknown>> = (params: P) => LingRecord; export type LingFn<P extends Record<string, unknown>> = (params: P) => LingRecord;
/**
* Función de pluralización: siempre requiere `count` más cualquier extra.
* Separado de LingFn para poder detectarlo explícitamente en HasParams.
*/
export type LingPluralFn<P extends Record<string, unknown> = Record<never, never>> =
(params: { count: number } & P) => LingRecord;
export type LingValue = export type LingValue =
| LingRecord | LingRecord
| ((...args: any[]) => any) | LingFn<any>
| LingPluralFn<any>
| IDLing; | IDLing;
/** /**
* LING NODE: Es el tipo recursivo. * Nodo recursivo del schema de traducciones.
* Un nodo puede ser un valor final (LingValue)
* o un objeto que contiene más LingNodes.
*/ */
export type LingNode = export type LingNode =
| LingValue | LingValue
| { [key: string]: LingNode }; | { [key: string]: LingNode };
/**
* Un valor que puede ser un string simple (invariante de locale)
* o un LocaleRecord con traducciones por locale.
* Útil para campos de datos que pueden o no estar localizados.
*
* @example
* interface Product {
* id: string;
* description: LingString;
* }
*
* const product: Product = {
* id: '1',
* description: { es: 'una descripción', en: 'a description' }
* };
*
* // o también válido:
* const product2: Product = {
* id: '2',
* description: 'fixed string'
* };
*/
export type LingString = export type LingString =
string | | string
LingRecord | | LingRecord
IDLing; | IDLing;
// ============================== // ==============================
// TYPE UTILITIES // TYPE UTILITIES
@ -101,7 +77,7 @@ type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
/** /**
* `LeafPaths` solo expone las rutas que terminan en una hoja * `LeafPaths` solo expone las rutas que terminan en una hoja
* (LocaleRecord o función), no las rutas intermedias (namespaces). * (LingRecord o función), no las rutas intermedias (namespaces).
*/ */
export type LeafPaths<T, D extends number = 6> = export type LeafPaths<T, D extends number = 6> =
[D] extends [never] [D] extends [never]
@ -129,27 +105,35 @@ export type GetTypeAtPath<
: never : never
: P extends keyof Current : P extends keyof Current
? Current[P] extends `#?${infer AliasPath}` ? Current[P] extends `#?${infer AliasPath}`
? GetTypeAtPath<Root, Root, AliasPath> // 🔍 Salto cuántico: reiniciamos desde el Root ? GetTypeAtPath<Root, Root, AliasPath>
: Current[P] : Current[P]
: never; : never;
/**
* Extrae los parámetros de una función de traducción o pluralización.
* Para LingPluralFn siempre incluirá `count: number`.
*/
export type ParamsFor<T> = export type ParamsFor<T> =
T extends (params: infer P) => any T extends (params: infer P) => any
? P ? P
: never; : never;
/**
* Detecta si un tipo requiere parámetros:
* - LingFn<P> → true (parámetros arbitrarios)
* - LingPluralFn<P> → true (siempre incluye count)
* - LingRecord → false (sin parámetros)
* - IDLing → false (referencia, se resuelve)
*/
export type HasParams<T> = export type HasParams<T> =
T extends (params: any) => any T extends (params: any) => any
? true ? true
: false; : false;
/** * Define las formas posibles según el estándar Unicode (zero, one, two, few, many, other) /** Formas plurales según el estándar Unicode */
*/ export type PluralForms = Partial<Record<PluralCategory, string>> & { other: string };
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string };
export type PluralConfig = { export type PluralConfig = {
[K in SupportedLocale]?: PluralForms; [K in SupportedLocale]?: PluralForms;
@ -158,27 +142,19 @@ export type PluralConfig = {
}; };
// ============================== // ==============================
// INSTANCE TYPE // INSTANCE TYPE
// ============================== // ==============================
/**
* Tipo de la instancia de ling parametrizado por el schema S.
* Usar este tipo en lugar de `ReturnType<typeof createLing>`
* para preservar la información del schema y tener t() tipado correctamente.
*
* @example
* function useTranslations(ling: LingInstance<typeof mySchema>) {
* ling.t('my.key') // ✅ tipado contra mySchema
* }
*/
export type LingInstance<S extends LingNode = LingNode> = { export type LingInstance<S extends LingNode = LingNode> = {
// Fíjate en el GetTypeAtPath<S, S, P> (pasamos la S dos veces: como Root y como Current) t: {
t: <P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>( <P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P, path: P,
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : [] ...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
) => string; ): string;
(path: string, params?: Record<string, any>): string;
};
ts: (value: LingString) => string; ts: (value: LingString) => string;
tForLocale: <P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>( tForLocale: <P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
@ -190,45 +166,22 @@ export type LingInstance<S extends LingNode = LingNode> = {
setLocale : (locale: SupportedLocale) => void; setLocale : (locale: SupportedLocale) => void;
getLocale : () => SupportedLocale; getLocale : () => SupportedLocale;
onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void; onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void;
/** Muta la instancia actual añadiendo un módulo lazy. El type-safety viene de FullSchema. */
extend : (namespace: string, module: LingNode) => void;
/** Devuelve una nueva instancia con el tipo actualizado. Útil para tests o contextos aislados. */
register : <NS extends string, M extends LingNode>( register : <NS extends string, M extends LingNode>(
namespace: NS, namespace: NS,
module: M module: M
) => LingInstance<S & { [K in NS]: M }>; ) => LingInstance<S & { [K in NS]: M }>;
/**
* Inyecta un logger externo que reemplaza el comportamiento por defecto (console).
* Solo puede llamarse una vez — una vez inyectado no se puede reemplazar.
* En desarrollo emite un warning si se intenta llamar más de una vez.
*
* Diseñado para romper la dependencia cíclica ling ↔ logr:
* ling arranca con console, logr se inicializa con ling,
* y entonces ling adopta logr como logger definitivo.
*
* @example
* const ling = createLing(translations, "es"); // usa console
* const logr = createLogr(ling, options); // logr listo
* ling.setLogger(logr); // ling adopta logr
*/
setLogger : (logger: LingLogger) => void; setLogger : (logger: LingLogger) => void;
}; };
// ============================== // ==============================
// LING LOGGER INTERFACE // LING LOGGER INTERFACE
// ============================== // ==============================
/**
* Interfaz mínima estructural que ling necesita de un logger.
* Intencionalmente no es `Logr` completo para evitar dependencia
* de compilación entre ling y logr.
*
* Cualquier objeto que tenga `warn` y `error` con esta firma es válido.
*
* @example
* // Logr satisface esta interfaz automáticamente
* ling.setLogger(logr);
*
* // También un logger custom
* ling.setLogger({ warn: console.warn, error: console.error });
*/
export interface LingLogger { export interface LingLogger {
warn : (category: string, message: string) => void; warn : (category: string, message: string) => void;
error: (category: string, message: string) => void; error: (category: string, message: string) => void;

Loading…
Cancel
Save

Powered by TurnKey Linux.