Initial commit: SvelteKit application with modular libraries

Add comprehensive library system for internationalization, logging,
formatting, and UI state management:

- ling: i18n library with translation keys, plural rules, and JSON serialization
- logr: logging system with levels, filters, history, and transports
- glob: number (numr), date (dati), currency (curr), and unit (unit) formatters
- actx: application context integrating all modules with Svelte 5 reactivity
- uiux: reactive UI state for theme, toasts, modals, menus, and breakpoints

Features:
- 247 passing tests across 4 test suites
- Svelte 5 runes ($state) for reactive context
- Lazy initialization for optional modules
- Type-safe configuration with TypeScript
- Locale-aware formatting using Intl APIs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
main
dev 7 months ago
commit 781bfb7e4b

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git add:*)"
]
}
}

23
.gitignore vendored

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

@ -0,0 +1 @@
engine-strict=true

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/routes/layout.css"
}

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss"]
}

@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}

2922
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,38 @@
{
"name": "vicen",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.5.0",
"@vitest/browser-playwright": "^4.1.0",
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.0",
"vitest-browser-svelte": "^2.0.2"
}
}

13
src/app.d.ts vendored

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

@ -0,0 +1,87 @@
/**
* actx.svelte.ts — Singleton del contexto global.
*
* La aplicación decide aquí:
* - El schema de traducciones y locale inicial de ling
* - Las monedas disponibles y su moneda activa
* - El sistema de unidades
* - Los defaults de formateo, log y UI
*
* Importar en componentes Svelte:
* import { actx } from '$lib/actx/actx.svelte.ts';
*/
import { createLing } from '../../ling/lib';
import { LogLevel } from '../../logr/lib';
import { p } from '../../ling/lib';
import { createACtx } from './engine.svelte.ts';
// ── Schema de traducciones de la app ──────────────────────────────────────────
// Importa las traducciones de tu proyecto.
// Los módulos (unit, curr) registran las suyas vía ling.extend() automáticamente.
import { translations } from '../../ling/lib/translations';
// ── Monedas ───────────────────────────────────────────────────────────────────
// Cada CurrDefinition define symbol, code y name (LingPluralFn).
// p() crea la función de pluralización que curr usa para display: 'name'.
const EUR = {
symbol: '€',
code : 'EUR',
name : p({
es: { one: 'euro', other: 'euros' },
en: { one: 'euro', other: 'euros' },
}),
conversionValue: 1,
};
const USD = {
symbol: '$',
code : 'USD',
name : p({
es: { one: 'dólar', other: 'dólares' },
en: { one: 'dollar', other: 'dollars' },
}),
conversionValue: 1.08,
};
// ── Singleton ─────────────────────────────────────────────────────────────────
export const actx = createACtx({
ling: createLing(translations, 'es'),
logr: {
level: LogLevel.WARN,
},
numr: {
minDecimals: 0,
maxDecimals: 2,
},
dati: {
selectedTimeFormat: '24h',
selectedDateOrder : 'DMY',
},
curr: {
definitions : { EUR, USD },
selectedCurrency: 'EUR',
display : 'symbol',
decimals : 2,
},
unit: {
selectedSystem: 'metric',
// categories: { weight: myCustomWeight }, // sobreescribe si necesitas
},
uiuxConfig: {
theme: { mode: 'auto' },
toast: { defaultDuration: 4000 },
},
});

@ -0,0 +1,132 @@
import type { LingNode } from '../../ling/lib/types';
import type { GlobLocale } from '../../glob/lib/comn_types';
import type { ACtxConfig, ACtx, ReactiveACtx } from './types';
import { createLogr } from '../../logr/lib/engine';
import { createNumr } from '../../glob/lib/numr_engine';
import { createDati } from '../../glob/lib/dati_engine';
import { createCurr } from '../../glob/lib/curr_engine';
import { createUnit } from '../../glob/lib/unit_engine';
import { createUiux, type ModalEntry, type ThemeMode, type Toast, type UiuxConfig } from '../../uiux/lib';
// ============================================================================
// ENGINE (REACTIVE WITH SVELTE 5 RUNES)
// ============================================================================
/**
* Crea el contexto global de la aplicación (versión reactiva con Svelte 5).
*
* Esta versión usa $state para que Svelte rastree las dependencias automáticamente.
* Los métodos de formateo están envueltos para acceder a locale de forma reactiva.
*/
export function createACtx<S extends LingNode>(
config: ACtxConfig<S>
): ReactiveACtx<S> {
const { ling } = config;
const getLocale = (): GlobLocale => ling.getLocale() as GlobLocale;
// ── Engines puro TS ───────────────────────────────────────────────────────
const _logr = createLogr(ling, config.logr ?? {});
const _numr = createNumr(config.numr ?? {}, getLocale);
const _dati = createDati(config.dati ?? {}, getLocale);
const _curr = createCurr(
config.curr?.definitions ?? {},
ling,
config.curr ?? {},
getLocale
);
const _unit = createUnit(
ling,
config.unit?.categories ?? {},
config.unit ?? {},
getLocale
);
const uiuxConfig: UiuxConfig = config.uiuxConfig ?? {};
const _uiux = createUiux(uiuxConfig);
// ── $state — reactivo para Svelte ─────────────────────────────────────────
// Válido dentro de funciones en ficheros .svelte.ts (Svelte 5).
// Cada llamada a createACtx() crea su propio estado independiente.
let locale = $state<GlobLocale>(getLocale());
let themeResolved = $state<'light' | 'dark'>(_uiux.theme.resolved);
let themeMode = $state<ThemeMode>(_uiux.theme.mode);
let toastQueue = $state<Toast[]>([]);
let modalStack = $state<ModalEntry[]>([]);
let breakCurrent = $state<string>(_uiux.break.current);
// ── Sincronización engines → $state ───────────────────────────────────────
ling.onLocaleChange(l => { locale = l as GlobLocale; });
_uiux.theme.onChange((mode, resolved) => {
themeMode = mode;
themeResolved = resolved;
});
_uiux.toast.onChange(q => { toastQueue = q; });
_uiux.modal.onChange(s => { modalStack = s; });
_uiux.break.onChange(bp => { breakCurrent = bp; });
// ── Instancia ─────────────────────────────────────────────────────────────
return {
// Delegates de ling
// Leer `locale` ($state) dentro del getter establece la dependencia reactiva.
// Cuando el locale cambia, Svelte re-evalúa cualquier template que use t() o ts().
get t() { locale; return ling.t.bind(ling); },
get ts() { locale; return ling.ts.bind(ling); },
// Módulos puro TS
ling,
logr : _logr,
numr : _numr,
dati : _dati,
curr : _curr,
unit : _unit,
// Locale reactivo
get locale() { return locale; },
// UIX reactivo
uiux: {
theme: {
get resolved() { return themeResolved; },
get mode() { return themeMode; },
set : _uiux.theme.set.bind(_uiux.theme),
toggle : _uiux.theme.toggle.bind(_uiux.theme),
onChange: _uiux.theme.onChange.bind(_uiux.theme),
},
toast: {
get queue() { return toastQueue; },
show : _uiux.toast.show.bind(_uiux.toast),
dismiss : _uiux.toast.dismiss.bind(_uiux.toast),
clear : _uiux.toast.clear.bind(_uiux.toast),
onChange: _uiux.toast.onChange.bind(_uiux.toast),
},
modal: {
get stack() { return modalStack; },
get active() {
return modalStack.length > 0
? modalStack[modalStack.length - 1]
: null;
},
open : _uiux.modal.open.bind(_uiux.modal),
close : _uiux.modal.close.bind(_uiux.modal),
closeAll: _uiux.modal.closeAll.bind(_uiux.modal),
isOpen : _uiux.modal.isOpen.bind(_uiux.modal),
onChange: _uiux.modal.onChange.bind(_uiux.modal),
},
menu: _uiux.menu,
break: {
get current() { return breakCurrent; },
get isMobile() { return !_uiux.break.is('md'); },
get isTablet() { return _uiux.break.is('md') && !_uiux.break.is('lg'); },
get isDesktop() { return _uiux.break.is('lg'); },
is : _uiux.break.is.bind(_uiux.break),
onChange: _uiux.break.onChange.bind(_uiux.break),
},
},
};
}

@ -0,0 +1,2 @@
export * from './types.ts';
export * from './engine.ts';

@ -0,0 +1,53 @@
/**
* actx.ts — Singleton puro TS del contexto global.
*
* Sin reactividad Svelte. Útil para:
* - SSR / SvelteKit load functions
* - Workers y edge functions
* - Tests unitarios
* - Scripts Node.js
*
* Para el singleton reactivo en componentes Svelte usa `actx.svelte.ts`.
*/
import { createLing } from '@/ling/lib';
import { LogLevel } from '@/logr/lib';
import { translations } from '@/ling/lib';
import { createACtx } from './nr_engine';
// import { EUR, USD } from '$lib/currencies.ts';
export const actx = createACtx({
ling: createLing(translations, 'es'),
logr: {
level: LogLevel.WARN,
},
numr: {
minDecimals: 0,
maxDecimals: 2,
},
dati: {
selectedTimeFormat: '24h',
selectedDateOrder : 'DMY',
},
curr: {
// definitions: { EUR, USD },
definitions : {},
selectedCurrency: 'EUR',
display : 'symbol',
decimals : 2,
},
unit: {
selectedSystem: 'metric',
},
uiuxConfig: {
theme: { mode: 'light' }, // sin 'auto' — no hay window en SSR
toast: { defaultDuration: 4000 },
},
});

@ -0,0 +1,104 @@
import type { LingNode } from '../../ling/lib/types';
import type { GlobLocale } from '../../glob/lib/comn_types';
import type { ACtxConfig, ACtx } from './types';
import { createLogr } from '../../logr/lib/engine';
import { createNumr } from '../../glob/lib/numr_engine';
import { createDati } from '../../glob/lib/dati_engine';
import { createCurr } from '../../glob/lib/curr_engine';
import { createUnit } from '../../glob/lib/unit_engine';
import { createUiux } from '../../uiux/lib';
// ============================================================================
// HELPERS
// ============================================================================
function missingModule(name: string, hint: string): never {
throw new Error(
`[actx] '${name}' no está configurado. ` +
`Añade { ${hint} } a createACtx().`
);
}
// ============================================================================
// ENGINE (NON-REACTIVE)
// ============================================================================
/**
* Crea el contexto global de la aplicación (versión no reactiva).
*
* Solo `ling` es obligatorio. Los demás módulos se activan pasando su config
* y se inicializan de forma lazy en el primer acceso.
*
* Para la versión reactiva con Svelte 5, usa `engine.svelte.ts`.
*/
export function createACtx<S extends LingNode>(
config: ACtxConfig<S>
): ACtx<S> {
const { ling } = config;
const getLocale = (): GlobLocale => ling.getLocale() as GlobLocale;
let _logr: ReturnType<typeof createLogr> | undefined;
let _numr: ReturnType<typeof createNumr> | undefined;
let _dati: ReturnType<typeof createDati> | undefined;
let _curr: ReturnType<typeof createCurr> | undefined;
let _unit: ReturnType<typeof createUnit> | undefined;
let _uiux: ReturnType<typeof createUiux> | undefined;
return {
// Delegates de ling
t : ling.t.bind(ling),
ts : ling.ts.bind(ling),
// Locale reactivo (simulado con getter)
get locale() { return ling.getLocale() as GlobLocale; },
// Módulo ling
ling,
// Módulos con lazy initialization
get logr() {
if (!config.logr) missingModule('logr', 'logr: { level: LogLevel.WARN }');
return _logr ??= createLogr(ling, config.logr);
},
get numr() {
if (!config.numr) missingModule('numr', 'numr: {}');
return _numr ??= createNumr(config.numr, getLocale);
},
get dati() {
if (!config.dati) missingModule('dati', 'dati: {}');
return _dati ??= createDati(config.dati, getLocale);
},
get curr() {
if (!config.curr) missingModule('curr', 'curr: { definitions: { EUR, USD } }');
return _curr ??= createCurr(
config.curr.definitions ?? {},
ling,
config.curr,
getLocale
);
},
get unit() {
if (!config.unit) missingModule('unit', 'unit: { selectedSystem: "metric" }');
return _unit ??= createUnit(
ling,
config.unit.categories ?? {},
config.unit,
getLocale
);
},
get uiux() {
if (!config.uiuxConfig) missingModule('uiux', 'uiuxConfig: { theme: { mode: "auto" } }');
if (!_uiux) {
_uiux = createUiux(config.uiuxConfig);
}
return _uiux;
},
};
}

@ -0,0 +1,89 @@
// ==============================
// ACTX TYPES
// ==============================
import type { LingInstance, LingNode, LingString } from '../../ling/lib/types';
import type { Logr, LogrOptions } from '../../logr/lib/types';
import type { CurrInstance, CurrDefinition, CurrConfig } from '../../glob/lib/curr_types';
import type { UnitInstance, UnitCategoryData, UnitCategoryKey, UnitSystem } from '../../glob/lib/unit_types';
import type { DatiInstance, DatiConfig } from '../../glob/lib/dati_types';
import type { NumrInstance, NumrConfig } from '../../glob/lib/numr_types';
import type { GlobLocale } from '../../glob/lib/comn_types';
import type { ReactiveUiux, Uiux, UiuxConfig } from '../../uiux/lib/types';
// ============================================================================
// CONFIG
// ============================================================================
/**
* Configuración de `createACtx`.
*
* Solo `ling` es obligatorio. El resto de módulos son opcionales y se
* inicializan de forma lazy cuando se accede por primera vez.
*
* @example
* const actx = createACtx({
* ling,
* logr : { level: LogLevel.WARN },
* numr : { maxDecimals: 2 },
* dati : { selectedTimeFormat: '24h' },
* curr : { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
* unit : { selectedSystem: 'metric' },
* uiux : { theme: { mode: 'auto' } },
* });
*/
export interface ACtxConfig<S extends LingNode> {
/** Instancia de i18n — fuente de verdad del locale para todo el contexto. */
ling : LingInstance<S>;
/** Configuración del logger. */
logr? : LogrOptions;
/** Configuración del formateador de números. */
numr? : NumrConfig;
/** Configuración del formateador de fechas. */
dati? : DatiConfig;
/** Configuración del formateador de monedas con pluralización completa. */
curr? : CurrConfig & { definitions?: Record<string, CurrDefinition> };
/** Configuración del formateador de unidades con pluralización completa. */
unit? : {
selectedSystem?: UnitSystem;
categories? : Partial<Record<UnitCategoryKey, UnitCategoryData>>;
};
/** Configuración del contexto de UI (tema, breakpoints, toasts, modales, menús). */
uiuxConfig? : UiuxConfig;
}
// ============================================================================
// INSTANCE
// ============================================================================
/**
* Contexto global de la aplicación.
*
* @example
* actx.t('common.ok')
* actx.ts({ es: 'Hola', en: 'Hi' })
* actx.locale
* actx.logr.warn('auth', 'Error')
* actx.numr.f(1234.5)
* actx.dati.f(new Date())
* actx.curr.f(99.99, 'name')
* actx.unit.weight.fTo(75, 'kg', 'lb')
* actx.uiux.theme.toggle()
* actx.uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' })
* actx.uiux.modal.open('confirm', { title: '¿Eliminar?' })
* actx.uiux.break.isMobile
*/
export interface ACtx<S extends LingNode = LingNode> {
t : LingInstance<S>['t'];
ts : (value: LingString) => string;
readonly locale: GlobLocale;
ling : LingInstance<S>;
logr : Logr;
numr : NumrInstance;
dati : DatiInstance;
curr : CurrInstance;
unit : UnitInstance;
uiux : ReactiveUiux;
}

@ -0,0 +1,321 @@
/**
* actx.svelte — test suite (versión reactiva)
*
* Estos tests requieren un entorno Svelte 5 con soporte para runes ($state).
*
* Ejecutar: npx vitest run src/lib/actx/test/actx.svelte.test.ts
*
* Módulos cubiertos:
* - engine.svelte (createACtx reactivo)
* - reactividad de $state
* - sincronización engines → $state
* - uiux reactivo
*/
import type { CurrDefinition } from '../../glob/lib';
import { describe, it, expect } from 'vitest';
import { createLing } from '../../ling/lib';
import { createACtx } from '../lib/engine.svelte';
import { LogLevel } from '../../logr/lib';
// ============================================================================
// FIXTURES
// ============================================================================
const translations = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' },
},
messages: {
welcome: { es: 'Bienvenido', en: 'Welcome' },
},
};
const EUR: CurrDefinition = {
symbol: '€',
code: 'EUR',
name: ({ count }) => ({
es: count === 1 ? 'euro' : 'euros',
en: count === 1 ? 'euro' : 'euros',
}),
conversionValue: 1,
};
const USD: CurrDefinition = {
symbol: '$',
code: 'USD',
name: ({ count }) => ({
es: count === 1 ? 'dólar' : 'dólares',
en: count === 1 ? 'dollar' : 'dollars',
}),
conversionValue: 1.08,
};
// ============================================================================
// CREATE ACTX (REACTIVE)
// ============================================================================
describe('createACtx (reactivo con Svelte 5)', () => {
describe('creación básica', () => {
it('crea una instancia con ling', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.ling).toBeDefined();
expect(actx.locale).toBe('es');
});
it('t() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.t('common.ok')).toBe('Aceptar');
});
it('ts() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
});
});
describe('módulos configurados', () => {
it('crea instancia con todos los módulos configurados', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
logr: { level: LogLevel.WARN },
numr: { maxDecimals: 2 },
dati: { selectedTimeFormat: '24h' },
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
unit: { selectedSystem: 'metric' },
uiuxConfig: { theme: { mode: 'auto' } },
});
// Módulos básicos
expect(actx.ling).toBeDefined();
expect(actx.logr).toBeDefined();
expect(actx.numr).toBeDefined();
expect(actx.dati).toBeDefined();
expect(actx.curr).toBeDefined();
expect(actx.unit).toBeDefined();
// UIX reactivo
expect(actx.uiux).toBeDefined();
expect(actx.uiux.theme).toBeDefined();
expect(actx.uiux.toast).toBeDefined();
expect(actx.uiux.modal).toBeDefined();
expect(actx.uiux.break).toBeDefined();
});
});
describe('reactividad de locale', () => {
it('locale es reactivo y cambia con ling.setLocale()', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.locale).toBe('es');
// El cambio en ling debería reflejarse en el estado reactivo
// (en un entorno Svelte real, esto sería automático)
actx.ling.setLocale('en');
// En el entorno de test, verificamos que ling cambió
expect(actx.ling.getLocale()).toBe('en');
});
});
describe('uiux reactivo', () => {
it('theme tiene propiedades de lectura', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
uiuxConfig: { theme: { mode: 'dark' } },
});
// Props de lectura (reactivas en Svelte)
expect(actx.uiux.theme.resolved).toBeDefined();
expect(actx.uiux.theme.mode).toBe('dark');
});
it('theme tiene métodos de acción', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
uiuxConfig: { theme: { mode: 'light' } },
});
// Métodos de acción
expect(actx.uiux.theme.set).toBeInstanceOf(Function);
expect(actx.uiux.theme.toggle).toBeInstanceOf(Function);
expect(actx.uiux.theme.onChange).toBeInstanceOf(Function);
});
it('toast tiene propiedades de lectura', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
uiuxConfig: { toast: { defaultDuration: 4000 } },
});
// Props de lectura
expect(actx.uiux.toast.queue).toBeDefined();
expect(Array.isArray(actx.uiux.toast.queue)).toBe(true);
});
it('toast tiene métodos de acción', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.toast.show).toBeInstanceOf(Function);
expect(actx.uiux.toast.dismiss).toBeInstanceOf(Function);
expect(actx.uiux.toast.clear).toBeInstanceOf(Function);
expect(actx.uiux.toast.onChange).toBeInstanceOf(Function);
});
it('modal tiene propiedades de lectura', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.modal.stack).toBeDefined();
expect(Array.isArray(actx.uiux.modal.stack)).toBe(true);
});
it('modal tiene métodos de acción', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.modal.open).toBeInstanceOf(Function);
expect(actx.uiux.modal.close).toBeInstanceOf(Function);
expect(actx.uiux.modal.closeAll).toBeInstanceOf(Function);
expect(actx.uiux.modal.isOpen).toBeInstanceOf(Function);
expect(actx.uiux.modal.onChange).toBeInstanceOf(Function);
});
it('break tiene propiedades de lectura', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.break.current).toBeDefined();
// Las propiedades computadas dependen del entorno
expect(typeof actx.uiux.break.isMobile).toBe('boolean');
});
it('break tiene métodos de acción', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.break.is).toBeInstanceOf(Function);
expect(actx.uiux.break.onChange).toBeInstanceOf(Function);
});
});
describe('integración con módulos', () => {
it('numr funciona correctamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
});
const result = actx.numr.f(1234.5);
expect(result).toBeTruthy();
expect(result).toContain('5');
});
it('curr funciona correctamente con pluralización', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
});
expect(actx.curr.f(1, 'name')).toBe('1,00 euro');
expect(actx.curr.f(2, 'name')).toBe('2,00 euros');
});
it('dati funciona correctamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
dati: { selectedTimeFormat: '24h' },
});
const date = new Date('2024-03-15');
const result = actx.dati.f(date);
expect(result).toBeTruthy();
});
it('unit funciona correctamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
unit: { selectedSystem: 'metric' },
});
const result = actx.unit.weight.f(75);
expect(result).toBeTruthy();
expect(result).toContain('75');
});
});
describe('logr integrado con ling', () => {
it('logr usa ling para resolver mensajes', () => {
const ling = createLing(translations, 'es');
const captured: string[] = [];
const actx = createACtx({
ling,
logr: {
level: LogLevel.INFO,
transports: [
{
write: (_entry: any, resolvedMessage: string) => captured.push(resolvedMessage)
}
],
},
});
actx.logr.info('test', '#?common.ok');
expect(captured[0]).toBe('Aceptar');
});
});
describe('casos de uso reales', () => {
it('simula una aplicación multilingüe completa', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
dati: { selectedTimeFormat: '24h' },
unit: { selectedSystem: 'metric' },
uiuxConfig: {
theme: { mode: 'auto' },
toast: { defaultDuration: 4000 },
},
});
// Traducciones
expect(actx.t('common.ok')).toBe('Aceptar');
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
// Formateo
expect(actx.numr.f(1000)).toBeTruthy();
expect(actx.curr.f(100)).toBe('100,00 €');
expect(actx.dati.f(new Date())).toBeTruthy();
expect(actx.unit.weight.f(75)).toContain('75');
// Locale
expect(actx.locale).toBe('es');
// UIX
expect(actx.uiux.theme.mode).toBe('auto');
// Cambio de locale
actx.ling.setLocale('en');
expect(actx.t('common.ok')).toBe('OK');
});
});
});

