dev 7 months ago
commit 0426d9f46d

8
.idea/.gitignore vendored

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,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…
Cancel
Save

Powered by TurnKey Linux.