dev 7 months ago
parent 0426d9f46d
commit 04580608a2

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

@ -1,295 +1,382 @@
# 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/
├── index.ts # Barrel — punto de entrada público
├── engine.ts # Singleton global (wiring de schema base + factory)
├── instance.ts # Factory: createLing()
├── types.ts # Tipos e interfaces
├── translations.ts # Traducciones base de la aplicación
└── tests/
└── ling.test.ts
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
```
Cada módulo de la aplicación define sus propias traducciones y las registra en el singleton:
### Flujo de resolución
```
auth/
└── ling.ts # Registra el namespace 'auth'
Cuando llamas a `ling.t('some.key', params)` el engine sigue este orden:
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
// translations.ts
import type { TranslationNode } from './ling.types';
export const translations = {
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;
const greeting: LingRecord = {
es: 'Hola',
en: 'Hello',
fr: 'Bonjour',
};
```
> `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
// ling.engine.ts
import { createLing } from './ling.factory';
import { translations } from './translations';
### LingFn
export const ling = createLing(translations, 'es');
```
### 3. Cada módulo registra sus traducciones
Función de traducción con parámetros tipados. TypeScript infiere los parámetros requeridos y los exige en `t()`.
```ts
// auth/ling.ts
import { ling } from '@/ling.engine';
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}`,
}),
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.
```ts
// checkout/ling.ts
import { ling } from '@/ling.engine';
const schema = {
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' },
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;
### 4. Uso dentro de cada módulo
export type TranslationSchema = typeof translations;
```
```ts
// auth/login.ts
import { authLing } from './ling';
authLing.t('auth.loginFailed') // → "Login fallido"
authLing.t('auth.welcome', { name: 'Ana' }) // → "Bienvenido, Ana"
authLing.t('common.ok') // → "Aceptar" (base disponible)
// ❌ Incorrecto — borra la información de rutas, t() pierde type-safety
export const translations: LingNode = { ... };
```
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
### `t(path, params?)`
## Uso básico
Traduce una clave del schema al locale actual.
### Traducción simple
```ts
authLing.t('auth.loginFailed')
authLing.t('auth.welcome', { name: 'Ana' })
authLing.t('common.ok')
```
import { ling } from '@/ling';
- Solo acepta rutas que terminan en una traducción real — rutas intermedias como `'auth'` dan error de tipos.
- Los params son obligatorios si la traducción los requiere, y TypeScript los infiere automáticamente.
ling.t('checkout.pay'); // → 'Pagar' (locale: es)
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('texto fijo') // → "texto fijo" (pass-through)
ts({ es: 'una descripción', en: 'a description' }) // → "una descripción"
```
```ts
import type { LingString } from '@/ling';
interface Product {
id : string;
name: LingString;
}
ling.t('checkout.total', { amount: 99, currency: '€' }); // → 'Total: 99€'
ling.t('errors.generic', { code: 404 }); // → 'Ha ocurrido un error (404)'
const product: Product = { id: '1', name: { es: 'Silla', en: 'Chair' } };
ling.ts(product.name) // → "Silla"
// 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
```
### `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
authLing.tForLocale('auth.loginFailed', 'en') // → "Login failed"
ling.getLocale() // → "es" (no ha cambiado)
ling.t('messages.unread', { count: 3 }); // → '3 mensajes sin leer'
ling.t('messages.unread', { count: 1 }); // → '1 mensaje sin leer'
```
### `register(namespace, module)`
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.
### Cambio de locale
```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.
- Cada módulo ve sus claves tipadas más las del schema base.
- Encadenar `register()` acumula namespaces:
### Traducción para un locale puntual sin cambiar el activo
```ts
const full = ling
.register('auth', authTranslations)
.register('checkout', checkoutTranslations);
ling.tForLocale('common.ok', 'fr'); // → 'OK' (sin cambiar currentLocale)
```
### `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
ling.setLocale('en')
// authLing, checkoutLing... todos reflejan 'en' automáticamente
const description: LingString = { es: 'Descripción', en: 'Description' };
ling.ts(description); // → 'Descripción'
```
### `getLocale()`
### Escuchar cambios de locale
```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
const unsubscribe = ling.onLocaleChange((locale) => {
console.log('Nuevo locale:', locale);
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' },
});
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 |
|--------|--------|
| `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`:
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")`.
```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 |
|------|-------------|
| `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 |
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
## 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
import { ref } from 'vue';
import { ling } from '@/ling.engine';
import { ling } from '@/ling';
import { logr } from '@/logr';
export const locale = ref(ling.getLocale());
ling.onLocaleChange(l => locale.value = l);
ling.setLogger(logr);
```
**Svelte**
Cualquier objeto que implemente la interfaz `LingLogger` es válido:
```ts
import { writable } from 'svelte/store';
import { ling } from '@/ling.engine';
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:
export const locale = writable(ling.getLocale());
ling.onLocaleChange(l => locale.set(l));
```ts
// 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
import { useSyncExternalStore } from 'react';
import { ling } from '@/ling.engine';
// ✅ TypeScript conoce el schema exacto → t() queda completamente tipado
export const ling = createLing<TranslationSchema>(translations, 'es');
export function useLocale() {
return useSyncExternalStore(ling.onLocaleChange, ling.getLocale);
}
// ❌ Sin genérico, S = LingNode → LeafPaths<LingNode> = never → t() no compila
export const ling = createLing(translations, 'es');
```
---
## Tests
## Locales soportados
```bash
vitest
```
| 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 |
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,
LingString, LingInstance, LingLogger, LingNode, PluralConfig,
} from './types.ts';
import { LING_ERRORS } from './errors.ts';
import {isIDLing, isLingRecord} from "@/ling/guards.ts";
import {LING_ERRORS} from "@/ling/errors.ts";
import {idPrefix, loggerCategory, maxResolveDeep} from "@/ling/consts.ts";
import {isIDLing, isLingRecord} from "@/ling/guards.ts";
import {pluralRule} from "@/ling/plural-rules.ts";
// ==============================
// 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
// ==============================
@ -55,15 +48,9 @@ export function createLing<S extends LingNode>(
// Logger
// -------------------------------------------------------------------------
/**
* Inyecta un logger externo. Solo puede llamarse una vez.
* En desarrollo avisa si se intenta sobreescribir.
*/
function setLogger(external: LingLogger): void {
if (loggerSet) {
if (isDev()) {
console.warn(LING_ERRORS.LOGGER_ALREADY_SET);
}
if (isDev()) console.warn(LING_ERRORS.LOGGER_ALREADY_SET);
return;
}
logger = external;
@ -92,103 +79,75 @@ export function createLing<S extends LingNode>(
// Resolución interna
// -------------------------------------------------------------------------
function tsRecord(record: LingRecord, path?: string, params?: any): string {
const translationInLocale = record[currentLocale];
const isMissing = translationInLocale === undefined;
// 1. Resolución del valor (con fallback)
let translation = translationInLocale ?? record[defaultLocale] ?? path ?? '';
// 2. Interpolación global (aplica a la traducción final)
if (params) {
Object.entries(params).forEach(([key, 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) {
const msg = path
? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale)
: LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale);
logger.warn(loggerCategory, msg);
}
return translation;
}
/**
* Resuelve referencias de forma recursiva con un límite de profundidad
* para evitar bucles infinitos.
* Resuelve un valor del schema siguiendo referencias (#?) y funciones
* de forma recursiva. Lanza si se supera el límite de profundidad.
*/
function resolveValue(value: any, args: any[], depth: number): any {
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");
}
if (isIDLing(value)) {
const path = value.substring(idPrefix.length);
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') {
return value(args[0]);
}
return value; // LingRecord, string, lo que sea
return value; // LingRecord, string, etc.
}
// -------------------------------------------------------------------------
// 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 => {
// 1. Buscamos el valor en el árbol de traducciones
const rawValue = resolvePath(currentSchema, path);
// 2. Si no existe nada en esa ruta, devolvemos el path y logueamos error
if (rawValue === undefined) {
if (isDev()) {
logger.error(loggerCategory, LING_ERRORS.KEY_NOT_FOUND(path));
}
if (isDev()) logger.error(loggerCategory, LING_ERRORS.KEY_NOT_FOUND(path));
return path;
}
let finalValue = rawValue;
finalValue = resolveValue(rawValue, args, 0);
// Toda la resolución de referencias y funciones vive en resolveValue.
// t() solo orquesta: busca → resuelve → traduce.
const 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') {
// Ejecutamos la función pasándole los parámetros (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]);
return tsRecord(finalValue(args[0]), path, args[0]);
}
// CASO B: Es un LingRecord directo (objeto con idiomas { es: '...', en: '...' })
if (isLingRecord(finalValue)) {
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);
};
@ -196,7 +155,6 @@ export function createLing<S extends LingNode>(
function ts(value: LingString): string {
if (!value) return '';
const finalValue = resolveValue(value, [], 0);
if (isLingRecord(finalValue)) return tsRecord(finalValue);
return typeof finalValue === 'string' ? finalValue : String(finalValue);
}
@ -214,37 +172,54 @@ export function createLing<S extends LingNode>(
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>(
namespace: NS,
module: M
): LingInstance<S & { [K in NS]: M }> {
currentSchema = {
const newSchema = {
...(currentSchema as Record<string, any>),
[namespace]: module,
};
const extended = createLing(
currentSchema as S & { [K in NS]: M },
defaultLocale
);
} as S & { [K in NS]: M };
const extended = createLing(newSchema, defaultLocale);
extended.setLocale(currentLocale);
onLocaleChange(locale => extended.setLocale(locale));
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.
* 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}}).
*/
export const p = (config: PluralConfig) =>
@ -253,14 +228,20 @@ export const p = (config: PluralConfig) =>
for (const [locale, forms] of Object.entries(config)) {
if (!forms) continue;
// Seleccionamos la regla (one, other, etc.) según el idioma
const rule = new Intl.PluralRules(locale).select(params.count);
const rule = pluralRule(locale, params.count);
const typedForms = forms as PluralForms;
// Si la regla específica no existe (ej: 'few'), usamos 'other'
result[locale] = typedForms[rule] || typedForms.other;
}
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 type { TranslationSchema } 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;

@ -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', () => {
// @ts-expect-error — clave inexistente a propósito
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';
/**
* MEJORA: `satisfies TranslationNode` en lugar de `satisfies Record<string, any>`.
* Ahora TypeScript valida que cada hoja sea un LocaleRecord válido o una función tipada.
* Si añades una clave malformada (ej: { es: 123 }), obtendrás un error en tiempo de compilación.
* Sin `: LingNode` en la declaración — TypeScript infiere el tipo exacto.
* `satisfies LingNode` valida la estructura sin borrar la información.
*
* Esto permite que LeafPaths<TranslationSchema> derive rutas concretas
* como "checkout.total" y que t() quede completamente type-safe.
*/
export const translations = {
checkout: {
@ -12,6 +15,8 @@ export const translations = {
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 }) => ({
es: `Total: ${params.amount}${params.currency}`,
en: `Total: ${params.currency}${params.amount}`
@ -38,9 +43,16 @@ export const translations = {
es: `Ha ocurrido un error (${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;
export type TranslationSchema = typeof translations;

@ -2,19 +2,16 @@
// LOCALES
// ==============================
import {idPrefix} from "@/ling/consts.ts";
import type {idPrefix} from "@/ling/consts.ts";
import type {PluralCategory} from "@/ling/plural-rules.ts";
/**
* Referencia interna: #?path.del.schema
*/
export type IDLing = `${typeof idPrefix}${string}`;
export type DefaultLocale = 'es';
export type SupportedLocale =
| DefaultLocale
| 'en'
@ -27,7 +24,6 @@ export type SupportedLocale =
| 'gl';
// ==============================
// LOCALIZED TYPES
// ==============================
@ -42,56 +38,36 @@ export type LingRecord = {
[K in DefaultLocale]: string;
};
/**
* Params tipado con un genérico en lugar de `any`,
* así las funciones de traducción con parámetros son completamente type-safe.
* Función de traducción tipada con parámetros genéricos.
*/
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 =
| LingRecord
| ((...args: any[]) => any)
| LingFn<any>
| LingPluralFn<any>
| IDLing;
/**
* LING NODE: Es el tipo recursivo.
* Un nodo puede ser un valor final (LingValue)
* o un objeto que contiene más LingNodes.
* Nodo recursivo del schema de traducciones.
*/
export type LingNode =
| LingValue
| { [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 =
string |
LingRecord |
IDLing;
| string
| LingRecord
| IDLing;
// ==============================
// 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
* (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> =
[D] extends [never]
@ -129,27 +105,35 @@ export type GetTypeAtPath<
: never
: P extends keyof Current
? Current[P] extends `#?${infer AliasPath}`
? GetTypeAtPath<Root, Root, AliasPath> // 🔍 Salto cuántico: reiniciamos desde el Root
? GetTypeAtPath<Root, Root, AliasPath>
: Current[P]
: 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> =
T extends (params: infer P) => any
? P
: 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> =
T extends (params: any) => any
? true
: false;
/** * Define las formas posibles según el estándar Unicode (zero, one, two, few, many, other)
*/
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string };
/** Formas plurales según el estándar Unicode */
export type PluralForms = Partial<Record<PluralCategory, string>> & { other: string };
export type PluralConfig = {
[K in SupportedLocale]?: PluralForms;
@ -158,27 +142,19 @@ export type PluralConfig = {
};
// ==============================
// 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> = {
// Fíjate en el GetTypeAtPath<S, S, P> (pasamos la S dos veces: como Root y como Current)
t: <P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
t: {
<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P,
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
) => string;
): string;
(path: string, params?: Record<string, any>): string;
};
ts: (value: LingString) => string;
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;
getLocale : () => SupportedLocale;
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>(
namespace: NS,
module: 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;
};
// ==============================
// 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 {
warn : (category: string, message: string) => void;
error: (category: string, message: string) => void;

Loading…
Cancel
Save

Powered by TurnKey Linux.