@ -0,0 +1,518 @@
/**
* actx — test suite (versión no reactiva)
*
* Ejecutar: npx vitest run
*
* Módulos cubiertos:
* - engine (createACtx)
* - lazy initialization
* - integración de módulos
* - reactividad básica
*/
import type { CurrDefinition } from '../../glob/lib';
import { describe, it, expect } from 'vitest';
import { createLing } from '../../ling/lib';
import { createACtx } from '../lib/nr_engine';
import { LogLevel } from '../../logr/lib';
// ============================================================================
// FIXTURES
// ============================================================================
const translations = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' },
},
messages: {
welcome: { es: 'Bienvenido', en: 'Welcome' },
},
};
const EUR: CurrDefinition = {
symbol: '€',
code: 'EUR',
name: ({ count }) => ({
es: count === 1 ? 'euro' : 'euros',
en: count === 1 ? 'euro' : 'euros',
}),
conversionValue: 1,
};
const USD: CurrDefinition = {
symbol: '$',
code: 'USD',
name: ({ count }) => ({
es: count === 1 ? 'dólar' : 'dólares',
en: count === 1 ? 'dollar' : 'dollars',
}),
conversionValue: 1.1,
};
// ============================================================================
// CREATE ACTX
// ============================================================================
describe('createACtx (no reactivo)', () => {
describe('configuración mínima (solo ling)', () => {
it('crea una instancia con solo ling', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.ling).toBeDefined();
expect(actx.locale).toBe('es');
});
it('t() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.t('common.ok')).toBe('Aceptar');
});
it('ts() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
});
it('ling.setLocale() cambia el locale', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
actx.ling.setLocale('en');
expect(actx.locale).toBe('en');
expect(actx.t('common.ok')).toBe('OK');
});
it('locale es un getter que refleja el locale de ling', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.locale).toBe(ling.getLocale());
});
it('ling.onLocaleChange() permite escuchar cambios', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const spy = [] as string[];
const unsub = actx.ling.onLocaleChange((loc) => spy.push(loc));
actx.ling.setLocale('en');
expect(spy).toEqual(['en']);
unsub();
});
});
describe('acceso a módulos no configurados', () => {
it('lanza error al acceder a logr si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.logr).toThrow('[actx] \'logr\' no está configurado');
});
it('lanza error al acceder a numr si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.numr).toThrow('[actx] \'numr\' no está configurado');
});
it('lanza error al acceder a dati si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.dati).toThrow('[actx] \'dati\' no está configurado');
});
it('lanza error al acceder a curr si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.curr).toThrow('[actx] \'curr\' no está configurado');
});
it('lanza error al acceder a unit si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.unit).toThrow('[actx] \'unit\' no está configurado');
});
it('lanza error al acceder a uiux si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.uiux).toThrow('[actx] \'uiux\' no está configurado');
});
});
describe('lazy initialization', () => {
it('logr se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, logr: { level: LogLevel.INFO } });
expect(actx.logr).toBeDefined();
expect(actx.logr.info).toBeInstanceOf(Function);
});
it('numr se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: { maxDecimals: 3 } });
expect(actx.numr).toBeDefined();
expect(actx.numr.f).toBeInstanceOf(Function);
});
it('dati se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, dati: { selectedTimeFormat: '12h' } });
expect(actx.dati).toBeDefined();
expect(actx.dati.f).toBeInstanceOf(Function);
});
it('curr se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
});
expect(actx.curr).toBeDefined();
expect(actx.curr.f).toBeInstanceOf(Function);
});
it('unit se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, unit: { selectedSystem: 'metric' } });
expect(actx.unit).toBeDefined();
expect(actx.unit.weight).toBeDefined();
});
it('uiux se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, uiuxConfig: { theme: { mode: 'light' } } });
expect(actx.uiux).toBeDefined();
expect(actx.uiux.theme).toBeDefined();
});
it('múltiples accesos devuelven la misma instancia (singleton)', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
const numr1 = actx.numr;
const numr2 = actx.numr;
expect(numr1).toBe(numr2);
});
});
describe('integración de módulos con ling', () => {
it('numr lee el locale de ling automáticamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
const esResult = actx.numr.f(1234.5);
expect(esResult).toBeTruthy();
expect(esResult).toContain('5');
actx.ling.setLocale('en');
const enResult = actx.numr.f(1234.5);
expect(enResult).toBeTruthy();
expect(enResult).toContain('5');
});
it('dati lee el locale de ling automáticamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, dati: {} });
const testDate = new Date('2024-03-15T14:30:00Z');
const esResult = actx.dati.f(testDate, 'short');
expect(esResult).toBeTruthy();
actx.ling.setLocale('en');
const enResult = actx.dati.f(testDate, 'short');
expect(enResult).toBeTruthy();
});
it('curr lee el locale de ling automáticamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
});
const esResult = actx.curr.f(99.99, 'name');
expect(esResult).toBeTruthy();
expect(esResult).toContain('99');
actx.ling.setLocale('en');
const enResult = actx.curr.f(99.99, 'name');
expect(enResult).toBeTruthy();
expect(enResult).toContain('99');
});
it('curr usa ling para pluralizar nombres', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
});
expect(actx.curr.f(1, 'name')).toBe('1,00 euro');
expect(actx.curr.f(2, 'name')).toBe('2,00 euros');
});
it('logr usa ling para resolver mensajes localizados', () => {
const ling = createLing(translations, 'es');
const captured: string[] = [];
const actx = createACtx({
ling,
logr: {
level: LogLevel.INFO,
transports: [
{
write: (_entry: any, resolvedMessage: string) => captured.push(resolvedMessage)
}
],
},
});
actx.logr.info('test', '#?common.ok');
expect(captured[0]).toBe('Aceptar');
actx.ling.setLocale('en');
actx.logr.info('test', '#?common.ok');
expect(captured[1]).toBe('OK');
});
});
describe('cambio de locale', () => {
it('ling.setLocale() cambia el locale para todos los módulos', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
dati: {},
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
unit: { selectedSystem: 'metric' },
});
// Todos los módulos en español
const esNum = actx.numr.f(1234.5);
const esDate = actx.dati.f(new Date());
const esCurr = actx.curr.f(100);
const esUnit = actx.unit.weight.f(75);
expect(esNum).toBeTruthy();
expect(esDate).toBeTruthy();
expect(esCurr).toBeTruthy();
expect(esUnit).toBeTruthy();
actx.ling.setLocale('en');
// Todos los módulos en inglés
const enNum = actx.numr.f(1234.5);
const enDate = actx.dati.f(new Date());
const enCurr = actx.curr.f(100);
const enUnit = actx.unit.weight.f(75);
expect(enNum).toBeTruthy();
expect(enDate).toBeTruthy();
expect(enCurr).toBeTruthy();
expect(enUnit).toBeTruthy();
});
it('no es necesario reconectar módulos tras cambiar locale', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
const numrRef = actx.numr;
actx.ling.setLocale('en');
// La misma instancia ahora usa el nuevo locale
expect(actx.numr).toBe(numrRef);
const result = actx.numr.f(1234.5);
expect(result).toBeTruthy();
expect(result).toContain('5');
});
});
describe('onLocaleChange', () => {
it('notifica cuando cambia el locale a través de ling.setLocale', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const locales: string[] = [];
actx.ling.onLocaleChange((loc) => locales.push(loc));
actx.ling.setLocale('en');
actx.ling.setLocale('fr');
expect(locales).toEqual(['en', 'fr']);
});
it('el unsubscribe deja de notificar', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const locales: string[] = [];
const unsub = actx.ling.onLocaleChange((loc) => locales.push(loc));
unsub();
actx.ling.setLocale('en');
expect(locales).toEqual([]);
});
it('los callbacks se ejecutan después de que el locale cambió', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const capturedLocales: string[] = [];
actx.ling.onLocaleChange(() => {
capturedLocales.push(actx.locale);
});
actx.ling.setLocale('en');
expect(capturedLocales[0]).toBe('en');
});
});
describe('configuración completa de todos los módulos', () => {
it('crea un contexto completo con todos los módulos configurados', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
logr: { level: LogLevel.WARN },
numr: { minDecimals: 0, maxDecimals: 2 },
dati: { selectedTimeFormat: '24h', selectedDateOrder: 'DMY' },
curr: {
definitions: { EUR, USD },
selectedCurrency: 'EUR',
display: 'symbol',
decimals: 2,
},
unit: { selectedSystem: 'metric' },
uiuxConfig: { theme: { mode: 'dark' } },
});
// Todos los módulos están disponibles
expect(actx.ling).toBeDefined();
expect(actx.logr).toBeDefined();
expect(actx.numr).toBeDefined();
expect(actx.dati).toBeDefined();
expect(actx.curr).toBeDefined();
expect(actx.unit).toBeDefined();
expect(actx.uiux).toBeDefined();
// Configuración de numr
const numrResult = actx.numr.f(1234);
expect(numrResult).toBeTruthy();
expect(numrResult).toContain('1234');
// Configuración de dati
expect(actx.dati.getTimeFormat()).toBe('24h');
// Configuración de curr
expect(actx.curr.getCurrency()).toBe('EUR');
expect(actx.curr.get('EUR')).toEqual(EUR);
// Configuración de unit
expect(actx.unit.getSystem()).toBe('metric');
// Configuración de uiux
expect(actx.uiux.theme.mode).toBe('dark');
// Configuración de logr
expect(() => actx.logr.warn('test', 'msg')).not.toThrow();
});
});
describe('actx como punto único de entrada', () => {
it('proporciona acceso unificado a toda la funcionalidad', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: {},
dati: {},
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
unit: {},
logr: { level: LogLevel.NONE },
uiuxConfig: { theme: { mode: 'auto' } },
});
// Traducción directa
expect(actx.t('common.ok')).toBe('Aceptar');
// Traducción de string
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
// Locale
expect(actx.locale).toBe('es');
// Números
const numResult = actx.numr.f(1234.5);
expect(numResult).toBeTruthy();
expect(numResult).toContain('5');
// Fechas
const date = new Date('2024-03-15');
expect(actx.dati.f(date)).toBeTruthy();
// Monedas
expect(actx.curr.f(100)).toBe('100,00 €');
// Unidades
expect(actx.unit.weight.f(75)).toContain('75');
// UIX
expect(actx.uiux.theme.mode).toBe('auto');
// Cambio de locale
actx.ling.setLocale('en');
expect(actx.locale).toBe('en');
expect(actx.t('common.ok')).toBe('OK');
});
});
describe('uso típico en aplicación', () => {
it('simula el flujo de una aplicación multilingüe', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
logr: { level: LogLevel.INFO },
});
// Usuario en español
expect(actx.t('messages.welcome')).toBe('Bienvenido');
const esNum = actx.numr.f(1000);
expect(esNum).toBeTruthy();
expect(esNum).toContain('1');
expect(actx.curr.f(50)).toContain('50');
// Usuario cambia a inglés
actx.ling.setLocale('en');
expect(actx.t('messages.welcome')).toBe('Welcome');
const enNum = actx.numr.f(1000);
expect(enNum).toBeTruthy();
expect(enNum).toContain('1');
expect(actx.curr.f(50)).toContain('50');
// Conversión de moneda
actx.curr.setCurrency('USD');
const converted = actx.curr.convertTo(100, 'EUR');
expect(converted).toBeCloseTo(90.91);
});
});
});

@ -0,0 +1,10 @@
// ==============================
// HELPERS
// ==============================
import type { GlobLocale, LocaleResolver } from "./comn_types.ts";
/** Normaliza un `LocaleResolver` a un string de locale listo para `Intl.*`. */
export function resolveLocale(l: LocaleResolver): GlobLocale {
return typeof l === 'function' ? l() : l;
}

@ -0,0 +1,21 @@
import type { SupportedLocale } from '../../ling/lib';
// ==============================
// LOCALE
// ==============================
/** Cualquier código BCP 47 válido ('es', 'en', 'es-MX', …). */
export type GlobLocale = SupportedLocale | (string & {});
/**
* Valor directo o función — los módulos llaman a `resolveLocale()` en cada `f()`.
*
* - String fijo: el locale se resuelve una sola vez en la llamada.
* - Función: el locale se lee en cada invocación, lo que permite reactividad
* sin necesidad de recrear los módulos.
*
* @example
* numr.f(1234, {}, 'en') // fijo
* numr.f(1234, {}, () => user.locale) // dinámico
*/
export type LocaleResolver = GlobLocale | (() => GlobLocale);

@ -0,0 +1,138 @@
import type {
CurrInstance,
CurrDefinition,
CurrDisplay,
CurrConfig,
ConversionValue
} from './curr_types.ts';
import type {
LingInstance,
LingNode
} from '../../ling/lib/types';
import type {
LocaleResolver,
GlobLocale
} from './comn_types.ts';
import { resolveLocale } from './comn_engine.ts';
import { DEFAULT_ISO_LOCALE } from '../../ling/lib';
import { currLingNode } from './curr_langs.ts';
// Flag de módulo — registra traducciones en ling solo una vez.
let currExtended = false;
/**
* Crea un módulo de moneda con formateo y conversión.
*
* @param definitions - Mapa de monedas disponibles indexado por código ISO.
* @param ling - Instancia de ling para pluralización de nombres. Opcional —
* si no se pasa, `display: 'name'` devuelve el código ISO como fallback.
* @param opts - Configuración inicial (moneda activa, decimales, display).
* @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito.
*
* @remarks
* **Tasas de conversión**: todas las `conversionValue` deben expresarse respecto
* a la misma moneda base. La fórmula es `(amount / fromRate) * toRate`.
*/
export function createCurr<S extends LingNode>(
definitions : Record<string, CurrDefinition>,
ling : LingInstance<S> | null = null,
opts : CurrConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): CurrInstance {
// Registra traducciones predefinidas de monedas en ling solo una vez
if (ling && !currExtended) {
ling.extend('curr', currLingNode);
currExtended = true;
}
let selectedCurrency: string = opts.selectedCurrency ?? Object.keys(definitions)[0] ?? '';
const defaults = {
display : opts.display ?? 'symbol' as CurrDisplay,
decimals : opts.decimals ?? 2,
};
function getCurrency(): string { return selectedCurrency; }
function setCurrency(currency: string): void { selectedCurrency = currency.toUpperCase(); }
function get(currency: string): CurrDefinition | undefined {
return definitions[currency.toUpperCase()];
}
function formatAmount(amount: number, decimals: number, locale: GlobLocale): string {
return new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(amount);
}
/**
* Resuelve el nombre plural de la moneda.
* Requiere `ling` — sin él devuelve el código ISO como fallback.
*/
/**
* Resuelve el nombre plural de la moneda.
* Lee de ling via 'curr.{CODE}.name' — registrado en extend().
* Fallback: def.name() si existe, luego el código ISO.
*/
function resolveName(def: CurrDefinition, amount: number, locale: GlobLocale): string {
if (!ling) return def.code;
// Intenta leer desde el nodo 'curr' registrado en ling
const path = `curr.${def.code}.name`;
const fromLing = (ling.t as any)(path, { count: Math.abs(amount) });
if (fromLing && fromLing !== path) return fromLing;
// Fallback: función name() definida en CurrDefinition
if (def.name) {
const record = def.name({ count: Math.abs(amount) });
const baseLocale = locale.split('-')[0] as keyof typeof record;
const fallbackLocale = resolveLocale(defaultLocale).split('-')[0] as keyof typeof record;
return record[baseLocale] ?? record[fallbackLocale] ?? record['es'] ?? def.code;
}
return def.code;
}
function resolveConversionValue(value: ConversionValue): number | null {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return value;
return value();
}
function f(
amount : number,
display : CurrDisplay = defaults.display,
locale : LocaleResolver = defaultLocale
): string {
const loc = resolveLocale(locale);
const formatted = formatAmount(amount, defaults.decimals, loc);
const def = get(selectedCurrency);
if (!def) return `${formatted} ${selectedCurrency}`;
switch (display) {
case 'symbol': return `${formatted} ${def.symbol}`;
case 'code': return `${formatted} ${def.code}`;
case 'name': return `${formatted} ${resolveName(def, amount, loc)}`;
}
}
function convert(amount: number, from: string, to: string): number | null {
if (from.toUpperCase() === to.toUpperCase()) return amount;
const fromDef = get(from);
const toDef = get(to);
if (!fromDef || !toDef) return null;
const fromRate = resolveConversionValue(fromDef.conversionValue ?? null);
const toRate = resolveConversionValue(toDef.conversionValue ?? null);
if (fromRate === null || toRate === null) return null;
return (amount / fromRate) * toRate;
}
function convertTo(amount: number, to: string): number | null {
return convert(amount, selectedCurrency, to);
}
function getCurrencies(): string[] { return Object.keys(definitions); }
return { f, convert, convertTo, setCurrency, getCurrency, get, getCurrencies };
}

@ -0,0 +1,113 @@
import { p } from '../../ling/lib';
import type { LingNode } from '../../ling/lib/types';
/**
* Traducciones predefinidas de monedas.
* Se registra en ling bajo el nodo 'curr' via createCurr().
* Las definiciones concretas (symbol, code, conversionValue)
* las aporta el usuario en ACtxConfig.curr.definitions.
*/
export const currLingNode = {
EUR: {
name: p({
es: { one: 'euro', other: 'euros' },
en: { one: 'euro', other: 'euros' },
de: { one: 'Euro', other: 'Euro' },
fr: { one: 'euro', other: 'euros' },
it: { one: 'euro', other: 'euro' },
pt: { one: 'euro', other: 'euros' },
}),
},
USD: {
name: p({
es: { one: 'dólar', other: 'dólares' },
en: { one: 'dollar', other: 'dollars' },
de: { one: 'Dollar', other: 'Dollar' },
fr: { one: 'dollar', other: 'dollars' },
it: { one: 'dollaro', other: 'dollari' },
pt: { one: 'dólar', other: 'dólares' },
}),
},
GBP: {
name: p({
es: { one: 'libra', other: 'libras' },
en: { one: 'pound', other: 'pounds' },
de: { one: 'Pfund', other: 'Pfund' },
fr: { one: 'livre', other: 'livres' },
it: { one: 'sterlina', other: 'sterline' },
pt: { one: 'libra', other: 'libras' },
}),
},
JPY: {
name: p({
es: { one: 'yen', other: 'yenes' },
en: { one: 'yen', other: 'yen' },
de: { one: 'Yen', other: 'Yen' },
fr: { one: 'yen', other: 'yens' },
it: { one: 'yen', other: 'yen' },
pt: { one: 'iene', other: 'ienes' },
}),
},
CHF: {
name: p({
es: { one: 'franco suizo', other: 'francos suizos' },
en: { one: 'Swiss franc', other: 'Swiss francs' },
de: { one: 'Schweizer Franken', other: 'Schweizer Franken' },
fr: { one: 'franc suisse', other: 'francs suisses' },
it: { one: 'franco svizzero', other: 'franchi svizzeri' },
pt: { one: 'franco suíço', other: 'francos suíços' },
}),
},
CAD: {
name: p({
es: { one: 'dólar canadiense', other: 'dólares canadienses' },
en: { one: 'Canadian dollar', other: 'Canadian dollars' },
de: { one: 'Kanadischer Dollar', other: 'Kanadische Dollar' },
fr: { one: 'dollar canadien', other: 'dollars canadiens' },
it: { one: 'dollaro canadese', other: 'dollari canadesi' },
pt: { one: 'dólar canadiano', other: 'dólares canadianos' },
}),
},
AUD: {
name: p({
es: { one: 'dólar australiano', other: 'dólares australianos' },
en: { one: 'Australian dollar', other: 'Australian dollars' },
de: { one: 'Australischer Dollar', other: 'Australische Dollar' },
fr: { one: 'dollar australien', other: 'dollars australiens' },
it: { one: 'dollaro australiano', other: 'dollari australiani' },
pt: { one: 'dólar australiano', other: 'dólares australianos' },
}),
},
MXN: {
name: p({
es: { one: 'peso mexicano', other: 'pesos mexicanos' },
en: { one: 'Mexican peso', other: 'Mexican pesos' },
de: { one: 'Mexikanischer Peso', other: 'Mexikanische Pesos' },
fr: { one: 'peso mexicain', other: 'pesos mexicains' },
it: { one: 'peso messicano', other: 'pesos messicani' },
pt: { one: 'peso mexicano', other: 'pesos mexicanos' },
}),
},
BRL: {
name: p({
es: { one: 'real brasileño', other: 'reales brasileños' },
en: { one: 'Brazilian real', other: 'Brazilian reais' },
de: { one: 'Brasilianischer Real', other: 'Brasilianische Real' },
fr: { one: 'réal brésilien', other: 'réals brésiliens' },
it: { one: 'real brasiliano', other: 'real brasiliani' },
pt: { one: 'real brasileiro', other: 'reais brasileiros' },
}),
},
CNY: {
name: p({
es: { one: 'yuan chino', other: 'yuanes chinos' },
en: { one: 'Chinese yuan', other: 'Chinese yuan' },
de: { one: 'Chinesischer Yuan', other: 'Chinesische Yuan' },
fr: { one: 'yuan chinois', other: 'yuans chinois' },
it: { one: 'yuan cinese', other: 'yuan cinesi' },
pt: { one: 'yuan chinês', other: 'yuans chineses' },
}),
},
} satisfies LingNode;
export type CurrLingNode = typeof currLingNode;

@ -0,0 +1,68 @@
// ==============================
// CURR TYPES
// ==============================
import type { LingPluralFn } from "../../ling/lib";
import type { LocaleResolver } from "./comn_types.ts";
/**
* Tasa de conversión de una moneda respecto a la moneda base.
* - `number` : tasa estática.
* - `() => number | null` : tasa dinámica (ej. consultada en tiempo real).
* - `null` : conversión no disponible para esta moneda.
*
* @remarks
* Todas las tasas deben expresarse respecto a la **misma** moneda base
* (ej. todas en USD). La fórmula de conversión es `(amount / fromRate) * toRate`.
* Mezclar bases distintas produce resultados incorrectos sin aviso.
*/
export type ConversionValue = number | (() => number | null) | null;
/** Cómo se muestra la moneda en `f()`. */
export type CurrDisplay = 'symbol' | 'code' | 'name';
/** Definición completa de una moneda. */
export interface CurrDefinition {
/** Símbolo gráfico, ej. `'€'`, `'$'`. */
symbol : string;
/** Función plural para el nombre de la moneda (ej. "euro" / "euros"). */
name : LingPluralFn<Record<never, never>>;
/** Código ISO 4217, ej. `'EUR'`, `'USD'`. */
code : string;
/** Tasa respecto a la moneda base. Omitir o `null` deshabilita la conversión. */
conversionValue?: ConversionValue;
}
export interface CurrInstance {
/** Formatea `amount` en la moneda activa según `display` y `locale`. */
f : (amount: number, display?: CurrDisplay, locale?: LocaleResolver) => string;
/**
* Convierte `amount` de la moneda `from` a la moneda `to`.
* Devuelve `null` si alguna de las monedas no tiene `conversionValue`.
* Fórmula: `(amount / fromRate) * toRate`
*/
convert : (amount: number, from: string, to: string) => number | null;
/** Convierte `amount` desde la moneda activa (`selectedCurrency`) hacia `to`. */
convertTo : (amount: number, to: string) => number | null;
/** Cambia la moneda activa. Acepta mayúsculas y minúsculas. */
setCurrency: (currency: string) => void;
/** Devuelve el código ISO de la moneda activa. */
getCurrency: () => string;
/** Busca la definición de una moneda por código ISO (insensible a mayúsculas). */
get : (currency: string) => CurrDefinition | undefined;
/** Devuelve todos los códigos ISO disponibles en las definiciones. */
getCurrencies: () => string[];
}
/** Configuración de `createCurr`. */
export interface CurrConfig {
/** Moneda base para los cálculos de conversión. Default: `selectedCurrency`. */
baseCurrency? : string;
/** Moneda activa al iniciar. Default: primera moneda de `definitions`. */
selectedCurrency?: string;
/** Modo de display por defecto. Default: `'symbol'`. */
display? : CurrDisplay;
/** Decimales fijos para el formateo. Default: `2`. */
decimals? : number;
}

@ -0,0 +1,80 @@
import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder, DatiConfig } from './dati_types.ts';
import type { LocaleResolver, GlobLocale } from './comn_types.ts';
import { resolveLocale } from './comn_engine.ts';
import { DEFAULT_ISO_LOCALE } from '../../ling/lib/consts.ts';
// ==============================
// HELPERS
// ==============================
/**
* Normaliza `DatiOptions` a las opciones que acepta `Intl.DateTimeFormat`.
* - Sin opciones → solo fecha larga.
* - String → solo fecha con ese estilo.
* - Objeto → combina `date` y `time` según lo indicado.
*/
function resolveOpts(opts?: DatiOptions): {
dateStyle?: DatiStyleType;
timeStyle?: DatiStyleType;
} {
if (!opts) return { dateStyle: 'long' };
if (typeof opts === 'string') return { dateStyle: opts };
const { date, time } = opts;
if (!date && !time) return { dateStyle: 'long' };
return { dateStyle: date, timeStyle: time };
}
// ==============================
// ENGINE
// ==============================
/**
* Crea un formateador de fechas y horas basado en `Intl.DateTimeFormat`.
*
* @param opts - Preferencias iniciales (formato de hora, orden de fecha).
* @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito.
*/
export function createDati(
opts : DatiConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): DatiInstance {
let timeFormat: DatiTimeFormat = opts.selectedTimeFormat ?? '24h';
let dateOrder : DatiDateOrder = opts.selectedDateOrder ?? 'DMY';
// ── Preferencias ──────────────────────────────────────────────────────────
function getTimeFormat(): DatiTimeFormat { return timeFormat; }
function setTimeFormat(format: DatiTimeFormat): void { timeFormat = format; }
function getDateOrder(): DatiDateOrder { return dateOrder; }
function setDateOrder(order: DatiDateOrder): void { dateOrder = order; }
// ── Format ────────────────────────────────────────────────────────────────
/**
* Formatea `date` usando `Intl.DateTimeFormat`.
* Cuando se incluye hora, aplica `hour12` según `timeFormat` de la instancia.
*/
function f(
date : Date,
callOpts: DatiOptions = 'long',
locale : LocaleResolver = defaultLocale
): string {
const { dateStyle, timeStyle } = resolveOpts(callOpts);
const loc = resolveLocale(locale);
const intlOpts: Intl.DateTimeFormatOptions = {};
if (dateStyle) intlOpts.dateStyle = dateStyle;
if (timeStyle) {
intlOpts.timeStyle = timeStyle;
intlOpts.hour12 = timeFormat === '12h';
}
return new Intl.DateTimeFormat(loc, intlOpts).format(date);
}
return { f, getTimeFormat, setTimeFormat, getDateOrder, setDateOrder };
}

@ -0,0 +1,42 @@
// ==============================
// DATI
// ==============================
import type { LocaleResolver } from "./comn_types.ts";
/**
* Nivel de detalle en el formateo de fecha u hora.
* Mapea directamente a los valores que acepta `Intl.DateTimeFormat`.
*/
export type DatiStyleType = 'short' | 'medium' | 'long' | 'full';
/** Formato de horas: reloj de 12 o 24 horas. */
export type DatiTimeFormat = '12h' | '24h';
/** Orden preferido de los componentes de fecha (informativo para la UI). */
export type DatiDateOrder = 'DMY' | 'MDY' | 'YMD';
/**
* Opciones de `f()`:
* - `'short'` / `'medium'` / `'long'` / `'full'` : solo fecha en ese estilo.
* - `{ date?, time? }` : control independiente de fecha y hora.
*/
export type DatiOptions =
| DatiStyleType
| { date?: DatiStyleType; time?: DatiStyleType };
/** Configuración inicial de `createDati`. */
export interface DatiConfig {
/** Formato de horas. Default: `'24h'`. */
selectedTimeFormat?: DatiTimeFormat;
/** Orden de los componentes de fecha. Default: `'DMY'`. */
selectedDateOrder?: DatiDateOrder;
}
export interface DatiInstance {
/** Formatea `date` según las opciones y el locale indicados. */
f : (date: Date, opts?: DatiOptions, locale?: LocaleResolver) => string;
setTimeFormat: (format: DatiTimeFormat) => void;
getTimeFormat: () => DatiTimeFormat;
/** Guarda la preferencia de orden de fecha para uso de la UI. No afecta a `Intl`. */
setDateOrder : (order: DatiDateOrder) => void;
getDateOrder : () => DatiDateOrder;
}

@ -0,0 +1,11 @@
export * from './comn_types.ts';
export * from './curr_types.ts';
export * from './dati_types.ts';
export * from './numr_types.ts';
export * from './unit_types.ts';
export * from './comn_engine.ts';
export * from './curr_engine.ts';
export * from './dati_engine.ts';
export * from './numr_engine.ts';
export * from './unit_engine.ts';

