From 4d5f1f16ec32501bfd7e108d1ab3057be695a400 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 21 Feb 2026 23:40:33 +0100 Subject: [PATCH] First Commit --- .gitignore | 11 ++ .idea/.gitignore | 8 + docs/i18n.md | 268 +++++++++++++++++++++++++++ package.json | 26 +++ src/index.ts | 1 + src/libs/i18n/i18n.engine.ts | 123 +++++++++++++ src/libs/i18n/i18n.factory.ts | 11 ++ src/libs/i18n/i18n.test.ts | 302 +++++++++++++++++++++++++++++++ src/libs/i18n/i18n.types.ts | 125 +++++++++++++ src/libs/i18n/index.ts | 25 +++ src/libs/i18n/translations.ts | 45 +++++ src/libs/olds/i18n/i18n.test.ts | 85 +++++++++ src/libs/olds/i18n/i18n.ts | 78 ++++++++ src/libs/olds/i18n/i18n.types.ts | 37 ++++ src/libs/olds/i18n/index.ts | 39 ++++ src/libs/olds/i18n/schema.ts | 25 +++ src/libs/olds/itn/i18n.ts | 50 +++++ src/libs/olds/itn/i18n.types.ts | 81 +++++++++ src/libs/olds/itn/schema.ts | 25 +++ tsconfig.json | 31 ++++ vitest.config.ts | 28 +++ 21 files changed, 1424 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 docs/i18n.md create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/libs/i18n/i18n.engine.ts create mode 100644 src/libs/i18n/i18n.factory.ts create mode 100644 src/libs/i18n/i18n.test.ts create mode 100644 src/libs/i18n/i18n.types.ts create mode 100644 src/libs/i18n/index.ts create mode 100644 src/libs/i18n/translations.ts create mode 100644 src/libs/olds/i18n/i18n.test.ts create mode 100644 src/libs/olds/i18n/i18n.ts create mode 100644 src/libs/olds/i18n/i18n.types.ts create mode 100644 src/libs/olds/i18n/index.ts create mode 100644 src/libs/olds/i18n/schema.ts create mode 100644 src/libs/olds/itn/i18n.ts create mode 100644 src/libs/olds/itn/i18n.types.ts create mode 100644 src/libs/olds/itn/schema.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32b877b --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/tmp +/out-tsc + +/node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/.pnp +.pnp.js + +.vscode/* \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..0983d19 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,268 @@ +# i18n + +Sistema de internacionalización type-safe para TypeScript. Agnóstico de framework, sin dependencias externas. + +--- + +## Estructura de ficheros + +``` +i18n/ +├── index.ts # Barrel — punto de entrada público +├── i18n.ts # Instancia por defecto (wiring de schema + factory) +├── i18n.factory.ts # Factory: createI18n() +├── i18n.types.ts # Tipos e interfaces +├── translations.ts # Árbol de traducciones +└── i18n.factory.test.ts +``` + +--- + +## Instalación y setup + +### 1. Define tus traducciones + +```ts +// translations.ts +import type { TranslationNode } from './i18n.types'; + +export const translations = { + common: { + ok: { es: 'Aceptar', en: 'OK' }, + cancel: { es: 'Cancelar', en: 'Cancel' }, + }, + checkout: { + pay: { es: 'Pagar', en: 'Pay' }, + + // Con parámetros — TypeScript infiere el tipo de params automáticamente + total: (params: { amount: number; currency: string }) => ({ + es: `Total: ${params.amount}${params.currency}`, + en: `Total: ${params.currency}${params.amount}`, + }), + }, +} satisfies TranslationNode; + +export type TranslationSchema = typeof translations; +``` + +> **Regla:** `es` es obligatorio en cada hoja. El resto de locales son opcionales y hacen fallback a `es` si faltan. + +### 2. Crea la instancia + +```ts +// i18n.ts +import { createI18n, translations } from './'; + +export const i18n = createI18n(translations, 'es'); + +// Desestructura para usar directamente sin prefijo +export const { t, ts, tForLocale, setLocale, getLocale, onLocaleChange } = i18n; +``` + +--- + +## API + +### `t(path, params?)` + +Traduce una clave del schema al locale actual. + +```ts +t('common.ok') // → "Aceptar" +t('checkout.pay') // → "Pagar" +t('checkout.total', { amount: 99, currency: '€' }) // → "Total: 99€" +``` + +- El path tiene **autocompletado** y **validación en compilación** — no puedes escribir una clave que no exista. +- Los params son **obligatorios** si la traducción los requiere, y TypeScript los infiere automáticamente. +- Solo acepta rutas que terminan en una traducción real. Rutas intermedias como `'checkout'` dan error de tipos. + +### `ts(value)` + +*Translate String* — resuelve un `I18nString` con el locale actual. + +A diferencia de `t()`, no trabaja con el schema sino con valores dinámicos de tus datos. + +```ts +ts('texto fijo') // → "texto fijo" (pass-through) +ts({ es: 'una descripción', en: 'a description' }) // → "una descripción" +``` + +Útil cuando un campo de tu modelo puede estar localizado o no: + +```ts +import type { I18nString } from './'; + +interface Product { + id: string; + name: I18nString; // puede ser string simple o LocaleRecord +} + +const product: Product = { + id: '1', + name: { es: 'Silla', en: 'Chair' }, +}; + +ts(product.name) // → "Silla" (locale 'es') +``` + +### `tForLocale(path, locale, params?)` + +Resuelve una clave en una locale específica **sin cambiar el estado global**. + +```ts +tForLocale('common.ok', 'en') // → "OK" +getLocale() // → "es" (no ha cambiado) +``` + +Útil para SSR, generación de emails en el idioma del usuario, o tests sin efectos secundarios. + +### `setLocale(locale)` + +Cambia el locale actual y notifica a todos los listeners registrados. + +```ts +setLocale('en') +t('common.ok') // → "OK" +``` + +### `getLocale()` + +Devuelve el locale actual. + +```ts +getLocale() // → "es" +``` + +### `onLocaleChange(fn)` + +Registra un listener que se ejecuta cada vez que cambia el locale. Devuelve una función de `unsubscribe`. + +```ts +const unsubscribe = onLocaleChange((locale) => { + console.log('Nuevo locale:', locale); +}); + +setLocale('fr'); // → "Nuevo locale: fr" + +unsubscribe(); // deja de escuchar +setLocale('en'); // el listener ya no se ejecuta +``` + +> `tForLocale()` **no** dispara los listeners — solo cambia el locale internamente de forma temporal. + +--- + +## 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 | + +Para añadir una nueva locale, solo hay que añadirla al tipo `SupportedLocale` en `i18n.types.ts`: + +```ts +export type SupportedLocale = + | DefaultLocale + | 'en' + | 'de' + // añade aquí + | 'ja'; +``` + +TypeScript marcará automáticamente cualquier hoja del schema donde falte la nueva locale si decides hacerla obligatoria. + +--- + +## Tipos públicos + +| Tipo | Descripción | +|------|-------------| +| `SupportedLocale` | Unión de todas las locales soportadas | +| `DefaultLocale` | `'es'` — locale obligatoria en cada traducción | +| `LocaleRecord` | Objeto `{ es: string, en?: string, ... }` | +| `I18nString` | `string \| LocaleRecord` — para campos de datos opcionalmente localizados | +| `TranslationNode` | Tipo recursivo del árbol de traducciones | +| `TranslationFn

` | Función de traducción con parámetros tipados | +| `TranslationSchema` | Tipo inferido del árbol `translations` | + +--- + +## Fallback + +Cuando el locale actual no tiene traducción para una clave, el sistema cae en cascada: + +``` +locale actual → defaultLocale → clave como texto +``` + +En desarrollo (`NODE_ENV === 'development'`) se emite un `console.warn` indicando qué clave y locale fallan. En producción la degradación es silenciosa para no romper la UI. + +--- + +## Múltiples instancias + +La factory permite crear instancias independientes con distintos schemas o locales por defecto: + +```ts +import { createI18n } from './'; +import { translations } from './translations'; +import { adminTranslations } from './admin.translations'; + +export const i18n = createI18n(translations, 'es'); +export const adminI18n = createI18n(adminTranslations, 'en'); +``` + +Cada instancia tiene su propio estado de locale y sus propios listeners, completamente aislados entre sí. + +--- + +## Integración con frameworks + +La factory es agnóstica. Para integrarla en cualquier framework basta con conectar `setLocale` y `onLocaleChange` al sistema reactivo correspondiente. + +**Vue 3** +```ts +import { ref } from 'vue'; +import { i18n } from './i18n'; + +export const locale = ref(i18n.getLocale()); +i18n.onLocaleChange((l) => (locale.value = l)); +``` + +**Svelte** +```ts +import { writable } from 'svelte/store'; +import { i18n } from './i18n'; + +export const locale = writable(i18n.getLocale()); +i18n.onLocaleChange((l) => locale.set(l)); +``` + +**React** +```ts +import { useSyncExternalStore } from 'react'; +import { i18n } from './i18n'; + +export function useLocale() { + return useSyncExternalStore(i18n.onLocaleChange, i18n.getLocale); +} +``` + +--- + +## Tests + +```bash +vitest +``` + +Los tests usan un schema propio independiente del de producción, por lo que no hay acoplamiento entre la suite y las traducciones reales. diff --git a/package.json b/package.json new file mode 100644 index 0000000..386b1e9 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "visual-config-engine", + "version": "1.0.0", + "description": "", + "main": "dist/index.js", + "scripts": { + "build": "tsc" + }, + "keywords": [ + "configuration", + "visual", + "typescript" + ], + "author": "ACTIVE THING", + "license": "EULA", + "dependencies": {}, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@types/node": "^25.2.3", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vitest": "^4.0.18" + }, + "private": true +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..04bf26e --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +console.log('Happy developing ✨') diff --git a/src/libs/i18n/i18n.engine.ts b/src/libs/i18n/i18n.engine.ts new file mode 100644 index 0000000..c8c54d9 --- /dev/null +++ b/src/libs/i18n/i18n.engine.ts @@ -0,0 +1,123 @@ +import type { + SupportedLocale, + LeafPaths, + GetTypeAtPath, + ParamsFor, + HasParams, + TranslationNode, + LocaleRecord, + I18nString +} from './i18n.types'; + +// ============================== +// HELPERS +// ============================== + +function resolvePath(obj: any, path: string): any { + return path.split('.').reduce((acc, key) => acc?.[key], obj); +} + +function isDev(): boolean { + return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'; +} + +// ============================== +// FACTORY +// ============================== + +export function createI18n( + schema: S, + defaultLocale: SupportedLocale +) { + let currentLocale: SupportedLocale = defaultLocale; + + const listeners = new Set<(locale: SupportedLocale) => void>(); + + function setLocale(locale: SupportedLocale): void { + currentLocale = locale; + listeners.forEach(fn => fn(locale)); + } + + function getLocale(): SupportedLocale { + return currentLocale; + } + + function onLocaleChange(fn: (locale: SupportedLocale) => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); + } + + /** + * Resuelve un LocaleRecord con el locale actual, + * con fallback al defaultLocale. + */ + function resolveRecord(record: LocaleRecord, path?: string): string { + const translation = record[currentLocale]; + + if (translation === undefined) { + if (isDev()) { + console.warn( + `[i18n] Missing translation${path ? ` for "${path}"` : ''} in "${currentLocale}". Falling back to "${defaultLocale}".` + ); + } + return record[defaultLocale] ?? path ?? ''; + } + + return translation; + } + + /** + * ts traduce un I18nString con el locale actual. + * - Si es un string simple, lo devuelve tal cual. + * - Si es un LocaleRecord, lo resuelve con el locale actual con fallback. + * + * @example + * ts('texto fijo') // → "texto fijo" + * ts({ es: 'descripción', en: 'description' }) // → "descripción" (locale 'es') + */ + function ts(value: I18nString): string { + if (typeof value === 'string') return value; + return resolveRecord(value); + } + + function t< + P extends LeafPaths, + TType = GetTypeAtPath + >( + path: P, + ...args: HasParams extends true ? [params: ParamsFor] : [] + ): string { + const value = resolvePath(schema, path as string); + + if (value === undefined || value === null) { + if (isDev()) { + console.error(`[i18n] Translation key not found: "${path as string}"`); + } + return path as string; + } + + const record: LocaleRecord = + typeof value === 'function' + ? (value as Function)(args[0]) + : value; + + return resolveRecord(record, path as string); + } + + function tForLocale< + P extends LeafPaths, + TType = GetTypeAtPath + >( + path: P, + locale: SupportedLocale, + ...args: HasParams extends true ? [params: ParamsFor] : [] + ): string { + const prev = currentLocale; + currentLocale = locale; + const result = t(path, ...(args as any)); + currentLocale = prev; + return result; + } + + return { t, tForLocale, resolve: ts, setLocale, getLocale, onLocaleChange }; +} \ No newline at end of file diff --git a/src/libs/i18n/i18n.factory.ts b/src/libs/i18n/i18n.factory.ts new file mode 100644 index 0000000..8fee746 --- /dev/null +++ b/src/libs/i18n/i18n.factory.ts @@ -0,0 +1,11 @@ +// index.ts + +import { createI18n } from './i18n.engine'; +import { translations } from './translations'; + + + +export const i18n = createI18n(translations, 'es'); + +// Desestructura si prefieres usar t() directamente +export const { t, tForLocale, setLocale, getLocale, onLocaleChange } = i18n; \ No newline at end of file diff --git a/src/libs/i18n/i18n.test.ts b/src/libs/i18n/i18n.test.ts new file mode 100644 index 0000000..749b88b --- /dev/null +++ b/src/libs/i18n/i18n.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createI18n } from './index'; +import type { TranslationNode, I18nString } from './index'; + +// ============================== +// SCHEMA DE TEST +// ============================== + +const testSchema = { + common: { + ok: { + es: 'Aceptar', + en: 'OK', + fr: 'Accepter' + }, + cancel: { + es: 'Cancelar', + en: 'Cancel' + } + }, + checkout: { + pay: { + es: 'Pagar', + en: 'Pay' + }, + total: (params: { amount: number; currency: string }) => ({ + es: `Total: ${params.amount}${params.currency}`, + en: `Total: ${params.currency}${params.amount}` + }) + }, + errors: { + generic: (params: { code: number }) => ({ + es: `Error ${params.code}`, + en: `Error ${params.code}` + }) + }, + // Clave solo en español (para probar fallback) + onlySpanish: { + es: 'Solo en español' + } +} satisfies TranslationNode; + +// ============================== +// TESTS +// ============================== + +describe('createI18n', () => { + + describe('instanciación', () => { + it('crea una instancia con el locale por defecto', () => { + const i18n = createI18n(testSchema, 'es'); + expect(i18n.getLocale()).toBe('es'); + }); + + it('crea instancias independientes con distintos locales', () => { + const i18nEs = createI18n(testSchema, 'es'); + const i18nEn = createI18n(testSchema, 'en'); + + expect(i18nEs.getLocale()).toBe('es'); + expect(i18nEn.getLocale()).toBe('en'); + + // Cambiar uno no afecta al otro + i18nEs.setLocale('fr'); + expect(i18nEn.getLocale()).toBe('en'); + }); + }); + + describe('t() — traducciones simples', () => { + let i18n: ReturnType>; + + beforeEach(() => { + i18n = createI18n(testSchema, 'es'); + }); + + it('devuelve la traducción en el locale actual', () => { + expect(i18n.t('common.ok')).toBe('Aceptar'); + }); + + it('devuelve la traducción tras cambiar de locale', () => { + i18n.setLocale('en'); + expect(i18n.t('common.ok')).toBe('OK'); + }); + + it('devuelve la traducción en francés', () => { + i18n.setLocale('fr'); + expect(i18n.t('common.ok')).toBe('Accepter'); + }); + }); + + describe('t() — traducciones con parámetros', () => { + let i18n: ReturnType>; + + beforeEach(() => { + i18n = createI18n(testSchema, 'es'); + }); + + it('interpola parámetros correctamente en español', () => { + expect(i18n.t('checkout.total', { amount: 99, currency: '€' })) + .toBe('Total: 99€'); + }); + + it('interpola parámetros correctamente en inglés', () => { + i18n.setLocale('en'); + expect(i18n.t('checkout.total', { amount: 99, currency: '$' })) + .toBe('Total: $99'); + }); + + it('interpola parámetros numéricos', () => { + expect(i18n.t('errors.generic', { code: 404 })) + .toBe('Error 404'); + }); + }); + + describe('t() — fallback', () => { + let i18n: ReturnType>; + + beforeEach(() => { + i18n = createI18n(testSchema, 'es'); + }); + + it('hace fallback al locale por defecto si falta la traducción', () => { + i18n.setLocale('de'); // 'de' no existe en onlySpanish + expect(i18n.t('onlySpanish')).toBe('Solo en español'); + }); + + 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'); + }); + + it('loguea un warning en desarrollo cuando hace fallback', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + i18n.setLocale('de'); + i18n.t('onlySpanish'); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Missing translation') + ); + + process.env.NODE_ENV = originalEnv; + warn.mockRestore(); + }); + + it('no loguea en producción', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + i18n.setLocale('de'); + i18n.t('onlySpanish'); + + expect(warn).not.toHaveBeenCalled(); + + process.env.NODE_ENV = originalEnv; + warn.mockRestore(); + }); + }); + + describe('tForLocale()', () => { + it('resuelve en la locale indicada sin cambiar el estado global', () => { + const i18n = createI18n(testSchema, 'es'); + + const result = i18n.tForLocale('common.ok', 'en'); + + expect(result).toBe('OK'); + expect(i18n.getLocale()).toBe('es'); // no ha cambiado + }); + + it('funciona con parámetros', () => { + const i18n = createI18n(testSchema, 'es'); + + const result = i18n.tForLocale('checkout.total', 'en', { amount: 50, currency: '$' }); + + expect(result).toBe('Total: $50'); + expect(i18n.getLocale()).toBe('es'); + }); + }); + + describe('setLocale() / getLocale()', () => { + it('actualiza el locale correctamente', () => { + const i18n = createI18n(testSchema, 'es'); + i18n.setLocale('en'); + expect(i18n.getLocale()).toBe('en'); + }); + }); + + describe('onLocaleChange()', () => { + it('notifica al cambiar de locale', () => { + const i18n = createI18n(testSchema, 'es'); + const listener = vi.fn(); + + i18n.onLocaleChange(listener); + i18n.setLocale('en'); + + expect(listener).toHaveBeenCalledWith('en'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('no notifica después de hacer unsubscribe', () => { + const i18n = createI18n(testSchema, 'es'); + const listener = vi.fn(); + + const unsubscribe = i18n.onLocaleChange(listener); + unsubscribe(); + i18n.setLocale('en'); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('soporta múltiples listeners', () => { + const i18n = createI18n(testSchema, 'es'); + const l1 = vi.fn(); + const l2 = vi.fn(); + + i18n.onLocaleChange(l1); + i18n.onLocaleChange(l2); + i18n.setLocale('fr'); + + expect(l1).toHaveBeenCalledWith('fr'); + expect(l2).toHaveBeenCalledWith('fr'); + }); + + it('tForLocale no dispara los listeners', () => { + const i18n = createI18n(testSchema, 'es'); + const listener = vi.fn(); + + i18n.onLocaleChange(listener); + i18n.tForLocale('common.ok', 'en'); + + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); + + +// ============================== +// TESTS resolve() +// ============================== + +describe('resolve()', () => { + let i18n: ReturnType>; + + beforeEach(() => { + i18n = createI18n(testSchema, 'es'); + }); + + it('devuelve el string tal cual si es un string simple', () => { + expect(i18n.resolve('texto fijo')).toBe('texto fijo'); + }); + + it('devuelve el string vacío sin errores', () => { + expect(i18n.resolve('')).toBe(''); + }); + + it('resuelve un LocaleRecord con el locale actual', () => { + const desc: I18nString = { es: 'una descripción', en: 'a description' }; + expect(i18n.resolve(desc)).toBe('una descripción'); + }); + + it('resuelve un LocaleRecord tras cambiar de locale', () => { + const desc: I18nString = { es: 'una descripción', en: 'a description' }; + i18n.setLocale('en'); + expect(i18n.resolve(desc)).toBe('a description'); + }); + + it('hace fallback al defaultLocale si el locale actual no existe en el record', () => { + const desc: I18nString = { es: 'solo español' }; + i18n.setLocale('de'); + expect(i18n.resolve(desc)).toBe('solo español'); + }); + + it('funciona con un interface que usa I18nString', () => { + interface Producto { + id: string; + descripcion: I18nString; + } + + const producto: Producto = { + id: '1', + descripcion: { es: 'Silla de madera', en: 'Wooden chair' } + }; + + expect(i18n.resolve(producto.descripcion)).toBe('Silla de madera'); + + i18n.setLocale('en'); + expect(i18n.resolve(producto.descripcion)).toBe('Wooden chair'); + }); + + it('funciona mezclando strings simples y LocaleRecord en el mismo array', () => { + const items: I18nString[] = [ + 'id-invariante', + { es: 'nombre', en: 'name' }, + 'otro-invariante' + ]; + + const resolved = items.map(i18n.resolve); + expect(resolved).toEqual(['id-invariante', 'nombre', 'otro-invariante']); + }); +}); \ No newline at end of file diff --git a/src/libs/i18n/i18n.types.ts b/src/libs/i18n/i18n.types.ts new file mode 100644 index 0000000..0af14a0 --- /dev/null +++ b/src/libs/i18n/i18n.types.ts @@ -0,0 +1,125 @@ +// ============================== +// LOCALES +// ============================== + +export type DefaultLocale = 'es'; + +export type SupportedLocale = + | DefaultLocale + | 'en' + | 'de' + | 'fr' + | 'it' + | 'pt' + | 'ca' + | 'eu' + | 'gl'; + +// ============================== +// LOCALIZED TYPES +// ============================== + +/** + * Un registro de traducciones donde `es` es obligatorio + * y el resto de locales son opcionales. + */ +export type LocaleRecord = { + [K in SupportedLocale]?: string; +} & { + [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. + */ +export type TranslationFn

