From 04580608a2ce3d2e2f1885dcf415afb1f590edd1 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 1 Mar 2026 01:45:17 +0100 Subject: [PATCH] . --- src/ling/consts.ts | 7 +- src/ling/docs/ling.md | 441 +++++++++++++++++----------- src/ling/engine.ts | 137 ++++----- src/ling/instance.ts | 28 +- src/ling/plural-rules.ts | 323 ++++++++++++++++++++ src/ling/tests/ling-schemas.test.ts | 253 ++++++++++++++++ src/ling/tests/ling.test.ts | 2 +- src/ling/translations.ts | 26 +- src/ling/types.ts | 139 +++------ 9 files changed, 992 insertions(+), 364 deletions(-) create mode 100644 src/ling/plural-rules.ts create mode 100644 src/ling/tests/ling-schemas.test.ts diff --git a/src/ling/consts.ts b/src/ling/consts.ts index 7d8898f..77fe9d8 100644 --- a/src/ling/consts.ts +++ b/src/ling/consts.ts @@ -1,5 +1,4 @@ - - +import type {LingLogger} from "@/ling/types.ts"; export const idPrefix = '#?'; @@ -8,4 +7,6 @@ export const defaultISOLocale = 'es' ; export const maxResolveDeep = 3; -export const loggerCategory = 'ling'; \ No newline at end of file +export const loggerCategory = 'ling'; + + diff --git a/src/ling/docs/ling.md b/src/ling/docs/ling.md index fe049b8..e87ca9e 100644 --- a/src/ling/docs/ling.md +++ b/src/ling/docs/ling.md @@ -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. - -### 2. Crea el singleton +### LingNode -```ts -// ling.engine.ts -import { createLing } from './ling.factory'; -import { translations } from './translations'; +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`. -export const ling = createLing(translations, 'es'); -``` +### LingFn -### 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}`, }); ``` -```ts -// checkout/ling.ts -import { ling } from '@/ling.engine'; - -export const checkoutLing = ling.register('checkout', { - pay : { es: 'Pagar', en: 'Pay' }, - total: (params: { amount: number; currency: string }) => ({ - es: `Total: ${params.amount}${params.currency}`, - en: `Total: ${params.currency}${params.amount}`, - }), -}); -``` +### LingPluralFn -### 4. Uso dentro de cada módulo +Función de pluralización. Siempre lleva `{ count: number }` más cualquier parámetro extra. Se construye con el helper `p()`. -```ts -// auth/login.ts -import { authLing } from './ling'; +### IDLing -authLing.t('auth.loginFailed') // → "Login fallido" -authLing.t('auth.welcome', { name: 'Ana' }) // → "Bienvenido, Ana" -authLing.t('common.ok') // → "Aceptar" (base disponible) -``` +Referencia interna con el prefijo `#?`. Permite que una clave apunte a otra sin duplicar la traducción. -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. +```ts +const schema = { + actions: { + confirm: { es: 'Confirmar', en: 'Confirm' }, + submit: '#?actions.confirm', // alias — mismo texto + } +}; +``` --- -## API +## 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` derive rutas concretas como `"checkout.total"`. -### `t(path, params?)` +```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; -Traduce una clave del schema al locale actual. +export type TranslationSchema = typeof translations; +``` ```ts -authLing.t('auth.loginFailed') -authLing.t('auth.welcome', { name: 'Ana' }) -authLing.t('common.ok') +// ❌ Incorrecto — borra la información de rutas, t() pierde type-safety +export const translations: LingNode = { ... }; ``` -- 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. +--- -### `ts(value)` +## Uso básico -*Translate String* — resuelve un `LingString` con el locale actual. Útil para campos de datos que pueden estar localizados o no. +### Traducción simple ```ts -ts('texto fijo') // → "texto fijo" (pass-through) -ts({ es: 'una descripción', en: 'a description' }) // → "una descripción" +import { ling } from '@/ling'; + +ling.t('checkout.pay'); // → 'Pagar' (locale: es) +ling.t('common.cancel'); // → 'Cancelar' ``` -```ts -import type { LingString } from '@/ling'; +### Traducción con parámetros -interface Product { - id : string; - name: LingString; -} +TypeScript exige los parámetros correctos en tiempo de compilación. -const product: Product = { id: '1', name: { es: 'Silla', en: 'Chair' } }; -ling.ts(product.name) // → "Silla" +```ts +ling.t('checkout.total', { amount: 99, currency: '€' }); // → 'Total: 99€' +ling.t('errors.generic', { code: 404 }); // → 'Ha ocurrido un error (404)' + +// TS2345 si faltan parámetros o el tipo es incorrecto: +ling.t('checkout.total'); // ❌ Error de compilación +ling.t('checkout.total', { amount: '99', currency: '€' }); // ❌ amount debe ser number ``` -### `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 - -| 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 | +## Referencias internas (`#?`) -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

