master
parent
0426d9f46d
commit
04580608a2
@ -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`.
|
||||||
@ -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
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
Loading…
Reference in new issue