> = (params: P) => LocaleRecord; + +export type TranslationValue = + | LocaleRecord + | TranslationFn; + +/** + * Tipo recursivo estricto para el árbol de traducciones. + */ +export type TranslationNode = + | TranslationValue + | { [key: string]: TranslationNode }; + +/** + * 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: I18nString; + * } + * + * 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 I18nString = string | LocaleRecord; + +// ============================== +// TYPE UTILITIES +// ============================== + +type Prev = [never, 0, 1, 2, 3, 4, 5, 6]; + +type Join = + P extends string + ? `${K & string}.${P}` + : never; + +/** + * `LeafPaths` solo expone las rutas que terminan en una hoja + * (LocaleRecord o función), no las rutas intermedias (namespaces). + */ +export type LeafPaths< + T, + D extends number = 6 +> = + [D] extends [never] + ? never + : T extends TranslationValue + ? '' + : T extends object + ? { + [K in keyof T & string]: + T[K] extends TranslationValue + ? K + : `${K}.${LeafPaths & string}` + }[keyof T & string] + : never; + +export type GetTypeAtPath< + T, + P extends string +> = + P extends `${infer K}.${infer Rest}` + ? K extends keyof T + ? GetTypeAtPath + : never + : P extends keyof T + ? T[P] + : never; + +export type ParamsFor = + T extends (params: infer P) => any + ? P + : never; + +export type HasParams = + T extends (params: any) => any + ? true + : false; \ No newline at end of file diff --git a/src/libs/i18n/index.ts b/src/libs/i18n/index.ts new file mode 100644 index 0000000..821f817 --- /dev/null +++ b/src/libs/i18n/index.ts @@ -0,0 +1,25 @@ + +export { createI18n } from './i18n.engine'; +export { translations } from './translations'; + +export type { + // Locales + SupportedLocale, + DefaultLocale, + + // Tipos de datos + LocaleRecord, + I18nString, + TranslationFn, + TranslationValue, + TranslationNode, + + // Utilidades de tipos + LeafPaths, + GetTypeAtPath, + ParamsFor, + HasParams, +} from './i18n.types'; + +export type { TranslationSchema } from './translations'; + diff --git a/src/libs/i18n/translations.ts b/src/libs/i18n/translations.ts new file mode 100644 index 0000000..eea06d8 --- /dev/null +++ b/src/libs/i18n/translations.ts @@ -0,0 +1,45 @@ +import type { TranslationNode, SupportedLocale } from './i18n.types'; + +/** + * 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. + */ +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}` + }) + }, + + common: { + ok: { + es: "Aceptar", + en: "OK" + }, + cancel: { + es: "Cancelar", + en: "Cancel" + } + }, + + errors: { + notFound: { + es: "Página no encontrada", + en: "Page not found" + }, + generic: (params: { code: number }) => ({ + es: `Ha ocurrido un error (${params.code})`, + en: `An error occurred (${params.code})` + }) + } + +} satisfies TranslationNode; + +export type TranslationSchema = typeof translations; \ No newline at end of file diff --git a/src/libs/olds/i18n/i18n.test.ts b/src/libs/olds/i18n/i18n.test.ts new file mode 100644 index 0000000..0f027b8 --- /dev/null +++ b/src/libs/olds/i18n/i18n.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { i18n } from '@/libs/olds/i18n/index.ts'; // Importamos la instancia configurada + + + + +describe('I18N Modular System', () => { + + beforeEach(() => { + // Resetear al idioma por defecto antes de cada test + i18n.setLocale('es'); + }); + + describe('Core Functionality', () => { + it('debe cargar las traducciones iniciales del core', () => { + expect(i18n.t('core.loading')).toBe('Cargando...'); + expect(i18n.t('core.error', undefined, 'en')).toBe('An unexpected error occurred'); + }); + + it('debe cambiar el idioma globalmente', () => { + i18n.setLocale('en'); + expect(i18n.t('core.loading')).toBe('Loading...'); + }); + }); + + describe('Dynamic Registration (addResource)', () => { + it('debe permitir añadir nuevos recursos después de la instanciación', () => { + // Registramos un recurso que no existía inicialmente + i18n.addResource('es', { + ui: { + buttons: { save: 'Guardar' } + } + }); + + expect(i18n.t('ui.buttons.save' as any)).toBe('Guardar'); + }); + + it('debe registrar y ejecutar funciones dinámicas añadidas a posteriori', () => { + i18n.addResource('es', { + jsonLogic: { + errors: { + UNKNOWN_OPERATOR: (op: string) => `Op desconocido: ${op}` + } + } + }); + + // Validamos que la función se ejecute correctamente + expect(i18n.t('jsonLogic.errors.UNKNOWN_OPERATOR' as any, ['XOR'])).toBe('Op desconocido: XOR'); + }); + + it('debe sobrescribir traducciones existentes si se registra la misma clave', () => { + i18n.addResource('es', { + core: { loading: 'Espera un momento...' } + }); + + expect(i18n.t('core.loading')).toBe('Espera un momento...'); + }); + }); + + describe('Fallback & Edge Cases', () => { + it('debe hacer fallback al idioma por defecto si la clave falta en el idioma actual', () => { + i18n.setLocale('en'); + // 'ui.buttons.save' solo lo añadimos en 'es' en el test anterior + // (Asumiendo que los tests comparten instancia o lo añadimos aquí) + i18n.addResource('es', { common: { cancel: 'Cancelar' } }); + + expect(i18n.t('common.cancel' as any)).toBe('Cancelar'); + }); + + it('debe devolver la clave si no existe en ningún idioma', () => { + // @ts-expect-error - Probando comportamiento ante claves inexistentes + expect(i18n.t('non.existent.key')).toBe('non.existent.key'); + }); + + it('debe manejar correctamente la interpolación con valores 0 o strings vacíos', () => { + i18n.addResource('es', { + test: { zero: 'Valor: {{val}}', empty: 'Texto: {{val}}' } + }); + + expect(i18n.t('test.zero' as any, { val: 0 })).toBe('Valor: 0'); + expect(i18n.t('test.empty' as any, { val: '' })).toBe('Texto: '); + }); + }); +}); + diff --git a/src/libs/olds/i18n/i18n.ts b/src/libs/olds/i18n/i18n.ts new file mode 100644 index 0000000..b175fdc --- /dev/null +++ b/src/libs/olds/i18n/i18n.ts @@ -0,0 +1,78 @@ +// i18n.ts +import type { + DotNestedKeys, + GetTypeAtPath, + InterpolationParams, + TranslationStore +} from './i18n-types'; + +export function createI18n( + initialStore: Partial> = {}, // Ahora puede empezar vacío + defaultLocale: TLocale +) { + let currentLocale: TLocale = defaultLocale; + + // El almacén plano donde se acumulará todo + const flatStore = {} as Record>; + + function flatten(obj: any, prefix = ''): Record { + return Object.keys(obj).reduce((acc: any, k: string) => { + const path = prefix ? `${prefix}.${k}` : k; + if (typeof obj[k] === 'object' && obj[k] !== null && !Array.isArray(obj[k])) { + Object.assign(acc, flatten(obj[k], path)); + } else { + acc[path] = obj[k]; + } + return acc; + }, {}); + } + + // Función para procesar y añadir recursos + const addResource = (locale: TLocale, resource: any) => { + if (!flatStore[locale]) flatStore[locale] = {}; + const flattened = flatten(resource); + Object.assign(flatStore[locale], flattened); + }; + + // Inicializar con lo que venga en el constructor + for (const loc in initialStore) { + addResource(loc as TLocale, initialStore[loc]); + } + + return { + setLocale: (l: TLocale) => { currentLocale = l; }, + getLocale: () => currentLocale, + + /** + * Añade nuevas traducciones a un idioma específico después de la instanciación. + * Útil para cargar traducciones de módulos o plugins bajo demanda. + */ + addResource, + + t: >( + key: K, + args?: GetTypeAtPath extends (...args: infer P) => any ? P : InterpolationParams, + overrideLocale?: TLocale + ): string => { + const activeLocale = overrideLocale || currentLocale; + const entry = flatStore[activeLocale]?.[key] || flatStore[defaultLocale]?.[key]; + + if (!entry) return key; + + if (typeof entry === 'function') { + const params = Array.isArray(args) ? args : []; + return entry(...params); + } + + if (typeof entry === 'string' && args && !Array.isArray(args)) { + const params = args as InterpolationParams; + return entry.replace(/\{\{([^}]+)}}/g, (match: string, k: string): string => { + const value = params[k]; + return value !== undefined ? String(value) : match; + }); + } + + return String(entry); + } + }; +} \ No newline at end of file diff --git a/src/libs/olds/i18n/i18n.types.ts b/src/libs/olds/i18n/i18n.types.ts new file mode 100644 index 0000000..5eeb2bc --- /dev/null +++ b/src/libs/olds/i18n/i18n.types.ts @@ -0,0 +1,37 @@ +/** + * Determina si un nodo es un terminal (un string o una función) + * o si debemos seguir navegando por el objeto. + */ +export type IsTerminal = T extends (...args: any[]) => any + ? true + : T extends object ? false : true; + +/** + * Genera claves en notación de puntos (ej: 'errors.UNKNOWN_OPERATOR') + */ +export type DotNestedKeys = T extends object + ? { + [K in keyof T & string]: IsTerminal extends true + ? K + : `${K}.${DotNestedKeys}` + }[keyof T & string] + : ''; + +/** + * Extrae el tipo exacto (String o Función) de una ruta específica + */ +export type GetTypeAtPath = Path extends `${infer Head}.${infer Tail}` + ? Head extends keyof T ? GetTypeAtPath : never + : Path extends keyof T ? T[Path] : never; + +/** + * Estructura para variables de interpolación {{var}} + */ +export type InterpolationParams = Record; + +/** + * Definición del almacén de traducciones + */ +export type TranslationStore = { + [L in TLocale]: TSchema; +}; \ No newline at end of file diff --git a/src/libs/olds/i18n/index.ts b/src/libs/olds/i18n/index.ts new file mode 100644 index 0000000..6eecb70 --- /dev/null +++ b/src/libs/olds/i18n/index.ts @@ -0,0 +1,39 @@ +import type { TranslationStore } from './i18n-types'; +import type { AppSchema } from "@/libs/olds/i18n/schema.ts"; +import { createI18n } from './i18n.ts'; + + +/** + * 1. IDIOMAS SOPORTADOS + */ +export type SupportedLocales = 'es' | 'en'; + +/** + * 2. TRADUCCIONES INICIALES (CORE) + * Solo incluimos lo mínimo indispensable para que la app arranque. + */ +const initialTranslations: Partial> = { + es: { + core: { + error : "Ha ocurrido un error inesperado", + loading: "Cargando..." + } + }, + en: { + core: { + error : "An unexpected error occurred", + loading: "Loading..." + } + } +}; + +/** + * 4. INSTANCIA EXPORTABLE + */ +export const i18n = createI18n( + initialTranslations, + 'es' +); + +// Helper para exportar directamente la función de traducción +export const t = i18n.t; \ No newline at end of file diff --git a/src/libs/olds/i18n/schema.ts b/src/libs/olds/i18n/schema.ts new file mode 100644 index 0000000..5cee04b --- /dev/null +++ b/src/libs/olds/i18n/schema.ts @@ -0,0 +1,25 @@ +/** + * 1. DEFINICIÓN DEL ESQUEMA GLOBAL + * Reúne todas las claves posibles de tu aplicación o librería. + * Si usas módulos opcionales, puedes definirlos como parciales. + */ +export type AppSchema = { + // Diccionario base (siempre presente) + core: { + error: string; + loading: string; + }; + // Diccionarios de módulos (se pueden llenar vía addResource) + jsonLogic?: { + errors: { + UNKNOWN_OPERATOR: (op: string) => string; + MISSING_DATA: (field: string) => string; + }; + }; + ui?: { + buttons: { + save: string; + delete: string; + }; + }; +}; \ No newline at end of file diff --git a/src/libs/olds/itn/i18n.ts b/src/libs/olds/itn/i18n.ts new file mode 100644 index 0000000..1c130d0 --- /dev/null +++ b/src/libs/olds/itn/i18n.ts @@ -0,0 +1,50 @@ +import type { + Paths, + GetTypeAtPath, + ParamsFor, + HasParams +} from './i18n.types.ts'; +import { translations } from './schema.ts'; +import type { TranslationSchema } from './schema.ts'; +import type { SupportedLocale, DefaultLocale } from './i18n.types.ts'; + +let currentLocale: SupportedLocale = 'es'; + +export function setLocale(locale: SupportedLocale) { + currentLocale = locale; +} + +export function getLocale() { + return currentLocale; +} + +function resolvePath(obj: any, path: string): any { + return path.split('.').reduce((acc, key) => acc?.[key], obj); +} + +export function t< + P extends Paths, + TType = GetTypeAtPath +>( + path: P, + ...args: HasParams extends true + ? [params: ParamsFor] + : [] +): string { + + const value = resolvePath(translations, path); + + if (!value) { + throw new Error(`Missing translation key: ${path}`); + } + + const record = + typeof value === 'function' + ? value(args[0]) + : value; + + return ( + record[currentLocale] ?? + record['es'] // fallback default + ); +} \ No newline at end of file diff --git a/src/libs/olds/itn/i18n.types.ts b/src/libs/olds/itn/i18n.types.ts new file mode 100644 index 0000000..67edef9 --- /dev/null +++ b/src/libs/olds/itn/i18n.types.ts @@ -0,0 +1,81 @@ +// ============================== +// LOCALES +// ============================== + +export type DefaultLocale = 'es'; + +export type SupportedLocale = + | DefaultLocale + | 'en' + | 'de' + | 'fr' + | 'it' + | 'pt' + | 'ca' + | 'eu' + | 'gl'; + +// ============================== +// LOCALIZED TYPES +// ============================== + +export type LocaleRecord = { + [K in L]?: string; +} & { + [K in DefaultLocale]: string; +}; + +export type TranslationValue = + | LocaleRecord + | ((params: any) => LocaleRecord); + +// ============================== +// TYPE UTILITIES +// ============================== + +type DotPrefix = T extends '' ? '' : `.${T}`; + +type Prev = [never, 0, 1, 2, 3, 4, 5, 6]; + +type Join = + P extends string + ? `${K & string}.${P}` + : never; + +export type Paths< + T, + D extends number = 6 +> = + [D] extends [never] + ? never + : T extends object + ? { + [K in keyof T & string]: + | K + | Join> + }[keyof T & string] + : never; + + +export type GetTypeAtPath< + T, + P extends string +> = + P extends `${infer K}.${infer Rest}` + ? K extends keyof T + ? GetTypeAtPath + : never + : P extends keyof T + ? T[P] + : never; + + +export type ParamsFor = + T extends (params: infer P) => any + ? P + : never; + +export type HasParams = + T extends (params: any) => any + ? true + : false; \ No newline at end of file diff --git a/src/libs/olds/itn/schema.ts b/src/libs/olds/itn/schema.ts new file mode 100644 index 0000000..3c0e0c2 --- /dev/null +++ b/src/libs/olds/itn/schema.ts @@ -0,0 +1,25 @@ +import type { SupportedLocale, TranslationValue } from './i18n.types.ts'; + +export const translations = { + checkout: { + pay: { + es: "Pagar", + en: "Pay" + }, + + total: (params: { amount: number }) => ({ + es: `Total: ${params.amount}€`, + en: `Total: $${params.amount}` + }) + }, + + common: { + ok: { + es: "Aceptar", + en: "OK" + } + } + +} satisfies Record; + +export type TranslationSchema = typeof translations; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1b00c10 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "baseUrl": "./src", + "paths": { + "@/libs/*" : ["libs/*"], + "@/types/*" : ["types/*"], + "@/utils/*" : ["utils/*"], + "@/engine/*" : ["engine/*"], + "@/constants/*" : ["constants/*"], + "@/adapters/*" : ["adapters/*"], + "@/svelte/*" : ["adapters/svelte/*"], + "@/*" : ["*"] + }, + + // ✅ Opciones CRÍTICAS para usar .ts en imports: + "allowImportingTsExtensions": true, + "noEmit": true, // Obligatorio con la opción anterior + "module": "esnext", // Cambiado de nodenext a esnext + "moduleResolution": "bundler", // Permite resoluciones modernas tipo Vite/Bun + + "target": "esnext", + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f38c275 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: [ + 'src/**/*.ts', + 'src/libs/**/*.ts', + 'src/libs/i18n/*.ts' + ], + exclude: [ + 'src/**/*.test.ts', + 'src/**/*.d.ts'] + } + }, + resolve: { + alias: { + '@': resolve(__dirname, './src') + } + } +});