First Commit

master
dev 8 months ago
commit 4d5f1f16ec

11
.gitignore vendored

@ -0,0 +1,11 @@
/tmp
/out-tsc
/node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/.pnp
.pnp.js
.vscode/*

8
.idea/.gitignore vendored

@ -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…
Cancel
Save

Powered by TurnKey Linux.