@ -0,0 +1,48 @@
import { DEFAULT_ISO_LOCALE } from '../../ling/lib';
import { resolveLocale } from './comn_engine.ts';
import type { LocaleResolver } from './comn_types.ts';
import type { NumrConfig, NumrInstance } from './numr_types.ts';
// ==============================
// ENGINE
// ==============================
/**
* Crea un formateador de números basado en `Intl.NumberFormat`.
*
* @param opts - Decimales por defecto de la instancia.
* @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito.
*/
export function createNumr(
opts : NumrConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): NumrInstance {
const defaults: Required<NumrConfig> = {
minDecimals: opts.minDecimals ?? 0,
maxDecimals: opts.maxDecimals ?? 2,
};
/**
* Formatea `n` con el locale y los decimales indicados.
* Los `callOpts` tienen prioridad sobre los defaults de la instancia.
* Si `minDecimals > maxDecimals`, `maxDecimals` se eleva al valor de `minDecimals`
* para evitar un `RangeError` de `Intl.NumberFormat`.
*/
function f(
n : number,
callOpts?: NumrConfig,
locale : LocaleResolver = defaultLocale
): string {
const min = callOpts?.minDecimals ?? defaults.minDecimals;
const max = Math.max(callOpts?.maxDecimals ?? defaults.maxDecimals, min);
return new Intl.NumberFormat(resolveLocale(locale), {
minimumFractionDigits: min,
maximumFractionDigits: max,
}).format(n);
}
return { f };
}

@ -0,0 +1,22 @@
// ==============================
// NUMR TYPES
// ==============================
import type { LocaleResolver } from "./comn_types.ts";
/** Defaults de decimales para la instancia. Pueden sobreescribirse en cada llamada a `f()`. */
export interface NumrConfig {
/** Mínimo de decimales mostrados. Default: `0`. */
minDecimals?: number;
/** Máximo de decimales mostrados. Default: `2`. */
maxDecimals?: number;
}
export interface NumrInstance {
/**
* Formatea un número según el locale activo.
* Los `opts` de cada llamada sobreescriben los defaults de la instancia.
* Si `minDecimals > maxDecimals`, `maxDecimals` se eleva automáticamente.
*/
f: (n: number, opts?: NumrConfig, locale?: LocaleResolver) => string;
}

@ -0,0 +1,71 @@
import type { UnitCategoryKey, UnitCategoryData } from './unit_types.ts';
/**
* Datos numéricos de las unidades — factores de conversión y sistemas.
* Las traducciones (symbol, name) viven en units_lang.ts → ling.
*/
export const unitData: Record<UnitCategoryKey, UnitCategoryData> = {
weight: {
base : 'kg',
baseImperial: 'lb',
units: {
kg: { factor: 1, system: 'metric' },
g: { factor: 0.001, system: 'metric' },
lb: { factor: 0.453592, system: 'imperial' },
oz: { factor: 0.0283495, system: 'imperial' },
t: { factor: 1000, system: 'metric' },
},
},
length: {
base : 'm',
baseImperial: 'mi',
units: {
km: { factor: 1000, system: 'metric' },
m: { factor: 1, system: 'metric' },
cm: { factor: 0.01, system: 'metric' },
mm: { factor: 0.001, system: 'metric' },
mi: { factor: 1609.344, system: 'imperial' },
ft: { factor: 0.3048, system: 'imperial' },
in: { factor: 0.0254, system: 'imperial' },
yd: { factor: 0.9144, system: 'imperial' },
},
},
temp: {
base : 'c',
baseImperial: 'f',
units: {
c: { factor: 1, system: 'metric' },
f: { factor: 1, system: 'imperial' },
k: { factor: 1, system: 'metric' },
},
},
volume: {
base : 'l',
baseImperial: 'gal',
units: {
l: { factor: 1, system: 'metric' },
ml: { factor: 0.001, system: 'metric' },
m3: { factor: 1000, system: 'metric' },
gal: { factor: 3.78541, system: 'imperial' },
floz: { factor: 0.029574, system: 'imperial' },
pt: { factor: 0.473176, system: 'imperial' },
},
},
area: {
base : 'm2',
baseImperial: 'ft2',
units: {
m2: { factor: 1, system: 'metric' },
km2: { factor: 1000000, system: 'metric' },
ha: { factor: 10000, system: 'metric' },
ft2: { factor: 0.092903, system: 'imperial' },
mi2: { factor: 2589988.11, system: 'imperial' },
ac: { factor: 4046.856, system: 'imperial' },
},
},
};

@ -0,0 +1,143 @@
import type { LocaleResolver, GlobLocale } from "./comn_types.ts";
import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types.ts";
import { resolveLocale } from "./comn_engine.ts";
import { DEFAULT_ISO_LOCALE } from "../../ling/lib/consts.ts";
import { type LingNode, type LingInstance } from "../../ling/lib";
import { unitData } from "./unit_data.ts";
import { unitsLingNode } from "./unit_langs.ts";
// ==============================
// CONVERSIÓN DE TEMPERATURA
// ==============================
function convertTemp(value: number, from: string, to: string): number {
if (from === to) return value;
let c: number;
switch (from) {
case 'c': c = value; break;
case 'f': c = (value - 32) * 5 / 9; break;
case 'k': c = value - 273.15; break;
default: c = value;
}
switch (to) {
case 'c': return c;
case 'f': return c * 9 / 5 + 32;
case 'k': return c + 273.15;
default: return c;
}
}
// ==============================
// CATEGORY ENGINE
// ==============================
function createCategory<S extends LingNode>(
categoryKey : UnitCategoryKey,
category : UnitCategoryData,
getSystem : () => UnitSystem,
defaultLocale: LocaleResolver,
lingInst : LingInstance<S>,
): UnitCategoryInstance {
function resolveTargetUnit(): string {
return getSystem() === 'imperial' ? category.baseImperial : category.base;
}
function formatValue(value: number, unitKey: string, display: UnitDisplay, locale: GlobLocale): string {
const formatted = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
if (display === 'abbr') {
const abbr = lingInst.t(`units.${categoryKey}.${unitKey}.abbr` as any);
return `${formatted} ${abbr}`;
}
const name = lingInst.t(`units.${categoryKey}.${unitKey}.name` as any, { count: value });
return `${formatted} ${name}`;
}
function convert(value: number, from: string, to: string): number {
if (from === to) return value;
if (categoryKey === 'temp') return convertTemp(value, from, to);
const fromData = category.units[from];
const toData = category.units[to];
if (!fromData || !toData) return value;
return (value * fromData.factor) / toData.factor;
}
/**
* Formatea `value` en la unidad base del sistema activo.
* Asume que `value` ya está expresado en esa unidad.
* Para convertir desde otra unidad primero, usa `fTo()`.
*/
function f(value: number, display: UnitDisplay = 'abbr', locale: LocaleResolver = defaultLocale): string {
const loc = resolveLocale(locale);
const unit = resolveTargetUnit();
return formatValue(value, unit, display, loc);
}
/**
* Convierte `value` desde `fromUnit` a `targetUnit` y lo formatea.
* Si se omite `fromUnit`, usa la unidad base del sistema activo.
* Si se omite `targetUnit`, usa la unidad base del sistema activo.
*/
function fTo(
value : number,
fromUnit? : string,
targetUnit?: string,
display : UnitDisplay = 'abbr',
locale : LocaleResolver = defaultLocale
): string {
const loc = resolveLocale(locale);
const from = fromUnit ?? resolveTargetUnit();
const to = targetUnit ?? resolveTargetUnit();
const converted = convert(value, from, to);
return formatValue(converted, to, display, loc);
}
return { f, fTo, convert };
}
// ==============================
// UNIT ENGINE
// ==============================
// Flag de módulo para registrar traducciones en ling solo una vez,
// independientemente de cuántas veces se llame a createUnit().
let unitsExtended = false;
export function createUnit<S extends LingNode>(
lingInst : LingInstance<S>,
categories : Partial<Record<UnitCategoryKey, UnitCategoryData>> = {},
opts : { selectedSystem?: UnitSystem } = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): UnitInstance {
// Registra traducciones solo en la primera llamada — idempotente y eficiente
if (!unitsExtended) {
lingInst.extend('units', unitsLingNode);
unitsExtended = true;
}
let currentSystem: UnitSystem = opts.selectedSystem ?? 'metric';
function getSystem(): UnitSystem { return currentSystem; }
function setSystem(s: UnitSystem) { currentSystem = s; }
const merged: Record<UnitCategoryKey, UnitCategoryData> = {
...unitData,
...categories,
};
const makeCategory = (key: UnitCategoryKey) =>
createCategory(key, merged[key], getSystem, defaultLocale, lingInst);
return {
getSystem,
setSystem,
weight : makeCategory('weight'),
length : makeCategory('length'),
temp : makeCategory('temp'),
volume : makeCategory('volume'),
area : makeCategory('area'),
};
}

@ -0,0 +1,50 @@
import { p } from '../../ling/lib/engine.ts';
import type { LingNode } from '../../ling/lib/types.ts';
/**
* Traducciones de unidades de medida.
* Se registra en ling bajo el nodo 'units' via glob_engine.
* Los datos numéricos (factor, system, base) viven en units_data.ts.
*/
export const unitsLingNode = {
weight: {
kg: { abbr: { es: 'kg', en: 'kg' }, name: p({ es: { one: 'kilogramo', other: 'kilogramos' }, en: { one: 'kilogram', other: 'kilograms' } }) },
g: { abbr: { es: 'g', en: 'g' }, name: p({ es: { one: 'gramo', other: 'gramos' }, en: { one: 'gram', other: 'grams' } }) },
lb: { abbr: { es: 'lb', en: 'lb' }, name: p({ es: { one: 'libra', other: 'libras' }, en: { one: 'pound', other: 'pounds' } }) },
oz: { abbr: { es: 'oz', en: 'oz' }, name: p({ es: { one: 'onza', other: 'onzas' }, en: { one: 'ounce', other: 'ounces' } }) },
t: { abbr: { es: 't', en: 't' }, name: p({ es: { one: 'tonelada', other: 'toneladas' }, en: { one: 'tonne', other: 'tonnes' } }) },
},
length: {
km: { abbr: { es: 'km', en: 'km' }, name: p({ es: { one: 'kilómetro', other: 'kilómetros' }, en: { one: 'kilometer', other: 'kilometers' } }) },
m: { abbr: { es: 'm', en: 'm' }, name: p({ es: { one: 'metro', other: 'metros' }, en: { one: 'meter', other: 'meters' } }) },
cm: { abbr: { es: 'cm', en: 'cm' }, name: p({ es: { one: 'centímetro', other: 'centímetros' }, en: { one: 'centimeter', other: 'centimeters' } }) },
mm: { abbr: { es: 'mm', en: 'mm' }, name: p({ es: { one: 'milímetro', other: 'milímetros' }, en: { one: 'millimeter', other: 'millimeters' } }) },
mi: { abbr: { es: 'mi', en: 'mi' }, name: p({ es: { one: 'milla', other: 'millas' }, en: { one: 'mile', other: 'miles' } }) },
ft: { abbr: { es: 'ft', en: 'ft' }, name: p({ es: { one: 'pie', other: 'pies' }, en: { one: 'foot', other: 'feet' } }) },
in: { abbr: { es: 'in', en: 'in' }, name: p({ es: { one: 'pulgada', other: 'pulgadas' }, en: { one: 'inch', other: 'inches' } }) },
yd: { abbr: { es: 'yd', en: 'yd' }, name: p({ es: { one: 'yarda', other: 'yardas' }, en: { one: 'yard', other: 'yards' } }) },
},
temp: {
c: { abbr: { es: '°C', en: '°C' }, name: p({ es: { one: 'grado Celsius', other: 'grados Celsius' }, en: { one: 'degree Celsius', other: 'degrees Celsius' } }) },
f: { abbr: { es: '°F', en: '°F' }, name: p({ es: { one: 'grado Fahrenheit', other: 'grados Fahrenheit' }, en: { one: 'degree Fahrenheit', other: 'degrees Fahrenheit' } }) },
k: { abbr: { es: 'K', en: 'K' }, name: p({ es: { one: 'kelvin', other: 'kelvins' }, en: { one: 'kelvin', other: 'kelvins' } }) },
},
volume: {
l: { abbr: { es: 'l', en: 'l' }, name: p({ es: { one: 'litro', other: 'litros' }, en: { one: 'liter', other: 'liters' } }) },
ml: { abbr: { es: 'ml', en: 'ml' }, name: p({ es: { one: 'mililitro', other: 'mililitros' }, en: { one: 'milliliter', other: 'milliliters' } }) },
m3: { abbr: { es: 'm³', en: 'm³' }, name: p({ es: { one: 'metro cúbico', other: 'metros cúbicos'}, en: { one: 'cubic meter', other: 'cubic meters' } }) },
gal: { abbr: { es: 'gal', en: 'gal' }, name: p({ es: { one: 'galón', other: 'galones' }, en: { one: 'gallon', other: 'gallons' } }) },
floz: { abbr: { es: 'fl oz', en: 'fl oz' }, name: p({ es: { one: 'onza líquida', other: 'onzas líquidas'}, en: { one: 'fluid ounce', other: 'fluid ounces' } }) },
pt: { abbr: { es: 'pt', en: 'pt' }, name: p({ es: { one: 'pinta', other: 'pintas' }, en: { one: 'pint', other: 'pints' } }) },
},
area: {
m2: { abbr: { es: 'm²', en: 'm²' }, name: p({ es: { one: 'metro cuadrado', other: 'metros cuadrados' }, en: { one: 'square meter', other: 'square meters' } }) },
km2: { abbr: { es: 'km²', en: 'km²' }, name: p({ es: { one: 'kilómetro cuadrado', other: 'kilómetros cuadrados' }, en: { one: 'square kilometer', other: 'square kilometers' } }) },
ha: { abbr: { es: 'ha', en: 'ha' }, name: p({ es: { one: 'hectárea', other: 'hectáreas' }, en: { one: 'hectare', other: 'hectares' } }) },
ft2: { abbr: { es: 'ft²', en: 'ft²' }, name: p({ es: { one: 'pie cuadrado', other: 'pies cuadrados' }, en: { one: 'square foot', other: 'square feet' } }) },
mi2: { abbr: { es: 'mi²', en: 'mi²' }, name: p({ es: { one: 'milla cuadrada', other: 'millas cuadradas' }, en: { one: 'square mile', other: 'square miles' } }) },
ac: { abbr: { es: 'ac', en: 'ac' }, name: p({ es: { one: 'acre', other: 'acres' }, en: { one: 'acre', other: 'acres' } }) },
},
} satisfies LingNode;
export type UnitsLingNode = typeof unitsLingNode;

@ -0,0 +1,53 @@
// ==============================
// UNIT TYPES
// ==============================
import type { LocaleResolver } from "./comn_types.ts";
export type UnitDisplay = 'abbr' | 'name';
export type UnitSystem = 'metric' | 'imperial';
export type UnitCategoryKey = 'weight' | 'length' | 'temp' | 'volume' | 'area';
/** Solo datos numéricos — sin texto, sin traducciones */
export interface UnitData {
factor : number;
system : UnitSystem;
}
/** Datos de categoría — base y factores de conversión */
export interface UnitCategoryData {
base : string;
baseImperial : string;
units : Record<string, UnitData>;
}
export interface UnitCategoryInstance {
/**
* Formatea `value` en la unidad activa del sistema actual (métrico o imperial).
* Asume que `value` ya está expresado en esa unidad base.
* Para convertir desde otra unidad, usa `fTo()`.
*/
f : (value: number, display?: UnitDisplay, locale?: LocaleResolver) => string;
/**
* Convierte `value` desde `fromUnit` (o la unidad base del sistema actual si se omite)
* a `targetUnit` (o la unidad base del sistema actual si se omite) y lo formatea.
*/
fTo : (value: number, fromUnit?: string, targetUnit?: string, display?: UnitDisplay, locale?: LocaleResolver) => string;
/** Convierte `value` de la unidad `from` a la unidad `to`. */
convert: (value: number, from: string, to: string) => number;
}
export interface UnitConfig {
selectedSystem?: UnitSystem;
categories?: Partial<Record<UnitCategoryKey, UnitCategoryData>>;
}
export interface UnitInstance {
weight : UnitCategoryInstance;
length : UnitCategoryInstance;
temp : UnitCategoryInstance;
volume : UnitCategoryInstance;
area : UnitCategoryInstance;
setSystem: (system: UnitSystem) => void;
getSystem: () => UnitSystem;
}

