First Commit

master
dev 8 months ago
parent 5465379e93
commit 14a1a68aed

@ -47,11 +47,7 @@ export function createI18n<S extends TranslationNode>(
return () => listeners.delete(fn);
}
/**
* Resuelve un LocaleRecord con el locale actual,
* con fallback al defaultLocale.
*/
function resolveRecord(record: LocaleRecord, path?: string): string {
function tsRecord(record: LocaleRecord, path?: string): string {
const translation = record[currentLocale];
if (translation === undefined) {
@ -67,17 +63,17 @@ export function createI18n<S extends TranslationNode>(
}
/**
* ts traduce un I18nString con el locale actual.
* Translate String — resuelve un I18nString con el locale actual.
* - Si es un string simple, lo devuelve tal cual.
* - Si es un LocaleRecord, lo resuelve con el locale actual con fallback.
* - Si es un LocaleRecord, lo resuelve con fallback al defaultLocale.
*
* @example
* ts('texto fijo') // → "texto fijo"
* ts({ es: 'descripción', en: 'description' }) // → "descripción" (locale 'es')
* ts({ es: 'descripción', en: 'a description' }) // → "descripción" (locale 'es')
*/
function ts(value: I18nString): string {
if (typeof value === 'string') return value;
return resolveRecord(value);
return tsRecord(value);
}
function t<
@ -101,7 +97,7 @@ export function createI18n<S extends TranslationNode>(
? (value as Function)(args[0])
: value;
return resolveRecord(record, path as string);
return tsRecord(record, path as string);
}
function tForLocale<
@ -119,5 +115,6 @@ export function createI18n<S extends TranslationNode>(
return result;
}
return { t, tForLocale, resolve: ts, setLocale, getLocale, onLocaleChange };
return { t, tForLocale, ts, setLocale, getLocale, onLocaleChange };
}

@ -7,5 +7,7 @@ import { translations } from './translations';
export const i18n = createI18n(translations, 'es');
// Desestructura si prefieres usar t() directamente
export const { t, tForLocale, setLocale, getLocale, onLocaleChange } = i18n;

@ -6,6 +6,8 @@ import type { TranslationNode, I18nString } from './index';
// SCHEMA DE TEST
// ==============================
const testSchema = {
common: {
ok: {
@ -248,28 +250,28 @@ describe('resolve()', () => {
});
it('devuelve el string tal cual si es un string simple', () => {
expect(i18n.resolve('texto fijo')).toBe('texto fijo');
expect(i18n.ts('texto fijo')).toBe('texto fijo');
});
it('devuelve el string vacío sin errores', () => {
expect(i18n.resolve('')).toBe('');
expect(i18n.ts('')).toBe('');
});
it('resuelve un LocaleRecord con el locale actual', () => {
const desc: I18nString = { es: 'una descripción', en: 'a description' };
expect(i18n.resolve(desc)).toBe('una descripción');
expect(i18n.ts(desc)).toBe('una descripción');
});
it('resuelve un LocaleRecord tras cambiar de locale', () => {
const desc: I18nString = { es: 'una descripción', en: 'a description' };
i18n.setLocale('en');
expect(i18n.resolve(desc)).toBe('a description');
expect(i18n.ts(desc)).toBe('a description');
});
it('hace fallback al defaultLocale si el locale actual no existe en el record', () => {
const desc: I18nString = { es: 'solo español' };
i18n.setLocale('de');
expect(i18n.resolve(desc)).toBe('solo español');
expect(i18n.ts(desc)).toBe('solo español');
});
it('funciona con un interface que usa I18nString', () => {
@ -283,10 +285,10 @@ describe('resolve()', () => {
descripcion: { es: 'Silla de madera', en: 'Wooden chair' }
};
expect(i18n.resolve(producto.descripcion)).toBe('Silla de madera');
expect(i18n.ts(producto.descripcion)).toBe('Silla de madera');
i18n.setLocale('en');
expect(i18n.resolve(producto.descripcion)).toBe('Wooden chair');
expect(i18n.ts(producto.descripcion)).toBe('Wooden chair');
});
it('funciona mezclando strings simples y LocaleRecord en el mismo array', () => {
@ -296,7 +298,11 @@ describe('resolve()', () => {
'otro-invariante'
];
const resolved = items.map(i18n.resolve);
const resolved = items.map(i18n.ts);
expect(resolved).toEqual(['id-invariante', 'nombre', 'otro-invariante']);
});
});
const i18n = createI18n(testSchema, 'es');
console.log(Object.keys(i18n)); // ¿aparece 'ts'?

@ -1,3 +1,4 @@
import { createI18n } from './i18n.engine.ts';
export { createI18n } from './i18n.engine';
export { translations } from './translations';
@ -21,5 +22,7 @@ export type {
HasParams,
} from './i18n.types';
export type I18n = ReturnType<typeof createI18n>;
export type { TranslationSchema } from './translations';

@ -0,0 +1,14 @@
import { createLogger} from "./logr.engine.ts";
export * from './logr.types.ts';
export * from './logr.engine.ts';
// ============================================================================
// TYPE HELPER
// ============================================================================
export type Logger = ReturnType<typeof createLogger>;

@ -0,0 +1,119 @@
import type { I18n, I18nString } from '@/libs/i18n';
import type { LoggerOptions, LogEntry, MessageCategory} from "./logr.types.ts";
import { LogLevel } from "./logr.types";
// ============================================================================
// FACTORY
// ============================================================================
export function createLogger(i18n: I18n, options: LoggerOptions = {}) {
let level : LogLevel = options.level ?? LogLevel.WARN;
let maxLogs : number = options.maxLogs ?? 1000;
let entries : LogEntry[] = [];
function setLevel(l: LogLevel): void {
level = l;
}
function setMaxLogs(max: number): void {
maxLogs = max;
}
function log(
lvl : LogLevel,
category: MessageCategory,
message : I18nString,
context?: Record<string, unknown>
): void {
if (lvl < level) return;
const entry: LogEntry = {
timestamp: new Date(),
level : lvl,
category,
message,
context,
};
entries.push(entry);
if (entries.length > maxLogs) {
entries.shift();
}
output(entry);
}
function output(entry: LogEntry): void {
const message = i18n.ts(entry.message);
const prefix = `[${entry.category}]`;
const timestamp = entry.timestamp.toISOString();
const ctx = entry.context ?? '';
switch (entry.level) {
case LogLevel.DEBUG: console.debug(timestamp, prefix, message, ctx); break;
case LogLevel.INFO: console.info (timestamp, prefix, message, ctx); break;
case LogLevel.WARN: console.warn (timestamp, prefix, message, ctx); break;
case LogLevel.ERROR: console.error(timestamp, prefix, message, ctx); break;
}
}
function debug(category: MessageCategory, message: I18nString, context?: Record<string, unknown>): void {
log(LogLevel.DEBUG, category, message, context);
}
function info(category: MessageCategory, message: I18nString, context?: Record<string, unknown>): void {
log(LogLevel.INFO, category, message, context);
}
function warn(category: MessageCategory, message: I18nString, context?: Record<string, unknown>): void {
log(LogLevel.WARN, category, message, context);
}
function error(category: MessageCategory, message: I18nString, context?: Record<string, unknown>): void {
log(LogLevel.ERROR, category, message, context);
}
function getLogs(filters?: {
level? : LogLevel;
category?: MessageCategory;
since? : Date;
}): 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 = [];
}
function serialize(): string {
return JSON.stringify(
entries.map(e => ({
...e,
message : i18n.ts(e.message),
locale : i18n.getLocale(),
timestamp: e.timestamp.toISOString(),
})),
null,
2
);
}
return { debug, info, warn, error, getLogs, clear, serialize, setLevel, setMaxLogs };
}

@ -0,0 +1,288 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLogger, LogLevel } from './index.ts';
import { createI18n } from '@/libs/i18n';
import type { TranslationNode, SupportedLocale } from '@/libs/i18n';
// ============================================================================
// 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 = createI18n(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 = createLogger(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 = createLogger(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 = createLogger(i18n, { level: LogLevel.DEBUG });
const l2 = createLogger(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 createLogger>;
beforeEach(() => {
i18n = makeI18n();
logger = createLogger(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 = createLogger(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 = createLogger(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 = createLogger(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 = createLogger(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 = createLogger(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 createLogger>;
beforeEach(() => {
i18n = makeI18n();
logger = createLogger(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 = createLogger(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 = createLogger(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 = createLogger(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 = createLogger(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,29 @@
import type { I18nString, SupportedLocale } from '@/libs/i18n';
// ============================================================================
// TYPES
// ============================================================================
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
NONE = 4
}
export type MessageCategory = string;
export interface LogEntry {
timestamp : Date;
level : LogLevel;
category : MessageCategory;
message : I18nString;
context? : Record<string, unknown>;
}
export interface LoggerOptions {
level? : LogLevel;
maxLogs? : number;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.