` | Función de traducción con parámetros tipados | -| `LingInstance` | 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. -``` -locale actual → defaultLocale → clave como texto -``` +```ts +// translations.ts +import { shopTranslations } from '@/shop/translations'; +import { adminTranslations } from '@/admin/translations'; + +export const translations = { + ...core, + shop: shopTranslations, + admin: adminTranslations, +} satisfies LingNode; -En desarrollo (`NODE_ENV === 'development'`) se emite `console.warn` cuando se usa el fallback. En producción la degradación es silenciosa. +export type TranslationSchema = typeof translations; ---- +// instance.ts +export const ling = createLing(translations, 'es'); + +ling.t('shop.product'); // ✅ tipado +ling.t('shop.total', { amount: 99, currency: '€' }); // ✅ parámetros tipados +ling.t('admin.users'); // ✅ tipado +``` -## Integración con frameworks +### Lazy — módulos cargados en runtime con `extend()` -Conecta `setLocale` y `onLocaleChange` al sistema reactivo del framework. +`extend()` muta la instancia global en runtime. Las rutas del módulo lazy no están tipadas — `t()` acepta cualquier `string` para cubrirlas. -**Vue 3** ```ts -import { ref } from 'vue'; -import { ling } from '@/ling.engine'; +// instance.ts — solo el schema base +export const ling = createLing(translations, 'es'); + +// En el router, cuando el módulo se carga +const { shopTranslations } = await import('@/shop/translations'); +ling.extend('shop', shopTranslations); -export const locale = ref(ling.getLocale()); -ling.onLocaleChange(l => locale.value = l); +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 ``` -**Svelte** +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 -import { writable } from 'svelte/store'; -import { ling } from '@/ling.engine'; +const base = createLing(coreSchema, 'es'); +const lingShop = base.register('shop', shopTranslations); -export const locale = writable(ling.getLocale()); -ling.onLocaleChange(l => locale.set(l)); +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 ``` -**React** +El tipo de retorno es `LingInstance` — TypeScript conoce ambas partes sin declaración extra. + +| | `extend()` | `register()` | +|---|---|---| +| Instancia | Muta la actual | Nueva instancia | +| Type-safety en rutas nuevas | No — acepta `string` | Sí — inferido al momento | +| Caso de uso | Global + lazy loading | Tests, contextos aislados | + +--- + +## Inyección de logger + +Por defecto ling usa `console.warn` / `console.error` solo en `NODE_ENV === 'development'`. Una vez que tu sistema de logging propio está listo, puedes inyectarlo con `setLogger()`. Solo puede llamarse una vez — es inmutable tras la primera inyección. + ```ts -import { useSyncExternalStore } from 'react'; -import { ling } from '@/ling.engine'; +import { ling } from '@/ling'; +import { logr } from '@/logr'; + +ling.setLogger(logr); +``` + +Cualquier objeto que implemente la interfaz `LingLogger` es válido: -export function useLocale() { - return useSyncExternalStore(ling.onLocaleChange, ling.getLocale); +```ts +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. + --- -## Tests +## Type-safety: cómo funciona + +El sistema de tipos se apoya en tres utilidades definidas en `types.ts`: + +**`LeafPaths`** — 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`** — 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. -```bash -vitest +**`HasParams` + `ParamsFor`** — 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: + +```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 ``` -Los tests usan schemas propios independientes del de producción — no hay acoplamiento entre la suite y las traducciones reales. \ No newline at end of file +En `instance.ts` el genérico explícito es imprescindible: + +```ts +// ✅ TypeScript conoce el schema exacto → t() queda completamente tipado +export const ling = createLing(translations, 'es'); + +// ❌ Sin genérico, S = LingNode → LeafPaths = never → t() no compila +export const ling = createLing(translations, 'es'); +``` + +--- + +## Locales soportados + +| Código | Idioma | +|--------|------------| +| `es` | Español *(obligatorio, locale por defecto)* | +| `en` | Inglés | +| `de` | Alemán | +| `fr` | Francés | +| `it` | Italiano | +| `pt` | Portugués | +| `ca` | Catalán | +| `eu` | Euskera | +| `gl` | Gallego | + +Para añadir un nuevo locale, extender `SupportedLocale` en `types.ts`. \ No newline at end of file diff --git a/src/ling/engine.ts b/src/ling/engine.ts index 04ed839..f376443 100644 --- a/src/ling/engine.ts +++ b/src/ling/engine.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( // 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( // 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['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; + // Toda la resolución de referencias y funciones vive en resolveValue. + // t() solo orquesta: busca → resuelve → traduce. + const finalValue = resolveValue(rawValue, args, 0); - - finalValue = resolveValue(rawValue, args, 0); - - // 4. LÓGICA DE EJECUCIÓN: ¿Es una función (plural) o un objeto (LingRecord)? - - // CASO A: Es una función (ej: resultado de p()) if (typeof finalValue === 'function') { - // 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( 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); } @@ -206,7 +164,7 @@ export function createLing( path: P, locale: SupportedLocale, ...args: HasParams extends true ? [params: ParamsFor] : [] - ) : string { + ) : string { const prev = currentLocale; currentLocale = locale; const result = t(path, ...(args as any)); @@ -214,37 +172,54 @@ export function createLing( 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), + [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( namespace: NS, module: M ): LingInstance { - currentSchema = { - ...(currentSchema as Record), + const newSchema = { + ...(currentSchema as Record), [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' para permitir + * El tipo de retorno incluye `& Record` 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; - }; \ No newline at end of file + }; + + + + +// 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), +}; \ No newline at end of file diff --git a/src/ling/instance.ts b/src/ling/instance.ts index 1f8865f..c1445a8 100644 --- a/src/ling/instance.ts +++ b/src/ling/instance.ts @@ -1,12 +1,30 @@ import { createLing } from './engine.ts'; -import { translations } from './translations'; +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(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(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; \ No newline at end of file diff --git a/src/ling/plural-rules.ts b/src/ling/plural-rules.ts new file mode 100644 index 0000000..28d7a70 --- /dev/null +++ b/src/ling/plural-rules.ts @@ -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 = { + + // ── 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'; +} \ No newline at end of file diff --git a/src/ling/tests/ling-schemas.test.ts b/src/ling/tests/ling-schemas.test.ts new file mode 100644 index 0000000..e28a8a1 --- /dev/null +++ b/src/ling/tests/ling-schemas.test.ts @@ -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(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>; + + 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 + }); + +}); \ No newline at end of file diff --git a/src/ling/tests/ling.test.ts b/src/ling/tests/ling.test.ts index c8ba595..7d22e0a 100644 --- a/src/ling/tests/ling.test.ts +++ b/src/ling/tests/ling.test.ts @@ -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'); }); diff --git a/src/ling/translations.ts b/src/ling/translations.ts index 1ee032d..a38a749 100644 --- a/src/ling/translations.ts +++ b/src/ling/translations.ts @@ -1,17 +1,22 @@ -import type { LingNode } from './types.ts'; +import { p } from './engine.ts'; +import type { LingNode } from './types.ts'; /** - * MEJORA: `satisfies TranslationNode` en lugar de `satisfies Record`. - * 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 derive rutas concretas + * como "checkout.total" y que t() quede completamente type-safe. */ -export const translations = { +export const translations = { checkout: { pay: { es: "Pagar", 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; - +export type TranslationSchema = typeof translations; \ No newline at end of file diff --git a/src/ling/types.ts b/src/ling/types.ts index d355479..e32240e 100644 --- a/src/ling/types.ts +++ b/src/ling/types.ts @@ -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

