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
commit
781bfb7e4b
@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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,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"
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in new issue