master
commit
0426d9f46d
@ -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,41 @@
|
||||
{
|
||||
"name": "visual-engine-configurator-information",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "vite dev"
|
||||
},
|
||||
"keywords": [
|
||||
"configuration",
|
||||
"visual",
|
||||
"typescript"
|
||||
],
|
||||
"author": "ACTIVE THING",
|
||||
"license": "EULA",
|
||||
|
||||
"dependencies": {
|
||||
"svelte": "^5.53.0",
|
||||
"@tailwindcss/vite": "^4.1.18"
|
||||
},
|
||||
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tsconfig/svelte": "^5.0.6",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@types/node": "^25.2.3",
|
||||
"jsdom": "^28.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
|
||||
|
||||
|
||||
|
||||
export const idPrefix = '#?';
|
||||
|
||||
export const defaultISOLocale = 'es' ;
|
||||
|
||||
export const maxResolveDeep = 3;
|
||||
|
||||
export const loggerCategory = 'ling';
|
||||
@ -0,0 +1,295 @@
|
||||
# ling
|
||||
|
||||
Sistema de internacionalización type-safe para TypeScript. Agnóstico de framework, sin dependencias externas.
|
||||
|
||||
---
|
||||
|
||||
## Estructura de ficheros
|
||||
|
||||
```
|
||||
ling/
|
||||
├── index.ts # Barrel — punto de entrada público
|
||||
├── engine.ts # Singleton global (wiring de schema base + factory)
|
||||
├── instance.ts # Factory: createLing()
|
||||
├── types.ts # Tipos e interfaces
|
||||
├── translations.ts # Traducciones base de la aplicación
|
||||
└── tests/
|
||||
└── ling.test.ts
|
||||
|
||||
```
|
||||
|
||||
Cada módulo de la aplicación define sus propias traducciones y las registra en el singleton:
|
||||
|
||||
```
|
||||
auth/
|
||||
└── ling.ts # Registra el namespace 'auth'
|
||||
|
||||
checkout/
|
||||
└── ling.ts # Registra el namespace 'checkout'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Define las traducciones base
|
||||
|
||||
Solo las claves compartidas por toda la aplicación — common, errors, etc.
|
||||
|
||||
```ts
|
||||
// translations.ts
|
||||
import type { TranslationNode } from './ling.types';
|
||||
|
||||
export const translations = {
|
||||
common: {
|
||||
ok : { es: 'Aceptar', en: 'OK' },
|
||||
cancel: { es: 'Cancelar', en: 'Cancel' },
|
||||
},
|
||||
errors: {
|
||||
generic: (params: { code: number }) => ({
|
||||
es: `Ha ocurrido un error (${params.code})`,
|
||||
en: `An error occurred (${params.code})`,
|
||||
}),
|
||||
},
|
||||
} satisfies TranslationNode;
|
||||
```
|
||||
|
||||
> `es` es obligatorio en cada hoja. El resto de locales son opcionales y hacen fallback a `es` si faltan.
|
||||
|
||||
### 2. Crea el singleton
|
||||
|
||||
```ts
|
||||
// ling.engine.ts
|
||||
import { createLing } from './ling.factory';
|
||||
import { translations } from './translations';
|
||||
|
||||
export const ling = createLing(translations, 'es');
|
||||
```
|
||||
|
||||
### 3. Cada módulo registra sus traducciones
|
||||
|
||||
```ts
|
||||
// auth/ling.ts
|
||||
import { ling } from '@/ling.engine';
|
||||
|
||||
export const authLing = ling.register('auth', {
|
||||
loginFailed : { es: 'Login fallido', en: 'Login failed' },
|
||||
sessionExpired: { es: 'Sesión expirada', en: 'Session expired' },
|
||||
welcome : (params: { name: string }) => ({
|
||||
es: `Bienvenido, ${params.name}`,
|
||||
en: `Welcome, ${params.name}`,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// checkout/ling.ts
|
||||
import { ling } from '@/ling.engine';
|
||||
|
||||
export const checkoutLing = ling.register('checkout', {
|
||||
pay : { es: 'Pagar', en: 'Pay' },
|
||||
total: (params: { amount: number; currency: string }) => ({
|
||||
es: `Total: ${params.amount}${params.currency}`,
|
||||
en: `Total: ${params.currency}${params.amount}`,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Uso dentro de cada módulo
|
||||
|
||||
```ts
|
||||
// auth/login.ts
|
||||
import { authLing } from './ling';
|
||||
|
||||
authLing.t('auth.loginFailed') // → "Login fallido"
|
||||
authLing.t('auth.welcome', { name: 'Ana' }) // → "Bienvenido, Ana"
|
||||
authLing.t('common.ok') // → "Aceptar" (base disponible)
|
||||
```
|
||||
|
||||
Cada módulo tiene **autocompletado y validación en compilación** solo de sus claves más las del schema base. No ve las claves de otros módulos.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `t(path, params?)`
|
||||
|
||||
Traduce una clave del schema al locale actual.
|
||||
|
||||
```ts
|
||||
authLing.t('auth.loginFailed')
|
||||
authLing.t('auth.welcome', { name: 'Ana' })
|
||||
authLing.t('common.ok')
|
||||
```
|
||||
|
||||
- Solo acepta rutas que terminan en una traducción real — rutas intermedias como `'auth'` dan error de tipos.
|
||||
- Los params son obligatorios si la traducción los requiere, y TypeScript los infiere automáticamente.
|
||||
|
||||
### `ts(value)`
|
||||
|
||||
*Translate String* — resuelve un `LingString` con el locale actual. Útil para campos de datos que pueden estar localizados o no.
|
||||
|
||||
```ts
|
||||
ts('texto fijo') // → "texto fijo" (pass-through)
|
||||
ts({ es: 'una descripción', en: 'a description' }) // → "una descripción"
|
||||
```
|
||||
|
||||
```ts
|
||||
import type { LingString } from '@/ling';
|
||||
|
||||
interface Product {
|
||||
id : string;
|
||||
name: LingString;
|
||||
}
|
||||
|
||||
const product: Product = { id: '1', name: { es: 'Silla', en: 'Chair' } };
|
||||
ling.ts(product.name) // → "Silla"
|
||||
```
|
||||
|
||||
### `tForLocale(path, locale, params?)`
|
||||
|
||||
Resuelve una clave en una locale específica sin cambiar el estado global. Útil para SSR o generación de emails.
|
||||
|
||||
```ts
|
||||
authLing.tForLocale('auth.loginFailed', 'en') // → "Login failed"
|
||||
ling.getLocale() // → "es" (no ha cambiado)
|
||||
```
|
||||
|
||||
### `register(namespace, module)`
|
||||
|
||||
Registra las traducciones de un módulo bajo un namespace. Devuelve una nueva instancia con los tipos extendidos que comparte el mismo estado reactivo que el singleton.
|
||||
|
||||
```ts
|
||||
export const authLing = ling.register('auth', { ... });
|
||||
```
|
||||
|
||||
- El locale se sincroniza automáticamente con el singleton — un solo `setLocale` actualiza todos los módulos.
|
||||
- Cada módulo ve sus claves tipadas más las del schema base.
|
||||
- Encadenar `register()` acumula namespaces:
|
||||
|
||||
```ts
|
||||
const full = ling
|
||||
.register('auth', authTranslations)
|
||||
.register('checkout', checkoutTranslations);
|
||||
```
|
||||
|
||||
### `setLocale(locale)`
|
||||
|
||||
Cambia el locale del singleton y propaga el cambio a todos los módulos registrados.
|
||||
|
||||
```ts
|
||||
ling.setLocale('en')
|
||||
// authLing, checkoutLing... todos reflejan 'en' automáticamente
|
||||
```
|
||||
|
||||
### `getLocale()`
|
||||
|
||||
```ts
|
||||
ling.getLocale() // → "es"
|
||||
```
|
||||
|
||||
### `onLocaleChange(fn)`
|
||||
|
||||
Registra un listener que se ejecuta cuando cambia el locale. Devuelve `unsubscribe`.
|
||||
|
||||
```ts
|
||||
const unsubscribe = ling.onLocaleChange((locale) => {
|
||||
console.log('Nuevo locale:', locale);
|
||||
});
|
||||
|
||||
unsubscribe(); // deja de escuchar
|
||||
```
|
||||
|
||||
> `tForLocale()` no dispara los listeners.
|
||||
|
||||
---
|
||||
|
||||
## 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, edita `SupportedLocale` en `ling.types.ts`:
|
||||
|
||||
```ts
|
||||
export type SupportedLocale = DefaultLocale | 'en' | 'de' | 'fr' | ... | 'ja';
|
||||
```
|
||||
|
||||
TypeScript marcará todas las hojas del schema donde falte la nueva locale.
|
||||
|
||||
---
|
||||
|
||||
## Tipos públicos
|
||||
|
||||
| Tipo | Descripción |
|
||||
|------|-------------|
|
||||
| `SupportedLocale` | Unión de todas las locales soportadas |
|
||||
| `DefaultLocale` | `'es'` — locale obligatoria en cada traducción |
|
||||
| `LocaleRecord` | `{ es: string, en?: string, ... }` |
|
||||
| `LingString` | `string \| LocaleRecord` — campos opcionalmente localizados |
|
||||
| `TranslationNode` | Tipo recursivo del árbol de traducciones |
|
||||
| `TranslationFn<P>` | Función de traducción con parámetros tipados |
|
||||
| `LingInstance<S>` | Tipo de la instancia parametrizado por el schema |
|
||||
|
||||
---
|
||||
|
||||
## Fallback
|
||||
|
||||
```
|
||||
locale actual → defaultLocale → clave como texto
|
||||
```
|
||||
|
||||
En desarrollo (`NODE_ENV === 'development'`) se emite `console.warn` cuando se usa el fallback. En producción la degradación es silenciosa.
|
||||
|
||||
---
|
||||
|
||||
## Integración con frameworks
|
||||
|
||||
Conecta `setLocale` y `onLocaleChange` al sistema reactivo del framework.
|
||||
|
||||
**Vue 3**
|
||||
```ts
|
||||
import { ref } from 'vue';
|
||||
import { ling } from '@/ling.engine';
|
||||
|
||||
export const locale = ref(ling.getLocale());
|
||||
ling.onLocaleChange(l => locale.value = l);
|
||||
```
|
||||
|
||||
**Svelte**
|
||||
```ts
|
||||
import { writable } from 'svelte/store';
|
||||
import { ling } from '@/ling.engine';
|
||||
|
||||
export const locale = writable(ling.getLocale());
|
||||
ling.onLocaleChange(l => locale.set(l));
|
||||
```
|
||||
|
||||
**React**
|
||||
```ts
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { ling } from '@/ling.engine';
|
||||
|
||||
export function useLocale() {
|
||||
return useSyncExternalStore(ling.onLocaleChange, ling.getLocale);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
vitest
|
||||
```
|
||||
|
||||
Los tests usan schemas propios independientes del de producción — no hay acoplamiento entre la suite y las traducciones reales.
|
||||
@ -0,0 +1,266 @@
|
||||
import type {
|
||||
SupportedLocale,
|
||||
LeafPaths,
|
||||
GetTypeAtPath,
|
||||
ParamsFor,
|
||||
HasParams,
|
||||
PluralForms,
|
||||
LingRecord,
|
||||
LingString, LingInstance, LingLogger, LingNode, PluralConfig,
|
||||
} from './types.ts';
|
||||
|
||||
import { LING_ERRORS } from './errors.ts';
|
||||
import {isIDLing, isLingRecord} from "@/ling/guards.ts";
|
||||
import {idPrefix, loggerCategory, maxResolveDeep} from "@/ling/consts.ts";
|
||||
|
||||
// ==============================
|
||||
// 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';
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Logger por defecto — console puro, sin dependencias externas.
|
||||
// Se reemplaza con setLogger() una vez logr está inicializado.
|
||||
const consoleLogger: LingLogger = {
|
||||
warn : (category, message) => isDev() && console.warn (message),
|
||||
error: (category, message) => isDev() && console.error(message),
|
||||
};
|
||||
|
||||
// ==============================
|
||||
// ENGINE
|
||||
// ==============================
|
||||
|
||||
export function createLing<S extends LingNode>(
|
||||
schema: S,
|
||||
defaultLocale: SupportedLocale
|
||||
): LingInstance<S> {
|
||||
|
||||
let currentSchema : LingNode = schema;
|
||||
let currentLocale : SupportedLocale = defaultLocale;
|
||||
let logger : LingLogger = consoleLogger;
|
||||
let loggerSet : boolean = false;
|
||||
|
||||
const listeners = new Set<(locale: SupportedLocale) => void>();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Logger
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Inyecta un logger externo. Solo puede llamarse una vez.
|
||||
* En desarrollo avisa si se intenta sobreescribir.
|
||||
*/
|
||||
function setLogger(external: LingLogger): void {
|
||||
if (loggerSet) {
|
||||
if (isDev()) {
|
||||
console.warn(LING_ERRORS.LOGGER_ALREADY_SET);
|
||||
}
|
||||
return;
|
||||
}
|
||||
logger = external;
|
||||
loggerSet = true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Locale
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Resolución interna
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
|
||||
function tsRecord(record: LingRecord, path?: string, params?: any): string {
|
||||
const translationInLocale = record[currentLocale];
|
||||
const isMissing = translationInLocale === undefined;
|
||||
|
||||
// 1. Resolución del valor (con fallback)
|
||||
let translation = translationInLocale ?? record[defaultLocale] ?? path ?? '';
|
||||
|
||||
// 2. Interpolación global (aplica a la traducción final)
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, val]) => {
|
||||
translation = translation.replace(new RegExp(`{{${key}}}`, 'g'), String(val));
|
||||
});
|
||||
}
|
||||
|
||||
// 3. LOGGING: Solo si estamos en desarrollo Y falta la traducción
|
||||
if (isDev() && isMissing) {
|
||||
const msg = path
|
||||
? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale)
|
||||
: LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale);
|
||||
|
||||
logger.warn(loggerCategory, msg);
|
||||
}
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Resuelve referencias de forma recursiva con un límite de profundidad
|
||||
* para evitar bucles infinitos.
|
||||
*/
|
||||
function resolveValue(value: any, args: any[], depth: number): any {
|
||||
if (depth > maxResolveDeep) {
|
||||
logger.error(loggerCategory, LING_ERRORS.CIRCULAR_REFERENCE(value));
|
||||
throw new Error("Circular reference in ling");
|
||||
}
|
||||
|
||||
if (isIDLing(value)) {
|
||||
const path = value.substring(idPrefix.length);
|
||||
const resolved = resolvePath(currentSchema, path);
|
||||
return resolveValue(resolved, args, depth + 1); // recursión con depth+1
|
||||
}
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return value(args[0]);
|
||||
}
|
||||
|
||||
return value; // LingRecord, string, lo que sea
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API pública
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Función principal de traducción.
|
||||
* Soporta navegación por puntos (dot-notation), pluralización e interpolación.
|
||||
*/
|
||||
const t: LingInstance<S>['t'] = (path: string, ...args: any[]): any => {
|
||||
// 1. Buscamos el valor en el árbol de traducciones
|
||||
const rawValue = resolvePath(currentSchema, path);
|
||||
|
||||
// 2. Si no existe nada en esa ruta, devolvemos el path y logueamos error
|
||||
if (rawValue === undefined) {
|
||||
if (isDev()) {
|
||||
logger.error(loggerCategory, LING_ERRORS.KEY_NOT_FOUND(path));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
let finalValue = rawValue;
|
||||
|
||||
|
||||
finalValue = resolveValue(rawValue, args, 0);
|
||||
|
||||
// 4. LÓGICA DE EJECUCIÓN: ¿Es una función (plural) o un objeto (LingRecord)?
|
||||
|
||||
// CASO A: Es una función (ej: resultado de p())
|
||||
if (typeof finalValue === 'function') {
|
||||
// Ejecutamos la función pasándole los parámetros (args[0])
|
||||
// Esto devuelve un LingRecord (ej: { es: '1 mensaje', en: '1 message' })
|
||||
const record = finalValue(args[0]);
|
||||
|
||||
// Delegamos en tsRecord para elegir el idioma e interpolar {{variables}}
|
||||
return tsRecord(record, path, args[0]);
|
||||
}
|
||||
|
||||
// CASO B: Es un LingRecord directo (objeto con idiomas { es: '...', en: '...' })
|
||||
if (isLingRecord(finalValue)) {
|
||||
return tsRecord(finalValue, path, args[0]);
|
||||
}
|
||||
|
||||
// CASO C: Es un string simple o fallback
|
||||
// Si por algún motivo llegamos a un valor que no es objeto ni función
|
||||
return String(finalValue);
|
||||
};
|
||||
|
||||
|
||||
function ts(value: LingString): string {
|
||||
if (!value) return '';
|
||||
const finalValue = resolveValue(value, [], 0);
|
||||
|
||||
if (isLingRecord(finalValue)) return tsRecord(finalValue);
|
||||
return typeof finalValue === 'string' ? finalValue : String(finalValue);
|
||||
}
|
||||
|
||||
|
||||
function tForLocale<P extends LeafPaths<S>, TType = GetTypeAtPath<S, 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;
|
||||
}
|
||||
|
||||
function register<NS extends string, M extends LingNode>(
|
||||
namespace: NS,
|
||||
module: M
|
||||
): LingInstance<S & { [K in NS]: M }> {
|
||||
currentSchema = {
|
||||
...(currentSchema as Record<string,any>),
|
||||
[namespace]: module,
|
||||
};
|
||||
|
||||
const extended = createLing(
|
||||
currentSchema as S & { [K in NS]: M },
|
||||
defaultLocale
|
||||
);
|
||||
|
||||
extended.setLocale(currentLocale);
|
||||
onLocaleChange(locale => extended.setLocale(locale));
|
||||
|
||||
return extended;
|
||||
}
|
||||
|
||||
return { t, tForLocale, ts, setLocale, getLocale, onLocaleChange, register, setLogger };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper para generar traducciones pluralizadas.
|
||||
* Mapea cada idioma a sus respectivas reglas gramaticales.
|
||||
*/
|
||||
/**
|
||||
* Helper de pluralización.
|
||||
* El tipo de retorno ahora incluye '& Record<string, any>' para permitir
|
||||
* parámetros adicionales de interpolación (como {{name}}).
|
||||
*/
|
||||
export const p = (config: PluralConfig) =>
|
||||
(params: { count: number } & Record<string, any>): LingRecord => {
|
||||
const result: any = {};
|
||||
|
||||
for (const [locale, forms] of Object.entries(config)) {
|
||||
if (!forms) continue;
|
||||
|
||||
// Seleccionamos la regla (one, other, etc.) según el idioma
|
||||
const rule = new Intl.PluralRules(locale).select(params.count);
|
||||
const typedForms = forms as PluralForms;
|
||||
|
||||
// Si la regla específica no existe (ej: 'few'), usamos 'other'
|
||||
result[locale] = typedForms[rule] || typedForms.other;
|
||||
}
|
||||
|
||||
return result as LingRecord;
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
import type { SupportedLocale } from './types.ts';
|
||||
|
||||
// ============================================================================
|
||||
// LING ERROR & WARNING MESSAGES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mensajes de error y warning del sistema ling centralizados como constantes.
|
||||
*
|
||||
* No se usa el sistema de `Logr` para evitar dependencia cíclica —
|
||||
* `Logr` depende de `ling`, por lo que `ling` no puede depender de `Logr`.
|
||||
*
|
||||
* Estos mensajes se emiten directamente por consola solo en desarrollo
|
||||
* (`NODE_ENV === 'development'`). En producción la degradación es silenciosa.
|
||||
*/
|
||||
export const LING_ERRORS = {
|
||||
|
||||
/**
|
||||
* La clave de traducción no existe en el schema.
|
||||
* Se emite como `console.error` — indica un error de programación,
|
||||
* no una traducción faltante.
|
||||
*
|
||||
* @example
|
||||
* LING_ERRORS.KEY_NOT_FOUND('checkout.total')
|
||||
* // → '[ling] Translation key not found: "checkout.total"'
|
||||
*/
|
||||
KEY_NOT_FOUND: (path: string): string =>
|
||||
`[ling] Translation key not found: "${path}"`,
|
||||
|
||||
CIRCULAR_REFERENCE: (path: string): string =>
|
||||
`[ling] Circular reference in "${path}".`,
|
||||
|
||||
/**
|
||||
* La clave existe pero no tiene traducción para el locale solicitado.
|
||||
* Se emite como `console.warn` — degradación controlada con fallback.
|
||||
*
|
||||
* @example
|
||||
* LING_ERRORS.MISSING_TRANSLATION('common.ok', 'de', 'es')
|
||||
* // → '[ling] Missing translation for "common.ok" in "de". Falling back to "es".'
|
||||
*/
|
||||
MISSING_TRANSLATION: (path: string, locale: SupportedLocale, fallback: SupportedLocale): string =>
|
||||
`[ling] Missing translation for "${path}" in "${locale}". Falling back to "${fallback}".`,
|
||||
|
||||
/**
|
||||
* Variante de MISSING_TRANSLATION para cuando no hay path disponible
|
||||
* — usado en `ts()` al resolver un `LocaleRecord` sin contexto de clave.
|
||||
*
|
||||
* @example
|
||||
* LING_ERRORS.MISSING_TRANSLATION_RECORD('de', 'es')
|
||||
* // → '[ling] Missing translation in "de". Falling back to "es".'
|
||||
*/
|
||||
MISSING_TRANSLATION_RECORD: (locale: SupportedLocale, fallback: SupportedLocale): string =>
|
||||
`[ling] Missing translation in "${locale}". Falling back to "${fallback}".`,
|
||||
|
||||
|
||||
/**
|
||||
* Se intentó llamar a setLogger() más de una vez.
|
||||
* El logger solo puede inyectarse una vez — post-init es inmutable.
|
||||
*/
|
||||
LOGGER_ALREADY_SET: '[ling] Logger already set. setLogger() can only be called once.',
|
||||
|
||||
} as const;
|
||||
@ -0,0 +1,29 @@
|
||||
import type {DefaultLocale, IDLing, LingRecord} from "@/ling/types.ts";
|
||||
import {defaultISOLocale, idPrefix} from "@/ling/consts.ts";
|
||||
|
||||
/**
|
||||
* Guard para identificar referencias.
|
||||
* Usamos un chequeo de longitud para evitar que "#?" vacío sea válido.
|
||||
*/
|
||||
export function isIDLing(value: unknown): value is IDLing {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.startsWith(idPrefix) &&
|
||||
value.length > 2
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Guard para registros de idioma.
|
||||
* Valida la existencia de la DefaultLocale definida en el sistema.
|
||||
*/
|
||||
export function isLingRecord(value: unknown): value is LingRecord {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
defaultISOLocale in value // obligatorio según DefaultLocale
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
export { translations } from './translations';
|
||||
|
||||
|
||||
export * from './types.ts';
|
||||
export * from './engine.ts';
|
||||
export * from './instance.ts';
|
||||
export * from './translations';
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
|
||||
import { createLing } from './engine.ts';
|
||||
import { translations } from './translations';
|
||||
|
||||
|
||||
|
||||
export const ling = createLing(translations, 'es');
|
||||
|
||||
|
||||
|
||||
// Desestructura si prefieres usar t() directamente
|
||||
export const { t, tForLocale, setLocale, getLocale, onLocaleChange } = ling;
|
||||
@ -0,0 +1,390 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {createLing, ling} from '@/ling';
|
||||
import type { LingNode, LingString } from '@/ling';
|
||||
|
||||
|
||||
// ==============================
|
||||
// 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 LingNode;
|
||||
|
||||
// ==============================
|
||||
// TESTS
|
||||
// ==============================
|
||||
|
||||
describe('createI18n', () => {
|
||||
|
||||
describe('instanciación', () => {
|
||||
it('crea una instancia con el locale por defecto', () => {
|
||||
const i18n = createLing(testSchema, 'es');
|
||||
expect(i18n.getLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('crea instancias independientes con distintos locales', () => {
|
||||
const i18nEs = createLing(testSchema, 'es');
|
||||
const i18nEn = createLing(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 ln: ReturnType<typeof createLing<typeof testSchema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
ln = createLing(testSchema, 'es');
|
||||
});
|
||||
|
||||
it('devuelve la traducción en el locale actual', () => {
|
||||
expect(ln.t('common.ok')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('devuelve la traducción tras cambiar de locale', () => {
|
||||
ln.setLocale('en');
|
||||
expect(ln.t('common.ok')).toBe('OK');
|
||||
});
|
||||
|
||||
it('devuelve la traducción en francés', () => {
|
||||
ln.setLocale('fr');
|
||||
expect(ln.t('common.ok')).toBe('Accepter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('t() — traducciones con parámetros', () => {
|
||||
let ln: ReturnType<typeof createLing<typeof testSchema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
ln = createLing(testSchema, 'es');
|
||||
});
|
||||
|
||||
it('interpola parámetros correctamente en español', () => {
|
||||
expect(ling.t('checkout.total', { amount: 99, currency: '€' }))
|
||||
.toBe('Total: 99€');
|
||||
});
|
||||
|
||||
it('interpola parámetros correctamente en inglés', () => {
|
||||
ln.setLocale('en');
|
||||
expect(ln.t('checkout.total', { amount: 99, currency: '$' }))
|
||||
.toBe('Total: $99');
|
||||
});
|
||||
|
||||
it('interpola parámetros numéricos', () => {
|
||||
expect(ln.t('errors.generic', { code: 404 }))
|
||||
.toBe('Error 404');
|
||||
});
|
||||
});
|
||||
|
||||
describe('t() — fallback', () => {
|
||||
let i18n: ReturnType<typeof createLing<typeof testSchema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = createLing(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 lng = createLing(testSchema, 'es');
|
||||
|
||||
const result = lng.tForLocale('common.ok', 'en');
|
||||
|
||||
expect(result).toBe('OK');
|
||||
expect(lng.getLocale()).toBe('es'); // no ha cambiado
|
||||
});
|
||||
|
||||
it('funciona con parámetros', () => {
|
||||
const lng = createLing(testSchema, 'es');
|
||||
|
||||
const result = lng.tForLocale('checkout.total', 'en', { amount: 50, currency: '$' });
|
||||
|
||||
expect(result).toBe('Total: $50');
|
||||
expect(lng.getLocale()).toBe('es');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLocale() / getLocale()', () => {
|
||||
it('actualiza el locale correctamente', () => {
|
||||
const i18n = createLing(testSchema, 'es');
|
||||
i18n.setLocale('en');
|
||||
expect(i18n.getLocale()).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLocaleChange()', () => {
|
||||
it('notifica al cambiar de locale', () => {
|
||||
const i18n = createLing(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 = createLing(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 = createLing(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 = createLing(testSchema, 'es');
|
||||
const listener = vi.fn();
|
||||
|
||||
i18n.onLocaleChange(listener);
|
||||
i18n.tForLocale('common.ok', 'en');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ==============================
|
||||
// TESTS ts()
|
||||
// ==============================
|
||||
|
||||
describe('resolve()', () => {
|
||||
let i18n: ReturnType<typeof createLing<typeof testSchema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = createLing(testSchema, 'es');
|
||||
});
|
||||
|
||||
it('devuelve el string tal cual si es un string simple', () => {
|
||||
expect(i18n.ts('texto fijo')).toBe('texto fijo');
|
||||
});
|
||||
|
||||
it('devuelve el string vacío sin errores', () => {
|
||||
expect(i18n.ts('')).toBe('');
|
||||
});
|
||||
|
||||
it('resuelve un LocaleRecord con el locale actual', () => {
|
||||
const desc: LingString = { es: 'una descripción', en: 'a description' };
|
||||
expect(i18n.ts(desc)).toBe('una descripción');
|
||||
});
|
||||
|
||||
it('resuelve un LocaleRecord tras cambiar de locale', () => {
|
||||
const desc: LingString = { es: 'una descripción', en: 'a description' };
|
||||
i18n.setLocale('en');
|
||||
expect(i18n.ts(desc)).toBe('a description');
|
||||
});
|
||||
|
||||
it('hace fallback al defaultLocale si el locale actual no existe en el record', () => {
|
||||
const desc: LingString = { es: 'solo español' };
|
||||
i18n.setLocale('de');
|
||||
expect(i18n.ts(desc)).toBe('solo español');
|
||||
});
|
||||
|
||||
it('funciona con un interface que usa I18nString', () => {
|
||||
interface Producto {
|
||||
id: string;
|
||||
descripcion: LingString;
|
||||
}
|
||||
|
||||
const producto: Producto = {
|
||||
id: '1',
|
||||
descripcion: { es: 'Silla de madera', en: 'Wooden chair' }
|
||||
};
|
||||
|
||||
expect(i18n.ts(producto.descripcion)).toBe('Silla de madera');
|
||||
|
||||
i18n.setLocale('en');
|
||||
expect(i18n.ts(producto.descripcion)).toBe('Wooden chair');
|
||||
});
|
||||
|
||||
it('funciona mezclando strings simples y LocaleRecord en el mismo array', () => {
|
||||
const items: LingString[] = [
|
||||
'id-invariante',
|
||||
{ es: 'nombre', en: 'name' },
|
||||
'otro-invariante'
|
||||
];
|
||||
|
||||
const resolved = items.map(i18n.ts);
|
||||
expect(resolved).toEqual(['id-invariante', 'nombre', 'otro-invariante']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ==============================
|
||||
// TESTS register()
|
||||
// ==============================
|
||||
|
||||
describe('register()', () => {
|
||||
|
||||
const baseSchema = {
|
||||
common: {
|
||||
ok: { es: 'Aceptar', en: 'OK' }
|
||||
}
|
||||
} satisfies LingNode;
|
||||
|
||||
it('registra traducciones de un módulo bajo un namespace', () => {
|
||||
const i18n = createLing(baseSchema, 'es');
|
||||
|
||||
const extended = i18n.register('auth', {
|
||||
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||||
});
|
||||
|
||||
expect(extended.t('auth.loginFailed')).toBe('Login fallido');
|
||||
});
|
||||
|
||||
it('mantiene acceso a las claves del schema base', () => {
|
||||
const i18n = createLing(baseSchema, 'es');
|
||||
|
||||
const extended = i18n.register('auth', {
|
||||
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||||
});
|
||||
|
||||
expect(extended.t('common.ok')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('hereda el locale actual en el momento del registro', () => {
|
||||
const i18n = createLing(baseSchema, 'es');
|
||||
i18n.setLocale('en');
|
||||
|
||||
const extended = i18n.register('auth', {
|
||||
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||||
});
|
||||
|
||||
expect(extended.t('auth.loginFailed')).toBe('Login failed');
|
||||
});
|
||||
|
||||
it('se sincroniza cuando cambia el locale en la instancia base', () => {
|
||||
const i18n = createLing(baseSchema, 'es');
|
||||
const extended = i18n.register('auth', {
|
||||
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||||
});
|
||||
|
||||
i18n.setLocale('en');
|
||||
|
||||
expect(extended.t('auth.loginFailed')).toBe('Login failed');
|
||||
expect(extended.t('common.ok')).toBe('OK');
|
||||
});
|
||||
|
||||
it('múltiples módulos se acumulan correctamente', () => {
|
||||
const i18n = createLing(baseSchema, 'es');
|
||||
const withAuth = i18n.register('auth', {
|
||||
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||||
});
|
||||
const withAll = withAuth.register('checkout', {
|
||||
pay: { es: 'Pagar', en: 'Pay' }
|
||||
});
|
||||
|
||||
expect(withAll.t('common.ok')).toBe('Aceptar');
|
||||
expect(withAll.t('auth.loginFailed')).toBe('Login fallido');
|
||||
expect(withAll.t('checkout.pay')).toBe('Pagar');
|
||||
});
|
||||
|
||||
it('cambiar locale en base se propaga a todos los módulos registrados', () => {
|
||||
const i18n = createLing(baseSchema, 'es');
|
||||
const withAuth = i18n.register('auth', {
|
||||
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||||
});
|
||||
const withAll = withAuth.register('checkout', {
|
||||
pay: { es: 'Pagar', en: 'Pay' }
|
||||
});
|
||||
|
||||
i18n.setLocale('en');
|
||||
|
||||
expect(withAll.t('common.ok')).toBe('OK');
|
||||
expect(withAll.t('auth.loginFailed')).toBe('Login failed');
|
||||
expect(withAll.t('checkout.pay')).toBe('Pay');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createLing } from '@/ling';
|
||||
import type { LingNode, LingString } from '@/ling';
|
||||
|
||||
// ==============================
|
||||
// SCHEMA DE TEST ACTUALIZADO
|
||||
// ==============================
|
||||
|
||||
const testSchema = {
|
||||
common: {
|
||||
save: { es: 'Guardar', en: 'Save' },
|
||||
cancel: { es: 'Cancelar', en: 'Cancel' }
|
||||
},
|
||||
checkout: {
|
||||
total: (params: { amount: number; currency: string }) => ({
|
||||
es: `Total: ${params.amount}${params.currency}`,
|
||||
en: `Total: ${params.currency}${params.amount}`
|
||||
})
|
||||
},
|
||||
// --- SECCIÓN DE ALIAS (#?) ---
|
||||
shortcuts: {
|
||||
// Alias simple
|
||||
confirm: '#?common.save',
|
||||
// Alias recursivo (doble salto)
|
||||
mainAction: '#?shortcuts.confirm',
|
||||
// Alias a función con parámetros
|
||||
invoice: '#?checkout.total'
|
||||
},
|
||||
danger: {
|
||||
// Referencia circular para test de seguridad
|
||||
loopA: '#?danger.loopB',
|
||||
loopB: '#?danger.loopA'
|
||||
}
|
||||
} satisfies LingNode;
|
||||
|
||||
describe('Ling Library', () => {
|
||||
|
||||
describe('Sistema de Referencias (#?)', () => {
|
||||
let ling: ReturnType<typeof createLing<typeof testSchema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
ling = createLing(testSchema, 'es');
|
||||
});
|
||||
|
||||
it('debería resolver un alias simple mediante t()', () => {
|
||||
// shortcuts.confirm -> common.save
|
||||
expect(ling.t('shortcuts.confirm')).toBe('Guardar');
|
||||
|
||||
ling.setLocale('en');
|
||||
expect(ling.t('shortcuts.confirm')).toBe('Save');
|
||||
});
|
||||
|
||||
it('debería resolver alias recursivos (saltos múltiples)', () => {
|
||||
// shortcuts.mainAction -> shortcuts.confirm -> common.save
|
||||
expect(ling.t('shortcuts.mainAction')).toBe('Guardar');
|
||||
});
|
||||
|
||||
it('debería resolver alias que apuntan a funciones con parámetros', () => {
|
||||
// shortcuts.invoice apunta a checkout.total
|
||||
const result = ling.t('shortcuts.invoice', { amount: 100, currency: '€' });
|
||||
expect(result).toBe('Total: 100€');
|
||||
|
||||
ling.setLocale('en');
|
||||
expect(ling.t('shortcuts.invoice', { amount: 100, currency: '$' })).toBe('Total: $100');
|
||||
});
|
||||
|
||||
it('debería manejar referencias circulares lanzando un error (depth limit)', () => {
|
||||
// Envolvemos la llamada en una función (arrow function)
|
||||
// para que Vitest pueda capturar la excepción en lugar de crashear.
|
||||
expect(() => {
|
||||
ling.t('danger.loopA');
|
||||
}).toThrow("Circular reference in ling"); // Verificamos que el mensaje del error sea el correcto
|
||||
});
|
||||
});
|
||||
|
||||
describe('ts() — Resolutor de LingString', () => {
|
||||
let ling: ReturnType<typeof createLing<typeof testSchema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
ling = createLing(testSchema, 'es');
|
||||
});
|
||||
|
||||
it('debería resolver una referencia #? pasada como LingString', () => {
|
||||
const externalRef: LingString = '#?common.save';
|
||||
expect(ling.ts(externalRef)).toBe('Guardar');
|
||||
});
|
||||
|
||||
it('debería resolver un LingRecord manual', () => {
|
||||
const record: LingString = { es: 'Hola', en: 'Hello' };
|
||||
expect(ling.ts(record)).toBe('Hola');
|
||||
});
|
||||
|
||||
it('debería devolver el string tal cual si es texto plano', () => {
|
||||
expect(ling.ts('Texto fijo')).toBe('Texto fijo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('t() — Funcionalidades Core (Legacy check)', () => {
|
||||
let ling: ReturnType<typeof createLing<typeof testSchema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
ling = createLing(testSchema, 'es');
|
||||
});
|
||||
|
||||
it('debería cambiar de idioma y afectar a todas las resoluciones', () => {
|
||||
expect(ling.t('common.save')).toBe('Guardar');
|
||||
ling.setLocale('en');
|
||||
expect(ling.t('common.save')).toBe('Save');
|
||||
});
|
||||
|
||||
it('tForLocale() no debería alterar el idioma global', () => {
|
||||
const res = ling.tForLocale('common.save', 'en');
|
||||
expect(res).toBe('Save');
|
||||
expect(ling.getLocale()).toBe('es');
|
||||
});
|
||||
});
|
||||
|
||||
describe('register() — Extensibilidad', () => {
|
||||
it('debería permitir registrar nuevos módulos y usar alias hacia la base', () => {
|
||||
const ling = createLing(testSchema, 'es');
|
||||
|
||||
const extended = ling.register('admin', {
|
||||
title: { es: 'Panel', en: 'Panel' },
|
||||
saveBtn: '#?common.save' // Alias hacia el schema padre
|
||||
});
|
||||
|
||||
expect(extended.t('admin.title')).toBe('Panel');
|
||||
expect(extended.t('admin.saveBtn')).toBe('Guardar');
|
||||
});
|
||||
});
|
||||
});// ling2.test.ts
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLing, p } from '@/ling';
|
||||
|
||||
|
||||
describe('Pluralización con helper p()', () => {
|
||||
const translations = {
|
||||
cart: {
|
||||
// Usamos el helper para definir las reglas
|
||||
items: p({
|
||||
es: { one: 'tienes {{count}} producto', other: 'tienes {{count}} productos' },
|
||||
en: { one: 'you have {{count}} item', other: 'you have {{count}} items' }
|
||||
})
|
||||
}
|
||||
} as const;
|
||||
|
||||
it('debería pluralizar correctamente en español (Singular)', () => {
|
||||
const ling = createLing(translations, 'es');
|
||||
// El tipado detectará que 'cart.items' requiere { count: number }
|
||||
expect(ling.t('cart.items', { count: 1 })).toBe('tienes 1 producto');
|
||||
});
|
||||
|
||||
it('debería pluralizar correctamente en español (Plural)', () => {
|
||||
const ling = createLing(translations, 'es');
|
||||
expect(ling.t('cart.items', { count: 5 })).toBe('tienes 5 productos');
|
||||
});
|
||||
|
||||
it('debería pluralizar correctamente en inglés (Singular)', () => {
|
||||
const ling = createLing(translations, 'en');
|
||||
expect(ling.t('cart.items', { count: 1 })).toBe('you have 1 item');
|
||||
});
|
||||
|
||||
it('debería pluralizar correctamente en inglés (Plural)', () => {
|
||||
const ling = createLing(translations, 'en');
|
||||
expect(ling.t('cart.items', { count: 2 })).toBe('you have 2 items');
|
||||
});
|
||||
|
||||
it('debería manejar el caso de "cero" según las reglas del idioma (en ES/EN es plural)', () => {
|
||||
const ling = createLing(translations, 'es');
|
||||
// Intl.PluralRules en español devuelve 'other' para 0
|
||||
expect(ling.t('cart.items', { count: 0 })).toBe('tienes 0 productos');
|
||||
});
|
||||
|
||||
it('debería ser compatible con otros parámetros adicionales', () => {
|
||||
const transWithUser = {
|
||||
greet: p({
|
||||
es: { one: 'Hola {{name}}, tienes {{count}} mensaje', other: 'Hola {{name}}, tienes {{count}} mensajes' },
|
||||
en: { one: 'Hi {{name}}, you have {{count}} message', other: 'Hi {{name}}, you have {{count}} messages' }
|
||||
})
|
||||
} as const;
|
||||
|
||||
const ling = createLing(transWithUser, 'es');
|
||||
expect(ling.t('greet', { count: 10, name: 'Alex' }))
|
||||
.toBe('Hola Alex, tienes 10 mensajes');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
import type { LingNode } from './types.ts';
|
||||
|
||||
/**
|
||||
* 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 LingNode;
|
||||
|
||||
export type TranslationSchema = typeof translations;
|
||||
|
||||
@ -0,0 +1,235 @@
|
||||
// ==============================
|
||||
// LOCALES
|
||||
// ==============================
|
||||
|
||||
|
||||
import {idPrefix} from "@/ling/consts.ts";
|
||||
|
||||
/**
|
||||
* Referencia interna: #?path.del.schema
|
||||
*/
|
||||
export type IDLing = `${typeof idPrefix}${string}`;
|
||||
|
||||
|
||||
|
||||
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 LingRecord = {
|
||||
[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 LingFn<P extends Record<string, unknown>> = (params: P) => LingRecord;
|
||||
|
||||
export type LingValue =
|
||||
| LingRecord
|
||||
| ((...args: any[]) => any)
|
||||
| IDLing;
|
||||
|
||||
|
||||
/**
|
||||
* LING NODE: Es el tipo recursivo.
|
||||
* Un nodo puede ser un valor final (LingValue)
|
||||
* o un objeto que contiene más LingNodes.
|
||||
*/
|
||||
export type LingNode =
|
||||
| LingValue
|
||||
| { [key: string]: LingNode };
|
||||
|
||||
/**
|
||||
* Un valor que puede ser un string simple (invariante de locale)
|
||||
* o un LocaleRecord con traducciones por locale.
|
||||
* Útil para campos de datos que pueden o no estar localizados.
|
||||
*
|
||||
* @example
|
||||
* interface Product {
|
||||
* id: string;
|
||||
* description: LingString;
|
||||
* }
|
||||
*
|
||||
* const product: Product = {
|
||||
* id: '1',
|
||||
* description: { es: 'una descripción', en: 'a description' }
|
||||
* };
|
||||
*
|
||||
* // o también válido:
|
||||
* const product2: Product = {
|
||||
* id: '2',
|
||||
* description: 'fixed string'
|
||||
* };
|
||||
*/
|
||||
|
||||
|
||||
export type LingString =
|
||||
string |
|
||||
LingRecord |
|
||||
IDLing;
|
||||
|
||||
// ==============================
|
||||
// TYPE UTILITIES
|
||||
// ==============================
|
||||
|
||||
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
/**
|
||||
* `LeafPaths` solo expone las rutas que terminan en una hoja
|
||||
* (LocaleRecord o función), no las rutas intermedias (namespaces).
|
||||
*/
|
||||
export type LeafPaths<T, D extends number = 6> =
|
||||
[D] extends [never]
|
||||
? never
|
||||
: T extends LingValue
|
||||
? ''
|
||||
: T extends object
|
||||
? {
|
||||
[K in keyof T & string]:
|
||||
T[K] extends LingValue
|
||||
? K
|
||||
: `${K}.${LeafPaths<T[K], Prev[D]> & string}`
|
||||
}[keyof T & string]
|
||||
: never;
|
||||
|
||||
|
||||
export type GetTypeAtPath<
|
||||
Root,
|
||||
Current,
|
||||
P extends string
|
||||
> =
|
||||
P extends `${infer K}.${infer Rest}`
|
||||
? K extends keyof Current
|
||||
? GetTypeAtPath<Root, Current[K], Rest>
|
||||
: never
|
||||
: P extends keyof Current
|
||||
? Current[P] extends `#?${infer AliasPath}`
|
||||
? GetTypeAtPath<Root, Root, AliasPath> // 🔍 Salto cuántico: reiniciamos desde el Root
|
||||
: Current[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;
|
||||
|
||||
|
||||
/** * Define las formas posibles según el estándar Unicode (zero, one, two, few, many, other)
|
||||
*/
|
||||
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string };
|
||||
|
||||
export type PluralConfig = {
|
||||
[K in SupportedLocale]?: PluralForms;
|
||||
} & {
|
||||
[K in DefaultLocale]: PluralForms;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// ==============================
|
||||
// INSTANCE TYPE
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* Tipo de la instancia de ling parametrizado por el schema S.
|
||||
* Usar este tipo en lugar de `ReturnType<typeof createLing>`
|
||||
* para preservar la información del schema y tener t() tipado correctamente.
|
||||
*
|
||||
* @example
|
||||
* function useTranslations(ling: LingInstance<typeof mySchema>) {
|
||||
* ling.t('my.key') // ✅ tipado contra mySchema
|
||||
* }
|
||||
*/
|
||||
export type LingInstance<S extends LingNode = LingNode> = {
|
||||
// Fíjate en el GetTypeAtPath<S, S, P> (pasamos la S dos veces: como Root y como Current)
|
||||
t: <P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
|
||||
path: P,
|
||||
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
|
||||
) => string;
|
||||
ts : (value: LingString) => string;
|
||||
|
||||
tForLocale: <P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
|
||||
path: P,
|
||||
locale: SupportedLocale,
|
||||
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
|
||||
) => string;
|
||||
|
||||
setLocale : (locale: SupportedLocale) => void;
|
||||
getLocale : () => SupportedLocale;
|
||||
onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void;
|
||||
register : <NS extends string, M extends LingNode>(
|
||||
namespace: NS,
|
||||
module: M
|
||||
) => LingInstance<S & { [K in NS]: M }>;
|
||||
/**
|
||||
* Inyecta un logger externo que reemplaza el comportamiento por defecto (console).
|
||||
* Solo puede llamarse una vez — una vez inyectado no se puede reemplazar.
|
||||
* En desarrollo emite un warning si se intenta llamar más de una vez.
|
||||
*
|
||||
* Diseñado para romper la dependencia cíclica ling ↔ logr:
|
||||
* ling arranca con console, logr se inicializa con ling,
|
||||
* y entonces ling adopta logr como logger definitivo.
|
||||
*
|
||||
* @example
|
||||
* const ling = createLing(translations, "es"); // usa console
|
||||
* const logr = createLogr(ling, options); // logr listo
|
||||
* ling.setLogger(logr); // ling adopta logr
|
||||
*/
|
||||
setLogger : (logger: LingLogger) => void;
|
||||
};
|
||||
|
||||
// ==============================
|
||||
// LING LOGGER INTERFACE
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* Interfaz mínima estructural que ling necesita de un logger.
|
||||
* Intencionalmente no es `Logr` completo para evitar dependencia
|
||||
* de compilación entre ling y logr.
|
||||
*
|
||||
* Cualquier objeto que tenga `warn` y `error` con esta firma es válido.
|
||||
*
|
||||
* @example
|
||||
* // Logr satisface esta interfaz automáticamente
|
||||
* ling.setLogger(logr);
|
||||
*
|
||||
* // También un logger custom
|
||||
* ling.setLogger({ warn: console.warn, error: console.error });
|
||||
*/
|
||||
export interface LingLogger {
|
||||
warn : (category: string, message: string) => void;
|
||||
error: (category: string, message: string) => void;
|
||||
}
|
||||
@ -0,0 +1,304 @@
|
||||
# Logr
|
||||
|
||||
Sistema de logging estructurado con soporte nativo para mensajes localizados y sistema de transports extensible. Depende de `@/libs/i18n` para la resolución de mensajes al locale activo.
|
||||
|
||||
---
|
||||
|
||||
## Estructura de ficheros
|
||||
|
||||
```
|
||||
logr/
|
||||
├── index.ts # Barrel — punto de entrada público
|
||||
├── logr.engine.ts # Factory: createLogr()
|
||||
├── logr.transports.ts # Transports built-in
|
||||
├── logr.types.ts # Tipos e interfaces
|
||||
├── logr.test.ts # Tests del engine
|
||||
└── logr.transports.test.ts # Tests de los transports
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// logr/index.ts — instancia global
|
||||
import { createLogr, LogLevel, consoleTransport, httpTransport } from './logr.engine';
|
||||
import { i18n } from '@/libs/i18n/i18n.engine';
|
||||
|
||||
export const logr = createLogr(i18n, {
|
||||
level : LogLevel.WARN,
|
||||
transports: [
|
||||
consoleTransport(),
|
||||
httpTransport({
|
||||
url : 'https://logs.myapp.com/ingest',
|
||||
headers: { 'Authorization': 'Bearer my-token' },
|
||||
level : LogLevel.ERROR, // solo errores al servidor
|
||||
}),
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Configuración por entorno
|
||||
if (import.meta.env.DEV) {
|
||||
logr.setLevel(LogLevel.DEBUG);
|
||||
logr.setMaxLogs(5000);
|
||||
}
|
||||
```
|
||||
|
||||
> Si no se especifican transports, usa `consoleTransport()` por defecto.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `debug / info / warn / error`
|
||||
|
||||
Los cuatro métodos de log comparten la misma firma:
|
||||
|
||||
```ts
|
||||
logr.debug(category, message, context?)
|
||||
logr.info (category, message, context?)
|
||||
logr.warn (category, message, context?)
|
||||
logr.error(category, message, context?)
|
||||
```
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
|------------|---------------------------|-------------|
|
||||
| `category` | `string` | Dominio funcional — `'auth'`, `'db'`, `'render'`... |
|
||||
| `message` | `I18nString` | String simple o `LocaleRecord` con traducciones |
|
||||
| `context` | `Record<string, unknown>` | Datos adicionales opcionales para diagnóstico |
|
||||
|
||||
```ts
|
||||
// String simple
|
||||
logr.warn('db', 'Connection timeout', { host: 'db.prod', ms: 3000 });
|
||||
|
||||
// LocaleRecord — se resuelve al locale activo automáticamente
|
||||
logr.error('auth', { es: 'Login fallido', en: 'Login failed' });
|
||||
```
|
||||
|
||||
Un log solo se procesa si su nivel es **mayor o igual** al nivel configurado:
|
||||
|
||||
```
|
||||
DEBUG(0) < INFO(1) < WARN(2) < ERROR(3) < NONE(4)
|
||||
```
|
||||
|
||||
### `getLogs(filters?)`
|
||||
|
||||
Devuelve las entradas del historial en memoria. Los filtros son opcionales y se combinan con AND.
|
||||
|
||||
```ts
|
||||
logr.getLogs() // todas las entradas
|
||||
logr.getLogs({ level: LogLevel.ERROR }) // solo errores
|
||||
logr.getLogs({ category: 'auth' }) // solo entradas de 'auth'
|
||||
logr.getLogs({ since: new Date('2024-01-01') }) // desde una fecha
|
||||
logr.getLogs({ category: 'auth', level: LogLevel.WARN }) // combinados
|
||||
```
|
||||
|
||||
> Los mensajes se devuelven como `I18nString` sin resolver.
|
||||
|
||||
### `serialize()`
|
||||
|
||||
Exporta el historial como JSON. Los mensajes se resuelven al locale activo **en el momento de la llamada**.
|
||||
|
||||
```ts
|
||||
const json = logr.serialize();
|
||||
// [
|
||||
// {
|
||||
// "timestamp": "2024-01-01T00:00:00.000Z",
|
||||
// "level": 2,
|
||||
// "category": "auth",
|
||||
// "message": "Login failed",
|
||||
// "locale": "en"
|
||||
// }
|
||||
// ]
|
||||
```
|
||||
|
||||
### `clear()`
|
||||
|
||||
```ts
|
||||
logr.clear();
|
||||
logr.getLogs(); // → []
|
||||
```
|
||||
|
||||
### `setLevel(level)`
|
||||
|
||||
```ts
|
||||
logr.setLevel(LogLevel.DEBUG) // activa todos los niveles
|
||||
logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en tests
|
||||
```
|
||||
|
||||
### `setMaxLogs(max)`
|
||||
|
||||
```ts
|
||||
logr.setMaxLogs(5000) // desarrollo
|
||||
logr.setMaxLogs(500) // producción
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transports
|
||||
|
||||
Un transport es un destino de salida para las entradas de log. El engine emite cada entrada a todos los transports registrados, con el mensaje ya resuelto al locale activo.
|
||||
|
||||
### `consoleTransport(options?)`
|
||||
|
||||
Emite los logs a la consola usando el método apropiado según el nivel.
|
||||
|
||||
```ts
|
||||
consoleTransport() // con timestamp y prefijo
|
||||
consoleTransport({ timestamp: false }) // sin timestamp
|
||||
consoleTransport({ prefix: false }) // sin prefijo [category]
|
||||
consoleTransport({ timestamp: false, prefix: false }) // solo el mensaje
|
||||
```
|
||||
|
||||
| Opción | Tipo | Default | Descripción |
|
||||
|-------------|-----------|---------|-------------|
|
||||
| `timestamp` | `boolean` | `true` | Incluye el ISO timestamp en el output |
|
||||
| `prefix` | `boolean` | `true` | Incluye el prefijo `[category]` |
|
||||
|
||||
### `httpTransport(options)`
|
||||
|
||||
Envía las entradas a un endpoint HTTP remoto via POST. Fire-and-forget — los errores de red no interrumpen la aplicación.
|
||||
|
||||
```ts
|
||||
httpTransport({
|
||||
url : 'https://logs.myapp.com/ingest',
|
||||
headers: { 'Authorization': 'Bearer token' },
|
||||
level : LogLevel.ERROR // nivel mínimo independiente del logger
|
||||
})
|
||||
```
|
||||
|
||||
| Opción | Tipo | Default | Descripción |
|
||||
|-----------|---------------|-----------------|-------------|
|
||||
| `url` | `string` | — | Endpoint que recibe los logs |
|
||||
| `headers` | `Record<string, string>` | `{}` | Headers adicionales |
|
||||
| `level` | `LogLevel` | `LogLevel.ERROR` | Nivel mínimo para enviar al servidor |
|
||||
|
||||
El body enviado tiene la forma:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:00.000Z",
|
||||
"level" : 3,
|
||||
"category" : "auth",
|
||||
"message" : "Login failed",
|
||||
"context" : { "userId": 42 }
|
||||
}
|
||||
```
|
||||
|
||||
### `callbackTransport(fn)`
|
||||
|
||||
Invoca una función por cada entrada. El transport más flexible — útil para integraciones custom, Sentry, o capturar logs en tests sin output a consola.
|
||||
|
||||
```ts
|
||||
// Integración con Sentry
|
||||
callbackTransport((entry, message) => {
|
||||
if (entry.level >= LogLevel.ERROR) {
|
||||
Sentry.captureMessage(message, { extra: entry.context });
|
||||
}
|
||||
})
|
||||
|
||||
// Captura en tests — sin output a consola
|
||||
const captured: string[] = [];
|
||||
const logr = createLogr(i18n, {
|
||||
level : LogLevel.DEBUG,
|
||||
transports: [ callbackTransport((_, msg) => captured.push(msg)) ]
|
||||
});
|
||||
```
|
||||
|
||||
### Transport custom
|
||||
|
||||
Cualquier objeto que implemente la interfaz `Transport` es válido:
|
||||
|
||||
```ts
|
||||
import type { Transport } from '@/libs/logr';
|
||||
|
||||
const myTransport: Transport = {
|
||||
write(entry, resolvedMessage) {
|
||||
myService.send({
|
||||
level : entry.level,
|
||||
message: resolvedMessage,
|
||||
context: entry.context,
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Niveles
|
||||
|
||||
| Nivel | Valor | Uso recomendado |
|
||||
|---------|-------|-----------------|
|
||||
| `DEBUG` | 0 | Diagnóstico detallado en desarrollo |
|
||||
| `INFO` | 1 | Eventos relevantes del flujo normal |
|
||||
| `WARN` | 2 | Situaciones inesperadas no críticas |
|
||||
| `ERROR` | 3 | Errores que requieren atención |
|
||||
| `NONE` | 4 | Desactiva todos los logs |
|
||||
|
||||
---
|
||||
|
||||
## Integración con i18n
|
||||
|
||||
`Logr` no gestiona el locale internamente — lo delega al sistema i18n. Cuando cambia el locale, los mensajes siguientes se resuelven automáticamente sin ninguna reconfiguración:
|
||||
|
||||
```ts
|
||||
logr.warn('auth', { es: 'Sesión expirada', en: 'Session expired' });
|
||||
// output → "[auth] Sesión expirada"
|
||||
|
||||
i18n.setLocale('en');
|
||||
|
||||
logr.warn('auth', { es: 'Sesión expirada', en: 'Session expired' });
|
||||
// output → "[auth] Session expired"
|
||||
```
|
||||
|
||||
El mensaje se resuelve **una sola vez** por entrada y se pasa ya resuelto a todos los transports.
|
||||
|
||||
> El mensaje se almacena como `I18nString` sin resolver. La resolución al locale ocurre en el momento del output, y en `serialize()` en el momento de la llamada.
|
||||
|
||||
---
|
||||
|
||||
## Configuración por entorno
|
||||
|
||||
```ts
|
||||
export function setupDevelopmentLogging(): void {
|
||||
logr.setLevel(LogLevel.DEBUG);
|
||||
logr.setMaxLogs(5000);
|
||||
}
|
||||
|
||||
export function setupProductionLogging(): void {
|
||||
logr.setLevel(LogLevel.ERROR);
|
||||
logr.setMaxLogs(500);
|
||||
}
|
||||
|
||||
export function setupTestLogging(): void {
|
||||
logr.setLevel(LogLevel.NONE);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tipos públicos
|
||||
|
||||
| Tipo | Descripción |
|
||||
|-------------------------|-------------|
|
||||
| `LogLevel` | Enum de niveles de severidad |
|
||||
| `MessageCategory` | `string` — dominio funcional del log |
|
||||
| `LogEntry` | Entrada individual del historial |
|
||||
| `LogrOptions` | Opciones de configuración de `createLogr` |
|
||||
| `LogFilters` | Filtros para `getLogs()` |
|
||||
| `Transport` | Interfaz que deben implementar los transports |
|
||||
| `ConsoleTransportOptions` | Opciones de `consoleTransport()` |
|
||||
| `HttpTransportOptions` | Opciones de `httpTransport()` |
|
||||
| `Logr` | Tipo de la instancia |
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
vitest
|
||||
```
|
||||
|
||||
Los tests usan un schema de i18n propio independiente del de producción. Los transports se testean de forma aislada usando `callbackTransport` para capturar los mensajes sin output a consola.
|
||||
@ -0,0 +1,148 @@
|
||||
import type {
|
||||
LingInstance,
|
||||
LingString
|
||||
} from '@/ling';
|
||||
|
||||
import type {
|
||||
LogrOptions,
|
||||
LogEntry,
|
||||
MessageCategory,
|
||||
LogFilters,
|
||||
Logr,
|
||||
Transport
|
||||
} from './types.ts';
|
||||
import { LogLevel } from './types.ts';
|
||||
import { consoleTransport } from './transports.ts';
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Crea una instancia de `Logr` vinculada a una instancia de i18n.
|
||||
*
|
||||
* El logger no gestiona el locale internamente — lo delega al sistema i18n.
|
||||
* Cuando cambia el locale en i18n, los mensajes siguientes se resuelven
|
||||
* automáticamente al nuevo locale sin ninguna reconfiguración.
|
||||
*
|
||||
* Cada entrada se emite a todos los transports registrados.
|
||||
* Si no se especifican transports, usa `consoleTransport()` por defecto.
|
||||
*
|
||||
* @param ling - Instancia de i18n para resolución de mensajes localizados
|
||||
* @param options - Configuración de nivel, historial y transports
|
||||
*
|
||||
* @example
|
||||
* export const logr = createLogr(i18n, {
|
||||
* level : LogLevel.WARN,
|
||||
* transports: [
|
||||
* consoleTransport(),
|
||||
* httpTransport({ url: 'https://logs.myapp.com', level: LogLevel.ERROR }),
|
||||
* ]
|
||||
* });
|
||||
*/
|
||||
export function createLogr(ling: LingInstance, options: LogrOptions = {}): Logr {
|
||||
|
||||
let level : LogLevel = options.level ?? LogLevel.WARN;
|
||||
let maxLogs : number = options.maxLogs ?? 1000;
|
||||
let transports : Transport[] = options.transports ?? [consoleTransport()];
|
||||
let entries : LogEntry[] = [];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Configuración
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function setLevel(l: LogLevel): void {
|
||||
level = l;
|
||||
}
|
||||
|
||||
function setMaxLogs(max: number): void {
|
||||
maxLogs = max;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Core interno
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Centraliza el procesamiento de todos los niveles.
|
||||
* Aplica el filtro de nivel, construye la entrada, gestiona el historial
|
||||
* y emite a todos los transports.
|
||||
*/
|
||||
function log(
|
||||
lvl : LogLevel,
|
||||
category: MessageCategory,
|
||||
message : LingString,
|
||||
context?: Record<string, unknown>
|
||||
): void {
|
||||
if (lvl < level) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level : lvl,
|
||||
category,
|
||||
message,
|
||||
context,
|
||||
};
|
||||
|
||||
// Historial en memoria
|
||||
entries.push(entry);
|
||||
if (entries.length > maxLogs) entries.shift();
|
||||
|
||||
// Resuelve el mensaje una sola vez y lo emite a todos los transports
|
||||
const resolvedMessage = ling.ts(entry.message);
|
||||
transports.forEach(t => t.write(entry, resolvedMessage));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API pública
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function debug(category: MessageCategory, message: LingString, context?: Record<string, unknown>): void {
|
||||
log(LogLevel.DEBUG, category, message, context);
|
||||
}
|
||||
|
||||
function info(category: MessageCategory, message: LingString, context?: Record<string, unknown>): void {
|
||||
log(LogLevel.INFO, category, message, context);
|
||||
}
|
||||
|
||||
function warn(category: MessageCategory, message: LingString, context?: Record<string, unknown>): void {
|
||||
log(LogLevel.WARN, category, message, context);
|
||||
}
|
||||
|
||||
function error(category: MessageCategory, message: LingString, context?: Record<string, unknown>): void {
|
||||
log(LogLevel.ERROR, category, message, context);
|
||||
}
|
||||
|
||||
function getLogs(filters?: LogFilters): LogEntry[] {
|
||||
let result = entries;
|
||||
|
||||
if (filters?.level !== undefined) result = result.filter(e => e.level === filters.level);
|
||||
if (filters?.category !== undefined) result = result.filter(e => e.category === filters.category);
|
||||
if (filters?.since !== undefined) result = result.filter(e => e.timestamp >= filters.since!);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Los mensajes se resuelven al locale activo en el momento
|
||||
* de llamar a serialize(), no al momento en que se registraron.
|
||||
*/
|
||||
function serialize(): string {
|
||||
return JSON.stringify(
|
||||
entries.map(e => ({
|
||||
...e,
|
||||
message : ling.ts(e.message),
|
||||
locale : ling.getLocale(),
|
||||
timestamp: e.timestamp.toISOString(),
|
||||
})),
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
return { debug, info, warn, error, getLogs, clear, serialize, setLevel, setMaxLogs };
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export * from './engine.ts';
|
||||
export * from './transports.ts';
|
||||
export * from './types.ts';
|
||||
|
||||
@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createLogr, LogLevel } from '@/logr';
|
||||
import { createLing } from '@/ling';
|
||||
import type { TranslationNode, SupportedLocale } from '@/ling';
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// SCHEMA Y I18N DE TEST
|
||||
// ============================================================================
|
||||
|
||||
const schema = {
|
||||
auth: {
|
||||
loginFailed : { es: 'Login fallido', en: 'Login failed' },
|
||||
sessionExpired: { es: 'Sesión expirada', en: 'Session expired' },
|
||||
},
|
||||
db: {
|
||||
connectionError: { es: 'Error de conexión', en: 'Connection error' },
|
||||
},
|
||||
} satisfies TranslationNode;
|
||||
|
||||
function makeI18n(locale: SupportedLocale = 'es') {
|
||||
const i18n = createLing(schema, 'es');
|
||||
i18n.setLocale(locale);
|
||||
return i18n;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('createLogger', () => {
|
||||
|
||||
describe('instanciación', () => {
|
||||
it('crea una instancia con nivel WARN por defecto', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogr(i18n);
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.warn('auth', { es: 'aviso' });
|
||||
logger.debug('auth', { es: 'debug' });
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(debug).not.toHaveBeenCalled();
|
||||
|
||||
warn.mockRestore();
|
||||
debug.mockRestore();
|
||||
});
|
||||
|
||||
it('respeta el nivel configurado en options', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', { es: 'debug msg' });
|
||||
expect(debug).toHaveBeenCalledTimes(1);
|
||||
|
||||
debug.mockRestore();
|
||||
});
|
||||
|
||||
it('instancias son independientes entre sí', () => {
|
||||
const i18n = makeI18n();
|
||||
const l1 = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
const l2 = createLogr(i18n, { level: LogLevel.ERROR });
|
||||
|
||||
l1.setLevel(LogLevel.NONE);
|
||||
expect(l2.getLogs()).toHaveLength(0); // no se contaminan
|
||||
});
|
||||
});
|
||||
|
||||
describe('niveles de log', () => {
|
||||
let i18n: ReturnType<typeof makeI18n>;
|
||||
let logger: ReturnType<typeof createLogr>;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = makeI18n();
|
||||
logger = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
});
|
||||
|
||||
it('debug llama a console.debug', () => {
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
logger.debug('auth', { es: 'msg debug' });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('info llama a console.info', () => {
|
||||
const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
logger.info('auth', { es: 'msg info' });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('warn llama a console.warn', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
logger.warn('auth', { es: 'msg warn' });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('error llama a console.error', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
logger.error('auth', { es: 'msg error' });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('no loguea si el nivel es inferior al configurado', () => {
|
||||
const logger = createLogr(i18n, { level: LogLevel.ERROR });
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
logger.warn('auth', { es: 'esto no debe salir' });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolución i18n', () => {
|
||||
it('resuelve el mensaje con el locale actual del i18n', () => {
|
||||
const i18n = makeI18n('en');
|
||||
const logger = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'[auth]',
|
||||
'Login failed',
|
||||
''
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('resuelve en español cuando el locale es es', () => {
|
||||
const i18n = makeI18n('es');
|
||||
const logger = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'[auth]',
|
||||
'Login fallido',
|
||||
''
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('refleja el cambio de locale automáticamente sin reconfigurar el logger', () => {
|
||||
const i18n = makeI18n('es');
|
||||
const logger = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
expect(spy).toHaveBeenLastCalledWith(expect.any(String), '[auth]', 'Login fallido', '');
|
||||
|
||||
// Cambiamos locale en i18n — el logger lo recoge automáticamente
|
||||
i18n.setLocale('en');
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
expect(spy).toHaveBeenLastCalledWith(expect.any(String), '[auth]', 'Login failed', '');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('acepta I18nString como string simple (pass-through)', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', 'mensaje fijo');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'[auth]',
|
||||
'mensaje fijo',
|
||||
''
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogs()', () => {
|
||||
let i18n: ReturnType<typeof makeI18n>;
|
||||
let logger: ReturnType<typeof createLogr>;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = makeI18n();
|
||||
logger = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('retorna todos los logs sin filtros', () => {
|
||||
logger.debug('auth', { es: 'a' });
|
||||
logger.warn ('auth', { es: 'b' });
|
||||
logger.error('db', { es: 'c' });
|
||||
expect(logger.getLogs()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('filtra por nivel', () => {
|
||||
logger.debug('auth', { es: 'a' });
|
||||
logger.warn ('auth', { es: 'b' });
|
||||
logger.error('db', { es: 'c' });
|
||||
expect(logger.getLogs({ level: LogLevel.WARN })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filtra por categoría', () => {
|
||||
logger.debug('auth', { es: 'a' });
|
||||
logger.warn ('auth', { es: 'b' });
|
||||
logger.error('db', { es: 'c' });
|
||||
expect(logger.getLogs({ category: 'auth' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filtra por fecha', async () => {
|
||||
logger.debug('auth', { es: 'antes' });
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
const since = new Date();
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
logger.warn('auth', { es: 'después' });
|
||||
|
||||
expect(logger.getLogs({ since })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLogs', () => {
|
||||
it('respeta el límite de entradas', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogr(i18n, { level: LogLevel.DEBUG, maxLogs: 3 });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
logger.debug('auth', { es: `msg ${i}` });
|
||||
}
|
||||
|
||||
expect(logger.getLogs()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('descarta los más antiguos cuando supera el límite', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogr(i18n, { level: LogLevel.DEBUG, maxLogs: 2 });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', { es: 'primero' });
|
||||
logger.debug('auth', { es: 'segundo' });
|
||||
logger.debug('auth', { es: 'tercero' });
|
||||
|
||||
const logs = logger.getLogs();
|
||||
expect(logs[0].message).toEqual({ es: 'segundo' });
|
||||
expect(logs[1].message).toEqual({ es: 'tercero' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear()', () => {
|
||||
it('vacía el historial', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', { es: 'msg' });
|
||||
logger.clear();
|
||||
|
||||
expect(logger.getLogs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize()', () => {
|
||||
it('exporta los logs como JSON con mensajes resueltos', () => {
|
||||
const i18n = makeI18n('en');
|
||||
const logger = createLogr(i18n, { level: LogLevel.DEBUG });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
|
||||
const parsed = JSON.parse(logger.serialize());
|
||||
expect(parsed[0].message).toBe('Login failed');
|
||||
expect(parsed[0].locale).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,253 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {callbackTransport, consoleTransport, createLogr, httpTransport, type LogEntry, LogLevel} from '@/logr';
|
||||
import { createLing } from '@/ling';
|
||||
import type { TranslationNode, SupportedLocale } from '@/ling';
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
const schema = {
|
||||
auth: {
|
||||
loginFailed: { es: 'Login fallido', en: 'Login failed' },
|
||||
}
|
||||
} satisfies TranslationNode;
|
||||
|
||||
function makeI18n(locale: SupportedLocale = 'es') {
|
||||
const i18n = createLing(schema, 'es');
|
||||
i18n.setLocale(locale);
|
||||
return i18n;
|
||||
}
|
||||
|
||||
function makeEntry(overrides: Partial<LogEntry> = {}): LogEntry {
|
||||
return {
|
||||
timestamp: new Date(),
|
||||
level : LogLevel.WARN,
|
||||
category : 'auth',
|
||||
message : { es: 'Login fallido', en: 'Login failed' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// consoleTransport
|
||||
// ============================================================================
|
||||
|
||||
describe('consoleTransport', () => {
|
||||
|
||||
it('usa console.debug para nivel DEBUG', () => {
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
const t = consoleTransport();
|
||||
t.write(makeEntry({ level: LogLevel.DEBUG }), 'msg debug');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('usa console.info para nivel INFO', () => {
|
||||
const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
const t = consoleTransport();
|
||||
t.write(makeEntry({ level: LogLevel.INFO }), 'msg info');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('usa console.warn para nivel WARN', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const t = consoleTransport();
|
||||
t.write(makeEntry({ level: LogLevel.WARN }), 'msg warn');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('usa console.error para nivel ERROR', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const t = consoleTransport();
|
||||
t.write(makeEntry({ level: LogLevel.ERROR }), 'msg error');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('incluye timestamp y prefijo por defecto', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const t = consoleTransport();
|
||||
t.write(makeEntry({ category: 'auth' }), 'mensaje');
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\d{4}-\d{2}-\d{2}/), // ISO timestamp
|
||||
'[auth]',
|
||||
'mensaje',
|
||||
''
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('omite timestamp si timestamp: false', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const t = consoleTransport({ timestamp: false });
|
||||
t.write(makeEntry({ category: 'auth' }), 'mensaje');
|
||||
expect(spy).toHaveBeenCalledWith('[auth]', 'mensaje', '');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('omite prefijo si prefix: false', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const t = consoleTransport({ prefix: false });
|
||||
t.write(makeEntry(), 'mensaje');
|
||||
const args = spy.mock.calls[0];
|
||||
expect(args).not.toContain('[auth]');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('incluye el contexto en el output', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const t = consoleTransport();
|
||||
t.write(makeEntry({ context: { userId: 42 } }), 'msg');
|
||||
const args = spy.mock.calls[0];
|
||||
expect(args).toContainEqual(expect.objectContaining({ userId: 42 }));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// httpTransport
|
||||
// ============================================================================
|
||||
|
||||
describe('httpTransport', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('envía un POST al endpoint configurado', async () => {
|
||||
const t = httpTransport({ url: 'https://logs.example.com', level: LogLevel.ERROR });
|
||||
t.write(makeEntry({ level: LogLevel.ERROR }), 'Login failed');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://logs.example.com',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('incluye el mensaje resuelto en el body', async () => {
|
||||
const t = httpTransport({ url: 'https://logs.example.com', level: LogLevel.ERROR });
|
||||
t.write(makeEntry({ level: LogLevel.ERROR }), 'Login failed');
|
||||
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
||||
expect(body.message).toBe('Login failed');
|
||||
});
|
||||
|
||||
it('incluye headers custom', () => {
|
||||
const t = httpTransport({
|
||||
url : 'https://logs.example.com',
|
||||
headers: { 'Authorization': 'Bearer token' },
|
||||
level : LogLevel.ERROR
|
||||
});
|
||||
t.write(makeEntry({ level: LogLevel.ERROR }), 'msg');
|
||||
const headers = (fetch as any).mock.calls[0][1].headers;
|
||||
expect(headers['Authorization']).toBe('Bearer token');
|
||||
});
|
||||
|
||||
it('no envía si el nivel es inferior al mínimo configurado', () => {
|
||||
const t = httpTransport({ url: 'https://logs.example.com', level: LogLevel.ERROR });
|
||||
t.write(makeEntry({ level: LogLevel.WARN }), 'msg');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('usa ERROR como nivel mínimo por defecto', () => {
|
||||
const t = httpTransport({ url: 'https://logs.example.com' });
|
||||
t.write(makeEntry({ level: LogLevel.WARN }), 'msg');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
t.write(makeEntry({ level: LogLevel.ERROR }), 'msg');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('no lanza si fetch falla — fire and forget', () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const t = httpTransport({ url: 'https://logs.example.com', level: LogLevel.ERROR });
|
||||
expect(() => t.write(makeEntry({ level: LogLevel.ERROR }), 'msg')).not.toThrow();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// callbackTransport
|
||||
// ============================================================================
|
||||
|
||||
describe('callbackTransport', () => {
|
||||
|
||||
it('invoca el callback con la entrada y el mensaje resuelto', () => {
|
||||
const fn = vi.fn();
|
||||
const t = callbackTransport(fn);
|
||||
const entry = makeEntry();
|
||||
t.write(entry, 'Login fallido');
|
||||
expect(fn).toHaveBeenCalledWith(entry, 'Login fallido');
|
||||
});
|
||||
|
||||
it('se puede usar para capturar logs en tests', () => {
|
||||
const i18n = makeI18n('en');
|
||||
const captured: string[] = [];
|
||||
const logr = createLogr(i18n, {
|
||||
level : LogLevel.DEBUG,
|
||||
transports: [ callbackTransport((_, msg) => captured.push(msg)) ]
|
||||
});
|
||||
|
||||
logr.debug('auth', schema.auth.loginFailed);
|
||||
logr.warn ('auth', 'custom message');
|
||||
|
||||
expect(captured).toEqual(['Login failed', 'custom message']);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Integración — múltiples transports
|
||||
// ============================================================================
|
||||
|
||||
describe('múltiples transports', () => {
|
||||
|
||||
it('emite a todos los transports registrados', () => {
|
||||
const i18n = makeI18n();
|
||||
const fn1 = vi.fn();
|
||||
const fn2 = vi.fn();
|
||||
const logr = createLogr(i18n, {
|
||||
level : LogLevel.DEBUG,
|
||||
transports: [ callbackTransport(fn1), callbackTransport(fn2) ]
|
||||
});
|
||||
|
||||
logr.warn('auth', { es: 'aviso' });
|
||||
|
||||
expect(fn1).toHaveBeenCalledTimes(1);
|
||||
expect(fn2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('el mensaje se resuelve una sola vez y se comparte entre transports', () => {
|
||||
const i18n = makeI18n('en');
|
||||
const messages: string[] = [];
|
||||
const logr = createLogr(i18n, {
|
||||
level : LogLevel.DEBUG,
|
||||
transports: [
|
||||
callbackTransport((_, msg) => messages.push(msg)),
|
||||
callbackTransport((_, msg) => messages.push(msg)),
|
||||
]
|
||||
});
|
||||
|
||||
logr.debug('auth', schema.auth.loginFailed);
|
||||
|
||||
expect(messages).toEqual(['Login failed', 'Login failed']);
|
||||
});
|
||||
|
||||
it('un transport que falla no afecta a los demás', () => {
|
||||
const i18n = makeI18n();
|
||||
const fn = vi.fn();
|
||||
const logr = createLogr(i18n, {
|
||||
level : LogLevel.DEBUG,
|
||||
transports: [
|
||||
callbackTransport(() => { throw new Error('transport error'); }),
|
||||
callbackTransport(fn),
|
||||
]
|
||||
});
|
||||
|
||||
// El segundo transport sí debería ejecutarse aunque el primero falle
|
||||
// Este test documenta el comportamiento actual — si se quiere
|
||||
// aislamiento habría que añadir try/catch en el engine
|
||||
expect(() => logr.warn('auth', { es: 'msg' })).toThrow();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,123 @@
|
||||
import type { Transport, ConsoleTransportOptions, HttpTransportOptions, LogEntry, LogLevel } from './types.ts';
|
||||
|
||||
// ============================================================================
|
||||
// CONSOLE TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Transport que emite los logs a la consola del navegador/Node.
|
||||
* Es el transport por defecto si no se especifica ninguno en `LogrOptions`.
|
||||
*
|
||||
* Usa el método de consola apropiado según el nivel:
|
||||
* DEBUG → console.debug, INFO → console.info, WARN → console.warn, ERROR → console.error
|
||||
*
|
||||
* @example
|
||||
* createLogr(i18n, {
|
||||
* transports: [ consoleTransport() ]
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Sin timestamp ni prefijo
|
||||
* consoleTransport({ timestamp: false, prefix: false })
|
||||
*/
|
||||
export function consoleTransport(options: ConsoleTransportOptions = {}): Transport {
|
||||
const { timestamp: showTimestamp = true, prefix: showPrefix = true } = options;
|
||||
|
||||
return {
|
||||
write(entry: LogEntry, resolvedMessage: string): void {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (showTimestamp) parts.push(entry.timestamp.toISOString());
|
||||
if (showPrefix) parts.push(`[${entry.category}]`);
|
||||
parts.push(resolvedMessage);
|
||||
|
||||
const ctx = entry.context ?? '';
|
||||
|
||||
switch (entry.level) {
|
||||
case 0: console.debug(...parts, ctx); break; // DEBUG
|
||||
case 1: console.info (...parts, ctx); break; // INFO
|
||||
case 2: console.warn (...parts, ctx); break; // WARN
|
||||
case 3: console.error(...parts, ctx); break; // ERROR
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Transport que envía las entradas de log a un endpoint HTTP remoto via POST.
|
||||
* Útil para servicios de logging centralizados (Datadog, Logtail, custom APIs...).
|
||||
*
|
||||
* El envío es fire-and-forget — los errores de red se loguean en consola
|
||||
* pero no interrumpen el flujo de la aplicación.
|
||||
*
|
||||
* @example
|
||||
* httpTransport({
|
||||
* url : 'https://logs.myapp.com/ingest',
|
||||
* headers: { 'Authorization': 'Bearer my-token' },
|
||||
* level : LogLevel.ERROR // solo envía errores al servidor
|
||||
* })
|
||||
*/
|
||||
export function httpTransport(options: HttpTransportOptions): Transport {
|
||||
const minLevel = options.level ?? 3; // ERROR por defecto
|
||||
|
||||
return {
|
||||
write(entry: LogEntry, resolvedMessage: string): void {
|
||||
if (entry.level < minLevel) return;
|
||||
|
||||
const payload = {
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
level : entry.level,
|
||||
category : entry.category,
|
||||
message : resolvedMessage,
|
||||
context : entry.context,
|
||||
};
|
||||
|
||||
// Fire-and-forget — no bloqueamos el hilo principal
|
||||
fetch(options.url, {
|
||||
method : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(err => {
|
||||
console.error('[logr:httpTransport] Failed to send log:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CALLBACK TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Transport que invoca una función callback por cada entrada de log.
|
||||
* Es el transport más flexible — ideal para integraciones custom,
|
||||
* tests, o cuando necesitas lógica de routing entre destinos.
|
||||
*
|
||||
* @example
|
||||
* // Integración con Sentry
|
||||
* callbackTransport((entry, message) => {
|
||||
* if (entry.level >= LogLevel.ERROR) {
|
||||
* Sentry.captureMessage(message, { extra: entry.context });
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // En tests — captura los logs sin output a consola
|
||||
* const captured: string[] = [];
|
||||
* callbackTransport((entry, message) => captured.push(message))
|
||||
*/
|
||||
export function callbackTransport(
|
||||
fn: (entry: LogEntry, resolvedMessage: string) => void
|
||||
): Transport {
|
||||
return {
|
||||
write: fn
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,226 @@
|
||||
import type { LingString } from '@/ling';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// LOG LEVEL
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Niveles de severidad del log, ordenados de menor a mayor.
|
||||
* Un logger configurado con un nivel X solo procesa entradas
|
||||
* con nivel >= X.
|
||||
*
|
||||
* @example
|
||||
* const logr = createLogr(i18n, { level: LogLevel.WARN });
|
||||
* logr.debug('auth', 'msg'); // ignorado — DEBUG < WARN
|
||||
* logr.error('auth', 'msg'); // procesado — ERROR >= WARN
|
||||
*/
|
||||
export enum LogLevel {
|
||||
/** Información detallada para diagnóstico en desarrollo */
|
||||
DEBUG = 0,
|
||||
/** Eventos relevantes del flujo normal de la aplicación */
|
||||
INFO = 1,
|
||||
/** Situaciones inesperadas que no interrumpen la ejecución */
|
||||
WARN = 2,
|
||||
/** Errores que requieren atención inmediata */
|
||||
ERROR = 3,
|
||||
/** Desactiva todos los logs — útil en tests */
|
||||
NONE = 4
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CORE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Agrupa los logs por dominio funcional.
|
||||
* Se usa como prefijo en consola `[auth]` y como filtro en `getLogs()`.
|
||||
*
|
||||
* @example
|
||||
* 'auth' | 'checkout' | 'db' | 'render'
|
||||
*/
|
||||
export type MessageCategory = string;
|
||||
|
||||
/**
|
||||
* Entrada individual del historial de logs.
|
||||
* El mensaje se almacena como `I18nString` sin resolver —
|
||||
* la resolución al locale actual ocurre en el momento del output.
|
||||
*/
|
||||
export interface LogEntry {
|
||||
/** Momento exacto en que se registró el log */
|
||||
timestamp : Date;
|
||||
/** Nivel de severidad */
|
||||
level : LogLevel;
|
||||
/** Dominio funcional — ej: 'auth', 'checkout' */
|
||||
category : MessageCategory;
|
||||
/**
|
||||
* Mensaje del log. Puede ser un string simple o un `LocaleRecord`.
|
||||
* Se resuelve al locale activo del sistema i18n en el momento del output.
|
||||
*/
|
||||
message : LingString;
|
||||
/** Datos adicionales de contexto para facilitar el diagnóstico */
|
||||
context? : Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtros para consultar el historial de logs con `getLogs()`.
|
||||
* Todos los campos son opcionales y se combinan con AND.
|
||||
*
|
||||
* @example
|
||||
* logr.getLogs({ category: 'auth', level: LogLevel.ERROR })
|
||||
* logr.getLogs({ since: new Date('2024-01-01') })
|
||||
*/
|
||||
export interface LogFilters {
|
||||
/** Filtra por nivel exacto de severidad */
|
||||
level? : LogLevel;
|
||||
/** Filtra por categoría exacta */
|
||||
category?: MessageCategory;
|
||||
/** Filtra entradas con timestamp >= since */
|
||||
since? : Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TRANSPORTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Un transport es un destino de salida para las entradas de log.
|
||||
* El engine emite cada entrada a todos los transports registrados.
|
||||
*
|
||||
* Recibe tanto la entrada original (`LogEntry`) como el mensaje
|
||||
* ya resuelto al locale activo (`resolvedMessage`), para que el
|
||||
* transport no necesite conocer el sistema i18n.
|
||||
*
|
||||
* @example
|
||||
* // Transport custom
|
||||
* const myTransport: Transport = {
|
||||
* write: (entry, message) => {
|
||||
* myExternalService.send({ level: entry.level, message });
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
export interface Transport {
|
||||
/**
|
||||
* Recibe una entrada de log para procesarla.
|
||||
* @param entry - Entrada original con mensaje sin resolver
|
||||
* @param resolvedMessage - Mensaje ya traducido al locale activo
|
||||
*/
|
||||
write: (entry: LogEntry, resolvedMessage: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opciones para el transport de consola.
|
||||
*/
|
||||
export interface ConsoleTransportOptions {
|
||||
/**
|
||||
* Incluye el timestamp en el output.
|
||||
* @default true
|
||||
*/
|
||||
timestamp?: boolean;
|
||||
/**
|
||||
* Incluye el prefijo de categoría `[category]` en el output.
|
||||
* @default true
|
||||
*/
|
||||
prefix?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opciones para el transport HTTP.
|
||||
*/
|
||||
export interface HttpTransportOptions {
|
||||
/** URL del endpoint que recibe los logs */
|
||||
url : string;
|
||||
/** Headers adicionales — útil para autenticación */
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Nivel mínimo para enviar al servidor.
|
||||
* Permite enviar solo errores al servidor aunque el logger esté en DEBUG.
|
||||
* @default LogLevel.ERROR
|
||||
*/
|
||||
level? : LogLevel;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Opciones de configuración al crear una instancia de `Logr`.
|
||||
*
|
||||
* @example
|
||||
* createLogr(i18n, {
|
||||
* level : LogLevel.DEBUG,
|
||||
* maxLogs : 5000,
|
||||
* transports: [ consoleTransport(), httpTransport({ url: '...' }) ]
|
||||
* })
|
||||
*/
|
||||
export interface LogrOptions {
|
||||
/**
|
||||
* Nivel mínimo de severidad a procesar.
|
||||
* @default LogLevel.WARN
|
||||
*/
|
||||
level? : LogLevel;
|
||||
/**
|
||||
* Número máximo de entradas a retener en el historial en memoria.
|
||||
* Cuando se supera, se descartan las entradas más antiguas.
|
||||
* @default 1000
|
||||
*/
|
||||
maxLogs? : number;
|
||||
/**
|
||||
* Lista de transports a los que se emitirá cada entrada.
|
||||
* Si no se especifica, usa `consoleTransport()` por defecto.
|
||||
*
|
||||
* @default [consoleTransport()]
|
||||
*/
|
||||
transports? : Transport[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INSTANCE TYPE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Interfaz pública de una instancia de Logr.
|
||||
* Creada mediante `createLogr(i18n, options)`.
|
||||
*
|
||||
* Los mensajes se aceptan como `I18nString` — pueden ser strings simples
|
||||
* o `LocaleRecord`, y se resuelven automáticamente al locale activo
|
||||
* del sistema i18n en el momento de hacer output.
|
||||
*
|
||||
* @example
|
||||
* const logr = createLogr(i18n, {
|
||||
* level : LogLevel.DEBUG,
|
||||
* transports: [ consoleTransport(), httpTransport({ url: 'https://logs.myapp.com' }) ]
|
||||
* });
|
||||
*
|
||||
* logr.debug('auth', { es: 'Iniciando sesión', en: 'Logging in' });
|
||||
* logr.warn('db', { es: 'Conexión lenta', en: 'Slow connection' }, { ms: 2000 });
|
||||
* logr.error('auth', 'Unexpected error');
|
||||
*/
|
||||
export type Logr = {
|
||||
/** Registra un mensaje de nivel DEBUG */
|
||||
debug : (category: MessageCategory, message: LingString, context?: Record<string, unknown>) => void;
|
||||
/** Registra un mensaje de nivel INFO */
|
||||
info : (category: MessageCategory, message: LingString, context?: Record<string, unknown>) => void;
|
||||
/** Registra un mensaje de nivel WARN */
|
||||
warn : (category: MessageCategory, message: LingString, context?: Record<string, unknown>) => void;
|
||||
/** Registra un mensaje de nivel ERROR */
|
||||
error : (category: MessageCategory, message: LingString, context?: Record<string, unknown>) => void;
|
||||
|
||||
/**
|
||||
* Devuelve las entradas del historial en memoria, opcionalmente filtradas.
|
||||
* Los mensajes se devuelven sin resolver — como `I18nString`.
|
||||
*/
|
||||
getLogs : (filters?: LogFilters) => LogEntry[];
|
||||
/** Vacía el historial en memoria */
|
||||
clear : () => void;
|
||||
/**
|
||||
* Exporta el historial como JSON con mensajes resueltos al locale activo.
|
||||
* Incluye el campo `locale` para trazabilidad.
|
||||
*/
|
||||
serialize : () => string;
|
||||
/** Cambia el nivel mínimo de severidad en tiempo de ejecución */
|
||||
setLevel : (level: LogLevel) => void;
|
||||
/** Cambia el número máximo de entradas a retener en memoria */
|
||||
setMaxLogs: (max: number) => void;
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"types": ["svelte", "vite/client"],
|
||||
"noEmit": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"moduleDetection": "force"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/ling/*" : ["ling/*"],
|
||||
"@/logr/*" : ["logr/*"],
|
||||
"@/veci/*" : ["veci/*"],
|
||||
"@/adapters/*" : ["adapters/*"],
|
||||
"@/svelte/*" : ["adapters/svelte/*"],
|
||||
"@/editor/*" : ["editor/*"],
|
||||
"@/*" : ["*"]
|
||||
},
|
||||
|
||||
// ✅ 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,31 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'path';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [svelte(),tailwindcss()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: [
|
||||
'src/**/*.ts',
|
||||
'src/ling/**/*.ts',
|
||||
'src/logr/**/*.ts',
|
||||
'src/veci/**/*.ts',
|
||||
],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.d.ts']
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src')
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Reference in new issue