commit 0426d9f46d37916620d31e65d13ae584be4f676d Author: dev Date: Sat Feb 28 21:46:12 2026 +0100 . diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..6095a70 --- /dev/null +++ b/package.json @@ -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 +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..516702f --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/src/ling/consts.ts b/src/ling/consts.ts new file mode 100644 index 0000000..7d8898f --- /dev/null +++ b/src/ling/consts.ts @@ -0,0 +1,11 @@ + + + + +export const idPrefix = '#?'; + +export const defaultISOLocale = 'es' ; + +export const maxResolveDeep = 3; + +export const loggerCategory = 'ling'; \ No newline at end of file diff --git a/src/ling/docs/ling.md b/src/ling/docs/ling.md new file mode 100644 index 0000000..fe049b8 --- /dev/null +++ b/src/ling/docs/ling.md @@ -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

` | Función de traducción con parámetros tipados | +| `LingInstance` | 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. \ No newline at end of file diff --git a/src/ling/engine.ts b/src/ling/engine.ts new file mode 100644 index 0000000..04ed839 --- /dev/null +++ b/src/ling/engine.ts @@ -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( + schema: S, + defaultLocale: SupportedLocale +): LingInstance { + + 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['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

, TType = GetTypeAtPath>( + path: P, + locale: SupportedLocale, + ...args: HasParams extends true ? [params: ParamsFor] : [] + ) : string { + const prev = currentLocale; + currentLocale = locale; + const result = t(path, ...(args as any)); + currentLocale = prev; + return result; + } + + function register( + namespace: NS, + module: M + ): LingInstance { + currentSchema = { + ...(currentSchema as Record), + [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' para permitir + * parámetros adicionales de interpolación (como {{name}}). + */ +export const p = (config: PluralConfig) => + (params: { count: number } & Record): 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; + }; \ No newline at end of file diff --git a/src/ling/errors.ts b/src/ling/errors.ts new file mode 100644 index 0000000..6119d73 --- /dev/null +++ b/src/ling/errors.ts @@ -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; \ No newline at end of file diff --git a/src/ling/guards.ts b/src/ling/guards.ts new file mode 100644 index 0000000..e85010e --- /dev/null +++ b/src/ling/guards.ts @@ -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 + ); +} \ No newline at end of file diff --git a/src/ling/index.ts b/src/ling/index.ts new file mode 100644 index 0000000..4ca79d8 --- /dev/null +++ b/src/ling/index.ts @@ -0,0 +1,8 @@ +export { translations } from './translations'; + + +export * from './types.ts'; +export * from './engine.ts'; +export * from './instance.ts'; +export * from './translations'; + diff --git a/src/ling/instance.ts b/src/ling/instance.ts new file mode 100644 index 0000000..1f8865f --- /dev/null +++ b/src/ling/instance.ts @@ -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; \ No newline at end of file diff --git a/src/ling/tests/ling.test.ts b/src/ling/tests/ling.test.ts new file mode 100644 index 0000000..c8ba595 --- /dev/null +++ b/src/ling/tests/ling.test.ts @@ -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>; + + 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>; + + 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>; + + 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>; + + 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'); + }); +}); \ No newline at end of file diff --git a/src/ling/tests/ling2.test.ts b/src/ling/tests/ling2.test.ts new file mode 100644 index 0000000..40fbc32 --- /dev/null +++ b/src/ling/tests/ling2.test.ts @@ -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>; + + 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>; + + 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>; + + 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 + diff --git a/src/ling/tests/plural.test.ts b/src/ling/tests/plural.test.ts new file mode 100644 index 0000000..9e11321 --- /dev/null +++ b/src/ling/tests/plural.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'); + }); +}); \ No newline at end of file diff --git a/src/ling/translations.ts b/src/ling/translations.ts new file mode 100644 index 0000000..1ee032d --- /dev/null +++ b/src/ling/translations.ts @@ -0,0 +1,46 @@ +import type { LingNode } from './types.ts'; + +/** + * MEJORA: `satisfies TranslationNode` en lugar de `satisfies Record`. + * 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; + diff --git a/src/ling/types.ts b/src/ling/types.ts new file mode 100644 index 0000000..d355479 --- /dev/null +++ b/src/ling/types.ts @@ -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

> = (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 = + [D] extends [never] + ? never + : T extends LingValue + ? '' + : T extends object + ? { + [K in keyof T & string]: + T[K] extends LingValue + ? K + : `${K}.${LeafPaths & string}` + }[keyof T & string] + : never; + + +export type GetTypeAtPath< + Root, + Current, + P extends string +> = + P extends `${infer K}.${infer Rest}` + ? K extends keyof Current + ? GetTypeAtPath + : never + : P extends keyof Current + ? Current[P] extends `#?${infer AliasPath}` + ? GetTypeAtPath // 🔍 Salto cuántico: reiniciamos desde el Root + : Current[P] + : never; + + + + +export type ParamsFor = + T extends (params: infer P) => any + ? P + : never; + +export type HasParams = + 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> & { 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` + * para preservar la información del schema y tener t() tipado correctamente. + * + * @example + * function useTranslations(ling: LingInstance) { + * ling.t('my.key') // ✅ tipado contra mySchema + * } + */ +export type LingInstance = { + // Fíjate en el GetTypeAtPath (pasamos la S dos veces: como Root y como Current) + t:

, TType = GetTypeAtPath>( + path: P, + ...args: HasParams extends true ? [params: ParamsFor] : [] + ) => string; + ts : (value: LingString) => string; + + tForLocale:

, TType = GetTypeAtPath>( + path: P, + locale: SupportedLocale, + ...args: HasParams extends true ? [params: ParamsFor] : [] + ) => string; + + setLocale : (locale: SupportedLocale) => void; + getLocale : () => SupportedLocale; + onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void; + register : ( + namespace: NS, + module: M + ) => LingInstance; + /** + * 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; +} \ No newline at end of file diff --git a/src/logr/docs/logr.md b/src/logr/docs/logr.md new file mode 100644 index 0000000..75e16a1 --- /dev/null +++ b/src/logr/docs/logr.md @@ -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` | 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` | `{}` | 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. \ No newline at end of file diff --git a/src/logr/engine.ts b/src/logr/engine.ts new file mode 100644 index 0000000..716cf33 --- /dev/null +++ b/src/logr/engine.ts @@ -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 + ): 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): void { + log(LogLevel.DEBUG, category, message, context); + } + + function info(category: MessageCategory, message: LingString, context?: Record): void { + log(LogLevel.INFO, category, message, context); + } + + function warn(category: MessageCategory, message: LingString, context?: Record): void { + log(LogLevel.WARN, category, message, context); + } + + function error(category: MessageCategory, message: LingString, context?: Record): 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 }; +} \ No newline at end of file diff --git a/src/logr/index.ts b/src/logr/index.ts new file mode 100644 index 0000000..4490106 --- /dev/null +++ b/src/logr/index.ts @@ -0,0 +1,4 @@ +export * from './engine.ts'; +export * from './transports.ts'; +export * from './types.ts'; + diff --git a/src/logr/tests/logr.test.ts b/src/logr/tests/logr.test.ts new file mode 100644 index 0000000..7933314 --- /dev/null +++ b/src/logr/tests/logr.test.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; + let logger: ReturnType; + + 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; + let logger: ReturnType; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/src/logr/tests/logr_transports.test.ts b/src/logr/tests/logr_transports.test.ts new file mode 100644 index 0000000..0c693b1 --- /dev/null +++ b/src/logr/tests/logr_transports.test.ts @@ -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 { + 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(); + }); +}); \ No newline at end of file diff --git a/src/logr/transports.ts b/src/logr/transports.ts new file mode 100644 index 0000000..caf5baf --- /dev/null +++ b/src/logr/transports.ts @@ -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 + }; +} + diff --git a/src/logr/types.ts b/src/logr/types.ts new file mode 100644 index 0000000..de03a8a --- /dev/null +++ b/src/logr/types.ts @@ -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; +} + +/** + * 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; + /** + * 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) => void; + /** Registra un mensaje de nivel INFO */ + info : (category: MessageCategory, message: LingString, context?: Record) => void; + /** Registra un mensaje de nivel WARN */ + warn : (category: MessageCategory, message: LingString, context?: Record) => void; + /** Registra un mensaje de nivel ERROR */ + error : (category: MessageCategory, message: LingString, context?: Record) => 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; +}; \ No newline at end of file diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..96b3455 --- /dev/null +++ b/svelte.config.js @@ -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(), +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..31c18cf --- /dev/null +++ b/tsconfig.app.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2b6a878 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..1bd15ec --- /dev/null +++ b/vite.config.ts @@ -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') + } + } +});