commit
4d5f1f16ec
@ -0,0 +1,11 @@
|
|||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
.vscode/*
|
||||||
@ -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
|
||||||
@ -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<P>` | 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.
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
console.log('Happy developing ✨')
|
||||||
@ -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<S extends TranslationNode>(
|
||||||
|
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<S>,
|
||||||
|
TType = GetTypeAtPath<S, P>
|
||||||
|
>(
|
||||||
|
path: P,
|
||||||
|
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
|
||||||
|
): 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<S>,
|
||||||
|
TType = GetTypeAtPath<S, P>
|
||||||
|
>(
|
||||||
|
path: P,
|
||||||
|
locale: SupportedLocale,
|
||||||
|
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
|
||||||
|
): 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 };
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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<typeof createI18n<typeof testSchema>>;
|
||||||
|
|
||||||
|
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<typeof createI18n<typeof testSchema>>;
|
||||||
|
|
||||||
|
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<typeof createI18n<typeof testSchema>>;
|
||||||
|
|
||||||
|
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<typeof createI18n<typeof testSchema>>;
|
||||||
|
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<P extends Record<string, unknown>> = (params: P) => LocaleRecord;
|
||||||
|
|
||||||
|
export type TranslationValue =
|
||||||
|
| LocaleRecord
|
||||||
|
| TranslationFn<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<K, P> =
|
||||||
|
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<T[K], Prev[D]> & string}`
|
||||||
|
}[keyof T & string]
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type GetTypeAtPath<
|
||||||
|
T,
|
||||||
|
P extends string
|
||||||
|
> =
|
||||||
|
P extends `${infer K}.${infer Rest}`
|
||||||
|
? K extends keyof T
|
||||||
|
? GetTypeAtPath<T[K], Rest>
|
||||||
|
: never
|
||||||
|
: P extends keyof T
|
||||||
|
? T[P]
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type ParamsFor<T> =
|
||||||
|
T extends (params: infer P) => any
|
||||||
|
? P
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type HasParams<T> =
|
||||||
|
T extends (params: any) => any
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
@ -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';
|
||||||
|
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import type { TranslationNode, SupportedLocale } from './i18n.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MEJORA: `satisfies TranslationNode` en lugar de `satisfies Record<string, any>`.
|
||||||
|
* Ahora TypeScript valida que cada hoja sea un LocaleRecord válido o una función tipada.
|
||||||
|
* Si añades una clave malformada (ej: { es: 123 }), obtendrás un error en tiempo de compilación.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
@ -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: ');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
// i18n.ts
|
||||||
|
import type {
|
||||||
|
DotNestedKeys,
|
||||||
|
GetTypeAtPath,
|
||||||
|
InterpolationParams,
|
||||||
|
TranslationStore
|
||||||
|
} from './i18n-types';
|
||||||
|
|
||||||
|
export function createI18n<TSchema extends object, TLocale extends string>(
|
||||||
|
initialStore: Partial<TranslationStore<TSchema, TLocale>> = {}, // Ahora puede empezar vacío
|
||||||
|
defaultLocale: TLocale
|
||||||
|
) {
|
||||||
|
let currentLocale: TLocale = defaultLocale;
|
||||||
|
|
||||||
|
// El almacén plano donde se acumulará todo
|
||||||
|
const flatStore = {} as Record<TLocale, Record<string, any>>;
|
||||||
|
|
||||||
|
function flatten(obj: any, prefix = ''): Record<string, any> {
|
||||||
|
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: <K extends DotNestedKeys<TSchema>>(
|
||||||
|
key: K,
|
||||||
|
args?: GetTypeAtPath<TSchema, K> 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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> = 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> = T extends object
|
||||||
|
? {
|
||||||
|
[K in keyof T & string]: IsTerminal<T[K]> extends true
|
||||||
|
? K
|
||||||
|
: `${K}.${DotNestedKeys<T[K]>}`
|
||||||
|
}[keyof T & string]
|
||||||
|
: '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae el tipo exacto (String o Función) de una ruta específica
|
||||||
|
*/
|
||||||
|
export type GetTypeAtPath<T, Path extends string> = Path extends `${infer Head}.${infer Tail}`
|
||||||
|
? Head extends keyof T ? GetTypeAtPath<T[Head], Tail> : never
|
||||||
|
: Path extends keyof T ? T[Path] : never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estructura para variables de interpolación {{var}}
|
||||||
|
*/
|
||||||
|
export type InterpolationParams = Record<string, string | number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definición del almacén de traducciones
|
||||||
|
*/
|
||||||
|
export type TranslationStore<TSchema, TLocale extends string> = {
|
||||||
|
[L in TLocale]: TSchema;
|
||||||
|
};
|
||||||
@ -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<TranslationStore<AppSchema, SupportedLocales>> = {
|
||||||
|
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<AppSchema, SupportedLocales>(
|
||||||
|
initialTranslations,
|
||||||
|
'es'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper para exportar directamente la función de traducción
|
||||||
|
export const t = i18n.t;
|
||||||
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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<TranslationSchema>,
|
||||||
|
TType = GetTypeAtPath<TranslationSchema, P>
|
||||||
|
>(
|
||||||
|
path: P,
|
||||||
|
...args: HasParams<TType> extends true
|
||||||
|
? [params: ParamsFor<TType>]
|
||||||
|
: []
|
||||||
|
): 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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<L extends string> = {
|
||||||
|
[K in L]?: string;
|
||||||
|
} & {
|
||||||
|
[K in DefaultLocale]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranslationValue<L extends string> =
|
||||||
|
| LocaleRecord<L>
|
||||||
|
| ((params: any) => LocaleRecord<L>);
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// TYPE UTILITIES
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`;
|
||||||
|
|
||||||
|
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
|
type Join<K, P> =
|
||||||
|
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<K, Paths<T[K], Prev[D]>>
|
||||||
|
}[keyof T & string]
|
||||||
|
: never;
|
||||||
|
|
||||||
|
|
||||||
|
export type GetTypeAtPath<
|
||||||
|
T,
|
||||||
|
P extends string
|
||||||
|
> =
|
||||||
|
P extends `${infer K}.${infer Rest}`
|
||||||
|
? K extends keyof T
|
||||||
|
? GetTypeAtPath<T[K], Rest>
|
||||||
|
: never
|
||||||
|
: P extends keyof T
|
||||||
|
? T[P]
|
||||||
|
: never;
|
||||||
|
|
||||||
|
|
||||||
|
export type ParamsFor<T> =
|
||||||
|
T extends (params: infer P) => any
|
||||||
|
? P
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type HasParams<T> =
|
||||||
|
T extends (params: any) => any
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
@ -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<string, any>;
|
||||||
|
|
||||||
|
export type TranslationSchema = typeof translations;
|
||||||
@ -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"]
|
||||||
|
}
|
||||||
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in new issue