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