> = (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

= Record> = + (params: { count: number } & P) => LingRecord; + export type LingValue = | LingRecord - | ((...args: any[]) => any) + | LingFn + | LingPluralFn | 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 = [D] extends [never] @@ -129,27 +105,35 @@ export type GetTypeAtPath< : never : P extends keyof Current ? Current[P] extends `#?${infer AliasPath}` - ? GetTypeAtPath // 🔍 Salto cuántico: reiniciamos desde el Root + ? GetTypeAtPath : 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 extends (params: infer P) => any ? P : never; +/** + * Detecta si un tipo requiere parámetros: + * - LingFn

→ true (parámetros arbitrarios) + * - LingPluralFn

→ true (siempre incluye count) + * - LingRecord → false (sin parámetros) + * - IDLing → false (referencia, se resuelve) + */ export type HasParams = 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> & { other: string }; +/** Formas plurales según el estándar Unicode */ +export type PluralForms = Partial> & { other: string }; export type PluralConfig = { [K in SupportedLocale]?: PluralForms; @@ -158,28 +142,20 @@ export type PluralConfig = { }; - // ============================== // INSTANCE TYPE // ============================== -/** - * Tipo de la instancia de ling parametrizado por el schema S. - * Usar este tipo en lugar de `ReturnType` - * para preservar la información del schema y tener t() tipado correctamente. - * - * @example - * function useTranslations(ling: LingInstance) { - * ling.t('my.key') // ✅ tipado contra mySchema - * } - */ export type LingInstance = { - // Fíjate en el GetTypeAtPath (pasamos la S dos veces: como Root y como Current) - t:

, TType = GetTypeAtPath>( - path: P, - ...args: HasParams extends true ? [params: ParamsFor] : [] - ) => string; - ts : (value: LingString) => string; + t: { +

, TType = GetTypeAtPath>( + path: P, + ...args: HasParams extends true ? [params: ParamsFor] : [] + ): string; + (path: string, params?: Record): string; + }; + + ts: (value: LingString) => string; tForLocale:

, TType = GetTypeAtPath>( path: P, @@ -190,45 +166,22 @@ export type LingInstance = { 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 : ( namespace: NS, module: M ) => LingInstance; - /** - * 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;