@ -0,0 +1,601 @@
/**
* glob — test suite
*
* Ejecutar: npx vitest run
*
* Módulos cubiertos:
* - comn_engine (resolveLocale)
* - numr_engine (createNumr)
* - curr_engine (createCurr)
* - dati_engine (createDati)
* - unit_engine (createUnit)
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { createLing } from '../../ling/lib';
import { createNumr } from '../lib/numr_engine';
import { createCurr } from '../lib/curr_engine';
import { createDati } from '../lib/dati_engine';
import { createUnit } from '../lib/unit_engine';
import { resolveLocale } from '../lib/comn_engine';
import { unitsLingNode } from '../lib/unit_langs';
import type { NumrConfig } from '../lib/numr_types';
import type { CurrDefinition } from '../lib/curr_types';
import type { DatiConfig, DatiOptions } from '../lib/dati_types';
import type { UnitSystem, UnitDisplay } from '../lib/unit_types';
// ============================================================================
// COMMON
// ============================================================================
describe('resolveLocale', () => {
it('devuelve el string si es un locale directo', () => {
expect(resolveLocale('es')).toBe('es');
expect(resolveLocale('en-US')).toBe('en-US');
});
it('ejecuta la función y devuelve el resultado', () => {
const resolver = () => 'fr';
expect(resolveLocale(resolver)).toBe('fr');
});
it('la función se ejecuta en cada llamada (reactividad)', () => {
let current = 'es';
const resolver = () => current;
expect(resolveLocale(resolver)).toBe('es');
current = 'de';
expect(resolveLocale(resolver)).toBe('de');
});
});
// ============================================================================
// NUMR
// ============================================================================
describe('createNumr', () => {
describe('f()', () => {
it('formatea números con el locale por defecto', () => {
const numr = createNumr({}, 'es');
const result = numr.f(1234.5);
// El formato exacto depende de la implementación de Intl
expect(result).toBeTruthy();
expect(result).toContain('5');
});
it('formatea con locale inglés', () => {
const numr = createNumr({}, 'en');
const result = numr.f(1234.5);
expect(result).toBeTruthy();
expect(result).toContain('5');
});
it('usa minDecimals por defecto (0)', () => {
const numr = createNumr({}, 'es');
const result = numr.f(1234);
expect(result).toBeTruthy();
expect(result).toContain('1234');
});
it('usa maxDecimals por defecto (2)', () => {
const numr = createNumr({}, 'es');
const result = numr.f(1234.5678);
expect(result).toBeTruthy();
// Debe tener máximo 2 decimales
expect(result).toContain('57');
});
it('respeta minDecimals de la instancia', () => {
const opts: NumrConfig = { minDecimals: 2 };
const numr = createNumr(opts, 'es');
const result = numr.f(1234);
expect(result).toBeTruthy();
// Debe tener 2 decimales
expect(result).toContain('00');
});
it('respeta maxDecimals de la instancia', () => {
const opts: NumrConfig = { maxDecimals: 4 };
const numr = createNumr(opts, 'es');
const result = numr.f(1234.56789);
expect(result).toBeTruthy();
// Debe tener 4 decimales
expect(result).toContain('5679');
});
it('los opts de llamada sobreescriben los defaults', () => {
const numr = createNumr({ minDecimals: 0, maxDecimals: 2 }, 'es');
const result = numr.f(1234.5, { minDecimals: 1, maxDecimals: 3 });
expect(result).toBeTruthy();
// Verifica que el resultado contiene el valor principal
expect(result).toContain('1234');
});
it('eleva maxDecimals si minDecimals > maxDecimals', () => {
const numr = createNumr({}, 'es');
const result = numr.f(1234, { minDecimals: 3, maxDecimals: 1 });
expect(result).toBeTruthy();
// Debe tener 3 decimales (elevado al minDecimals)
expect(result).toContain('000');
});
it('acepta locale como función (reactivo)', () => {
let current = 'es';
const numr = createNumr({}, () => current);
const esResult = numr.f(1234.5);
expect(esResult).toBeTruthy();
current = 'en';
const enResult = numr.f(1234.5);
expect(enResult).toBeTruthy();
// Los resultados deben ser diferentes (diferentes separadores)
expect(esResult).not.toBe(enResult);
});
it('permite override el locale en cada llamada', () => {
const numr = createNumr({}, 'es');
const result = numr.f(1234.5, {}, 'en');
expect(result).toBeTruthy();
});
});
});
// ============================================================================
// CURR
// ============================================================================
const EUR: CurrDefinition = {
symbol: '€',
code: 'EUR',
name: ({ count }) => ({
es: count === 1 ? 'euro' : 'euros',
en: count === 1 ? 'euro' : 'euros',
}),
conversionValue: 1, // Base
};
const USD: CurrDefinition = {
symbol: '$',
code: 'USD',
name: ({ count }) => ({
es: count === 1 ? 'dólar' : 'dólares',
en: count === 1 ? 'dollar' : 'dollars',
}),
conversionValue: 1.1, // 1 EUR = 1.1 USD (ejemplo)
};
const GBP: CurrDefinition = {
symbol: '£',
code: 'GBP',
name: ({ count }) => ({
es: count === 1 ? 'libra' : 'libras',
en: count === 1 ? 'pound' : 'pounds',
}),
conversionValue: 0.85, // 1 EUR = 0.85 GBP
};
describe('createCurr', () => {
let ling: ReturnType<typeof createLing>;
beforeEach(() => {
ling = createLing({}, 'es');
});
describe('f() - formateo', () => {
it('formatea con display symbol por defecto', () => {
const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.f(99.99)).toBe('99,99 €');
});
it('formatea con display code', () => {
const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.f(99.99, 'code')).toBe('99,99 EUR');
});
it('formatea con display name (singular)', () => {
const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.f(1, 'name')).toBe('1,00 euro');
});
it('formatea con display name (plural)', () => {
const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.f(2, 'name')).toBe('2,00 euros');
});
it('resuelve el nombre según el locale', () => {
const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'en');
expect(curr.f(2, 'name', 'en')).toBe('2.00 euros');
});
it('usa el locale por defecto si no se especifica', () => {
const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'en');
expect(curr.f(2, 'name')).toBe('2.00 euros');
});
it('si la moneda no existe, devuelve importe + código', () => {
const curr = createCurr({}, ling, { selectedCurrency: 'XXX' }, 'es');
expect(curr.f(99.99)).toBe('99,99 XXX');
});
});
describe('convert() - conversión', () => {
it('convierte entre monedas con tasas definidas', () => {
const curr = createCurr({ EUR, USD, GBP }, ling, {}, 'es');
// 100 EUR * 1.1 = 110 USD
expect(curr.convert(100, 'EUR', 'USD')).toBeCloseTo(110);
// 100 EUR * 0.85 = 85 GBP
expect(curr.convert(100, 'EUR', 'GBP')).toBe(85);
});
it('convierte en sentido inverso', () => {
const curr = createCurr({ EUR, USD }, ling, {}, 'es');
// 110 USD / 1.1 = 100 EUR
expect(curr.convert(110, 'USD', 'EUR')).toBeCloseTo(100);
});
it('devuelve null si una moneda no existe', () => {
const curr = createCurr({ EUR }, ling, {}, 'es');
expect(curr.convert(100, 'EUR', 'XXX')).toBeNull();
});
it('devuelve null si una moneda no tiene tasa', () => {
const curr = createCurr(
{
EUR: { ...EUR, conversionValue: 1 },
USD: { ...USD, conversionValue: null as any },
},
ling,
{},
'es'
);
expect(curr.convert(100, 'EUR', 'USD')).toBeNull();
});
it('devuelve el mismo valor si from === to', () => {
const curr = createCurr({ EUR }, ling, {}, 'es');
expect(curr.convert(100, 'EUR', 'EUR')).toBe(100);
});
it('es insensible a mayúsculas', () => {
const curr = createCurr({ EUR, USD }, ling, {}, 'es');
expect(curr.convert(100, 'eur', 'usd')).toBeCloseTo(110);
});
});
describe('convertTo() - conversión desde moneda activa', () => {
it('convierte desde la moneda activa', () => {
const curr = createCurr({ EUR, USD }, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.convertTo(100, 'USD')).toBeCloseTo(110);
});
it('se actualiza al cambiar la moneda activa', () => {
const curr = createCurr({ EUR, USD }, ling, { selectedCurrency: 'EUR' }, 'es');
curr.setCurrency('USD');
// 100 USD / 1.1 = 90.91 EUR
expect(curr.convertTo(100, 'EUR')).toBeCloseTo(90.91, 1);
});
});
describe('setCurrency / getCurrency', () => {
it('getCurrency devuelve la moneda activa', () => {
const curr = createCurr({ EUR, USD }, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.getCurrency()).toBe('EUR');
});
it('setCurrency cambia la moneda activa', () => {
const curr = createCurr({ EUR, USD }, ling, { selectedCurrency: 'EUR' }, 'es');
curr.setCurrency('USD');
expect(curr.getCurrency()).toBe('USD');
});
it('setCurrency normaliza a mayúsculas', () => {
const curr = createCurr({ EUR, USD }, ling, {}, 'es');
curr.setCurrency('eur');
expect(curr.getCurrency()).toBe('EUR');
});
});
describe('get() - búsqueda de definiciones', () => {
it('devuelve la definición de una moneda', () => {
const curr = createCurr({ EUR, USD }, ling, {}, 'es');
expect(curr.get('EUR')).toEqual(EUR);
});
it('es insensible a mayúsculas', () => {
const curr = createCurr({ EUR, USD }, ling, {}, 'es');
expect(curr.get('eur')).toEqual(EUR);
});
it('devuelve undefined si no existe', () => {
const curr = createCurr({ EUR }, ling, {}, 'es');
expect(curr.get('XXX')).toBeUndefined();
});
});
describe('getCurrencies()', () => {
it('devuelve todos los códigos ISO disponibles', () => {
const curr = createCurr({ EUR, USD, GBP }, ling, {}, 'es');
const codes = curr.getCurrencies();
expect(codes).toHaveLength(3);
expect(codes).toContain('EUR');
expect(codes).toContain('USD');
expect(codes).toContain('GBP');
});
});
});
// ============================================================================
// DATI
// ============================================================================
describe('createDati', () => {
const testDate = new Date('2024-03-15T14:30:00Z');
describe('f() - formateo', () => {
it('formatea con estilo long por defecto', () => {
const dati = createDati({}, 'es');
const result = dati.f(testDate);
expect(result).toBeTruthy();
expect(result.length).toBeGreaterThan(0);
});
it('formatea con estilo short', () => {
const dati = createDati({}, 'es');
const result = dati.f(testDate, 'short');
expect(result).toBeTruthy();
});
it('formatea con estilo medium', () => {
const dati = createDati({}, 'es');
const result = dati.f(testDate, 'medium');
expect(result).toBeTruthy();
});
it('formatea con estilo full', () => {
const dati = createDati({}, 'es');
const result = dati.f(testDate, 'full');
expect(result).toBeTruthy();
});
it('formatea fecha y hora por separado', () => {
const dati = createDati({ selectedTimeFormat: '24h' }, 'es');
const opts: DatiOptions = { date: 'short', time: 'short' };
const result = dati.f(testDate, opts);
expect(result).toBeTruthy();
});
it('respeta el formato de hora 12h', () => {
const dati = createDati({ selectedTimeFormat: '12h' }, 'en-US');
const opts: DatiOptions = { date: 'short', time: 'short' };
const result = dati.f(testDate, opts);
expect(result).toBeTruthy();
});
it('respeta el formato de hora 24h', () => {
const dati = createDati({ selectedTimeFormat: '24h' }, 'es');
const opts: DatiOptions = { date: 'short', time: 'short' };
const result = dati.f(testDate, opts);
expect(result).toBeTruthy();
});
it('permite override el locale en cada llamada', () => {
const dati = createDati({}, 'es');
const result = dati.f(testDate, 'long', 'en-US');
expect(result).toBeTruthy();
});
it('acepta locale como función (reactivo)', () => {
let current = 'es';
const dati = createDati({}, () => current);
const esResult = dati.f(testDate);
expect(esResult).toBeTruthy();
current = 'en-US';
const enResult = dati.f(testDate);
expect(enResult).toBeTruthy();
});
});
describe('setTimeFormat / getTimeFormat', () => {
it('getTimeFormat devuelve el formato por defecto (24h)', () => {
const dati = createDati({}, 'es');
expect(dati.getTimeFormat()).toBe('24h');
});
it('setTimeFormat cambia el formato de hora', () => {
const dati = createDati({}, 'es');
dati.setTimeFormat('12h');
expect(dati.getTimeFormat()).toBe('12h');
});
});
describe('setDateOrder / getDateOrder', () => {
it('getDateOrder devuelve el orden por defecto (DMY)', () => {
const dati = createDati({}, 'es');
expect(dati.getDateOrder()).toBe('DMY');
});
it('setDateOrder cambia el orden de fecha', () => {
const dati = createDati({}, 'es');
dati.setDateOrder('MDY');
expect(dati.getDateOrder()).toBe('MDY');
});
});
});
// ============================================================================
// UNIT
// ============================================================================
describe('createUnit', () => {
let ling: ReturnType<typeof createLing>;
beforeEach(() => {
ling = createLing({
units: unitsLingNode,
}, 'es');
});
describe('f() - formateo en sistema métrico', () => {
it('formatea peso en kg (sistema métrico)', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
expect(unit.weight.f(75)).toContain('75');
});
it('formatea longitud en m (sistema métrico)', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
expect(unit.length.f(180)).toContain('180');
});
it('formatea temperatura en °C (sistema métrico)', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
expect(unit.temp.f(20)).toContain('20');
});
});
describe('f() - formateo en sistema imperial', () => {
it('formatea peso en lb (sistema imperial)', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'imperial' }, 'en-US');
expect(unit.weight.f(165)).toContain('165');
});
it('formatea longitud en ft (sistema imperial)', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'imperial' }, 'en-US');
expect(unit.length.f(6)).toContain('6');
});
it('formatea temperatura en °F (sistema imperial)', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'imperial' }, 'en-US');
expect(unit.temp.f(68)).toContain('68');
});
});
describe('fTo() - conversión y formateo', () => {
it('convierte peso: kg a lb', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
const result = unit.weight.fTo(75, 'kg', 'lb');
// 75 kg ≈ 165.35 lb
expect(result).toBeTruthy();
expect(result).toContain('165');
});
it('convierte longitud: m a ft', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
const result = unit.length.fTo(1.8, 'm', 'ft');
// 1.8 m ≈ 5.9 ft
expect(result).toBeTruthy();
expect(result).toContain('5');
});
it('convierte temperatura: °C a °F', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
const result = unit.temp.fTo(20, 'c', 'f');
// 20°C = 68°F exactamente
expect(result).toContain('68');
});
it('convierte desde la unidad base del sistema actual si se omite fromUnit', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
const result = unit.weight.fTo(75, undefined, 'lb');
expect(result).toBeTruthy();
expect(result).toContain('165');
});
it('convierte a la unidad base del sistema actual si se omite targetUnit', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
const result = unit.weight.fTo(165, 'lb', undefined);
// 165 lb ≈ 74.84 kg
expect(result).toBeTruthy();
expect(result).toContain('74');
});
});
describe('convert() - conversión sin formateo', () => {
it('convierte peso: kg a lb', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.weight.convert(75, 'kg', 'lb')).toBeCloseTo(165.35, 1);
});
it('convierte longitud: m a ft', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.length.convert(1, 'm', 'ft')).toBeCloseTo(3.28, 2);
});
it('convierte temperatura: °C a °F', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.temp.convert(20, 'c', 'f')).toBe(68);
});
it('devuelve el mismo valor si from === to', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.weight.convert(75, 'kg', 'kg')).toBe(75);
});
});
describe('setSystem / getSystem', () => {
it('getSystem devuelve el sistema por defecto (metric)', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.getSystem()).toBe('metric');
});
it('setSystem cambia el sistema activo', () => {
const unit = createUnit(ling, {}, {}, 'es');
unit.setSystem('imperial');
expect(unit.getSystem()).toBe('imperial');
});
it('f() usa la unidad base del nuevo sistema tras setSystem', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
const metricResult = unit.weight.f(75);
expect(metricResult).toContain('kg');
unit.setSystem('imperial');
const imperialResult = unit.weight.f(165);
expect(imperialResult).toContain('lb');
});
});
describe('display option', () => {
it('display: "abbr" muestra abreviatura', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
const result = unit.weight.f(75, 'abbr');
expect(result).toContain('kg');
});
it('display: "name" muestra nombre completo', () => {
const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es');
const result = unit.weight.f(75, 'name');
// Contiene "kilogramo" o "kg" según traducciones
expect(result).toBeTruthy();
});
});
describe('todas las categorías disponibles', () => {
it('weight está disponible', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.weight.f(70)).toBeTruthy();
});
it('length está disponible', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.length.f(170)).toBeTruthy();
});
it('temp está disponible', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.temp.f(25)).toBeTruthy();
});
it('volume está disponible', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.volume.f(1000)).toBeTruthy();
});
it('area está disponible', () => {
const unit = createUnit(ling, {}, {}, 'es');
expect(unit.area.f(100)).toBeTruthy();
});
});
});

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

@ -0,0 +1,9 @@
export const ID_PREFIX = '#?';
export const DEFAULT_ISO_LOCALE = 'es' as const;
export const MAX_RESOLVE_DEEP = 3;
export const LOGGER_CATEGORY = 'ling';

@ -0,0 +1,320 @@
import type {
SupportedLocale,
LeafPaths,
GetTypeAtPath,
ParamsFor,
HasParams,
PluralForms,
LingRecord,
LingString, LingInstance, LingLogger, LingNode, PluralConfig,
} from './types.ts';
import {LING_ERRORS} from "./errors.ts";
import {ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP} from "./consts.ts";
import {isIDLing, isLingRecord} from "./guards.ts";
import {pluralRule} from "./plural_rules.ts";
import {resolvePath} from "./helpers.ts";
// Memoizado — evaluado una sola vez en módulo load.
// Usa import.meta.env.DEV (Vite/SvelteKit) con fallback a process.env para Node.
const DEV: boolean =
typeof import.meta !== 'undefined' && import.meta.env != null
? import.meta.env.DEV === true
: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
/** Deep-merge recursivo de objetos planos. Las funciones y primitivos de `source` sobreescriben `target`. */
function deepMerge(target: any, source: any): any {
const result = { ...target };
for (const key of Object.keys(source)) {
const srcVal = source[key];
const tgtVal = result[key];
if (
typeof srcVal === 'object' && srcVal !== null && !Array.isArray(srcVal) && typeof srcVal !== 'function' &&
typeof tgtVal === 'object' && tgtVal !== null && !Array.isArray(tgtVal) && typeof tgtVal !== 'function'
) {
result[key] = deepMerge(tgtVal, srcVal);
} else {
result[key] = srcVal;
}
}
return result;
}
// ==============================
// ENGINE
// ==============================
export function createLing<S extends LingNode>(
schema: S,
defaultLocale: SupportedLocale
): LingInstance<S> {
let currentSchema : LingNode = schema;
let currentLocale : SupportedLocale = defaultLocale;
let logger : LingLogger = consoleLogger;
let loggerSet : boolean = false;
const listeners = new Set<(locale: SupportedLocale) => void>();
const schemaListeners = new Set<() => void>();
// Expuesto para que instancias hijas (register) puedan ser destruidas.
let __dispose: (() => void) | undefined;
// -------------------------------------------------------------------------
// Logger
// -------------------------------------------------------------------------
function setLogger(external: LingLogger): void {
if (loggerSet) {
if (DEV) 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;
let translation = translationInLocale ?? record[defaultLocale] ?? path ?? '';
if (params) {
// replaceAll con string literal es más rápido que new RegExp y evita
// inyección de caracteres especiales de regex en las claves de los params.
for (const [key, val] of Object.entries(params)) {
translation = translation.replaceAll(`{{${key}}}`, String(val));
}
}
if (DEV && isMissing) {
const msg = path
? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale)
: LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale);
logger.warn(LOGGER_CATEGORY, msg);
}
return translation;
}
/**
* Resuelve un valor del schema siguiendo referencias (#?) y funciones
* de forma recursiva. Lanza si se supera el límite de profundidad.
*/
function resolveValue(value: any, args: any[], depth: number): any {
if (depth > MAX_RESOLVE_DEEP) {
logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(String(value)));
throw new Error("Circular reference in ling");
}
if (isIDLing(value)) {
const path = value.substring(ID_PREFIX.length);
const resolved = resolvePath(currentSchema, path);
return resolveValue(resolved, args, depth + 1);
}
if (typeof value === 'function') {
return value(args[0]);
}
return value; // LingRecord, string, etc.
}
// -------------------------------------------------------------------------
// API pública
// -------------------------------------------------------------------------
const t: LingInstance<S>['t'] = (path: string, ...args: any[]): any => {
const rawValue = resolvePath(currentSchema, path);
if (rawValue === undefined) {
if (DEV) logger.error(LOGGER_CATEGORY, LING_ERRORS.KEY_NOT_FOUND(path));
return path;
}
// Toda la resolución de referencias y funciones vive en resolveValue.
// t() solo orquesta: busca → resuelve → traduce.
const finalValue = resolveValue(rawValue, args, 0);
if (isLingRecord(finalValue)) {
return tsRecord(finalValue, path, args[0]);
}
return String(finalValue);
};
function ts(value: LingString): string {
if (value == null) return '';
const finalValue = resolveValue(value, [], 0);
if (isLingRecord(finalValue)) return tsRecord(finalValue);
return typeof finalValue === 'string' ? finalValue : String(finalValue);
}
/**
* Similar a ts() pero permite especificar un locale específico.
* Útil para serializar mensajes al locale en que se registraron.
*/
function tsForLocale(value: LingString, locale: SupportedLocale): string {
if (value == null) return '';
const prev = currentLocale;
try {
currentLocale = locale;
return ts(value);
} finally {
currentLocale = prev;
}
}
function tForLocale<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P,
locale: SupportedLocale,
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
) : string {
const prev = currentLocale;
try {
currentLocale = locale;
return t(path, ...(args as any));
} finally {
// Garantiza la restauración del locale aunque t() lance (ej. referencia circular).
currentLocale = prev;
}
}
/**
* Añade un módulo lazy al schema en runtime mutando la instancia actual.
* Soporta namespaces con puntos: 'vcen.editor.mtas' crea la jerarquía anidada correcta
* y hace deep-merge con lo que ya existiera en ese path.
*
* Nota: los componentes suscritos a onLocaleChange no se notifican automáticamente
* de cambios de schema. Si necesitas reactividad ante extend(), usa onSchemaChange().
*
* @example
* ling.extend('shop', shopTranslations);
* ling.extend('vcen.editor.mtas', mtasTranslations);
*/
function extend(namespace: string, module: LingNode): void {
const parts = namespace.split('.');
let nested: any = module;
for (let i = parts.length - 1; i > 0; i--) {
nested = { [parts[i]]: nested };
}
const root = parts[0];
currentSchema = {
...(currentSchema as Record<string, any>),
[root]: deepMerge((currentSchema as any)[root] ?? {}, nested),
};
schemaListeners.forEach(fn => fn());
}
/**
* Registra un módulo y devuelve una nueva instancia con el tipo actualizado.
* Útil para contextos aislados, tests, o cuando necesitas el tipo inferido
* sin declarar FullSchema de antemano.
*
* Llama a `dispose()` en la instancia devuelta cuando ya no la necesites
* para evitar memory leaks (elimina el listener de locale del padre).
*
* @example
* const { instance: lingTest, dispose } = ling.register('shop', shopTranslations);
* lingTest.t('shop.product'); // ✅ tipado
* dispose(); // limpia el listener cuando el contexto se destruye
*/
function register<NS extends string, M extends LingNode>(
namespace: NS,
module: M
): LingInstance<S & { [K in NS]: M }> {
const newSchema = {
...(currentSchema as Record<string, any>),
[namespace]: module,
} as S & { [K in NS]: M };
const extended = createLing(newSchema, defaultLocale);
extended.setLocale(currentLocale);
// Sincroniza el locale del hijo cuando cambie el padre.
// El unsubscribe se expone en la instancia hija como dispose().
const unsubscribe = onLocaleChange(locale => extended.setLocale(locale));
extended.__dispose = unsubscribe;
return extended;
}
function onSchemaChange(fn: () => void): () => void {
schemaListeners.add(fn);
return () => schemaListeners.delete(fn);
}
function dispose(): void {
__dispose?.();
}
return { t, tForLocale, ts, tsForLocale, setLocale, getLocale, onLocaleChange, onSchemaChange, extend, register, setLogger, dispose, get __dispose() { return __dispose; }, set __dispose(fn) { __dispose = fn; } };
}
/**
* Helper de pluralización.
* El tipo de retorno incluye `& Record<string, any>` para permitir
* parámetros adicionales de interpolación (como {{name}}).
*/
export const p = (config: PluralConfig) => {
// Caché por count: la selección de forma plural solo depende de count.
// Usamos String(count) como clave para evitar inestabilidad de floats en Map.
const cache = new Map<string, LingRecord>();
const fn = (params: { count: number } & Record<string, any>): LingRecord => {
const key = String(params.count);
const cached = cache.get(key);
if (cached) return cached;
const result: any = {};
for (const [locale, forms] of Object.entries(config)) {
if (!forms) continue;
const rule = pluralRule(locale, params.count);
const typedForms = forms as PluralForms;
result[locale] = typedForms[rule] || typedForms.other;
}
cache.set(key, result as LingRecord);
return result as LingRecord;
};
(fn as any).__pluralConfig = config;
return fn;
}
// Logger por defecto — console puro, sin dependencias externas.
// Se reemplaza con setLogger() una vez logr está inicializado.
const consoleLogger: LingLogger = {
warn : (_category: string, message: string) => DEV && console.warn (message),
error: (_category: string, message: string) => DEV && console.error(message),
};

@ -0,0 +1,62 @@
import type { SupportedLocale } from './types.ts';
// ============================================================================
// LING ERROR & WARNING MESSAGES
// ============================================================================
/**
* Mensajes de error y warning del sistema ling centralizados como constantes.
*
* No se usa el sistema de `Logr` para evitar dependencia cíclica —
* `Logr` depende de `ling`, por lo que `ling` no puede depender de `Logr`.
*
* Estos mensajes se emiten directamente por consola solo en desarrollo
* (`NODE_ENV === 'development'`). En producción la degradación es silenciosa.
*/
export const LING_ERRORS = {
/**
* La clave de traducción no existe en el schema.
* Se emite como `console.error` — indica un error de programación,
* no una traducción faltante.
*
* @example
* LING_ERRORS.KEY_NOT_FOUND('checkout.total')
* // → '[ling] Translation key not found: "checkout.total"'
*/
KEY_NOT_FOUND: (path: string): string =>
`[ling] Translation key not found: "${path}"`,
CIRCULAR_REFERENCE: (path: string): string =>
`[ling] Circular reference in "${path}".`,
/**
* La clave existe pero no tiene traducción para el locale solicitado.
* Se emite como `console.warn` — degradación controlada con fallback.
*
* @example
* LING_ERRORS.MISSING_TRANSLATION('common.ok', 'de', 'es')
* // → '[ling] Missing translation for "common.ok" in "de". Falling back to "es".'
*/
MISSING_TRANSLATION: (path: string, locale: SupportedLocale, fallback: SupportedLocale): string =>
`[ling] Missing translation for "${path}" in "${locale}". Falling back to "${fallback}".`,
/**
* Variante de MISSING_TRANSLATION para cuando no hay path disponible
* — usado en `ts()` al resolver un `LocaleRecord` sin contexto de clave.
*
* @example
* LING_ERRORS.MISSING_TRANSLATION_RECORD('de', 'es')
* // → '[ling] Missing translation in "de". Falling back to "es".'
*/
MISSING_TRANSLATION_RECORD: (locale: SupportedLocale, fallback: SupportedLocale): string =>
`[ling] Missing translation in "${locale}". Falling back to "${fallback}".`,
/**
* Se intentó llamar a setLogger() más de una vez.
* El logger solo puede inyectarse una vez — post-init es inmutable.
*/
LOGGER_ALREADY_SET: '[ling] Logger already set. setLogger() can only be called once.',
} as const;

@ -0,0 +1,45 @@
import type {IDLing, LingRecord, LingString} from "./types.ts";
import {DEFAULT_ISO_LOCALE, ID_PREFIX} from "./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(ID_PREFIX) &&
value.length > 2
);
}
/**
* Guard para registros de idioma.
* Valida que el objeto tenga la DefaultLocale y que su valor sea string,
* evitando falsos positivos con objetos de dominio que casualmente tengan esa clave.
*/
export function isLingRecord(value: unknown): value is LingRecord {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
typeof value !== 'function' &&
DEFAULT_ISO_LOCALE in value &&
typeof (value as Record<string, unknown>)[DEFAULT_ISO_LOCALE] === 'string'
);
}
/**
* Comprueba si un valor es un LingString.
* Un LingString puede ser:
* - string → texto plano
* - LingRecord → objeto multiidioma { es: '...', en: '...' }
* - IDLing → referencia con prefijo '#?...'
*/
export function isLingString(value: unknown): value is LingString {
return isIDLing(value) || isLingRecord(value) || typeof value === 'string';
}

@ -0,0 +1,37 @@
// ==============================
// HELPERS
// ==============================
import type {LingRecord, LingString} from "./types.ts";
import {DEFAULT_ISO_LOCALE} from "./consts.ts";
import { isLingString } from "./guards.ts";
export function resolvePath(obj: any, path: string): any {
return path.split('.').reduce((acc, key) => acc?.[key], obj);
}
export function makeLingRecord (text?: string) : LingRecord {
return {
[DEFAULT_ISO_LOCALE]: text ?? ''
} as LingRecord;
}
// src/lib/ling/lib/helpers.ts — añadir esto
/**
* Convierte un valor desconocido a LingString de forma segura.
* Útil en templates Svelte donde `as` no está disponible.
*
* - Si el valor ya es LingString válido → lo devuelve tal cual
* - Si es null/undefined → devuelve string vacío
* - Cualquier otra cosa → lo convierte a string con String()
*/
export function asLingString(value: unknown): LingString {
if (value == null) return '';
if (isLingString(value)) return value;
return String(value);
}

@ -0,0 +1,9 @@
export * from './translations.ts';
export * from './consts.ts';
export * from './guards.ts';
export * from './types.ts';
export * from './engine.ts';
export * from './helpers.ts';
export * from './instance.ts';
export * from './translations.ts';

@ -0,0 +1,27 @@
import type { TranslationSchema } from './translations.ts';
import { createLing } from './engine.ts';
import {translations} from './translations.ts';
// ─── CASO EAGER (esquema conocido en build time) ────────────────────────────────
export const ling = createLing<TranslationSchema>(translations, 'es');
// ─── CASO LAZY (módulos cargados en runtime) ─────────────────────────────────
// FullSchema declara en build time los tipos de los módulos lazy.
// import type no genera código — cero coste en el bundle.
// extend() en runtime mueve el schema para que coincida con lo que TypeScript ya sabe.
//
// import type { shopTranslations } from '@/shop/translations';
// import type { adminTranslations } from '@/admin/translations';
//
// export type FullSchema = typeof translations & {
// shop : typeof shopTranslations;
// admin : typeof adminTranslations;
// };
//
// export const ling = createLing<FullSchema>(translations, 'es');
//
// // En el router, cuando el módulo se carga:
// const { shopTranslations } = await import('@/shop/translations');
// ling.extend('shop', shopTranslations);
export const { t, tForLocale, setLocale, getLocale, onLocaleChange } = ling;

@ -0,0 +1,76 @@
import type { LingNode, LingRecord } from './types.ts';
import { p } from './engine.ts';
import { isLingRecord } from './guards.ts';
/**
* Convierte un LingNode completo (árbol de traducciones) a JSON.
* - Las funciones de interpolación se convierten en registros con {{tokens}}.
* - Los plurales se marcan con __type: 'plural' para ser reconstruidos.
* - Las referencias IDLing (#?path) se serializan como strings y son reconocidas
* en runtime por isIDLing() al reconstruir con JSONToLingNode.
*/
export function lingNodeToJSON(node: LingNode | any): any {
// 1. Strings y Referencias IDLing (#?...)
if (typeof node === 'string') return node;
// 2. Manejo de Funciones (Interpolación y Plurales)
if (typeof node === 'function') {
// Caso A: Es un plural generado por p()
if (node.__pluralConfig) {
return {
__type: 'plural',
config: node.__pluralConfig
};
}
// Caso B: Es una función de interpolación (LingFn)
// La ejecutamos con un Proxy para extraer las llaves como {{key}}
try {
const proxyParams = new Proxy({}, {
get: (_, prop) => `{{${String(prop)}}}`
});
// Al ejecutarla, obtenemos un LingRecord plano
return lingNodeToJSON(node(proxyParams));
} catch {
return {}; // Fallback si la función falla al ejecutarse
}
}
// 3. Objetos (Namespaces o LingRecords)
if (typeof node === 'object' && node !== null) {
const result: Record<string, any> = {};
for (const key in node) {
result[key] = lingNodeToJSON(node[key]);
}
return result;
}
return node;
}
/**
* Reconstruye un LingNode a partir de un objeto JSON.
* - Reactiva los closures de pluralización usando p().
* - Las referencias IDLing (#?path) se preservan como strings — isIDLing() las
* reconoce en runtime correctamente, por lo que el round-trip es transparente.
*/
export function JSONToLingNode(json: any): LingNode {
if (typeof json === 'string') return json as LingNode;
if (typeof json === 'object' && json !== null) {
// Detectar y reconstruir plurales
if (json.__type === 'plural' && json.config) {
return p(json.config) as unknown as LingNode;
}
// Procesar hijos recursivamente
const result: any = {};
for (const key in json) {
result[key] = JSONToLingNode(json[key]);
}
return result;
}
return json as LingNode;
}

@ -0,0 +1,323 @@
/**
* Reglas de pluralización CLDR para cardinales.
* Derivadas de la especificación Unicode CLDR (https://cldr.unicode.org/index/cldr-spec/plural-rules)
* y equivalentes a las generadas por make-plural (MIT License, https://github.com/eemeli/make-plural).
*
* Cero dependencias de entorno — funciona en Node, browser, edge, workers.
*
* Forma de uso:
* pluralRule('es', 1) // → 'one'
* pluralRule('es', 2) // → 'other'
* pluralRule('ar', 0) // → 'zero'
* pluralRule('ru', 3) // → 'few'
*/
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
type PluralFn = (n: number) => PluralCategory;
// ─── HELPERS ─────────────────────────────────────────────────────────────────
/** Parte entera de n */
const i = (n: number) => Math.floor(Math.abs(n));
/** Número de dígitos decimales visibles (sin trailing zeros) */
const v = (n: number) => {
const s = String(n);
const d = s.indexOf('.');
return d < 0 ? 0 : s.length - d - 1;
};
/** Dígitos decimales visibles como número entero (sin trailing zeros) */
const f = (n: number) => {
const s = String(n);
const d = s.indexOf('.');
return d < 0 ? 0 : parseInt(s.slice(d + 1).replace(/0+$/, '') || '0', 10);
};
/** n mod m */
const mod = (n: number, m: number) => n % m;
// ─── REGLAS POR LOCALE ───────────────────────────────────────────────────────
const rules: Record<string, PluralFn> = {
// ── one/other (n = 1 → one) ───────────────────────────────────────────────
// af, an, asa, az, bem, bez, bg, brx, ce, cgg, chr, ckb, dv, ee, el,
// eo, es, eu, fo, fur, gsw, ha, haw, hu, jgo, jmc, ka, kaj, kcg, kk,
// kkj, kl, ks, ksb, ku, ky, lb, lg, mas, mgo, ml, mn, mr, nah, nb,
// nd, ne, nn, nnh, no, nr, ny, nyn, om, or, os, pap, ps, rm, rof,
// rwk, saq, sd, seh, sn, so, sq, ss, ssy, st, syr, ta, te, teo,
// tig, tk, tn, tr, ts, uve, uz, ve, vo, vun, wae, xh, xog
af: n => n === 1 ? 'one' : 'other',
an: n => n === 1 ? 'one' : 'other',
az: n => n === 1 ? 'one' : 'other',
bg: n => n === 1 ? 'one' : 'other',
bn: n => i(n) === 0 || n === 1 ? 'one' : 'other',
ca: n => n === 1 && v(n) === 0 ? 'one' : 'other',
da: n => n === 1 || (n !== Math.floor(n) && [0, 1].includes(i(n))) ? 'one' : 'other',
de: n => n === 1 && v(n) === 0 ? 'one' : 'other',
el: n => n === 1 ? 'one' : 'other',
en: n => n === 1 && v(n) === 0 ? 'one' : 'other',
eo: n => n === 1 ? 'one' : 'other',
es: n => n === 1 ? 'one' : 'other',
et: n => n === 1 && v(n) === 0 ? 'one' : 'other',
eu: n => n === 1 ? 'one' : 'other',
fi: n => n === 1 && v(n) === 0 ? 'one' : 'other',
gl: n => n === 1 && v(n) === 0 ? 'one' : 'other',
gu: n => i(n) === 0 || n === 1 ? 'one' : 'other',
he: n => n === 1 && v(n) === 0 ? 'one' : n === 2 && v(n) === 0 ? 'two' : v(n) !== 0 ? 'many' : 'other',
hi: n => i(n) === 0 || n === 1 ? 'one' : 'other',
hu: n => n === 1 ? 'one' : 'other',
hy: n => i(n) === 0 || i(n) === 1 ? 'one' : 'other',
id: _ => 'other',
is: n => {
const mod10 = mod(i(n), 10);
const mod100 = mod(i(n), 100);
return (mod10 === 1 && mod100 !== 11) ? 'one' : 'other';
},
it: n => n === 1 && v(n) === 0 ? 'one' : 'other',
ja: _ => 'other',
ka: n => n === 1 ? 'one' : 'other',
km: _ => 'other',
kn: n => i(n) === 0 || n === 1 ? 'one' : 'other',
ko: _ => 'other',
lt: n => {
const n10 = mod(n, 10);
const n100 = mod(n, 100);
if (n10 === 1 && (n100 < 11 || n100 > 19)) return 'one';
if (n10 >= 2 && n10 <= 9 && (n100 < 11 || n100 > 19)) return 'few';
if (f(n) !== 0) return 'many';
return 'other';
},
lv: n => {
const n10 = mod(n, 10);
const n100 = mod(n, 100);
if (n === 0) return 'zero';
if (n10 === 1 && n100 !== 11) return 'one';
return 'other';
},
mk: n => {
const i_ = i(n);
const v_ = v(n);
if (v_ === 0 && mod(i_, 10) === 1 && mod(i_, 100) !== 11) return 'one';
if (v_ === 0 && mod(i_, 10) === 2 && mod(i_, 100) !== 12) return 'two';
if ((v_ === 0 && (mod(i_, 10) === 7 || mod(i_, 10) === 8) && mod(i_, 100) !== 17 && mod(i_, 100) !== 18) ||
(v_ !== 0 && (mod(f(n), 10) === 7 || mod(f(n), 10) === 8))) return 'many';
return 'other';
},
ml: n => n === 1 ? 'one' : 'other',
mn: n => n === 1 ? 'one' : 'other',
mr: n => n === 1 ? 'one' : 'other',
ms: _ => 'other',
my: _ => 'other',
nb: n => n === 1 ? 'one' : 'other',
ne: n => n === 1 ? 'one' : 'other',
nl: n => n === 1 && v(n) === 0 ? 'one' : 'other',
or: n => n === 1 ? 'one' : 'other',
pa: n => n === 0 || n === 1 ? 'one' : 'other',
pl: n => {
const v_ = v(n);
const i_ = i(n);
const n10 = mod(i_, 10);
const n100 = mod(i_, 100);
if (i_ === 1 && v_ === 0) return 'one';
if (v_ === 0 && n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
if (v_ === 0 && i_ !== 1 && (n10 === 0 || n10 === 1) ||
v_ === 0 && n10 >= 5 && n10 <= 9 ||
v_ === 0 && n100 >= 12 && n100 <= 14) return 'many';
return 'other';
},
pt: n => n >= 0 && n < 2 ? 'one' : 'other',
ro: n => {
const v_ = v(n);
const n100 = mod(n, 100);
if (i(n) === 1 && v_ === 0) return 'one';
if (v_ !== 0 || n === 0 || (n100 >= 2 && n100 <= 19)) return 'few';
return 'other';
},
ru: n => {
const v_ = v(n);
if (v_ !== 0) return 'other';
const n10 = mod(i(n), 10);
const n100 = mod(i(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
si: n => n === 0 || n === 1 || (i(n) === 0 && f(n) === 1) ? 'one' : 'other',
sk: n => {
const v_ = v(n);
const i_ = i(n);
if (i_ === 1 && v_ === 0) return 'one';
if (i_ >= 2 && i_ <= 4 && v_ === 0) return 'few';
if (v_ !== 0) return 'many';
return 'other';
},
sl: n => {
const v_ = v(n);
const n100 = mod(i(n), 100);
if (n100 === 1 && v_ === 0) return 'one';
if (n100 === 2 && v_ === 0) return 'two';
if ((n100 >= 3 && n100 <= 4 || v_ !== 0)) return 'few';
return 'other';
},
sq: n => n === 1 ? 'one' : 'other',
sr: n => {
const v_ = v(n);
const i_ = i(n);
const n10 = v_ === 0 ? mod(i_, 10) : mod(f(n), 10);
const n100 = v_ === 0 ? mod(i_, 100) : mod(f(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
sv: n => n === 1 && v(n) === 0 ? 'one' : 'other',
sw: n => n === 1 && v(n) === 0 ? 'one' : 'other',
ta: n => n === 1 ? 'one' : 'other',
te: n => n === 1 ? 'one' : 'other',
th: _ => 'other',
tr: n => n === 1 ? 'one' : 'other',
uk: n => {
const v_ = v(n);
if (v_ !== 0) return 'other';
const n10 = mod(i(n), 10);
const n100 = mod(i(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
ur: n => n === 1 && v(n) === 0 ? 'one' : 'other',
uz: n => n === 1 ? 'one' : 'other',
vi: _ => 'other',
zh: _ => 'other',
zu: n => i(n) === 0 || n === 1 ? 'one' : 'other',
// ── Árabe — 6 formas ──────────────────────────────────────────────────────
ar: n => {
if (n === 0) return 'zero';
if (n === 1) return 'one';
if (n === 2) return 'two';
const n100 = mod(n, 100);
if (n100 >= 3 && n100 <= 10) return 'few';
if (n100 >= 11 && n100 <= 99) return 'many';
return 'other';
},
// ── Galés — 6 formas ──────────────────────────────────────────────────────
cy: n => {
if (n === 0) return 'zero';
if (n === 1) return 'one';
if (n === 2) return 'two';
if (n === 3) return 'few';
if (n === 6) return 'many';
return 'other';
},
// ── Bretón — 5 formas ─────────────────────────────────────────────────────
br: n => {
const n10 = mod(n, 10);
const n100 = mod(n, 100);
const n1000000 = mod(n, 1000000);
if (n10 === 1 && n100 !== 11 && n100 !== 71 && n100 !== 91) return 'one';
if (n10 === 2 && n100 !== 12 && n100 !== 72 && n100 !== 92) return 'two';
if ((n10 === 3 || n10 === 4 || n10 === 9) && (n100 < 10 || n100 > 19) && (n100 < 70 || n100 > 79) && (n100 < 90 || n100 > 99)) return 'few';
if (n !== 0 && n1000000 === 0) return 'many';
return 'other';
},
// ── Francés ───────────────────────────────────────────────────────────────
fr: n => i(n) === 0 || i(n) === 1 ? 'one' : 'other',
// ── Gallego ───────────────────────────────────────────────────────────────
// (mismo que es, pt para cardinales)
// ── Irlandés — 5 formas ───────────────────────────────────────────────────
ga: n => {
if (n === 1) return 'one';
if (n === 2) return 'two';
if (n >= 3 && n <= 6) return 'few';
if (n >= 7 && n <= 10) return 'many';
return 'other';
},
// ── Escocés gaélico — 4 formas ────────────────────────────────────────────
gd: n => {
if (n === 1 || n === 11) return 'one';
if (n === 2 || n === 12) return 'two';
if ((n >= 3 && n <= 10) || (n >= 13 && n <= 19)) return 'few';
return 'other';
},
// ── Maltés — 4 formas ─────────────────────────────────────────────────────
mt: n => {
const n100 = mod(n, 100);
if (n === 1) return 'one';
if (n === 0 || (n100 >= 2 && n100 <= 10)) return 'few';
if (n100 >= 11 && n100 <= 19) return 'many';
return 'other';
},
// ── Bosnio/Croata/Serbio ──────────────────────────────────────────────────
bs: n => {
const v_ = v(n);
const i_ = i(n);
const n10 = v_ === 0 ? mod(i_, 10) : mod(f(n), 10);
const n100 = v_ === 0 ? mod(i_, 100) : mod(f(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
hr: n => {
const v_ = v(n);
const i_ = i(n);
const n10 = v_ === 0 ? mod(i_, 10) : mod(f(n), 10);
const n100 = v_ === 0 ? mod(i_, 100) : mod(f(n), 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
// ── Bielorruso ────────────────────────────────────────────────────────────
be: n => {
const n10 = mod(n, 10);
const n100 = mod(n, 100);
if (n10 === 1 && n100 !== 11) return 'one';
if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few';
return 'other';
},
// ── Checo/Eslovaco ────────────────────────────────────────────────────────
cs: n => {
const v_ = v(n);
const i_ = i(n);
if (i_ === 1 && v_ === 0) return 'one';
if (i_ >= 2 && i_ <= 4 && v_ === 0) return 'few';
if (v_ !== 0) return 'many';
return 'other';
},
// ── Amhárico/Tigriña ──────────────────────────────────────────────────────
am: n => i(n) === 0 || n === 1 ? 'one' : 'other',
// ── Persa ─────────────────────────────────────────────────────────────────
fa: n => i(n) === 0 || n === 1 ? 'one' : 'other',
};
/**
* Devuelve la categoría plural CLDR para un número y locale dados.
* Si el locale no está soportado, devuelve 'other' como fallback seguro.
*
* @example
* pluralRule('es', 1) // → 'one'
* pluralRule('es', 2) // → 'other'
* pluralRule('ar', 0) // → 'zero'
* pluralRule('ru', 3) // → 'few'
* pluralRule('xx', 5) // → 'other' (locale desconocido)
*/
export function pluralRule(locale: string, n: number): PluralCategory {
// Normalizar: 'es-ES' → 'es', 'zh-Hans' → 'zh'
const base = locale.split('-')[0].split('_')[0];
const fn = rules[base];
return fn ? fn(n) : 'other';
}

@ -0,0 +1,59 @@
import type { LingNode } from './types.ts';
import { p } from './engine.ts';
/**
* Sin `: LingNode` en la declaración — TypeScript infiere el tipo exacto.
* `satisfies LingNode` valida la estructura sin borrar la información.
*
* Esto permite que LeafPaths<TranslationSchema> derive rutas concretas
* como "checkout.total" y que t() quede completamente type-safe.
*/
export const translations = {
checkout: {
pay: {
es: "Pagar",
en: "Pay"
},
// LingPluralFn no aplica aquí, pero el tipo se infiere correctamente
// porque p() devuelve (params: { count: number } & ...) => LingRecord
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})`
})
},
// Ejemplo de uso de p() con LingPluralFn — TypeScript infiere { count: number }
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' },
})
}
} satisfies LingNode;
export type TranslationSchema = typeof translations;

@ -0,0 +1,197 @@
// ==============================
// LOCALES
// ==============================
import type {ID_PREFIX} from "./consts.ts";
import type {PluralCategory} from "./plural_rules.ts";
/**
* Referencia interna: #?path.del.schema
*/
export type IDLing = `${typeof ID_PREFIX}${string}`;
export type DefaultLocale = 'es';
export type SupportedLocale =
| DefaultLocale
| 'en'
| 'de'
| 'fr'
| 'it'
| 'pt'
| 'ca'
| 'eu'
| 'gl';
// ==============================
// LOCALIZED TYPES
// ==============================
/**
* Un registro de traducciones donde el DefaultLocale es obligatorio
* y el resto de locales son opcionales.
*/
export type LingRecord = {
[K in SupportedLocale]?: string;
} & {
[K in DefaultLocale]: string;
};
/**
* Función de traducción tipada con parámetros genéricos.
*/
export type LingFn<P extends Record<string, unknown>> = (params: P) => LingRecord;
/**
* Función de pluralización: siempre requiere `count` más cualquier extra.
* Separado de LingFn para poder detectarlo explícitamente en HasParams.
*/
export type LingPluralFn<P extends Record<string, unknown> = Record<never, never>> =
(params: { count: number } & P) => LingRecord;
export type LingValue =
| LingRecord
| LingFn<any>
| LingPluralFn<any>
| IDLing;
/**
* Nodo recursivo del schema de traducciones.
*/
export type LingNode =
| LingValue
| { [key: string]: LingNode };
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
* (LingRecord o función), no las rutas intermedias (namespaces).
*/
export type LeafPaths<T, D extends number = 6> =
[D] extends [never]
? never
: T extends LingValue
? ''
: T extends object
? {
[K in keyof T & string]:
T[K] extends LingValue
? K
: `${K}.${LeafPaths<T[K], Prev[D]> & string}`
}[keyof T & string]
: never;
export type GetTypeAtPath<
Root,
Current,
P extends string
> =
P extends `${infer K}.${infer Rest}`
? K extends keyof Current
? GetTypeAtPath<Root, Current[K], Rest>
: never
: P extends keyof Current
? Current[P] extends `#?${infer AliasPath}`
? GetTypeAtPath<Root, Root, AliasPath>
: Current[P]
: never;
/**
* Extrae los parámetros de una función de traducción o pluralización.
* Para LingPluralFn siempre incluirá `count: number`.
*/
export type ParamsFor<T> =
T extends (params: infer P) => any
? P
: never;
/**
* Detecta si un tipo requiere parámetros:
* - LingFn<P> → true (parámetros arbitrarios)
* - LingPluralFn<P> → true (siempre incluye count)
* - LingRecord → false (sin parámetros)
* - IDLing → false (referencia, se resuelve)
*/
export type HasParams<T> =
T extends (params: any) => any
? true
: false;
/** Formas plurales según el estándar Unicode */
export type PluralForms = Partial<Record<PluralCategory, string>> & { other: string };
export type PluralConfig = {
[K in SupportedLocale]?: PluralForms;
} & {
[K in DefaultLocale]: PluralForms;
};
// ==============================
// INSTANCE TYPE
// ==============================
export type LingInstance<S extends LingNode = LingNode> = {
t: {
<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P,
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
): string;
(path: string, params?: Record<string, any>): string;
};
ts: (value: LingString) => string;
tsForLocale: (value: LingString, locale: SupportedLocale) => string;
tForLocale: <P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P,
locale: SupportedLocale,
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
) => string;
setLocale : (locale: SupportedLocale) => void;
getLocale : () => SupportedLocale;
onLocaleChange : (fn: (locale: SupportedLocale) => void) => () => void;
/** Notifica cuando el schema cambia (extend() lazy). Devuelve un unsubscribe. */
onSchemaChange : (fn: () => void) => () => void;
/** Muta la instancia actual añadiendo un módulo lazy. El type-safety viene de FullSchema. */
extend : (namespace: string, module: LingNode) => void;
/** Devuelve una nueva instancia con el tipo actualizado. Útil para tests o contextos aislados.
* Llama a dispose() en la instancia devuelta para evitar memory leaks. */
register : <NS extends string, M extends LingNode>(
namespace: NS,
module: M
) => LingInstance<S & { [K in NS]: M }>;
setLogger : (logger: LingLogger) => void;
/** Elimina el listener de locale del padre. Llámalo cuando destruyas una instancia hija (register). */
dispose : () => void;
/** @internal — usado por register() para inyectar el unsubscribe del padre. */
__dispose : (() => void) | undefined;
};
// ==============================
// LING LOGGER INTERFACE
// ==============================
export interface LingLogger {
warn : (category: string, message: string) => void;
error: (category: string, message: string) => void;
}

@ -0,0 +1,630 @@
/**
* ling — test suite
*
* Ejecutar: npx vitest run
* Cobertura: npx vitest run --coverage
*
* Módulos cubiertos:
* - guards (isIDLing, isLingRecord, isLingString)
* - helpers (resolvePath, makeLingRecord)
* - plural-rules (pluralRule)
* - engine (createLing, p)
* - json (lingNodeToJSON, JSONToLingNode)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isIDLing, isLingRecord, isLingString } from '../lib/guards';
import { resolvePath, makeLingRecord } from '../lib/helpers';
import { pluralRule } from '../lib/plural_rules';
import { createLing, p } from '../lib/engine';
import { lingNodeToJSON, JSONToLingNode } from '../lib/json';
// ============================================================================
// GUARDS
// ============================================================================
describe('isIDLing', () => {
it('devuelve true para una referencia válida', () => {
expect(isIDLing('#?common.ok')).toBe(true);
});
it('devuelve false para el prefijo solo sin path', () => {
expect(isIDLing('#?')).toBe(false);
});
it('devuelve false para strings normales', () => {
expect(isIDLing('hola')).toBe(false);
expect(isIDLing('')).toBe(false);
});
it('devuelve false para no-strings', () => {
expect(isIDLing(null)).toBe(false);
expect(isIDLing(42)).toBe(false);
expect(isIDLing({})).toBe(false);
expect(isIDLing(undefined)).toBe(false);
});
it('devuelve true para referencias con paths anidados', () => {
expect(isIDLing('#?a.b.c')).toBe(true);
});
});
describe('isLingRecord', () => {
it('devuelve true para un registro con es obligatorio', () => {
expect(isLingRecord({ es: 'Aceptar', en: 'OK' })).toBe(true);
});
it('devuelve true con solo el locale por defecto', () => {
expect(isLingRecord({ es: 'Hola' })).toBe(true);
});
it('devuelve false si falta es', () => {
expect(isLingRecord({ en: 'Hello' })).toBe(false);
});
it('devuelve false si es no es string', () => {
expect(isLingRecord({ es: 42 })).toBe(false);
expect(isLingRecord({ es: null })).toBe(false);
});
it('devuelve false para arrays', () => {
expect(isLingRecord(['es', 'Hola'])).toBe(false);
});
it('devuelve false para funciones', () => {
expect(isLingRecord(() => ({ es: 'x' }))).toBe(false);
});
it('devuelve false para primitivos', () => {
expect(isLingRecord(null)).toBe(false);
expect(isLingRecord('hola')).toBe(false);
expect(isLingRecord(42)).toBe(false);
});
});
describe('isLingString', () => {
it('acepta strings planas', () => {
expect(isLingString('hola')).toBe(true);
expect(isLingString('')).toBe(true);
});
it('acepta LingRecord', () => {
expect(isLingString({ es: 'Hola' })).toBe(true);
});
it('acepta IDLing', () => {
expect(isLingString('#?common.ok')).toBe(true);
});
it('rechaza null y undefined', () => {
expect(isLingString(null)).toBe(false);
expect(isLingString(undefined)).toBe(false);
});
it('rechaza números', () => {
expect(isLingString(42)).toBe(false);
});
});
// ============================================================================
// HELPERS
// ============================================================================
describe('resolvePath', () => {
const obj = { a: { b: { c: 'valor' } } };
it('resuelve paths anidados', () => {
expect(resolvePath(obj, 'a.b.c')).toBe('valor');
});
it('resuelve un nivel', () => {
expect(resolvePath(obj, 'a')).toEqual({ b: { c: 'valor' } });
});
it('devuelve undefined para paths inexistentes', () => {
expect(resolvePath(obj, 'a.x.y')).toBeUndefined();
});
it('devuelve undefined para path vacío', () => {
expect(resolvePath(obj, '')).toBeUndefined();
});
it('no lanza con objetos null intermedios', () => {
expect(resolvePath({ a: null }, 'a.b')).toBeUndefined();
});
});
describe('makeLingRecord', () => {
it('crea un registro con el locale por defecto', () => {
const record = makeLingRecord('Hola');
expect(record.es).toBe('Hola');
});
it('usa string vacío si no se pasa texto', () => {
const record = makeLingRecord();
expect(record.es).toBe('');
});
it('usa string vacío si se pasa undefined explícitamente', () => {
const record = makeLingRecord(undefined);
expect(record.es).toBe('');
});
});
// ============================================================================
// PLURAL RULES
// ============================================================================
describe('pluralRule', () => {
describe('español (es) — one/other', () => {
it('1 → one', () => expect(pluralRule('es', 1)).toBe('one'));
it('0 → other', () => expect(pluralRule('es', 0)).toBe('other'));
it('2 → other', () => expect(pluralRule('es', 2)).toBe('other'));
it('21 → other', () => expect(pluralRule('es', 21)).toBe('other'));
});
describe('inglés (en) — one/other con v(n)=0', () => {
it('1 → one', () => expect(pluralRule('en', 1)).toBe('one'));
it('2 → other', () => expect(pluralRule('en', 2)).toBe('other'));
});
describe('árabe (ar) — 6 formas', () => {
it('0 → zero', () => expect(pluralRule('ar', 0)).toBe('zero'));
it('1 → one', () => expect(pluralRule('ar', 1)).toBe('one'));
it('2 → two', () => expect(pluralRule('ar', 2)).toBe('two'));
it('5 → few', () => expect(pluralRule('ar', 5)).toBe('few'));
it('15 → many', () => expect(pluralRule('ar', 15)).toBe('many'));
it('100 → other', () => expect(pluralRule('ar', 100)).toBe('other'));
});
describe('ruso (ru) — one/few/other', () => {
it('1 → one', () => expect(pluralRule('ru', 1)).toBe('one'));
it('21 → one', () => expect(pluralRule('ru', 21)).toBe('one'));
it('2 → few', () => expect(pluralRule('ru', 2)).toBe('few'));
it('5 → other', () => expect(pluralRule('ru', 5)).toBe('other'));
});
describe('locale desconocido — fallback a other', () => {
it('xx → other', () => expect(pluralRule('xx', 1)).toBe('other'));
});
describe('normalización de locale con región', () => {
it('es-ES se normaliza a es', () => expect(pluralRule('es-ES', 1)).toBe('one'));
it('en-US se normaliza a en', () => expect(pluralRule('en-US', 1)).toBe('one'));
});
});
// ============================================================================
// ENGINE — createLing
// ============================================================================
const schema = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' },
},
greet: (params: { name: string }) => ({
es: `Hola, {{name}}`,
en: `Hello, {{name}}`,
}),
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' },
}),
},
ref: '#?common.ok',
nested: {
deep: {
value: { es: 'Profundo', en: 'Deep' },
},
},
} as const;
describe('createLing — t()', () => {
let ling: ReturnType<typeof createLing<typeof schema>>;
beforeEach(() => {
ling = createLing(schema, 'es');
});
it('traduce una clave simple al locale por defecto', () => {
expect(ling.t('common.ok')).toBe('Aceptar');
});
it('traduce al locale activo tras setLocale', () => {
ling.setLocale('en');
expect(ling.t('common.ok')).toBe('OK');
});
it('devuelve el path si la clave no existe', () => {
expect((ling.t as any)('no.existe')).toBe('no.existe');
});
it('traduce con interpolación de params', () => {
expect((ling.t as any)('greet', { name: 'Ana' })).toBe('Hola, Ana');
});
it('traduce con interpolación en locale alternativo', () => {
ling.setLocale('en');
expect((ling.t as any)('greet', { name: 'Ana' })).toBe('Hello, Ana');
});
it('resuelve referencias IDLing (#?)', () => {
expect((ling.t as any)('ref')).toBe('Aceptar');
});
it('traduce paths anidados profundos', () => {
expect(ling.t('nested.deep.value')).toBe('Profundo');
});
it('traduce plurales — one', () => {
expect((ling.t as any)('messages.unread', { count: 1 })).toBe('1 mensaje sin leer');
});
it('traduce plurales — other', () => {
expect((ling.t as any)('messages.unread', { count: 5 })).toBe('5 mensajes sin leer');
});
it('traduce plurales en locale alternativo', () => {
ling.setLocale('en');
expect((ling.t as any)('messages.unread', { count: 1 })).toBe('1 unread message');
expect((ling.t as any)('messages.unread', { count: 3 })).toBe('3 unread messages');
});
it('hace fallback al locale por defecto si falta la traducción', () => {
const s = { only: { es: 'Solo español' } } as const;
const l = createLing(s, 'es');
l.setLocale('de');
expect(l.t('only')).toBe('Solo español');
});
it('keys con caracteres especiales de regex en params no rompen la interpolación', () => {
const s = { msg: (_: any) => ({ es: 'Código: {{code}}' }) } as const;
const l = createLing(s, 'es');
expect((l.t as any)('msg', { code: 'A+B' })).toBe('Código: A+B');
});
});
describe('createLing — ts()', () => {
let ling: ReturnType<typeof createLing<typeof schema>>;
beforeEach(() => {
ling = createLing(schema, 'es');
});
it('traduce un LingRecord directamente', () => {
expect(ling.ts({ es: 'Directo', en: 'Direct' })).toBe('Directo');
});
it('devuelve strings planas tal cual', () => {
expect(ling.ts('texto plano')).toBe('texto plano');
});
it('devuelve string vacío para null', () => {
expect(ling.ts(null as any)).toBe('');
});
it('NO devuelve string vacío para string vacío — ts(\'\') es válido', () => {
expect(ling.ts('')).toBe('');
});
it('resuelve referencias IDLing', () => {
expect(ling.ts('#?common.ok')).toBe('Aceptar');
});
});
describe('createLing — tForLocale()', () => {
let ling: ReturnType<typeof createLing<typeof schema>>;
beforeEach(() => {
ling = createLing(schema, 'es');
});
it('traduce en un locale específico sin cambiar el locale activo', () => {
ling.setLocale('es');
const result = (ling.tForLocale as any)('common.ok', 'en');
expect(result).toBe('OK');
expect(ling.getLocale()).toBe('es');
});
it('restaura el locale aunque el schema tenga una referencia circular', () => {
// Creamos un schema con referencia circular para forzar el throw
const circular = { a: '#?a' } as any;
const l = createLing(circular, 'es');
expect(() => (l.tForLocale as any)('a', 'en')).toThrow();
// El locale debe haberse restaurado a 'es'
expect(l.getLocale()).toBe('es');
});
});
describe('createLing — setLocale / getLocale / onLocaleChange', () => {
it('getLocale devuelve el locale inicial', () => {
const ling = createLing(schema, 'es');
expect(ling.getLocale()).toBe('es');
});
it('setLocale notifica a los listeners', () => {
const ling = createLing(schema, 'es');
const spy = vi.fn();
ling.onLocaleChange(spy);
ling.setLocale('en');
expect(spy).toHaveBeenCalledWith('en');
expect(spy).toHaveBeenCalledTimes(1);
});
it('el unsubscribe de onLocaleChange deja de notificar', () => {
const ling = createLing(schema, 'es');
const spy = vi.fn();
const unsub = ling.onLocaleChange(spy);
unsub();
ling.setLocale('en');
expect(spy).not.toHaveBeenCalled();
});
});
describe('createLing — extend()', () => {
it('añade un namespace simple', () => {
const ling = createLing(schema, 'es');
ling.extend('shop', { product: { es: 'Producto', en: 'Product' } });
expect((ling.t as any)('shop.product')).toBe('Producto');
});
it('añade con namespace con puntos', () => {
const ling = createLing(schema, 'es');
ling.extend('app.settings', { title: { es: 'Ajustes', en: 'Settings' } });
expect((ling.t as any)('app.settings.title')).toBe('Ajustes');
});
it('hace deep-merge sin borrar claves existentes del namespace', () => {
const ling = createLing(schema, 'es');
ling.extend('common', { extra: { es: 'Extra', en: 'Extra' } });
// La clave original debe seguir funcionando
expect(ling.t('common.ok')).toBe('Aceptar');
// Y la nueva también
expect((ling.t as any)('common.extra')).toBe('Extra');
});
it('notifica a los listeners de onSchemaChange', () => {
const ling = createLing(schema, 'es');
const spy = vi.fn();
ling.onSchemaChange(spy);
ling.extend('shop', { product: { es: 'Producto' } });
expect(spy).toHaveBeenCalledTimes(1);
});
it('el unsubscribe de onSchemaChange deja de notificar', () => {
const ling = createLing(schema, 'es');
const spy = vi.fn();
const unsub = ling.onSchemaChange(spy);
unsub();
ling.extend('shop', { product: { es: 'Producto' } });
expect(spy).not.toHaveBeenCalled();
});
});
describe('createLing — register()', () => {
it('devuelve una nueva instancia con el namespace registrado', () => {
const ling = createLing(schema, 'es');
const extended = ling.register('shop', { product: { es: 'Producto', en: 'Product' } });
expect((extended.t as any)('shop.product')).toBe('Producto');
});
it('la instancia hija sincroniza el locale con el padre', () => {
const ling = createLing(schema, 'es');
const child = ling.register('shop', { product: { es: 'Producto', en: 'Product' } });
ling.setLocale('en');
expect((child.t as any)('shop.product')).toBe('Product');
});
it('dispose() elimina el listener del padre — no más sincronización', () => {
const ling = createLing(schema, 'es');
const child = ling.register('shop', { product: { es: 'Producto', en: 'Product' } });
child.dispose();
ling.setLocale('en');
// El hijo ya no escucha al padre — sigue en 'es'
expect(child.getLocale()).toBe('es');
});
it('la instancia original no se ve afectada por register()', () => {
const ling = createLing(schema, 'es');
ling.register('shop', { product: { es: 'Producto' } });
expect((ling.t as any)('shop.product')).toBe('shop.product'); // no existe en el padre
});
});
describe('createLing — setLogger()', () => {
it('reemplaza el logger por defecto', () => {
const ling = createLing(schema, 'es');
const warn = vi.fn();
const error = vi.fn();
ling.setLogger({ warn, error });
// El logger se ha inyectado — verificamos que no lanza
expect(() => ling.setLogger({ warn, error })).not.toThrow();
});
it('setLogger() solo puede llamarse una vez — la segunda no reemplaza', () => {
const ling = createLing(schema, 'es');
const firstWarn = vi.fn();
const secondWarn = vi.fn();
ling.setLogger({ warn: firstWarn, error: vi.fn() });
ling.setLogger({ warn: secondWarn, error: vi.fn() });
// El segundo logger no debe haber sido instalado
// — verificamos que firstWarn sigue siendo el logger activo
// llamando a getLocale() para confirmar que no hay excepciones
expect(ling.getLocale()).toBe('es');
});
});
// ============================================================================
// ENGINE — p() (pluralización)
// ============================================================================
describe('p()', () => {
const plural = p({
es: { one: '{{count}} elemento', other: '{{count}} elementos' },
en: { one: '{{count}} item', other: '{{count}} items' },
});
it('selecciona la forma singular para 1', () => {
const record = plural({ count: 1 });
expect(record.es).toBe('{{count}} elemento');
});
it('selecciona la forma plural para 2', () => {
const record = plural({ count: 2 });
expect(record.es).toBe('{{count}} elementos');
});
it('devuelve el mismo objeto desde la caché para el mismo count', () => {
const r1 = plural({ count: 5 });
const r2 = plural({ count: 5 });
expect(r1).toBe(r2); // referencia idéntica
});
it('cachea correctamente con floats (String como clave)', () => {
const r1 = plural({ count: 1.5 });
const r2 = plural({ count: 1.5 });
expect(r1).toBe(r2);
});
it('tiene __pluralConfig para serialización JSON', () => {
expect((plural as any).__pluralConfig).toBeDefined();
});
it('usa other como fallback si la forma no existe', () => {
const fallback = p({ es: { other: 'varios' } });
const record = fallback({ count: 1 }); // 'one' no está definido
expect(record.es).toBe('varios');
});
});
// ============================================================================
// JSON — lingNodeToJSON / JSONToLingNode
// ============================================================================
describe('lingNodeToJSON', () => {
it('serializa strings planas', () => {
expect(lingNodeToJSON('hola')).toBe('hola');
});
it('serializa referencias IDLing como strings', () => {
expect(lingNodeToJSON('#?common.ok')).toBe('#?common.ok');
});
it('serializa LingRecord como objeto plano', () => {
const result = lingNodeToJSON({ es: 'Hola', en: 'Hello' });
expect(result).toEqual({ es: 'Hola', en: 'Hello' });
});
it('serializa namespaces recursivamente', () => {
const node = { common: { ok: { es: 'Aceptar', en: 'OK' } } };
const result = lingNodeToJSON(node);
expect(result.common.ok).toEqual({ es: 'Aceptar', en: 'OK' });
});
it('serializa funciones de interpolación con {{tokens}}', () => {
const fn = (p: any) => ({ es: `Hola ${p.name}`, en: `Hello ${p.name}` });
const result = lingNodeToJSON(fn);
expect(result.es).toBe('Hola {{name}}');
expect(result.en).toBe('Hello {{name}}');
});
it('serializa plurales con __type: plural', () => {
const plural = p({
es: { one: '1 elemento', other: '{{count}} elementos' },
});
const result = lingNodeToJSON(plural);
expect(result.__type).toBe('plural');
expect(result.config).toBeDefined();
});
it('devuelve {} si la función de interpolación lanza', () => {
const broken = () => { throw new Error('broken'); };
expect(lingNodeToJSON(broken)).toEqual({});
});
});
describe('JSONToLingNode', () => {
it('reconstruye strings planas', () => {
expect(JSONToLingNode('hola')).toBe('hola');
});
it('reconstruye LingRecord como objeto', () => {
const result = JSONToLingNode({ es: 'Hola', en: 'Hello' });
expect(result).toEqual({ es: 'Hola', en: 'Hello' });
});
it('reconstruye plurales como funciones', () => {
const config = {
es: { one: '{{count}} elemento', other: '{{count}} elementos' },
};
const result = JSONToLingNode({ __type: 'plural', config }) as any;
expect(typeof result).toBe('function');
expect(result({ count: 1 }).es).toBe('{{count}} elemento');
});
it('reconstruye namespaces recursivamente', () => {
const json = { common: { ok: { es: 'Aceptar' } } };
const result = JSONToLingNode(json) as any;
expect(result.common.ok.es).toBe('Aceptar');
});
});
describe('round-trip lingNodeToJSON → JSONToLingNode', () => {
it('LingRecord sobrevive el round-trip', () => {
const original = { es: 'Aceptar', en: 'OK' };
const json = lingNodeToJSON(original);
const restored = JSONToLingNode(json) as any;
expect(restored.es).toBe('Aceptar');
expect(restored.en).toBe('OK');
});
it('plural sobrevive el round-trip y sigue funcionando', () => {
const original = p({ es: { one: '1 item', other: '{{count}} items' } });
const json = lingNodeToJSON(original);
const restored = JSONToLingNode(json) as any;
expect(typeof restored).toBe('function');
expect(restored({ count: 1 }).es).toBe('1 item');
expect(restored({ count: 5 }).es).toBe('{{count}} items');
});
it('referencia IDLing sobrevive el round-trip como string', () => {
const json = lingNodeToJSON('#?common.ok');
const restored = JSONToLingNode(json);
expect(restored).toBe('#?common.ok');
// Y sigue siendo reconocida por isIDLing
expect(isIDLing(restored)).toBe(true);
});
it('árbol completo sobrevive el round-trip', () => {
const node = {
common: { ok: { es: 'Aceptar', en: 'OK' } },
ref: '#?common.ok',
items: p({ es: { one: '1 item', other: '{{count}} items' } }),
};
const json = lingNodeToJSON(node);
const restored = JSONToLingNode(json) as any;
expect(restored.common.ok.es).toBe('Aceptar');
expect(restored.ref).toBe('#?common.ok');
expect(typeof restored.items).toBe('function');
});
});

@ -0,0 +1,164 @@
import type {
LingInstance,
LingString
} from '@/ling/lib';
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 de forma aislada:
* si un transport lanza, los demás siguen ejecutándose.
*
* @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;
// Recorta inmediatamente si el historial actual supera el nuevo límite
if (entries.length > maxLogs) entries = entries.slice(-maxLogs);
}
// -------------------------------------------------------------------------
// 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 de forma aislada.
*/
function log(
lvl : LogLevel,
category: MessageCategory,
message : LingString,
context?: Record<string, unknown>
): void {
if (lvl < level) return;
const entry: LogEntry = {
timestamp: new Date(),
level : lvl,
category,
message,
// Captura el locale en el momento del registro para que serialize()
// pueda reproducir el mensaje tal como lo vio el usuario.
locale : ling.getLocale(),
context,
};
// Historial en memoria — slice(-maxLogs) en lugar de shift() para evitar
// O(n) por desplazamiento en cada inserción cuando se supera el límite.
entries.push(entry);
if (entries.length > maxLogs) entries = entries.slice(-maxLogs);
// Resuelve el mensaje al locale activo y emite a cada transport de forma
// aislada: un transport que lanza no impide que los demás reciban la entrada.
const resolvedMessage = ling.ts(entry.message);
for (const transport of transports) {
try {
transport.write(entry, resolvedMessage);
} catch (err) {
console.error('[logr] Transport error:', err);
}
}
}
// -------------------------------------------------------------------------
// API pública
// -------------------------------------------------------------------------
function debug(category: MessageCategory, message: LingString, context?: Record<string, unknown>): void {
log(LogLevel.DEBUG, category, message, context);
}
function info(category: MessageCategory, message: LingString, context?: Record<string, unknown>): void {
log(LogLevel.INFO, category, message, context);
}
function warn(category: MessageCategory, message: LingString, context?: Record<string, unknown>): void {
log(LogLevel.WARN, category, message, context);
}
function error(category: MessageCategory, message: LingString, context?: Record<string, unknown>): void {
log(LogLevel.ERROR, category, message, context);
}
function getLogs(filters?: LogFilters): LogEntry[] {
// Copia defensiva — el llamador no puede mutar el historial interno
let result = entries.map(e => ({ ...e }));
if (filters?.level !== undefined) result = result.filter(e => e.level === filters.level);
if (filters?.minLevel !== undefined) result = result.filter(e => e.level >= filters.minLevel!);
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 = [];
}
/**
* Exporta el historial como JSON con mensajes resueltos al locale original
* de cada entrada — el locale activo en el momento en que se registró.
* Esto garantiza que el JSON refleja lo que el usuario vio en pantalla,
* independientemente de cambios de locale posteriores.
*/
function serialize(): string {
return JSON.stringify(
entries.map(e => ({
...e,
message : ling.tsForLocale(e.message, e.locale as any),
timestamp: e.timestamp.toISOString(),
})),
null,
2
);
}
return { debug, info, warn, error, getLogs, clear, serialize, setLevel, setMaxLogs };
}

@ -0,0 +1,4 @@
export * from './engine.ts';
export * from './transports.ts';
export * from './types.ts';

@ -0,0 +1,130 @@
import type {
Transport,
ConsoleTransportOptions,
HttpTransportOptions,
LogEntry
} from './types.ts';
import { 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 args: unknown[] = [];
if (showTimestamp) args.push(entry.timestamp.toISOString());
if (showPrefix) args.push(`[${entry.category}]`);
args.push(resolvedMessage);
// Solo añade el contexto si existe — evita un '' vacío al final de cada línea
if (entry.context) args.push(entry.context);
switch (entry.level) {
case LogLevel.DEBUG: console.debug(...args as [unknown, ...unknown[]]); break;
case LogLevel.INFO: console.info (...args as [unknown, ...unknown[]]); break;
case LogLevel.WARN: console.warn (...args as [unknown, ...unknown[]]); break;
case LogLevel.ERROR: console.error(...args as [unknown, ...unknown[]]); break;
}
}
};
}
// ============================================================================
// 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.
*
* Nota: no incluye retry ni batching. Para alto volumen considera un transport
* personalizado con cola y reintentos.
*
* @example
* httpTransport({
* url : 'https://logs.myapp.com/ingest',
* headers: { 'Authorization': 'Bearer my-token' },
* level : LogLevel.ERROR
* })
*/
export function httpTransport(options: HttpTransportOptions): Transport {
// Usa la constante del enum en lugar del magic number 3
const minLevel = options.level ?? LogLevel.ERROR;
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((_, message) => captured.push(message))
*/
export function callbackTransport(
fn: (entry: LogEntry, resolvedMessage: string) => void
): Transport {
return { write: fn };
}

@ -0,0 +1,224 @@
import type { LingString } from '@/ling/lib';
// ============================================================================
// 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.
* Valor alto deliberado para que cualquier nivel futuro lo preceda. */
NONE = 999
}
// ============================================================================
// 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 `LingString` sin resolver —
* la resolución al locale original ocurre en serialize().
*/
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 LingRecord.
* Se resuelve al locale capturado en `locale` para preservar
* el contexto exacto en que se registró la entrada.
*/
message : LingString;
/**
* Locale activo en el momento en que se registró el log.
* Capturado por el engine para que serialize() pueda reproducir
* el mensaje tal como lo vio el usuario, independientemente de
* cambios de locale posteriores.
*/
locale : string;
/** Datos adicionales de contexto para facilitar el diagnóstico */
context? : Record<string, unknown>;
}
/**
* Filtros para consultar el historial de logs con `getLogs()`.
* Todos los campos son opcionales y se combinan con AND.
*
* @example
* logr.getLogs({ category: 'auth', minLevel: LogLevel.WARN })
* logr.getLogs({ since: new Date('2024-01-01') })
*/
export interface LogFilters {
/** Filtra por nivel exacto de severidad */
level? : LogLevel;
/** Filtra por nivel mínimo — incluye este nivel y todos los superiores */
minLevel? : 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
* const myTransport: Transport = {
* write: (entry, message) => {
* myExternalService.send({ level: entry.level, message });
* }
* };
*/
export interface Transport {
/**
* Recibe una entrada de log para procesarla.
* @param entry - Entrada original con mensaje sin resolver
* @param resolvedMessage - Mensaje ya traducido al locale activo
*/
write: (entry: LogEntry, resolvedMessage: string) => void;
}
/**
* Opciones para el transport de consola.
*/
export interface ConsoleTransportOptions {
/**
* Incluye el timestamp en el output.
* @default true
*/
timestamp?: boolean;
/**
* Incluye el prefijo de categoría `[category]` en el output.
* @default true
*/
prefix?: boolean;
}
/**
* Opciones para el transport HTTP.
*/
export interface HttpTransportOptions {
/** URL del endpoint que recibe los logs */
url : string;
/** Headers adicionales — útil para autenticación */
headers?: Record<string, string>;
/**
* Nivel mínimo para enviar al servidor.
* Permite enviar solo errores al servidor aunque el logger esté en DEBUG.
* @default LogLevel.ERROR
*/
level? : LogLevel;
}
// ============================================================================
// OPTIONS
// ============================================================================
/**
* Opciones de configuración al crear una instancia de `Logr`.
*
* @example
* createLogr(i18n, {
* level : LogLevel.DEBUG,
* maxLogs : 5000,
* transports: [ consoleTransport(), httpTransport({ url: '...' }) ]
* })
*/
export interface LogrOptions {
/**
* Nivel mínimo de severidad a procesar.
* @default LogLevel.WARN
*/
level? : LogLevel;
/**
* Número máximo de entradas a retener en el historial en memoria.
* Cuando se supera, se descartan las entradas más antiguas.
* @default 1000
*/
maxLogs? : number;
/**
* Lista de transports a los que se emitirá cada entrada.
* Si no se especifica, usa `consoleTransport()` por defecto.
* @default [consoleTransport()]
*/
transports? : Transport[];
}
// ============================================================================
// INSTANCE TYPE
// ============================================================================
/**
* Interfaz pública de una instancia de Logr.
* Creada mediante `createLogr(i18n, options)`.
*
* @example
* const logr = createLogr(i18n, {
* level : LogLevel.DEBUG,
* transports: [ consoleTransport(), httpTransport({ url: 'https://logs.myapp.com' }) ]
* });
*
* logr.debug('auth', { es: 'Iniciando sesión', en: 'Logging in' });
* logr.warn('db', { es: 'Conexión lenta', en: 'Slow connection' }, { ms: 2000 });
* logr.error('auth', 'Unexpected error');
*/
export type Logr = {
/** Registra un mensaje de nivel DEBUG */
debug : (category: MessageCategory, message: LingString, context?: Record<string, unknown>) => void;
/** Registra un mensaje de nivel INFO */
info : (category: MessageCategory, message: LingString, context?: Record<string, unknown>) => void;
/** Registra un mensaje de nivel WARN */
warn : (category: MessageCategory, message: LingString, context?: Record<string, unknown>) => void;
/** Registra un mensaje de nivel ERROR */
error : (category: MessageCategory, message: LingString, context?: Record<string, unknown>) => void;
/** Devuelve una copia del historial, opcionalmente filtrada */
getLogs : (filters?: LogFilters) => LogEntry[];
/** Vacía el historial en memoria */
clear : () => void;
/** Exporta el historial como JSON con mensajes resueltos al locale original de cada entrada */
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 y recorta si es necesario */
setMaxLogs: (max: number) => void;
};

@ -0,0 +1,821 @@
/**
* logr — test suite
*
* Ejecutar: npx vitest run
* Cobertura: npx vitest run --coverage
*
* Módulos cubiertos:
* - types (LogLevel, LogEntry, LogFilters, etc.)
* - engine (createLogr, log methods, serialize)
* - transports (console, http, callback)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createLing } from '../../ling/lib/engine';
import { createLogr } from '../lib/engine';
import { consoleTransport, httpTransport, callbackTransport } from '../lib/transports';
import { LogLevel } from '../lib/types';
// ============================================================================
// FIXTURES
// ============================================================================
const ling = createLing({
common: {
ok: { es: 'Aceptar', en: 'OK' },
error: { es: 'Error', en: 'Error' },
},
}, 'es');
// ============================================================================
// LOG LEVEL
// ============================================================================
describe('LogLevel', () => {
it('los valores numéricos son correctos', () => {
expect(LogLevel.DEBUG).toBe(0);
expect(LogLevel.INFO).toBe(1);
expect(LogLevel.WARN).toBe(2);
expect(LogLevel.ERROR).toBe(3);
expect(LogLevel.NONE).toBe(999);
});
it('permite comparar niveles correctamente', () => {
expect(LogLevel.DEBUG < LogLevel.INFO).toBe(true);
expect(LogLevel.INFO < LogLevel.WARN).toBe(true);
expect(LogLevel.WARN < LogLevel.ERROR).toBe(true);
expect(LogLevel.ERROR < LogLevel.NONE).toBe(true);
});
});
// ============================================================================
// ENGINE — createLing
// ============================================================================
describe('createLogr — nivel por defecto', () => {
let captured: string[] = [];
beforeEach(() => {
captured = [];
});
it('crea una instancia con nivel WARN por defecto', () => {
const logr = createLogr(ling, {
transports: [callbackTransport((_, msg) => captured.push(msg))],
});
logr.debug('test', 'debug msg');
logr.info('test', 'info msg');
logr.warn('test', 'warn msg');
expect(captured).toHaveLength(1);
expect(captured[0]).toBe('warn msg');
});
});
// ============================================================================
// ENGINE — métodos de logging
// ============================================================================
describe('createLogr — debug(), info(), warn(), error()', () => {
let captured: Array<{ level: number; category: string; message: string; context?: Record<string, unknown> }>;
beforeEach(() => {
captured = [];
ling.setLocale('es'); // Reset locale antes de cada test
});
it('registra mensajes de DEBUG', () => {
const logr = createLogr(ling, {
level: LogLevel.DEBUG,
transports: [callbackTransport((entry, msg) => {
captured.push({ level: entry.level, category: entry.category, message: msg });
})],
});
logr.debug('auth', 'Debug message');
expect(captured).toHaveLength(1);
expect(captured[0].level).toBe(LogLevel.DEBUG);
expect(captured[0].category).toBe('auth');
expect(captured[0].message).toBe('Debug message');
});
it('registra mensajes de INFO', () => {
const logr = createLogr(ling, {
level: LogLevel.INFO,
transports: [callbackTransport((entry, msg) => {
captured.push({ level: entry.level, category: entry.category, message: msg });
})],
});
logr.info('db', 'Info message');
expect(captured).toHaveLength(1);
expect(captured[0].level).toBe(LogLevel.INFO);
expect(captured[0].category).toBe('db');
});
it('registra mensajes de WARN', () => {
const logr = createLogr(ling, {
level: LogLevel.WARN,
transports: [callbackTransport((entry, msg) => {
captured.push({ level: entry.level, category: entry.category, message: msg });
})],
});
logr.warn('api', 'Warn message');
expect(captured).toHaveLength(1);
expect(captured[0].level).toBe(LogLevel.WARN);
expect(captured[0].category).toBe('api');
});
it('registra mensajes de ERROR', () => {
const logr = createLogr(ling, {
level: LogLevel.ERROR,
transports: [callbackTransport((entry, msg) => {
captured.push({ level: entry.level, category: entry.category, message: msg });
})],
});
logr.error('payment', 'Error message');
expect(captured).toHaveLength(1);
expect(captured[0].level).toBe(LogLevel.ERROR);
expect(captured[0].category).toBe('payment');
});
it('incluye contexto cuando se proporciona', () => {
const logr = createLogr(ling, {
level: LogLevel.INFO,
transports: [callbackTransport((entry, msg) => {
captured.push({ level: entry.level, category: entry.category, message: msg, context: entry.context });
})],
});
const contextData = { userId: 123, action: 'login' };
logr.info('auth', 'User logged in', contextData);
expect(captured).toHaveLength(1);
expect(captured[0].context).toEqual(contextData);
});
it('resuelve LingRecord al locale activo', () => {
const msgCaptured: string[] = [];
const logr = createLogr(ling, {
level: LogLevel.INFO,
transports: [callbackTransport((_, msg) => msgCaptured.push(msg))],
});
logr.info('test', { es: 'Hola', en: 'Hello' });
expect(msgCaptured[0]).toBe('Hola');
});
it('cambia el locale y resuelve correctamente', () => {
const msgCaptured: string[] = [];
const logr = createLogr(ling, {
level: LogLevel.INFO,
transports: [callbackTransport((_, msg) => msgCaptured.push(msg))],
});
logr.info('test', { es: 'Aceptar', en: 'OK' });
expect(msgCaptured[0]).toBe('Aceptar');
ling.setLocale('en');
logr.info('test', { es: 'Aceptar', en: 'OK' });
expect(msgCaptured[1]).toBe('OK');
});
it('resuelve referencias IDLing', () => {
const msgCaptured: string[] = [];
const logr = createLogr(ling, {
level: LogLevel.INFO,
transports: [callbackTransport((_, msg) => msgCaptured.push(msg))],
});
logr.info('test', '#?common.ok');
expect(msgCaptured[0]).toBe('Aceptar');
});
});
// ============================================================================
// ENGINE — filtrado por nivel
// ============================================================================
describe('createLogr — filtrado por nivel', () => {
let captured: string[];
beforeEach(() => {
captured = [];
});
it('DEBUG permite todos los niveles', () => {
const logr = createLogr(ling, {
level: LogLevel.DEBUG,
transports: [callbackTransport((_, msg) => captured.push(msg))],
});
logr.debug('test', 'd');
logr.info('test', 'i');
logr.warn('test', 'w');
logr.error('test', 'e');
expect(captured).toHaveLength(4);
});
it('INFO filtra DEBUG', () => {
const logr = createLogr(ling, {
level: LogLevel.INFO,
transports: [callbackTransport((_, msg) => captured.push(msg))],
});
logr.debug('test', 'd');
logr.info('test', 'i');
logr.warn('test', 'w');
logr.error('test', 'e');
expect(captured).toHaveLength(3);
expect(captured).toEqual(['i', 'w', 'e']);
});
it('WARN filtra DEBUG e INFO', () => {
const logr = createLogr(ling, {
level: LogLevel.WARN,
transports: [callbackTransport((_, msg) => captured.push(msg))],
});
logr.debug('test', 'd');
logr.info('test', 'i');
logr.warn('test', 'w');
logr.error('test', 'e');
expect(captured).toHaveLength(2);
expect(captured).toEqual(['w', 'e']);
});
it('ERROR filtra DEBUG, INFO y WARN', () => {
const logr = createLogr(ling, {
level: LogLevel.ERROR,
transports: [callbackTransport((_, msg) => captured.push(msg))],
});
logr.debug('test', 'd');
logr.info('test', 'i');
logr.warn('test', 'w');
logr.error('test', 'e');
expect(captured).toHaveLength(1);
expect(captured).toEqual(['e']);
});
it('NONE filtra todo', () => {
const logr = createLogr(ling, {
level: LogLevel.NONE,
transports: [callbackTransport((_, msg) => captured.push(msg))],
});
logr.debug('test', 'd');
logr.info('test', 'i');
logr.warn('test', 'w');
logr.error('test', 'e');
expect(captured).toHaveLength(0);
});
});
// ============================================================================
// ENGINE — historial
// ============================================================================
describe('createLogr — getLogs()', () => {
it('devuelve todas las entradas sin filtros', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.debug('cat1', 'msg1');
logr.info('cat2', 'msg2');
logr.warn('cat1', 'msg3');
const logs = logr.getLogs();
expect(logs).toHaveLength(3);
});
it('filtra por nivel exacto', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.debug('cat1', 'msg1');
logr.info('cat2', 'msg2');
logr.debug('cat1', 'msg3');
const logs = logr.getLogs({ level: LogLevel.DEBUG });
expect(logs).toHaveLength(2);
});
it('filtra por nivel mínimo', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.debug('cat1', 'msg1');
logr.info('cat2', 'msg2');
logr.warn('cat1', 'msg3');
logr.error('cat2', 'msg4');
const logs = logr.getLogs({ minLevel: LogLevel.WARN });
expect(logs).toHaveLength(2);
expect(logs.every(l => l.level >= LogLevel.WARN)).toBe(true);
});
it('filtra por categoría', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.debug('auth', 'msg1');
logr.info('db', 'msg2');
logr.warn('auth', 'msg3');
const logs = logr.getLogs({ category: 'auth' });
expect(logs).toHaveLength(2);
expect(logs.every(l => l.category === 'auth')).toBe(true);
});
it('filtra por fecha (since)', async () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.debug('cat1', 'msg1');
// Pequeño delay para asegurar diferencia en timestamps
await new Promise(resolve => setTimeout(resolve, 10));
const now = new Date();
logr.info('cat2', 'msg2');
const logs = logr.getLogs({ since: now });
expect(logs).toHaveLength(1);
expect(logs[0].message).toBe('msg2');
});
it('combina múltiples filtros con AND', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.debug('auth', 'msg1');
logr.info('db', 'msg2');
logr.warn('auth', 'msg3');
logr.error('auth', 'msg4');
const logs = logr.getLogs({ category: 'auth', minLevel: LogLevel.WARN });
expect(logs).toHaveLength(2);
expect(logs.every(l => l.category === 'auth' && l.level >= LogLevel.WARN)).toBe(true);
});
it('devuelve una copia defensiva — mutar no afecta el historial interno', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.info('test', 'original');
const logs = logr.getLogs();
logs[0].message = 'mutated';
const logs2 = logr.getLogs();
expect(logs2[0].message).toBe('original');
});
});
describe('createLogr — clear()', () => {
it('vacía el historial', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.debug('test', 'msg1');
logr.info('test', 'msg2');
expect(logr.getLogs()).toHaveLength(2);
logr.clear();
expect(logr.getLogs()).toHaveLength(0);
});
});
// ============================================================================
// ENGINE — serialize
// ============================================================================
describe('createLogr — serialize()', () => {
it('exporta el historial como JSON', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.info('auth', { es: 'Inicio de sesión', en: 'Login' });
logr.error('db', 'Connection failed');
const json = logr.serialize();
const parsed = JSON.parse(json);
expect(parsed).toHaveLength(2);
expect(parsed[0].category).toBe('auth');
expect(parsed[0].message).toBe('Inicio de sesión');
expect(parsed[1].category).toBe('db');
expect(parsed[1].message).toBe('Connection failed');
});
it('serializa el timestamp como ISO string', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.info('test', 'msg');
const json = logr.serialize();
const parsed = JSON.parse(json);
expect(parsed[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('preserva el locale original del mensaje', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.info('test', { es: 'Mensaje en español', en: 'English message' });
ling.setLocale('en');
const json = logr.serialize();
const parsed = JSON.parse(json);
// El mensaje serializado debe ser el del locale original (es)
expect(parsed[0].message).toBe('Mensaje en español');
});
it('serializa el contexto si existe', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG });
logr.info('test', 'msg', { userId: 123, extra: 'data' });
const json = logr.serialize();
const parsed = JSON.parse(json);
expect(parsed[0].context).toEqual({ userId: 123, extra: 'data' });
});
});
// ============================================================================
// ENGINE — configuración en runtime
// ============================================================================
describe('createLogr — setLevel()', () => {
let captured: string[];
beforeEach(() => {
captured = [];
});
it('cambia el nivel mínimo en runtime', () => {
const logr = createLogr(ling, {
level: LogLevel.ERROR,
transports: [callbackTransport((_, msg) => captured.push(msg))],
});
logr.debug('test', 'd');
logr.info('test', 'i');
expect(captured).toHaveLength(0);
logr.setLevel(LogLevel.DEBUG);
logr.debug('test', 'd');
logr.info('test', 'i');
expect(captured).toHaveLength(2);
});
});
describe('createLogr — setMaxLogs()', () => {
it('limita el tamaño del historial', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG, maxLogs: 3 });
logr.debug('test', '1');
logr.debug('test', '2');
logr.debug('test', '3');
logr.debug('test', '4');
const logs = logr.getLogs();
expect(logs).toHaveLength(3);
expect(logs[0].message).toBe('2'); // el más antiguo fue descartado
expect(logs[2].message).toBe('4');
});
it('recorta el historial si se reduce el maxLogs', () => {
const logr = createLogr(ling, { level: LogLevel.DEBUG, maxLogs: 100 });
logr.debug('test', '1');
logr.debug('test', '2');
logr.debug('test', '3');
logr.debug('test', '4');
expect(logr.getLogs()).toHaveLength(4);
logr.setMaxLogs(2);
const logs = logr.getLogs();
expect(logs).toHaveLength(2);
expect(logs[0].message).toBe('3');
expect(logs[1].message).toBe('4');
});
});
// ============================================================================
// TRANSPORTS
// ============================================================================
describe('consoleTransport', () => {
it('llama al método de consola correcto según el nivel', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const transport = consoleTransport();
const logr = createLogr(ling, {
level: LogLevel.DEBUG,
transports: [transport],
});
logr.debug('test', 'debug msg');
logr.info('test', 'info msg');
logr.warn('test', 'warn msg');
logr.error('test', 'error msg');
expect(debugSpy).toHaveBeenCalled();
expect(infoSpy).toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
debugSpy.mockRestore();
infoSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
});
it('puede desactivar timestamp', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const transport = consoleTransport({ timestamp: false });
transport.write({
timestamp: new Date(),
level: LogLevel.WARN,
category: 'test',
message: 'msg',
locale: 'es',
}, 'resolved');
const firstArg = warnSpy.mock.calls[0][0];
expect(firstArg).not.toMatch(/^\d{4}-\d{2}-\d{2}T/);
warnSpy.mockRestore();
});
it('puede desactivar el prefijo de categoría', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const transport = consoleTransport({ prefix: false });
transport.write({
timestamp: new Date(),
level: LogLevel.WARN,
category: 'test',
message: 'msg',
locale: 'es',
}, 'resolved');
const hasCategoryPrefix = warnSpy.mock.calls[0].some((arg: unknown) =>
typeof arg === 'string' && arg === '[test]'
);
expect(hasCategoryPrefix).toBe(false);
warnSpy.mockRestore();
});
it('incluye el contexto si existe', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const transport = consoleTransport();
transport.write({
timestamp: new Date(),
level: LogLevel.WARN,
category: 'test',
message: 'msg',
locale: 'es',
context: { key: 'value' },
}, 'resolved');
const callArgs = warnSpy.mock.calls[0];
expect(callArgs).toContainEqual({ key: 'value' });
warnSpy.mockRestore();
});
});
describe('httpTransport', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('envía POST al endpoint especificado', async () => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response());
const transport = httpTransport({ url: 'https://logs.example.com/api' });
transport.write({
timestamp: new Date(),
level: LogLevel.ERROR,
category: 'test',
message: 'error msg',
locale: 'es',
}, 'resolved');
expect(fetchSpy).toHaveBeenCalledWith(
'https://logs.example.com/api',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
);
});
it('incluye headers personalizados', async () => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response());
const transport = httpTransport({
url: 'https://logs.example.com/api',
headers: { 'Authorization': 'Bearer token123' },
});
transport.write({
timestamp: new Date(),
level: LogLevel.ERROR,
category: 'test',
message: 'msg',
locale: 'es',
}, 'resolved');
expect(fetchSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer token123',
}),
})
);
});
it('filtra por nivel mínimo (por defecto ERROR)', async () => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response());
const transport = httpTransport({ url: 'https://logs.example.com/api' });
transport.write({
timestamp: new Date(),
level: LogLevel.WARN,
category: 'test',
message: 'warn',
locale: 'es',
}, 'resolved');
transport.write({
timestamp: new Date(),
level: LogLevel.ERROR,
category: 'test',
message: 'error',
locale: 'es',
}, 'resolved');
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('puede configurar un nivel mínimo diferente', async () => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response());
const transport = httpTransport({
url: 'https://logs.example.com/api',
level: LogLevel.WARN,
});
transport.write({
timestamp: new Date(),
level: LogLevel.WARN,
category: 'test',
message: 'warn',
locale: 'es',
}, 'resolved');
transport.write({
timestamp: new Date(),
level: LogLevel.ERROR,
category: 'test',
message: 'error',
locale: 'es',
}, 'resolved');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('loggea en consola si fetch falla (fire-and-forget)', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'));
const transport = httpTransport({ url: 'https://logs.example.com/api' });
transport.write({
timestamp: new Date(),
level: LogLevel.ERROR,
category: 'test',
message: 'error',
locale: 'es',
}, 'resolved');
// Esperar a que la promesa sea rechazada
await new Promise(resolve => setTimeout(resolve, 10));
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[logr:httpTransport] Failed to send log:',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
describe('callbackTransport', () => {
it('invoca el callback con cada entrada', () => {
const received: Array<{ entry: any; message: string }> = [];
const transport = callbackTransport((entry, msg) => {
received.push({ entry, message: msg });
});
const logr = createLogr(ling, {
level: LogLevel.DEBUG,
transports: [transport],
});
logr.info('test', 'callback test');
expect(received).toHaveLength(1);
expect(received[0].message).toBe('callback test');
expect(received[0].entry.category).toBe('test');
});
it('permite capturar logs para tests', () => {
const captured: string[] = [];
const transport = callbackTransport((_, msg) => captured.push(msg));
const logr = createLogr(ling, {
level: LogLevel.DEBUG,
transports: [transport],
});
logr.debug('test', 'one');
logr.info('test', 'two');
logr.warn('test', 'three');
expect(captured).toEqual(['one', 'two', 'three']);
});
});
// ============================================================================
// MÚLTIPLES TRANSPORTS
// ============================================================================
describe('múltiples transports', () => {
it('emite a todos los transports registrados', () => {
const consoleCaptured: string[] = [];
const customCaptured: string[] = [];
const consoleTrans = callbackTransport((_, msg) => consoleCaptured.push(msg));
const customTrans = callbackTransport((_, msg) => customCaptured.push(msg));
const logr = createLogr(ling, {
level: LogLevel.INFO,
transports: [consoleTrans, customTrans],
});
logr.info('test', 'multi transport');
expect(consoleCaptured).toHaveLength(1);
expect(customCaptured).toHaveLength(1);
expect(consoleCaptured[0]).toBe('multi transport');
expect(customCaptured[0]).toBe('multi transport');
});
it('un transport que falla no impide que los demás reciban la entrada', () => {
const goodCaptured: string[] = [];
const failingTrans = callbackTransport(() => {
throw new Error('Transport error');
});
const goodTrans = callbackTransport((_, msg) => goodCaptured.push(msg));
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const logr = createLogr(ling, {
level: LogLevel.INFO,
transports: [failingTrans, goodTrans],
});
logr.info('test', 'msg');
expect(goodCaptured).toHaveLength(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[logr] Transport error:',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});

@ -0,0 +1,62 @@
import type { BreakConfig, BreakInstance, BreakpointKey } from './types.ts';
// Breakpoints por defecto — coinciden con Tailwind v3
const DEFAULT_POINTS: Record<string, number> = {
sm : 640,
md : 768,
lg : 1024,
xl : 1280,
'2xl': 1536,
};
/**
* Resuelve el breakpoint activo dado un ancho y un mapa de puntos.
* Devuelve la clave del mayor breakpoint que el ancho cumple, o 'xs' si ninguno.
*/
function resolveBreakpoint(width: number, points: Record<string, number>): BreakpointKey {
const sorted = Object.entries(points).sort(([, a], [, b]) => b - a);
for (const [key, value] of sorted) {
if (width >= value) return key;
}
return 'xs';
}
export function createBreak(config: BreakConfig = {}): BreakInstance {
const points = config.points ?? DEFAULT_POINTS;
const getWidth = () => typeof window !== 'undefined' ? window.innerWidth : 0;
let current : BreakpointKey = resolveBreakpoint(getWidth(), points);
const listeners = new Set<(breakpoint: BreakpointKey) => void>();
// ResizeObserver en browser, noop en SSR
if (typeof window !== 'undefined') {
const observer = new ResizeObserver(() => {
const next = resolveBreakpoint(window.innerWidth, points);
if (next === current) return;
current = next;
listeners.forEach(fn => fn(current));
});
observer.observe(document.documentElement);
}
function is(breakpoint: BreakpointKey): boolean {
const min = points[breakpoint];
if (min === undefined) return false;
return getWidth() >= min;
}
function onChange(fn: (breakpoint: BreakpointKey) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
return {
get current() { return current; },
get isMobile() { return !is('md'); },
get isTablet() { return is('md') && !is('lg'); },
get isDesktop() { return is('lg'); },
is,
onChange,
};
}

@ -0,0 +1,41 @@
import type { Uiux, UiuxConfig } from './types.ts';
import { createTheme } from './theme_engine.ts';
import { createBreak } from './break_engine.ts';
import { createToast } from './toast_engine.ts';
import { createModal } from './modal_engine.ts';
import { createMenu } from './menu_engine.ts';
/**
* Crea el contexto de UI de la aplicación.
*
* Todos los módulos están siempre disponibles — no requieren config explícita.
* La config solo ajusta los defaults (tema inicial, breakpoints, duración de toasts).
*
* Diseñado para funcionar como módulo independiente o como parte de `ACtx`:
*
* @example
* // Standalone
* const uiux = createUiux({ theme: { mode: 'auto' } });
*
* @example
* // Integrado en ACtx (cuando se añada uiux a ACtxConfig)
* actx.uiux.theme.toggle()
* actx.uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' })
* actx.uiux.modal.open('confirm', { title: '¿Eliminar?' })
* actx.uiux.menu.toggle('sidebar')
* actx.uiux.break.isMobile
*
* @remarks
* **Persistencia**: el tema se guarda en memoria en esta versión.
* Cuando `stor` esté disponible, `ACtx` conectará `uiux.theme` con
* `stor.local.get/set('uiux:theme')` sin modificar esta librería.
*/
export function createUiux(config: UiuxConfig = {}): Uiux {
return {
theme : createTheme(config.theme),
break : createBreak(config.break),
toast : createToast(config.toast?.defaultDuration),
modal : createModal(),
menu : createMenu(),
};
}

@ -0,0 +1,7 @@
export * from './types.ts';
export * from './engine.ts';
export * from './theme_engine.ts';
export * from './break_engine.ts';
export * from './toast_engine.ts';
export * from './modal_engine.ts';
export * from './menu_engine.ts';

@ -0,0 +1,45 @@
import type { MenuInstance } from './types.ts';
export function createMenu(): MenuInstance {
let openIds = new Set<string>();
const listeners = new Set<(openIds: Set<string>) => void>();
function notify(): void {
const snapshot = new Set(openIds);
listeners.forEach(fn => fn(snapshot));
}
function open(id: string): void {
if (openIds.has(id)) return;
openIds = new Set(openIds).add(id);
notify();
}
function close(id: string): void {
if (!openIds.has(id)) return;
openIds = new Set(openIds);
openIds.delete(id);
notify();
}
function toggle(id: string): void {
openIds.has(id) ? close(id) : open(id);
}
function isOpen(id: string): boolean {
return openIds.has(id);
}
function closeAll(): void {
if (openIds.size === 0) return;
openIds = new Set();
notify();
}
function onChange(fn: (openIds: Set<string>) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
return { open, close, toggle, isOpen, closeAll, onChange };
}

@ -0,0 +1,47 @@
import type { ModalEntry, ModalInstance } from './types.ts';
export function createModal(): ModalInstance {
let stack: ModalEntry[] = [];
const listeners = new Set<(stack: ModalEntry[]) => void>();
function notify(): void {
const snapshot = [...stack];
listeners.forEach(fn => fn(snapshot));
}
function open<P = unknown>(id: string, props?: P): void {
stack = [...stack, { id, props: props ?? {} }];
notify();
}
function close(): void {
if (stack.length === 0) return;
stack = stack.slice(0, -1);
notify();
}
function closeAll(): void {
if (stack.length === 0) return;
stack = [];
notify();
}
function isOpen(id: string): boolean {
return stack.some(m => m.id === id);
}
function onChange(fn: (stack: ModalEntry[]) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
return {
get stack() { return stack; },
get active() { return stack.length > 0 ? stack[stack.length - 1] : null; },
open,
close,
closeAll,
isOpen,
onChange,
};
}

@ -0,0 +1,100 @@
import type { ThemeConfig, ThemeInstance, ThemeMode } from './types.ts';
const DEFAULT_DARK_CLASS = 'dark';
/** Resuelve 'auto' al modo del sistema operativo. */
function resolveSystemMode(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(
resolved : 'light' | 'dark',
darkClass : string,
vars? : ThemeConfig['vars']
): void {
if (typeof document === 'undefined') return;
const html = document.documentElement;
// Clase dark de Tailwind
resolved === 'dark'
? html.classList.add(darkClass)
: html.classList.remove(darkClass);
// Variables CSS custom
if (vars) {
const themeVars = vars[resolved] ?? {};
for (const [key, value] of Object.entries(themeVars)) {
html.style.setProperty(key, value);
}
}
}
export function createTheme(config: ThemeConfig = {}): ThemeInstance {
const darkClass = config.darkClass ?? DEFAULT_DARK_CLASS;
let currentMode: ThemeMode = config.mode ?? 'auto';
let resolved : 'light' | 'dark' = currentMode === 'auto'
? resolveSystemMode()
: currentMode;
const listeners = new Set<(mode: ThemeMode, resolved: 'light' | 'dark') => void>();
// Escucha cambios del sistema cuando el modo es 'auto'
let mediaUnsubscribe: (() => void) | undefined;
function subscribeToSystem(): void {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
if (currentMode !== 'auto') return;
resolved = mq.matches ? 'dark' : 'light';
applyTheme(resolved, darkClass, config.vars);
listeners.forEach(fn => fn(currentMode, resolved));
};
mq.addEventListener('change', handler);
mediaUnsubscribe = () => mq.removeEventListener('change', handler);
}
function unsubscribeFromSystem(): void {
mediaUnsubscribe?.();
mediaUnsubscribe = undefined;
}
function set(mode: ThemeMode): void {
currentMode = mode;
if (mode === 'auto') {
resolved = resolveSystemMode();
subscribeToSystem();
} else {
unsubscribeFromSystem();
resolved = mode;
}
applyTheme(resolved, darkClass, config.vars);
listeners.forEach(fn => fn(currentMode, resolved));
}
function toggle(): void {
set(resolved === 'dark' ? 'light' : 'dark');
}
function onChange(fn: (mode: ThemeMode, resolved: 'light' | 'dark') => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// Aplicar tema inicial
applyTheme(resolved, darkClass, config.vars);
if (currentMode === 'auto') subscribeToSystem();
return {
get resolved() { return resolved; },
get mode() { return currentMode; },
set,
toggle,
onChange,
};
}

@ -0,0 +1,68 @@
import type { Toast, ToastInstance, ToastOptions } from './types.ts';
import type { LingString } from '@/ling/lib';
const DEFAULT_DURATION = 4000;
let _id = 0;
function nextId(): string { return `toast-${++_id}`; }
export function createToast(defaultDuration = DEFAULT_DURATION): ToastInstance {
let queue: Toast[] = [];
const listeners = new Set<(queue: Toast[]) => void>();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function notify(): void {
const snapshot = [...queue];
listeners.forEach(fn => fn(snapshot));
}
function dismiss(id: string): void {
const timer = timers.get(id);
if (timer) { clearTimeout(timer); timers.delete(id); }
queue = queue.filter(t => t.id !== id);
notify();
}
function show(message: LingString, opts: ToastOptions = {}): string {
const id = nextId();
const duration = opts.duration ?? defaultDuration;
const toast: Toast = {
id,
type : opts.type ?? 'info',
message,
duration,
context : opts.context,
};
queue = [...queue, toast];
notify();
// Auto-dismiss si duration > 0
if (duration > 0) {
timers.set(id, setTimeout(() => dismiss(id), duration));
}
return id;
}
function clear(): void {
timers.forEach(t => clearTimeout(t));
timers.clear();
queue = [];
notify();
}
function onChange(fn: (queue: Toast[]) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
return {
get queue() { return queue; },
show,
dismiss,
clear,
onChange,
};
}

@ -0,0 +1,206 @@
// ==============================
// UIUX TYPES
// ==============================
import type { LingString } from '@/ling/lib';
// ============================================================================
// THEME
// ============================================================================
export type ThemeMode = 'light' | 'dark' | 'auto';
export interface ThemeConfig {
/** Modo inicial. Default: `'auto'`. */
mode? : ThemeMode;
/**
* Clase CSS aplicada a `<html>` en modo dark.
* Debe coincidir con la configuración de Tailwind (`darkMode: 'class'`).
* Default: `'dark'`.
*/
darkClass? : string;
/**
* Variables CSS custom por tema. Se inyectan en `<html>` como `style`.
* Útil para temas custom más allá de light/dark.
* @example
* vars: { light: { '--color-brand': '#3B82F6' }, dark: { '--color-brand': '#60A5FA' } }
*/
vars? : Partial<Record<ThemeMode | string, Record<string, string>>>;
}
export interface ThemeInstance {
/** Modo activo resuelto — nunca `'auto'`, siempre `'light'` o `'dark'`. */
readonly resolved : 'light' | 'dark';
/** Modo configurado — puede ser `'auto'`. */
readonly mode : ThemeMode;
set : (mode: ThemeMode) => void;
toggle : () => void;
onChange : (fn: (mode: ThemeMode, resolved: 'light' | 'dark') => void) => () => void;
}
// ============================================================================
// BREAKPOINTS
// ============================================================================
export type BreakpointKey = string;
export interface BreakConfig {
/**
* Mapa de breakpoints en px. Default: breakpoints de Tailwind v3.
* @example { sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 }
*/
points?: Record<BreakpointKey, number>;
}
export interface BreakInstance {
/** Breakpoint activo según el ancho actual de la ventana. */
readonly current : BreakpointKey;
readonly isMobile : boolean; // < md
readonly isTablet : boolean; // >= md && < lg
readonly isDesktop: boolean; // >= lg
/** Devuelve true si el viewport cumple el breakpoint mínimo indicado. */
is : (breakpoint: BreakpointKey) => boolean;
onChange : (fn: (breakpoint: BreakpointKey) => void) => () => void;
}
// ============================================================================
// TOAST
// ============================================================================
export type ToastType = 'success' | 'error' | 'warn' | 'info';
export interface ToastOptions {
/** Tipo visual del toast. Default: `'info'`. */
type? : ToastType;
/**
* Duración en ms antes de auto-dismiss. `0` = permanente.
* Default: `4000`.
*/
duration?: number;
/** Contexto adicional — por ejemplo un código de error. */
context? : Record<string, unknown>;
}
export interface Toast {
id : string;
type : ToastType;
message : LingString;
duration: number;
context?: Record<string, unknown>;
}
export interface ToastInstance {
/** Cola de toasts activos. */
readonly queue : Toast[];
show : (message: LingString, opts?: ToastOptions) => string;
dismiss : (id: string) => void;
clear : () => void;
onChange : (fn: (queue: Toast[]) => void) => () => void;
}
// ============================================================================
// MODAL
// ============================================================================
export interface ModalEntry<P = unknown> {
id : string;
props : P;
}
export interface ModalInstance {
/** Stack de modales abiertos. El último es el activo. */
readonly stack : ModalEntry[];
/** Modal activo (último del stack) o `null` si no hay ninguno. */
readonly active : ModalEntry | null;
open : <P = unknown>(id: string, props?: P) => void;
close : () => void;
closeAll : () => void;
isOpen : (id: string) => boolean;
onChange : (fn: (stack: ModalEntry[]) => void) => () => void;
}
// ============================================================================
// MENU
// ============================================================================
export interface MenuInstance {
open : (id: string) => void;
close : (id: string) => void;
toggle : (id: string) => void;
isOpen : (id: string) => boolean;
closeAll: () => void;
onChange: (fn: (openIds: Set<string>) => void) => () => void;
}
// ============================================================================
// UIUX CONFIG & INSTANCE
// ============================================================================
export interface UiuxConfig {
theme? : ThemeConfig;
break? : BreakConfig;
toast? : { defaultDuration?: number };
}
/**
* Contexto de UI de la aplicación.
* Gestiona tema, breakpoints, toasts, modales y menús.
*
* Todos los módulos son siempre disponibles — no requieren config explícita.
* La config solo ajusta los defaults.
*
* @example
* uiux.theme.toggle()
* uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' })
* uiux.modal.open('confirm', { title: '¿Eliminar?' })
* uiux.menu.toggle('sidebar')
* uix.break.isMobile
*/
export interface Uiux {
theme : ThemeInstance;
break : BreakInstance;
toast : ToastInstance;
modal : ModalInstance;
menu : MenuInstance;
}
// ============================================================================
// REACTIVE UIX
// ============================================================================
/**
* Versión reactiva de `Uix` para el singleton de Svelte (`actx.svelte.ts`).
*
* Idéntica a `Uix` en métodos y estructura, pero las propiedades de solo
* lectura (`resolved`, `mode`, `queue`, `stack`, `active`, `current`,
* `isMobile`, `isTablet`, `isDesktop`) están respaldadas por `$state` —
* Svelte las observa y re-renderiza automáticamente cuando cambian.
*
* No añade ni elimina ninguna funcionalidad — solo garantiza reactividad
* en las propiedades que los componentes Svelte necesitan leer.
*/
export interface ReactiveUiux {
theme: Omit<ThemeInstance, 'resolved' | 'mode'> & {
readonly resolved: 'light' | 'dark'; // $state
readonly mode : ThemeMode; // $state
};
toast: Omit<ToastInstance, 'queue'> & {
readonly queue: Toast[]; // $state
};
modal: Omit<ModalInstance, 'stack' | 'active'> & {
readonly stack : ModalEntry[]; // $state
readonly active: ModalEntry | null; // $state derivado
};
menu : MenuInstance; // sin estado reactivo
break: Omit<BreakInstance, 'current' | 'isMobile' | 'isTablet' | 'isDesktop'> & {
readonly current : string; // $state
readonly isMobile : boolean; // derivado de $state
readonly isTablet : boolean; // derivado de $state
readonly isDesktop: boolean; // derivado de $state
};
}

@ -0,0 +1,9 @@
<script lang="ts">
import './layout.css';
let { children } = $props();
</script>
<svelte:head><link rel="icon" /></svelte:head>
{@render children()}

@ -0,0 +1,2 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

@ -0,0 +1,2 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';

@ -0,0 +1,526 @@
<script lang="ts">
import { actx } from "@/lib/actx/lib/actx.svelte";
// ── Test data ──────────────────────────────────────────────────────────────
const locales = ['es', 'en', 'de', 'fr', 'it', 'pt'] as const;
const now = new Date();
let logEntries = $state(actx.logr.getLogs());
let menuOpen = $state(false);
// ── Helpers ────────────────────────────────────────────────────────────────
function cycleLocale() {
const idx = locales.indexOf(actx.locale as any);
const next = locales[(idx + 1) % locales.length];
actx.ling.setLocale(next);
}
function fireToast(type: 'success' | 'error' | 'warn' | 'info') {
const msgs = {
success : { es: '¡Operación completada!', en: 'Operation complete!' },
error : { es: 'Ha ocurrido un error', en: 'An error occurred' },
warn : { es: 'Atención requerida', en: 'Attention required' },
info : { es: 'Información del sistema', en: 'System information' },
};
actx.uiux.toast.show(msgs[type], { type, duration: 3000 });
}
function fireLog(level: 'debug' | 'info' | 'warn' | 'error') {
actx.logr[level]('test', { es: `Log de nivel ${level}`, en: `${level} level log` }, { ts: Date.now() });
logEntries = actx.logr.getLogs();
}
const LOG_COLORS: Record<string, string> = {
'0': '#6366f1', '1': '#22c55e', '2': '#f59e0b', '3': '#ef4444',
};
</script>
<div class="page" class:dark={actx.uiux.theme.resolved === 'dark'}>
<!-- ── HEADER ───────────────────────────────────────────────────────────── -->
<header>
<div class="header-inner">
<span class="logo">ACTX<em>TEST</em></span>
<div class="header-right">
<span class="locale-badge" onclick={cycleLocale} role="button" tabindex="0">
{actx.locale.toUpperCase()}
</span>
<span class="break-badge">{actx.uiux.break.current}</span>
<button class="theme-btn" onclick={() => actx.uiux.theme.toggle()}>
{actx.uiux.theme.resolved === 'dark' ? '☀' : '◑'}
</button>
</div>
</div>
</header>
<main>
<!-- ── GRID ─────────────────────────────────────────────────────────── -->
<div class="grid">
<!-- LING -->
<section class="card card--ling">
<h2>ling</h2>
<div class="row">
<span class="label">t()</span>
<span class="value">{actx.t('common.ok')} / {actx.t('common.cancel')}</span>
</div>
<div class="row">
<span class="label">ts()</span>
<span class="value">{actx.ts({ es: 'Hola mundo', en: 'Hello world', de: 'Hallo Welt', fr: 'Bonjour monde', it: 'Ciao mondo', pt: 'Olá mundo' })}</span>
</div>
<div class="row">
<span class="label">locale</span>
<span class="value accent">{actx.locale}</span>
</div>
<div class="locales">
{#each locales as loc}
<button
class="loc-btn"
class:active={actx.locale === loc}
onclick={() => actx.ling.setLocale(loc)}
>{loc}</button>
{/each}
</div>
</section>
<!-- NUMR -->
<section class="card card--numr">
<h2>numr</h2>
<div class="row">
<span class="label">f(1234567.891)</span>
<span class="value">{actx.numr.f(1234567.891)}</span>
</div>
<div class="row">
<span class="label">f(0.5, {'{'}min:2,max:4{'}'})</span>
<span class="value">{actx.numr.f(0.5, { minDecimals: 2, maxDecimals: 4 })}</span>
</div>
<div class="row">
<span class="label">f(1000000)</span>
<span class="value">{actx.numr.f(1000000)}</span>
</div>
<div class="row">
<span class="label">f(3.14159)</span>
<span class="value">{actx.numr.f(3.14159, { maxDecimals: 4 })}</span>
</div>
</section>
<!-- DATI -->
<section class="card card--dati">
<h2>dati</h2>
<div class="row">
<span class="label">f(now, 'long')</span>
<span class="value">{actx.dati.f(now, 'long')}</span>
</div>
<div class="row">
<span class="label">f(now, 'short')</span>
<span class="value">{actx.dati.f(now, 'short')}</span>
</div>
<div class="row">
<span class="label">f(now, 'full')</span>
<span class="value small">{actx.dati.f(now, 'full')}</span>
</div>
<div class="row">
<span class="label">date+time</span>
<span class="value">{actx.dati.f(now, { date: 'short', time: 'short' })}</span>
</div>
</section>
<!-- UNIT -->
<section class="card card--unit">
<h2>unit</h2>
<div class="row">
<span class="label">75kg → lb</span>
<span class="value">{actx.unit.weight.fTo(75, 'kg', 'lb')}</span>
</div>
<div class="row">
<span class="label">100km → mi</span>
<span class="value">{actx.unit.length.fTo(100, 'km', 'mi')}</span>
</div>
<div class="row">
<span class="label">100°C → °F</span>
<span class="value">{actx.unit.temp.fTo(100, 'c', 'f')}</span>
</div>
<div class="row">
<span class="label">10L → gal</span>
<span class="value">{actx.unit.volume.fTo(10, 'l', 'gal')}</span>
</div>
<div class="row">
<span class="label">sistema</span>
<span class="value accent">{actx.unit.getSystem()}</span>
</div>
<div class="btn-row">
<button onclick={() => actx.unit.setSystem('metric')}>metric</button>
<button onclick={() => actx.unit.setSystem('imperial')}>imperial</button>
</div>
</section>
<!-- CURR -->
<section class="card card--curr">
<h2>curr</h2>
<div class="row">
<span class="label">moneda activa</span>
<span class="value accent">{actx.curr.getCurrency()}</span>
</div>
<div class="row">
<span class="label">f(99.99, symbol)</span>
<span class="value">{actx.curr.f(99.99, 'symbol')}</span>
</div>
<div class="row">
<span class="label">f(99.99, code)</span>
<span class="value">{actx.curr.f(99.99, 'code')}</span>
</div>
<div class="row">
<span class="label">monedas</span>
<span class="value muted">{actx.curr.getCurrencies().join(', ') || '(sin definir)'}</span>
</div>
</section>
<!-- THEME -->
<section class="card card--theme">
<h2>uiux.theme</h2>
<div class="row">
<span class="label">mode</span>
<span class="value accent">{actx.uiux.theme.mode}</span>
</div>
<div class="row">
<span class="label">resolved</span>
<span class="value accent">{actx.uiux.theme.resolved}</span>
</div>
<div class="btn-row">
<button onclick={() => actx.uiux.theme.set('light')}>light</button>
<button onclick={() => actx.uiux.theme.set('dark')}>dark</button>
<button onclick={() => actx.uiux.theme.set('auto')}>auto</button>
</div>
<div class="theme-swatch" style="background: {actx.uiux.theme.resolved === 'dark' ? '#1a1a2e' : '#f0f0f0'}">
{actx.uiux.theme.resolved}
</div>
</section>
<!-- BREAK -->
<section class="card card--break">
<h2>uiux.break</h2>
<div class="row">
<span class="label">current</span>
<span class="value accent">{actx.uiux.break.current}</span>
</div>
<div class="row">
<span class="label">isMobile</span>
<span class="value" class:true={actx.uiux.break.isMobile}>{actx.uiux.break.isMobile}</span>
</div>
<div class="row">
<span class="label">isTablet</span>
<span class="value" class:true={actx.uiux.break.isTablet}>{actx.uiux.break.isTablet}</span>
</div>
<div class="row">
<span class="label">isDesktop</span>
<span class="value" class:true={actx.uiux.break.isDesktop}>{actx.uiux.break.isDesktop}</span>
</div>
<div class="breakpoints">
{#each ['xs','sm','md','lg','xl','2xl'] as bp}
<span class="bp" class:active={actx.uiux.break.current === bp}>{bp}</span>
{/each}
</div>
</section>
<!-- TOAST -->
<section class="card card--toast">
<h2>uiux.toast</h2>
<div class="row">
<span class="label">queue</span>
<span class="value accent">{actx.uiux.toast.queue.length} activos</span>
</div>
<div class="btn-row">
<button class="btn-success" onclick={() => fireToast('success')}>success</button>
<button class="btn-error" onclick={() => fireToast('error')}>error</button>
<button class="btn-warn" onclick={() => fireToast('warn')}>warn</button>
<button class="btn-info" onclick={() => fireToast('info')}>info</button>
</div>
<button class="btn-clear" onclick={() => actx.uiux.toast.clear()}>clear all</button>
<div class="toast-list">
{#each actx.uiux.toast.queue as toast (toast.id)}
<div class="toast-item toast-item--{toast.type}">
<span>{actx.ts(toast.message)}</span>
<button onclick={() => actx.uiux.toast.dismiss(toast.id)}>✕</button>
</div>
{/each}
</div>
</section>
<!-- MODAL -->
<section class="card card--modal">
<h2>uiux.modal</h2>
<div class="row">
<span class="label">stack depth</span>
<span class="value accent">{actx.uiux.modal.stack.length}</span>
</div>
<div class="row">
<span class="label">active</span>
<span class="value muted">{actx.uiux.modal.active?.id ?? 'ninguno'}</span>
</div>
<div class="btn-row">
<button onclick={() => actx.uiux.modal.open('confirm', { msg: '¿Eliminar?' })}>open confirm</button>
<button onclick={() => actx.uiux.modal.open('alert', { msg: '¡Atención!' })}>open alert</button>
</div>
<div class="btn-row">
<button onclick={() => actx.uiux.modal.close()}>close</button>
<button onclick={() => actx.uiux.modal.closeAll()}>close all</button>
</div>
{#if actx.uiux.modal.stack.length > 0}
<div class="modal-stack">
{#each actx.uiux.modal.stack as m}
<div class="modal-item">
<strong>{m.id}</strong>
<span>{JSON.stringify(m.props)}</span>
</div>
{/each}
</div>
{/if}
</section>
<!-- MENU -->
<section class="card card--menu">
<h2>uiux.menu</h2>
<div class="row">
<span class="label">sidebar</span>
<span class="value accent">{actx.uiux.menu.isOpen('sidebar') ? 'open' : 'closed'}</span>
</div>
<div class="row">
<span class="label">dropdown</span>
<span class="value accent">{actx.uiux.menu.isOpen('dropdown') ? 'open' : 'closed'}</span>
</div>
<div class="btn-row">
<button onclick={() => actx.uiux.menu.toggle('sidebar')}>toggle sidebar</button>
<button onclick={() => actx.uiux.menu.toggle('dropdown')}>toggle dropdown</button>
</div>
<button class="btn-clear" onclick={() => actx.uiux.menu.closeAll()}>close all</button>
</section>
<!-- LOGR -->
<section class="card card--logr">
<h2>logr</h2>
<div class="btn-row">
<button onclick={() => fireLog('debug')}>debug</button>
<button onclick={() => fireLog('info')}>info</button>
<button onclick={() => fireLog('warn')}>warn</button>
<button onclick={() => fireLog('error')}>error</button>
</div>
<button class="btn-clear" onclick={() => { actx.logr.clear(); logEntries = []; }}>clear</button>
<div class="log-list">
{#each logEntries.slice(-6).reverse() as entry}
<div class="log-entry" style="border-left-color: {LOG_COLORS[String(entry.level)]}">
<span class="log-level" style="color: {LOG_COLORS[String(entry.level)]}">{entry.level}</span>
<span class="log-msg">{actx.ts(entry.message)}</span>
</div>
{/each}
{#if logEntries.length === 0}
<span class="muted">sin logs</span>
{/if}
</div>
</section>
</div>
</main>
</div>
<style>
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
.page {
min-height: 100vh;
background: #f7f6f3;
color: #1a1a1a;
font-family: 'DM Mono', 'Fira Code', 'Courier New', monospace;
transition: background .3s, color .3s;
}
.page.dark {
background: #0f0f13;
color: #e8e6e0;
}
/* ── HEADER ─────────────────────────────────────────────────────────────── */
header {
position: sticky; top: 0; z-index: 100;
background: #1a1a1a;
color: #f7f6f3;
border-bottom: 2px solid #f59e0b;
}
.page.dark header { background: #0a0a0d; }
.header-inner {
max-width: 1400px; margin: 0 auto;
padding: .75rem 1.5rem;
display: flex; align-items: center; justify-content: space-between;
}
.logo {
font-size: 1.1rem; font-weight: 700; letter-spacing: .15em;
text-transform: uppercase;
}
.logo em { color: #f59e0b; font-style: normal; }
.header-right { display: flex; align-items: center; gap: .75rem; }
.locale-badge, .break-badge {
padding: .2rem .6rem;
border: 1px solid #f59e0b;
border-radius: 2px;
font-size: .7rem; font-weight: 700; letter-spacing: .1em;
cursor: pointer;
color: #f59e0b;
}
.break-badge { color: #6366f1; border-color: #6366f1; cursor: default; }
.theme-btn {
background: none; border: 1px solid #f7f6f3;
color: #f7f6f3; border-radius: 50%;
width: 32px; height: 32px; cursor: pointer;
font-size: 1rem; display: flex; align-items: center; justify-content: center;
}
.theme-btn:hover { background: rgba(255,255,255,.1); }
/* ── MAIN GRID ───────────────────────────────────────────────────────────── */
main { max-width: 1400px; margin: 0 auto; padding: 2rem 1.5rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
/* ── CARDS ───────────────────────────────────────────────────────────────── */
.card {
background: #fff;
border: 1.5px solid #e0ddd6;
border-radius: 4px;
padding: 1.25rem;
display: flex; flex-direction: column; gap: .6rem;
}
.page.dark .card {
background: #16161d;
border-color: #2a2a35;
}
.card h2 {
font-size: .7rem; font-weight: 700;
letter-spacing: .2em; text-transform: uppercase;
color: #888; margin-bottom: .25rem;
}
.page.dark .card h2 { color: #555; }
/* Card accent borders */
.card--ling { border-top: 3px solid #6366f1; }
.card--numr { border-top: 3px solid #22c55e; }
.card--dati { border-top: 3px solid #3b82f6; }
.card--unit { border-top: 3px solid #f59e0b; }
.card--curr { border-top: 3px solid #ec4899; }
.card--theme { border-top: 3px solid #8b5cf6; }
.card--break { border-top: 3px solid #06b6d4; }
.card--toast { border-top: 3px solid #f97316; }
.card--modal { border-top: 3px solid #ef4444; }
.card--menu { border-top: 3px solid #10b981; }
.card--logr { border-top: 3px solid #6366f1; }
/* ── ROWS ────────────────────────────────────────────────────────────────── */
.row {
display: flex; align-items: baseline;
gap: .5rem; font-size: .8rem;
}
.label {
color: #999; font-size: .7rem;
min-width: 120px; flex-shrink: 0;
}
.page.dark .label { color: #555; }
.value { font-weight: 500; }
.value.small { font-size: .7rem; }
.value.accent { color: #f59e0b; font-weight: 700; }
.value.muted { color: #aaa; font-style: italic; }
.value.true { color: #22c55e; font-weight: 700; }
/* ── BUTTONS ─────────────────────────────────────────────────────────────── */
.btn-row { display: flex; flex-wrap: wrap; gap: .4rem; }
button {
padding: .3rem .7rem;
background: #1a1a1a; color: #f7f6f3;
border: none; border-radius: 2px;
font-family: inherit; font-size: .7rem; font-weight: 600;
letter-spacing: .05em; cursor: pointer;
transition: opacity .15s;
}
button:hover { opacity: .75; }
.page.dark button { background: #2a2a35; color: #e8e6e0; }
.btn-success { background: #166534; color: #dcfce7; }
.btn-error { background: #7f1d1d; color: #fee2e2; }
.btn-warn { background: #78350f; color: #fef3c7; }
.btn-info { background: #1e3a5f; color: #dbeafe; }
.btn-clear { background: none; border: 1px solid #ccc; color: #888; font-size: .65rem; align-self: flex-start; }
.page.dark .btn-clear { border-color: #333; color: #555; }
/* ── LOCALE BUTTONS ──────────────────────────────────────────────────────── */
.locales { display: flex; gap: .3rem; flex-wrap: wrap; }
.loc-btn {
background: none; border: 1px solid #ddd; color: #888;
padding: .2rem .5rem; font-size: .65rem;
}
.loc-btn.active {
background: #6366f1; border-color: #6366f1; color: #fff;
}
.page.dark .loc-btn { border-color: #333; color: #555; }
/* ── THEME SWATCH ────────────────────────────────────────────────────────── */
.theme-swatch {
height: 36px; border-radius: 2px;
display: flex; align-items: center; justify-content: center;
font-size: .65rem; font-weight: 700; letter-spacing: .1em;
color: #888; border: 1px solid rgba(0,0,0,.1);
}
/* ── BREAKPOINTS ─────────────────────────────────────────────────────────── */
.breakpoints { display: flex; gap: .3rem; flex-wrap: wrap; }
.bp {
padding: .2rem .5rem; font-size: .65rem;
border: 1px solid #ddd; border-radius: 2px; color: #aaa;
}
.bp.active { background: #06b6d4; border-color: #06b6d4; color: #fff; font-weight: 700; }
.page.dark .bp { border-color: #333; color: #444; }
/* ── TOAST LIST ──────────────────────────────────────────────────────────── */
.toast-list { display: flex; flex-direction: column; gap: .3rem; }
.toast-item {
display: flex; align-items: center; justify-content: space-between;
padding: .4rem .6rem; border-radius: 2px; font-size: .75rem;
border-left: 3px solid #ccc;
}
.toast-item button { background: none; color: inherit; padding: 0 .2rem; font-size: .8rem; }
.toast-item--success { background: #f0fdf4; border-left-color: #22c55e; color: #166534; }
.toast-item--error { background: #fef2f2; border-left-color: #ef4444; color: #7f1d1d; }
.toast-item--warn { background: #fffbeb; border-left-color: #f59e0b; color: #78350f; }
.toast-item--info { background: #eff6ff; border-left-color: #3b82f6; color: #1e3a5f; }
.page.dark .toast-item--success { background: #052e16; color: #86efac; }
.page.dark .toast-item--error { background: #2d0707; color: #fca5a5; }
.page.dark .toast-item--warn { background: #2d1a00; color: #fcd34d; }
.page.dark .toast-item--info { background: #0c1a2e; color: #93c5fd; }
/* ── MODAL STACK ─────────────────────────────────────────────────────────── */
.modal-stack { display: flex; flex-direction: column; gap: .3rem; }
.modal-item {
display: flex; flex-direction: column; gap: .1rem;
padding: .4rem .6rem; background: rgba(239,68,68,.08);
border-radius: 2px; font-size: .7rem;
border-left: 3px solid #ef4444;
}
.modal-item strong { color: #ef4444; }
/* ── LOG LIST ────────────────────────────────────────────────────────────── */
.log-list { display: flex; flex-direction: column; gap: .25rem; }
.log-entry {
display: flex; gap: .5rem; align-items: baseline;
padding: .3rem .5rem; font-size: .7rem;
background: rgba(0,0,0,.03); border-radius: 2px;
border-left: 3px solid #ccc;
}
.page.dark .log-entry { background: rgba(255,255,255,.03); }
.log-level { font-weight: 700; font-size: .65rem; min-width: 1.5rem; }
.log-msg { color: #666; }
.page.dark .log-msg { color: #555; }
.muted { color: #aaa; font-size: .75rem; font-style: italic; }
</style>

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

@ -0,0 +1,35 @@
import adapter from '@sveltejs/adapter-static';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter(),
alias: {
// @/ → src/
// Sincronizado con vite.config.ts para que TypeScript,
// el compilador de Svelte y Vitest resuelvan el mismo alias.
'@': resolve(__dirname, 'src'),
// @/ling → src/lib/ling
'@/ling': resolve(__dirname, 'src/lib/ling'),
// @/logr → src/lib/logr
'@/logr': resolve(__dirname, 'src/lib/logr'),
// @/glob → src/lib/glob
'@/glob': resolve(__dirname, 'src/lib/glob'),
// @/actx → src/lib/actx
'@/actx': resolve(__dirname, 'src/lib/actx'),
// @/uiux → src/lib/uiux
'@/uiux': resolve(__dirname, 'src/lib/uiux'),
},
},
vitePlugin: {
dynamicCompileOptions: ({ filename }) =>
filename.includes('node_modules') ? undefined : { runes: true }
}
};
export default config;

@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": [
"@types/node"
]
}
}

@ -0,0 +1,69 @@
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
import { fileURLToPath } from 'url';
import { resolve, dirname } from 'path';
// __dirname no existe en ESM — se reconstruye así
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
resolve: {
alias: {
// @/ → src/
'@': resolve(__dirname, 'src'),
// @/ling → src/lib/ling
'@/ling': resolve(__dirname, 'src/lib/ling'),
// @/logr → src/lib/logr
'@/logr': resolve(__dirname, 'src/lib/logr'),
// @/glob → src/lib/glob
'@/glob': resolve(__dirname, 'src/lib/glob'),
// @/actx → src/lib/actx
'@/actx': resolve(__dirname, 'src/lib/actx'),
// @/uiux → src/lib/uiux
'@/uiux': resolve(__dirname, 'src/lib/uiux'),
},
},
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'client',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']
}
},
{
extends: './vite.config.ts',
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@/ling': resolve(__dirname, 'src/lib/ling'),
'@/logr': resolve(__dirname, 'src/lib/logr'),
'@/glob': resolve(__dirname, 'src/lib/glob'),
'@/actx': resolve(__dirname, 'src/lib/actx'),
'@/uiux': resolve(__dirname, 'src/lib/uiux'),
},
},
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
});
Loading…
Cancel
Save

Powered by TurnKey Linux.