add glob module with numr, curr, dati, unit engines + docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
dev 7 months ago
parent 3aaf62c571
commit 8f422e2b4f

4
.gitignore vendored

@ -0,0 +1,4 @@
node_modules/
dist/
.idea/
package-lock.json

8
.idea/.gitignore vendored

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

@ -5,7 +5,9 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "vite dev"
"dev": "vite dev",
"test": "vitest",
"test:run": "vitest run"
},
"keywords": [
"configuration",
@ -16,17 +18,17 @@
"license": "EULA",
"dependencies": {
"svelte": "^5.53.0",
"svelte": "^5.53.6",
"@tailwindcss/vite": "^4.1.18"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/postcss": "^4.1.18",
"@tsconfig/svelte": "^5.0.6",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/node": "^25.2.3",
"@types/node": "^25.3.3",
"jsdom": "^28.1.0",
"ts-node": "^10.9.2",
"autoprefixer": "^10.4.23",
@ -37,5 +39,6 @@
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^4.0.18"
},
"private": true
"private": true,
"type": "module"
}

@ -0,0 +1,10 @@
// ==============================
// HELPERS
// ==============================
import type { GlobLocale, LocaleResolver } from "./comn_types";
/** 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 { LingInstance, LingNode, LingRecord, LingPluralFn, SupportedLocale } from '@/ling/types';
// ==============================
// 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,117 @@
import type { CurrInstance, CurrDefinition, CurrDisplay, CurrConfig, ConversionValue } from './curr_types';
import type { LingInstance, LingNode } from '@/ling/types';
import type { LocaleResolver, GlobLocale } from './comn_types';
import { resolveLocale } from './comn_engine';
import { defaultISOLocale } from '@/ling/consts';
/**
* 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, usada para la pluralización de nombres.
* @param opts - Configuración inicial (moneda activa, decimales, display).
* @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito.
*/
export function createCurr<S extends LingNode>(
definitions : Record<string, CurrDefinition>,
ling : LingInstance<S>,
opts : CurrConfig = {},
defaultLocale: LocaleResolver = defaultISOLocale
): CurrInstance {
let selectedCurrency: string = opts.selectedCurrency ?? Object.keys(definitions)[0] ?? '';
const baseCurrency : string = opts.baseCurrency ?? selectedCurrency;
const defaults = {
display : opts.display ?? 'symbol' as CurrDisplay,
decimals : opts.decimals ?? 2,
};
// ── Currency ──────────────────────────────────────────────────────────────
function getCurrency(): string { return selectedCurrency; }
function setCurrency(currency: string): void { selectedCurrency = currency.toUpperCase(); }
/** Busca una definición por código ISO, insensible a mayúsculas. */
function get(currency: string): CurrDefinition | undefined {
return definitions[currency.toUpperCase()];
}
// ── Helpers ───────────────────────────────────────────────────────────────
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 según `amount` y el idioma del locale. */
function resolveName(def: CurrDefinition, amount: number, locale: GlobLocale): string {
const record = def.name({ count: Math.abs(amount) });
const baseLocale = locale.split('-')[0] as keyof typeof record;
return record[baseLocale] ?? record['es'];
}
/** Normaliza `ConversionValue` a número o `null`. Evalúa la función si es dinámica. */
function resolveConversionValue(value: ConversionValue): number | null {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return value;
return value();
}
// ── Format ────────────────────────────────────────────────────────────────
/**
* Formatea `amount` en la moneda activa.
* Si la moneda no está registrada, devuelve el importe seguido del código ISO.
*/
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)}`;
}
}
// ── Conversión ────────────────────────────────────────────────────────────
/**
* Convierte `amount` de `from` a `to` usando sus tasas respecto a la moneda base.
* Fórmula: `(amount / fromRate) * toRate`
* Devuelve `null` si alguna moneda no tiene tasa de conversión.
*/
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;
// Convertir a moneda base y luego a destino
const inBase = amount / fromRate;
return inBase * toRate;
}
/** Convierte desde la moneda activa (`selectedCurrency`) hacia `to`. */
function convertTo(amount: number, to: string): number | null {
return convert(amount, selectedCurrency, to);
}
return { f, convert, convertTo, setCurrency, getCurrency, get };
}

@ -0,0 +1,63 @@
// ==============================
// CURR TYPES
// ==============================
import type { LingPluralFn } from "../ling";
import type { LocaleResolver } from "./comn_types";
/**
* 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.
*/
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 bilingüe 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;
}
/** Configuración de `createCurr`. */
export interface CurrConfig {
/** Mapa de definiciones de monedas indexado por código ISO. */
definitions? : Record<string, CurrDefinition>;
/** 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 } from './dati_types';
import type { LocaleResolver, GlobLocale } from './comn_types';
import { resolveLocale } from './comn_engine';
import { defaultISOLocale } from '@/ling/consts';
// ==============================
// 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 : { selectedTimeFormat?: DatiTimeFormat; selectedDateOrder?: DatiDateOrder } = {},
defaultLocale: LocaleResolver = defaultISOLocale
): 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,40 @@
// ==============================
// DATI
// ==============================
import type { LocaleResolver } from "./comn_types";
/** Nivel de detalle en el formateo de fecha u hora. */
export type DatiStyleType = 'short' | 'long';
/** 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()`:
* - `'long'` / `'short'` : 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,431 @@
# glob — Módulo de globalización
`glob` es la capa de globalización del proyecto. Agrupa cuatro módulos de formateo y conversión —número, moneda, fecha/hora y unidades— bajo un único punto de entrada con locale compartido.
---
## Índice
1. [Diseño](#diseño)
2. [LocaleResolver](#localeresolver)
3. [createGlob](#createglob)
4. [numr — Números](#numr)
5. [curr — Moneda](#curr)
6. [dati — Fecha y hora](#dati)
7. [unit — Unidades](#unit)
8. [Referencia de tipos](#referencia-de-tipos)
---
## Diseño
### Principios
- **Un locale, todos los módulos.** El locale vive en la instancia `ling`. Cuando se llama a `glob.setLocale()`, todos los módulos formatean con el nuevo locale de forma automática, sin necesidad de recrearlos.
- **Módulos independientes.** Cada módulo (`numr`, `curr`, `dati`, `unit`) puede instanciarse de forma autónoma con `createNumr`, `createCurr`, etc., sin necesitar `createGlob`.
- **Locale reactivo.** Cada función `f()` acepta un `LocaleResolver` opcional. Si se omite, usa el default configurado al crear la instancia (que puede ser una función que lee el locale en tiempo de ejecución).
- **Fábricas puras.** No hay clases ni singletons. Cada llamada a `createX()` devuelve un objeto cerrado con su estado interno.
### Estructura de archivos
```
src/glob/
├── comn_types.ts — GlobLocale, LocaleResolver
├── comn_engine.ts — resolveLocale()
├── glob_types.ts — GlobConfig, GlobInstance
├── glob_engine.ts — createGlob()
├── numr_types.ts — NumrConfig, NumrInstance
├── numr_engine.ts — createNumr()
├── curr_types.ts — CurrDefinition, CurrConfig, CurrInstance
├── curr_engine.ts — createCurr()
├── dati_types.ts — DatiConfig, DatiInstance, DatiOptions
├── dati_engine.ts — createDati()
├── unit_types.ts — UnitCategory, UnitConfig, UnitInstance
├── unit_engine.ts — createUnit()
└── unit_langs.ts — defaultCategories (definiciones por defecto)
```
### Flujo de locale en `createGlob`
```
glob.setLocale('en')
└─▶ ling.setLocale('en') // fuente de verdad
└─▶ listeners.forEach(fn => fn()) // notifica suscriptores externos
glob.numr.f(1234)
└─▶ resolveLocale(getLocale) // llama a ling.getLocale() en cada f()
└─▶ Intl.NumberFormat('en').format(1234)
```
Los módulos reciben `getLocale` (una función `() => GlobLocale`) como `defaultLocale`, por lo que leen el locale actual en cada invocación sin necesidad de reconectarse.
---
## LocaleResolver
```ts
type LocaleResolver = GlobLocale | (() => GlobLocale);
```
Todos los métodos `f()` aceptan un `LocaleResolver` como último parámetro. Puede ser:
| Forma | Cuándo usarla |
|---|---|
| `'es'` | Locale fijo en esa llamada concreta |
| `() => user.locale` | Locale dinámico que se lee en cada llamada |
| *(omitido)* | Usa el `defaultLocale` configurado al crear la instancia |
`resolveLocale(l)` en `comn_engine.ts` normaliza ambas formas:
```ts
resolveLocale('es') // → 'es'
resolveLocale(() => 'en') // → 'en'
```
---
## createGlob
Punto de entrada principal. Crea una instancia integrada con locale compartido.
```ts
import { createGlob } from '@/glob/glob_engine';
const glob = createGlob({
locale: 'es',
ling, // instancia de LingInstance
numr: { maxDecimals: 2 },
curr: {
definitions: { EUR: eurDef, USD: usdDef },
selectedCurrency: 'EUR',
},
dati: { selectedTimeFormat: '24h' },
unit: { selectedSystem: 'metric' },
});
```
### API de GlobInstance
| Método / propiedad | Descripción |
|---|---|
| `getLocale()` | Locale activo (delegado a `ling`) |
| `setLocale(locale)` | Cambia el locale en `ling` y notifica suscriptores |
| `onLocaleChange(fn)` | Suscribe un listener; devuelve función de desuscripción |
| `t(key, params?)` | Traducción via `ling.t` |
| `ts(key, params?)` | Traducción con soporte plural via `ling.ts` |
| `ling` | La instancia `LingInstance` completa |
| `numr` | Módulo de números |
| `curr` | Módulo de moneda |
| `dati` | Módulo de fecha/hora |
| `unit` | Módulo de unidades |
### Cambio reactivo de locale
```ts
glob.setLocale('en');
glob.numr.f(1234.5); // '1,234.5'
glob.curr.f(99); // '99.00 €'
glob.dati.f(new Date()); // 'March 2, 2026'
```
### Suscriptor de cambios
```ts
const unsub = glob.onLocaleChange(locale => {
console.log('Nuevo locale:', locale);
});
glob.setLocale('fr'); // → 'Nuevo locale: fr'
unsub(); // elimina el listener
```
---
## numr
Formateo de números usando `Intl.NumberFormat`.
### Creación independiente
```ts
import { createNumr } from '@/glob/numr_engine';
const numr = createNumr({ minDecimals: 0, maxDecimals: 2 }, 'es');
```
### `f(n, opts?, locale?)`
```ts
numr.f(1234567.89) // '1.234.567,89' (es)
numr.f(1234567.89, {}, 'en') // '1,234,567.89'
numr.f(1234.5, { maxDecimals: 0 }) // '1.235'
numr.f(0.1234, { minDecimals: 2, maxDecimals: 4 }) // '0,1234'
```
| Parámetro | Tipo | Default |
|---|---|---|
| `n` | `number` | — |
| `opts.minDecimals` | `number` | `0` |
| `opts.maxDecimals` | `number` | `2` |
| `locale` | `LocaleResolver` | defaultLocale de instancia |
---
## curr
Formateo y conversión de moneda.
### Definir monedas
```ts
import type { CurrDefinition } from '@/glob/curr_types';
import { p } from '@/ling/engine';
const EUR: CurrDefinition = {
symbol: '€',
code: 'EUR',
name: p({
es: { one: 'euro', other: 'euros' },
en: { one: 'euro', other: 'euros' },
}),
conversionValue: 1, // relativo a moneda base
};
```
### Creación independiente
```ts
import { createCurr } from '@/glob/curr_engine';
const curr = createCurr(
{ EUR, USD, MXN }, // definiciones
ling, // instancia LingInstance
{ selectedCurrency: 'EUR', decimals: 2 },
'es' // defaultLocale
);
```
### `f(amount, display?, locale?)`
```ts
curr.f(99.5) // '99,50 €' (symbol, es)
curr.f(99.5, 'symbol', 'en') // '99.50 €'
curr.f(99.5, 'code') // '99,50 EUR'
curr.f(1, 'name') // '1,00 euro'
curr.f(2, 'name') // '2,00 euros'
```
| `display` | Resultado ejemplo |
|---|---|
| `'symbol'` (default) | `99,50 €` |
| `'code'` | `99,50 EUR` |
| `'name'` | `99,50 euros` |
### Conversión
```ts
curr.convert(100, 'EUR', 'USD') // → 108 (si USD.conversionValue = 1.08)
curr.convertTo(100, 'USD') // convierte desde selectedCurrency
```
`conversionValue` puede ser un número estático o una función `() => number` para tasas dinámicas:
```ts
const USD: CurrDefinition = {
// ...
conversionValue: () => fetchLiveRate('USD'),
};
```
### Gestión de moneda activa
```ts
curr.getCurrency() // 'EUR'
curr.setCurrency('USD') // cambia la moneda activa
curr.get('EUR') // → CurrDefinition | undefined
```
---
## dati
Formateo de fechas y horas usando `Intl.DateTimeFormat`.
### Creación independiente
```ts
import { createDati } from '@/glob/dati_engine';
const dati = createDati({ selectedTimeFormat: '24h' }, 'es');
```
### `f(date, opts?, locale?)`
```ts
const d = new Date('2026-03-02T15:30:00');
dati.f(d) // '2 de marzo de 2026' (long, es)
dati.f(d, 'short') // '2/3/26'
dati.f(d, { date: 'long', time: 'short' }) // '2 de marzo de 2026, 15:30'
dati.f(d, { time: 'short' }, 'en') // '3:30 PM' (si timeFormat = '12h')
```
#### `DatiOptions`
| Valor | Resultado |
|---|---|
| `'long'` (default) | Solo fecha en formato largo |
| `'short'` | Solo fecha en formato corto |
| `{ date: 'long' }` | Solo fecha larga |
| `{ time: 'short' }` | Solo hora corta |
| `{ date: 'long', time: 'short' }` | Fecha y hora |
### Preferencias
```ts
dati.setTimeFormat('12h') // '12h' | '24h'
dati.getTimeFormat() // '12h'
dati.setDateOrder('MDY') // 'DMY' | 'MDY' | 'YMD'
dati.getDateOrder() // 'MDY'
```
> `dateOrder` está almacenado en el estado pero el orden real lo gestiona `Intl.DateTimeFormat` según el locale. Se expone para que la UI pueda reflejarlo.
---
## unit
Conversión y formateo de unidades de medida. Soporta dos sistemas: `metric` e `imperial`.
### Categorías disponibles
| Categoría | Base métrica | Base imperial | Unidades |
|---|---|---|---|
| `weight` | `kg` | `lb` | kg, g, lb, oz, t |
| `length` | `m` | `mi` | km, m, cm, mm, mi, ft, in, yd |
| `temp` | `c` | `f` | c, f, k |
| `volume` | `l` | `gal` | l, ml, m³, gal, fl oz, pt |
| `area` | `m²` | `ft²` | m², km², ha, ft², mi², ac |
### Creación independiente
```ts
import { createUnit } from '@/glob/unit_engine';
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
```
### Sistema activo
```ts
unit.setSystem('imperial')
unit.getSystem() // 'imperial'
```
### `category.f(value, display?, locale?)`
Formatea el valor en la unidad base del sistema activo:
```ts
unit.weight.f(70) // '70 kg' (metric, symbol)
unit.weight.f(70, 'name') // '70 kilogramos'
unit.weight.f(70, 'symbol', 'en') // '70 kg'
unit.setSystem('imperial');
unit.weight.f(70) // '154,32 lb'
```
### `category.fTo(value, targetUnit?, display?, locale?)`
Convierte desde la base métrica y formatea en la unidad destino:
```ts
unit.length.fTo(1000, 'km') // '1 km'
unit.temp.fTo(100, 'f') // '212 °F' (100°C → °F)
unit.area.fTo(10000, 'ha') // '1 ha'
```
Si se omite `targetUnit`, usa la base del sistema activo.
### `category.convert(value, from, to)`
Conversión numérica pura, sin formateo:
```ts
unit.weight.convert(1, 'kg', 'lb') // 2.204...
unit.temp.convert(100, 'c', 'f') // 212
unit.length.convert(1, 'mi', 'km') // 1.609...
```
La temperatura usa conversión directa (no por factor lineal), manejada internamente con `convertTemp()`.
### Personalizar categorías
Se pueden sobreescribir las categorías por defecto o añadir nuevas unidades:
```ts
const unit = createUnit(
{
weight: {
base: 'kg',
baseImperial: 'lb',
units: {
// solo las unidades que necesitas
kg: { factor: 1, system: 'metric', symbol: { es: 'kg', en: 'kg' }, name: ... },
},
},
},
{ selectedSystem: 'metric' },
'es'
);
```
---
## Referencia de tipos
### `GlobLocale`
```ts
type GlobLocale = SupportedLocale | (string & {});
```
Acepta cualquier código BCP 47 (`'es'`, `'en'`, `'fr'`, `'es-MX'`, etc.).
### `LocaleResolver`
```ts
type LocaleResolver = GlobLocale | (() => GlobLocale);
```
### `CurrDefinition`
```ts
interface CurrDefinition {
symbol : string;
code : string;
name : LingPluralFn<Record<never, never>>;
conversionValue?: number | (() => number | null) | null;
}
```
### `UnitDefinition`
```ts
interface UnitDefinition {
symbol : LingRecord; // { es: 'kg', en: 'kg' }
name : LingPluralFn<...>; // plural bilingüe
factor : number; // relativo a la base de la categoría
system : 'metric' | 'imperial';
}
```
La conversión entre unidades (excepto temperatura) se calcula como:
```
resultado = (valor × factor_origen) / factor_destino
```
donde `factor` es la equivalencia de cada unidad respecto a la base de la categoría (ej. `kg` tiene factor `1`, `g` tiene factor `0.001`).

@ -0,0 +1,53 @@
import type { GlobConfig, GlobInstance } from './glob_types';
import type { LingNode } from '@/ling/types';
import { createNumr } from './numr_engine';
import { createCurr } from './curr_engine';
import { createDati } from './dati_engine';
import { createUnit } from './unit_engine';
import type { GlobLocale } from './comn_types';
/**
* Crea una instancia integrada de globalización.
*
* El locale vive en `ling` — `glob` delega siempre a `ling.getLocale()`.
* Todos los módulos reciben `getLocale` como `defaultLocale`, por lo que
* leen el locale actual en cada llamada a `f()` sin necesidad de reconectarse.
*/
export function createGlob<S extends LingNode>(config: GlobConfig<S>): GlobInstance<S> {
const { ling } = config;
const listeners = new Set<(locale: GlobLocale) => void>();
// Fuente de verdad: siempre lee de ling, nunca almacena el locale localmente.
const getLocale = (): GlobLocale => ling.getLocale() as GlobLocale;
// Cada módulo recibe `getLocale` como resolver dinámico de locale.
const numr = createNumr(config.numr ?? {}, getLocale);
const curr = createCurr(config.curr?.definitions ?? {}, ling, config.curr ?? {}, getLocale);
const dati = createDati(config.dati ?? {}, getLocale);
const unit = createUnit(config.unit?.categories ?? {}, config.unit ?? {}, getLocale);
function setLocale(locale: GlobLocale): void {
ling.setLocale(locale as any);
listeners.forEach(fn => fn(locale));
}
/** Suscribe un listener al cambio de locale. Devuelve la función de desuscripción. */
function onLocaleChange(fn: (locale: GlobLocale) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
return {
getLocale,
setLocale,
onLocaleChange,
t : ling.t.bind(ling),
ts : ling.ts.bind(ling),
ling,
numr,
curr,
dati,
unit,
};
}

@ -0,0 +1,46 @@
// ==============================
// GLOB
// ==============================
import type { LingInstance, LingNode } from "../ling";
import type { CurrConfig, CurrInstance } from "./curr_types";
import type { DatiConfig, DatiInstance } from "./dati_types";
import type { NumrConfig, NumrInstance } from "./numr_types";
import type { GlobLocale } from "./comn_types";
import type { UnitConfig, UnitInstance } from "./unit_types";
/** Configuración de `createGlob`. Todos los módulos son opcionales. */
export interface GlobConfig<S extends LingNode> {
locale : GlobLocale;
/** Instancia de `ling` — es la fuente de verdad del locale compartido. */
ling : LingInstance<S>;
numr? : NumrConfig;
curr? : CurrConfig;
dati? : DatiConfig;
unit? : UnitConfig;
}
/** Instancia devuelta por `createGlob`. Punto de entrada unificado de globalización. */
export interface GlobInstance<S extends LingNode = LingNode> {
/** Cambia el locale en `ling` y notifica a todos los suscriptores registrados. */
setLocale : (locale: GlobLocale) => void;
/** Locale activo, delegado a `ling.getLocale()`. */
getLocale : () => GlobLocale;
/**
* Suscribe un callback al cambio de locale.
* @returns Función de desuscripción.
*/
onLocaleChange: (fn: (locale: GlobLocale) => void) => () => void;
/** Traducción simple, delegada a `ling.t`. */
t : LingInstance<S>['t'];
/** Traducción con plural, delegada a `ling.ts`. */
ts : LingInstance<S>['ts'];
numr : NumrInstance;
curr : CurrInstance;
dati : DatiInstance;
unit : UnitInstance;
/** Instancia `ling` subyacente, accesible para uso directo. */
ling : LingInstance<S>;
}

@ -0,0 +1,46 @@
import { defaultISOLocale } from '@/ling/consts';
import { resolveLocale } from './comn_engine';
import type { LocaleResolver } from './comn_types';
import type { NumrInstance } from './numr_types';
// ==============================
// 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 : { minDecimals?: number; maxDecimals?: number } = {},
defaultLocale: LocaleResolver = defaultISOLocale
): NumrInstance {
const defaults = {
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.
*/
function f(
n : number,
callOpts?: { minDecimals?: number; maxDecimals?: number },
locale : LocaleResolver = defaultLocale
): string {
const minDecimals = callOpts?.minDecimals ?? defaults.minDecimals;
const maxDecimals = callOpts?.maxDecimals ?? defaults.maxDecimals;
return new Intl.NumberFormat(resolveLocale(locale), {
minimumFractionDigits: minDecimals,
maximumFractionDigits: maxDecimals,
}).format(n);
}
return { f };
}

@ -0,0 +1,21 @@
// ==============================
// NUMR TYPES
// ==============================
import type { LocaleResolver } from "./comn_types";
export interface NumrInstance {
/**
* Formatea un número según el locale activo.
* Los `opts` de cada llamada sobreescriben los defaults de la instancia.
*/
f: (n: number, opts?: { minDecimals?: number; maxDecimals?: number }, locale?: LocaleResolver) => string;
}
/** 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;
}

@ -0,0 +1,226 @@
import { describe, it, expect } from 'vitest';
import { createCurr } from '@/glob/curr_engine';
import { createLing, p } from '@/ling/engine';
import type { CurrDefinition } from '../curr_types';
// =============================================================================
// FIXTURES
// =============================================================================
const ling = createLing({ common: { ok: { es: 'Aceptar', en: 'OK' } } } as const, 'es');
const definitions: Record<string, CurrDefinition> = {
EUR: {
symbol : '€',
code : 'EUR',
name : p({ es: { one: 'euro', other: 'euros' }, en: { one: 'euro', other: 'euros' } }),
conversionValue: 1,
},
USD: {
symbol : '$',
code : 'USD',
name : p({ es: { one: 'dólar', other: 'dólares' }, en: { one: 'dollar', other: 'dollars' } }),
conversionValue: 1.08,
},
MXN: {
symbol : '$',
code : 'MXN',
name : p({ es: { one: 'peso mexicano', other: 'pesos mexicanos' }, en: { one: 'Mexican peso', other: 'Mexican pesos' } }),
conversionValue: 18.5,
},
GBP: {
symbol : '£',
code : 'GBP',
name : p({ es: { one: 'libra esterlina', other: 'libras esterlinas' }, en: { one: 'pound sterling', other: 'pounds sterling' } }),
conversionValue: 0.85,
},
JPY: {
symbol : '¥',
code : 'JPY',
name : p({ es: { one: 'yen', other: 'yenes' }, en: { one: 'yen', other: 'yen' } }),
conversionValue: null, // sin conversión disponible
},
};
// =============================================================================
// CURR — Formato y conversión de moneda
// =============================================================================
describe('createCurr', () => {
describe('selectedCurrency', () => {
it('usa la primera moneda definida por defecto', () => {
const curr = createCurr(definitions, ling, {}, 'es');
expect(curr.getCurrency()).toBe('EUR');
});
it('acepta selectedCurrency en la configuración', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'MXN' }, 'es');
expect(curr.getCurrency()).toBe('MXN');
});
it('setCurrency cambia la moneda activa', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
curr.setCurrency('USD');
expect(curr.getCurrency()).toBe('USD');
});
it('setCurrency acepta minúsculas', () => {
const curr = createCurr(definitions, ling, {}, 'es');
curr.setCurrency('gbp');
expect(curr.getCurrency()).toBe('GBP');
});
});
describe('f() — display symbol (por defecto)', () => {
it('formatea con símbolo en es', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.f(99.5)).toBe('99,50 €');
});
it('formatea con símbolo en en', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'en');
expect(curr.f(99.5)).toBe('99.50 €');
});
it('usa selectedCurrency activa', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
curr.setCurrency('GBP');
expect(curr.f(99.5)).toBe('99,50 £');
});
});
describe('f() — display code', () => {
it('formatea con código ISO', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.f(99.5, 'code')).toBe('99,50 EUR');
});
});
describe('f() — display name (pluralización)', () => {
it('singular en es', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.f(1, 'name')).toBe('1,00 euro');
});
it('plural en es', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.f(99.5, 'name')).toBe('99,50 euros');
});
it('singular en en', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'en');
expect(curr.f(1, 'name')).toBe('1.00 euro');
});
it('plural MXN en es', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'MXN' }, 'es');
expect(curr.f(2, 'name')).toBe('2,00 pesos mexicanos');
});
});
describe('f() — locale reactivo via función', () => {
it('responde a cambios de locale sin recrear el módulo', () => {
let locale = 'es';
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, () => locale);
expect(curr.f(99.5)).toContain(',50 €');
locale = 'en';
expect(curr.f(99.5)).toContain('.50 €');
});
});
describe('f() — moneda no registrada', () => {
it('fallback al código ISO si la moneda no está definida', () => {
const curr = createCurr(definitions, ling, {}, 'es');
curr.setCurrency('XXX');
expect(curr.f(99.5)).toBe('99,50 XXX');
});
});
describe('convert()', () => {
it('convierte EUR a USD', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.convert(100, 'EUR', 'USD')).toBeCloseTo(108, 1);
});
it('convierte EUR a MXN', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.convert(100, 'EUR', 'MXN')).toBeCloseTo(1850, 0);
});
it('misma moneda devuelve el mismo valor', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.convert(100, 'EUR', 'EUR')).toBe(100);
});
it('devuelve null si conversionValue es null', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.convert(100, 'EUR', 'JPY')).toBeNull();
});
it('conversionValue como función dinámica', () => {
let rate = 1.08;
const defs = {
EUR: { ...definitions.EUR },
USD: { ...definitions.USD, conversionValue: () => rate },
};
const curr = createCurr(defs, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.convert(100, 'EUR', 'USD')).toBeCloseTo(108, 1);
rate = 1.20;
expect(curr.convert(100, 'EUR', 'USD')).toBeCloseTo(120, 1);
});
});
describe('convertTo()', () => {
it('convierte desde selectedCurrency a otra moneda', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
expect(curr.convertTo(100, 'USD')).toBeCloseTo(108, 1);
});
it('respeta la moneda activa cambiada con setCurrency', () => {
const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es');
curr.setCurrency('USD');
expect(curr.convertTo(108, 'EUR')).toBeCloseTo(100, 1);
});
});
describe('get()', () => {
it('devuelve la definición de una moneda registrada', () => {
const curr = createCurr(definitions, ling, {}, 'es');
expect(curr.get('EUR')?.symbol).toBe('€');
});
it('devuelve undefined para moneda no registrada', () => {
const curr = createCurr(definitions, ling, {}, 'es');
expect(curr.get('XXX')).toBeUndefined();
});
it('acepta minúsculas', () => {
const curr = createCurr(definitions, ling, {}, 'es');
expect(curr.get('eur')).toBeDefined();
});
});
});

@ -0,0 +1,159 @@
import { describe, it, expect } from 'vitest';
import { createDati } from '@/glob/dati_engine';
const DATE = new Date(2026, 1, 28, 10, 30, 0); // 28 feb 2026, 10:30:00
describe('createDati', () => {
describe('locale reactivo via función', () => {
it('responde a cambios de locale sin recrear el módulo', () => {
let locale = 'es';
const dati = createDati({}, () => locale);
expect(dati.f(DATE)).toBe('28 de febrero de 2026');
locale = 'en';
expect(dati.f(DATE)).toBe('February 28, 2026');
});
it('locale por llamada tiene prioridad sobre defaultLocale', () => {
let locale = 'es';
const dati = createDati({}, () => locale);
expect(dati.f(DATE, 'long', 'de')).toBe('28. Februar 2026');
});
});
describe('f() — sin opciones (fecha larga por defecto)', () => {
it('formatea fecha larga en es', () => {
const dati = createDati({}, 'es');
expect(dati.f(DATE)).toBe('28 de febrero de 2026');
});
it('formatea fecha larga en en', () => {
const dati = createDati({}, 'en');
expect(dati.f(DATE)).toBe('February 28, 2026');
});
it('formatea fecha larga en de', () => {
const dati = createDati({}, 'de');
expect(dati.f(DATE)).toBe('28. Februar 2026');
});
it('formatea fecha larga en fr', () => {
const dati = createDati({}, 'fr');
expect(dati.f(DATE)).toBe('28 février 2026');
});
});
describe('f() — opción string shortcut', () => {
it('short — fecha corta en es', () => {
const dati = createDati({}, 'es');
expect(dati.f(DATE, 'short')).toBe('28/2/26');
});
it('short — fecha corta en en', () => {
const dati = createDati({}, 'en');
expect(dati.f(DATE, 'short')).toBe('2/28/26');
});
it('long equivale a sin opciones', () => {
const dati = createDati({}, 'es');
expect(dati.f(DATE, 'long')).toBe(dati.f(DATE));
});
});
describe('f() — solo fecha', () => {
it('date long en es', () => {
const dati = createDati({}, 'es');
expect(dati.f(DATE, { date: 'long' })).toBe('28 de febrero de 2026');
});
it('date short en es', () => {
const dati = createDati({}, 'es');
expect(dati.f(DATE, { date: 'short' })).toBe('28/2/26');
});
});
describe('f() — solo hora', () => {
it('time short 24h en es', () => {
const dati = createDati({ selectedTimeFormat: '24h' }, 'es');
expect(dati.f(DATE, { time: 'short' })).toBe('10:30');
});
it('time short 12h en en', () => {
const dati = createDati({ selectedTimeFormat: '12h' }, 'en');
expect(dati.f(DATE, { time: 'short' })).toBe('10:30 AM');
});
it('time long 24h en es', () => {
const dati = createDati({ selectedTimeFormat: '24h' }, 'es');
expect(dati.f(DATE, { time: 'long' })).toContain('10:30:00');
});
});
describe('f() — fecha y hora combinadas', () => {
it('date long + time short en es', () => {
const dati = createDati({ selectedTimeFormat: '24h' }, 'es');
const result = dati.f(DATE, { date: 'long', time: 'short' });
expect(result).toContain('28 de febrero de 2026');
expect(result).toContain('10:30');
});
it('date short + time short en en 12h', () => {
const dati = createDati({ selectedTimeFormat: '12h' }, 'en');
const result = dati.f(DATE, { date: 'short', time: 'short' });
expect(result).toContain('2/28/26');
expect(result).toContain('10:30 AM');
});
});
describe('setTimeFormat / getTimeFormat', () => {
it('cambia el formato de hora', () => {
const dati = createDati({ selectedTimeFormat: '12h' }, 'es');
const result12h = dati.f(DATE, { time: 'short' });
const result24h = new Intl.DateTimeFormat('es', { timeStyle: 'short', hour12: false }).format(DATE);
// 12h y 24h deben ser distintos
expect(result12h).not.toBe(result24h);
dati.setTimeFormat('24h');
expect(dati.f(DATE, { time: 'short' })).toBe(result24h);
});
it('getTimeFormat devuelve el valor actual', () => {
const dati = createDati({ selectedTimeFormat: '12h' }, 'es');
expect(dati.getTimeFormat()).toBe('12h');
dati.setTimeFormat('24h');
expect(dati.getTimeFormat()).toBe('24h');
});
});
describe('setDateOrder / getDateOrder', () => {
it('getDateOrder devuelve el valor configurado', () => {
const dati = createDati({ selectedDateOrder: 'MDY' }, 'es');
expect(dati.getDateOrder()).toBe('MDY');
});
it('setDateOrder cambia el orden', () => {
const dati = createDati({}, 'es');
dati.setDateOrder('YMD');
expect(dati.getDateOrder()).toBe('YMD');
});
});
});

@ -0,0 +1,238 @@
import { describe, it, expect, vi } from 'vitest';
import { createGlob } from '@/glob/glob_engine';
import { createLing, p } from '@/ling/engine';
import type { CurrDefinition } from '../curr_types';
// =============================================================================
// FIXTURES
// =============================================================================
const schema = {
common: {
ok : { es: 'Aceptar', en: 'OK' },
cancel : { es: 'Cancelar', en: 'Cancel' },
},
checkout: {
pay: { es: 'Pagar', en: 'Pay' },
},
} as const;
const currDefinitions: Record<string, CurrDefinition> = {
EUR: {
symbol : '€',
code : 'EUR',
name : p({ es: { one: 'euro', other: 'euros' }, en: { one: 'euro', other: 'euros' } }),
conversionValue: 1,
},
USD: {
symbol : '$',
code : 'USD',
name : p({ es: { one: 'dólar', other: 'dólares' }, en: { one: 'dollar', other: 'dollars' } }),
conversionValue: 1.08,
},
};
function makeGlob(locale = 'es') {
const ling = createLing(schema, locale as any);
return createGlob({
locale,
ling,
numr : { minDecimals: 0, maxDecimals: 2 },
curr : { definitions: currDefinitions, selectedCurrency: 'EUR' },
dati : { selectedTimeFormat: '24h' },
unit : { selectedSystem: 'metric' },
});
}
const DATE = new Date(2026, 1, 28, 10, 30, 0);
// =============================================================================
// GLOB — Integración
// =============================================================================
describe('createGlob', () => {
describe('locale', () => {
it('getLocale devuelve el locale inicial', () => {
const glob = makeGlob('es');
expect(glob.getLocale()).toBe('es');
});
it('setLocale cambia el locale', () => {
const glob = makeGlob('es');
glob.setLocale('en');
expect(glob.getLocale()).toBe('en');
});
it('setLocale sincroniza ling', () => {
const glob = makeGlob('es');
expect(glob.t('common.ok')).toBe('Aceptar');
glob.setLocale('en');
expect(glob.t('common.ok')).toBe('OK');
});
it('setLocale sincroniza todos los módulos via LocaleResolver', () => {
const glob = makeGlob('es');
expect(glob.numr.f(1234.5)).toContain(',5'); // es — coma decimal
expect(glob.dati.f(DATE)).toContain('febrero');
expect(glob.curr.f(99)).toContain(',00 €');
glob.setLocale('en');
expect(glob.numr.f(1234.5)).toContain('.5'); // en — punto decimal
expect(glob.dati.f(DATE)).toContain('February');
expect(glob.curr.f(99)).toContain('.00 €');
});
});
describe('onLocaleChange', () => {
it('notifica a los listeners al cambiar locale', () => {
const glob = makeGlob('es');
const fn = vi.fn();
glob.onLocaleChange(fn);
glob.setLocale('en');
expect(fn).toHaveBeenCalledWith('en');
});
it('devuelve una función de unsubscribe', () => {
const glob = makeGlob('es');
const fn = vi.fn();
const unsub = glob.onLocaleChange(fn);
unsub();
glob.setLocale('en');
expect(fn).not.toHaveBeenCalled();
});
it('notifica a múltiples listeners', () => {
const glob = makeGlob('es');
const fn1 = vi.fn();
const fn2 = vi.fn();
glob.onLocaleChange(fn1);
glob.onLocaleChange(fn2);
glob.setLocale('en');
expect(fn1).toHaveBeenCalledWith('en');
expect(fn2).toHaveBeenCalledWith('en');
});
});
describe('t() — acceso directo a ling', () => {
it('traduce correctamente en es', () => {
const glob = makeGlob('es');
expect(glob.t('common.ok')).toBe('Aceptar');
});
it('traduce correctamente tras cambio de locale', () => {
const glob = makeGlob('es');
glob.setLocale('en');
expect(glob.t('common.ok')).toBe('OK');
});
});
describe('numr — integrado con glob', () => {
it('formatea en el locale de glob', () => {
const glob = makeGlob('es');
expect(glob.numr.f(1234.5)).toContain(',5');
});
it('responde al cambio de locale de glob', () => {
const glob = makeGlob('es');
glob.setLocale('en');
expect(glob.numr.f(1234.5)).toContain('.5');
});
});
describe('curr — integrado con glob', () => {
it('formatea en el locale de glob', () => {
const glob = makeGlob('es');
expect(glob.curr.f(99)).toContain(',00 €');
});
it('responde al cambio de locale de glob', () => {
const glob = makeGlob('es');
glob.setLocale('en');
expect(glob.curr.f(99)).toContain('.00 €');
});
it('setCurrency es independiente del locale', () => {
const glob = makeGlob('es');
glob.curr.setCurrency('USD');
expect(glob.curr.f(99)).toContain('$');
expect(glob.getLocale()).toBe('es'); // locale no cambia
});
it('convert funciona tras cambio de locale', () => {
const glob = makeGlob('es');
glob.setLocale('en');
expect(glob.curr.convert(100, 'EUR', 'USD')).toBeCloseTo(108, 1);
});
});
describe('dati — integrado con glob', () => {
it('formatea en el locale de glob', () => {
const glob = makeGlob('es');
expect(glob.dati.f(DATE)).toContain('febrero');
});
it('responde al cambio de locale de glob', () => {
const glob = makeGlob('es');
glob.setLocale('en');
expect(glob.dati.f(DATE)).toContain('February');
});
it('setTimeFormat es independiente del locale', () => {
const glob = makeGlob('es');
glob.dati.setTimeFormat('12h');
expect(glob.dati.getTimeFormat()).toBe('12h');
expect(glob.getLocale()).toBe('es');
});
});
describe('unit — integrado con glob', () => {
it('formatea en el locale de glob', () => {
const glob = makeGlob('es');
expect(glob.unit.weight.f(1, 'name')).toBe('1 kilogramo');
});
it('responde al cambio de locale de glob', () => {
const glob = makeGlob('es');
glob.setLocale('en');
expect(glob.unit.weight.f(1, 'name')).toBe('1 kilogram');
});
it('setSystem es independiente del locale', () => {
const glob = makeGlob('es');
glob.unit.setSystem('imperial');
expect(glob.unit.getSystem()).toBe('imperial');
expect(glob.getLocale()).toBe('es');
});
it('setSystem + setLocale son ortogonales', () => {
const glob = makeGlob('es');
glob.unit.setSystem('imperial');
glob.setLocale('en');
// sistema imperial, texto en inglés
expect(glob.unit.weight.f(1, 'name')).toBe('1 pound');
});
});
});

@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import { createNumr } from '../numr_engine';
// =============================================================================
// NUMR — Formato de números
// =============================================================================
describe('createNumr', () => {
describe('defaultLocale', () => {
it('acepta una función como defaultLocale', () => {
let locale = 'es';
const numr = createNumr({}, () => locale);
expect(numr.f(1234.5)).toBe('1234,5');
locale = 'en';
expect(numr.f(1234.5)).toBe('1,234.5');
});
it('acepta un string como defaultLocale', () => {
const numr = createNumr({}, 'en');
expect(numr.f(1234.5)).toBe('1,234.5');
});
});
describe('f() — formato básico', () => {
it('formatea enteros en es', () => {
const numr = createNumr({}, 'es');
expect(numr.f(1000000)).toBe('1.000.000');
});
it('formatea enteros en en', () => {
const numr = createNumr({}, 'en');
expect(numr.f(1000000)).toBe('1,000,000');
});
it('formatea decimales en es', () => {
const numr = createNumr({}, 'es');
expect(numr.f(1234.56)).toBe('1234,56');
});
it('formatea decimales en en', () => {
const numr = createNumr({}, 'en');
expect(numr.f(1234.56)).toBe('1,234.56');
});
it('formatea en de (alemán)', () => {
const numr = createNumr({}, 'de');
expect(numr.f(1234.56)).toBe('1.234,56');
});
it('formatea cero', () => {
const numr = createNumr({}, 'es');
expect(numr.f(0)).toBe('0');
});
it('formatea números negativos', () => {
const numr = createNumr({}, 'es');
expect(numr.f(-1234.5)).toBe('-1234,5');
});
});
describe('f() — opciones de decimales', () => {
it('respeta minDecimals del constructor', () => {
const numr = createNumr({ minDecimals: 2 }, 'en');
expect(numr.f(1)).toBe('1.00');
expect(numr.f(1.5)).toBe('1.50');
});
it('respeta maxDecimals del constructor', () => {
const numr = createNumr({ maxDecimals: 0 }, 'en');
expect(numr.f(1234.56)).toBe('1,235');
});
it('admite opciones por llamada que sobreescriben el constructor', () => {
const numr = createNumr({ maxDecimals: 2 }, 'en');
expect(numr.f(1.5, { minDecimals: 2 })).toBe('1.50');
expect(numr.f(1234.5, { maxDecimals: 0 })).toBe('1,235');
});
it('admite locale por llamada que sobreescribe el defaultLocale', () => {
const numr = createNumr({}, 'es');
expect(numr.f(1234.5, {}, 'en')).toBe('1,234.5');
});
it('minDecimals 0 y maxDecimals 0 — sin decimales', () => {
const numr = createNumr({ minDecimals: 0, maxDecimals: 0 }, 'en');
expect(numr.f(1234.9)).toBe('1,235');
});
it('minDecimals 4 — fuerza 4 decimales', () => {
const numr = createNumr({ minDecimals: 4, maxDecimals: 4 }, 'en');
expect(numr.f(1.5)).toBe('1.5000');
});
});
describe('locale reactivo via función', () => {
it('responde a cambios de locale sin recrear el módulo', () => {
let locale = 'es';
const numr = createNumr({}, () => locale);
expect(numr.f(1234.56)).toContain(',56');
locale = 'en';
expect(numr.f(1234.56)).toContain('.56');
});
it('locale por llamada tiene prioridad sobre defaultLocale', () => {
let locale = 'es';
const numr = createNumr({}, () => locale);
expect(numr.f(1234.5, {}, 'de')).toBe('1.234,5');
});
});
describe('locales extendidos', () => {
it('acepta locales extendidos como es-MX', () => {
const numr = createNumr({}, 'es-MX');
expect(() => numr.f(1234.5)).not.toThrow();
});
it('formatea en ja (japonés)', () => {
const numr = createNumr({}, 'ja');
expect(numr.f(1234)).toBe('1,234');
});
});
});

@ -0,0 +1,241 @@
import { describe, it, expect } from 'vitest';
import { createUnit } from '../unit_engine';
describe('createUnit', () => {
describe('selectedSystem', () => {
it('usa metric por defecto', () => {
const unit = createUnit({}, {}, 'es');
expect(unit.getSystem()).toBe('metric');
});
it('acepta imperial en la configuración', () => {
const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es');
expect(unit.getSystem()).toBe('imperial');
});
it('setSystem cambia el sistema activo', () => {
const unit = createUnit({}, {}, 'es');
unit.setSystem('imperial');
expect(unit.getSystem()).toBe('imperial');
});
it('el sistema reactivo afecta a f()', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.weight.f(33)).toContain('kg');
unit.setSystem('imperial');
expect(unit.weight.f(33)).toContain('lb');
});
});
describe('locale reactivo via función', () => {
it('responde a cambios de locale sin recrear el módulo', () => {
let locale = 'es';
const unit = createUnit({}, { selectedSystem: 'metric' }, () => locale);
expect(unit.weight.f(1, 'name')).toBe('1 kilogramo');
locale = 'en';
expect(unit.weight.f(1, 'name')).toBe('1 kilogram');
});
});
// =========================================================================
// WEIGHT
// =========================================================================
describe('weight', () => {
it('f() metric — unidad base kg', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.weight.f(33)).toContain('kg');
});
it('f() imperial — unidad base lb', () => {
const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es');
expect(unit.weight.f(33)).toContain('lb');
});
it('f() display name singular en es', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.weight.f(1, 'name')).toBe('1 kilogramo');
});
it('f() display name plural en es', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.weight.f(5, 'name')).toBe('5 kilogramos');
});
it('f() display name en en', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'en');
expect(unit.weight.f(1, 'name')).toBe('1 kilogram');
});
it('f() display name imperial en en', () => {
const unit = createUnit({}, { selectedSystem: 'imperial' }, 'en');
expect(unit.weight.f(1, 'name')).toBe('1 pound');
});
it('fTo() convierte kg a lb', () => {
const unit = createUnit({}, { selectedSystem: 'imperial' }, 'en');
const result = unit.weight.fTo(1);
expect(result).toContain('lb');
expect(result).toContain('2.2');
});
it('fTo() con unidad destino explícita', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.weight.fTo(1, 'oz')).toContain('oz');
});
it('fTo() sin conversión en metric devuelve kg', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.weight.fTo(33)).toContain('kg');
});
it('convert() kg a lb', () => {
const unit = createUnit();
expect(unit.weight.convert(1, 'kg', 'lb')).toBeCloseTo(2.2046, 2);
});
it('convert() kg a g', () => {
const unit = createUnit();
expect(unit.weight.convert(1, 'kg', 'g')).toBeCloseTo(1000, 0);
});
it('convert() misma unidad', () => {
const unit = createUnit();
expect(unit.weight.convert(33, 'kg', 'kg')).toBe(33);
});
});
// =========================================================================
// LENGTH
// =========================================================================
describe('length', () => {
it('f() metric — unidad base m', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.length.f(100)).toContain('m');
});
it('f() imperial — unidad base mi', () => {
const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es');
expect(unit.length.f(1609)).toContain('mi');
});
it('convert() m a km', () => {
const unit = createUnit();
expect(unit.length.convert(1000, 'm', 'km')).toBeCloseTo(1, 5);
});
it('convert() km a mi', () => {
const unit = createUnit();
expect(unit.length.convert(1.609344, 'km', 'mi')).toBeCloseTo(1, 3);
});
});
// =========================================================================
// TEMP
// =========================================================================
describe('temp', () => {
it('f() metric — °C', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.temp.f(100)).toContain('°C');
});
it('f() imperial — °F', () => {
const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es');
expect(unit.temp.f(212)).toContain('°F');
});
it('fTo() convierte °C a °F', () => {
const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es');
const result = unit.temp.fTo(100);
expect(result).toContain('°F');
expect(result).toContain('212');
});
it('convert() °C a °F', () => {
const unit = createUnit();
expect(unit.temp.convert(100, 'c', 'f')).toBeCloseTo(212, 0);
});
it('convert() °C a K', () => {
const unit = createUnit();
expect(unit.temp.convert(0, 'c', 'k')).toBeCloseTo(273.15, 1);
});
it('convert() °F a °C', () => {
const unit = createUnit();
expect(unit.temp.convert(32, 'f', 'c')).toBeCloseTo(0, 1);
});
});
// =========================================================================
// VOLUME
// =========================================================================
describe('volume', () => {
it('f() metric — l', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.volume.f(2)).toContain('l');
});
it('f() imperial — gal', () => {
const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es');
expect(unit.volume.f(2)).toContain('gal');
});
it('convert() l a ml', () => {
const unit = createUnit();
expect(unit.volume.convert(1, 'l', 'ml')).toBeCloseTo(1000, 0);
});
it('convert() l a gal', () => {
const unit = createUnit();
expect(unit.volume.convert(3.78541, 'l', 'gal')).toBeCloseTo(1, 2);
});
});
// =========================================================================
// AREA
// =========================================================================
describe('area', () => {
it('f() metric — m²', () => {
const unit = createUnit({}, { selectedSystem: 'metric' }, 'es');
expect(unit.area.f(100)).toContain('m²');
});
it('f() imperial — ft²', () => {
const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es');
expect(unit.area.f(100)).toContain('ft²');
});
it('convert() m² a ft²', () => {
const unit = createUnit();
expect(unit.area.convert(1, 'm2', 'ft2')).toBeCloseTo(10.7639, 2);
});
it('convert() ha a m²', () => {
const unit = createUnit();
expect(unit.area.convert(1, 'ha', 'm2')).toBeCloseTo(10000, 0);
});
});
});

@ -0,0 +1,152 @@
import type { LocaleResolver, GlobLocale } from "./comn_types";
import type { UnitCategory, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types";
import { resolveLocale } from "./comn_engine";
import { defaultCategories } from "./unit_langs";
import { defaultISOLocale } from "@/ling/consts";
// ==============================
// CONVERSIÓN DE TEMPERATURA
// ==============================
/**
* Convierte temperatura entre Celsius, Fahrenheit y Kelvin.
* Usa Celsius como pivote intermedio para todas las conversiones.
*/
function convertTemp(value: number, from: string, to: string): number {
if (from === to) return value;
let celsius: number;
switch (from) {
case 'c': celsius = value; break;
case 'f': celsius = (value - 32) * 5 / 9; break;
case 'k': celsius = value - 273.15; break;
default: celsius = value;
}
switch (to) {
case 'c': return celsius;
case 'f': return celsius * 9 / 5 + 32;
case 'k': return celsius + 273.15;
default: return celsius;
}
}
// ==============================
// CATEGORY ENGINE
// ==============================
/**
* Crea las funciones de formato y conversión para una categoría de unidades.
* El sistema activo se lee en cada llamada via `getSystem()`, por lo que
* responde a `setSystem()` sin necesidad de recrear la categoría.
*/
function createCategory(
categoryKey : UnitCategoryKey,
category : UnitCategory,
getSystem : () => UnitSystem,
defaultLocale: LocaleResolver,
): UnitCategoryInstance {
/** Devuelve la unidad base según el sistema activo en este momento. */
function resolveTargetUnit(): string {
return getSystem() === 'imperial' ? category.baseImperial : category.base;
}
/** Formatea `value` en la unidad `unitKey` con símbolo o nombre localizado. */
function formatValue(value: number, unitKey: string, display: UnitDisplay, locale: GlobLocale): string {
const def = category.units[unitKey];
if (!def) return `${value} ${unitKey}`;
const formatted = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
// Extrae solo el código de idioma (sin región) para buscar en symbol/name.
const baseLocale = locale.split('-')[0] as keyof typeof def.symbol;
if (display === 'symbol') {
const symbol = def.symbol[baseLocale] ?? def.symbol['es'];
return `${formatted} ${symbol}`;
}
const record = def.name({ count: value });
const name = record[baseLocale as keyof typeof record] ?? record['es'];
return `${formatted} ${name}`;
}
/**
* Conversión numérica entre dos unidades de la categoría.
* Para temperatura delega en `convertTemp` (conversión no lineal).
* Para el resto: `resultado = (valor × factorOrigen) / factorDestino`
*/
function convert(value: number, from: string, to: string): number {
if (from === to) return value;
if (categoryKey === 'temp') return convertTemp(value, from, to);
const fromDef = category.units[from];
const toDef = category.units[to];
if (!fromDef || !toDef) return value;
return (value * fromDef.factor) / toDef.factor;
}
/** Formatea `value` en la unidad base del sistema activo. */
function f(value: number, display: UnitDisplay = 'symbol', locale: LocaleResolver = defaultLocale): string {
const loc = resolveLocale(locale);
const unit = resolveTargetUnit();
return formatValue(value, unit, display, loc);
}
/**
* Convierte `value` desde la base métrica de la categoría hacia `targetUnit`
* y lo formatea. Si se omite `targetUnit`, usa la base del sistema activo.
*/
function fTo(value: number, targetUnit?: string, display: UnitDisplay = 'symbol', locale: LocaleResolver = defaultLocale): string {
const loc = resolveLocale(locale);
const to = targetUnit ?? resolveTargetUnit();
const converted = convert(value, category.base, to);
return formatValue(converted, to, display, loc);
}
return { f, fTo, convert };
}
// ==============================
// UNIT ENGINE
// ==============================
/**
* Crea un módulo de unidades de medida con conversión y formateo.
*
* Las categorías pasadas en `categories` sobreescriben las de `defaultCategories`,
* lo que permite reemplazar categorías completas sin tocar el resto.
*
* @param categories - Categorías personalizadas (parcial).
* @param opts - Sistema activo inicial.
* @param defaultLocale - Locale o resolver para todas las categorías.
*/
export function createUnit(
categories : Partial<Record<UnitCategoryKey, UnitCategory>> = {},
opts : { selectedSystem?: UnitSystem } = {},
defaultLocale: LocaleResolver = defaultISOLocale
): UnitInstance {
let currentSystem: UnitSystem = opts.selectedSystem ?? 'metric';
function getSystem(): UnitSystem { return currentSystem; }
function setSystem(s: UnitSystem) { currentSystem = s; }
// Las categorías personalizadas sobreescriben las definiciones por defecto.
const merged: Record<UnitCategoryKey, UnitCategory> = {
...defaultCategories,
...categories,
};
const makeCategory = (key: UnitCategoryKey) =>
createCategory(key, merged[key], getSystem, defaultLocale);
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/engine';
import type { LingNode } from '@/ling/types';
/**
* 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 unitsSchema = {
weight: {
kg: { symbol: { es: 'kg', en: 'kg' }, name: p({ es: { one: 'kilogramo', other: 'kilogramos' }, en: { one: 'kilogram', other: 'kilograms' } }) },
g: { symbol: { es: 'g', en: 'g' }, name: p({ es: { one: 'gramo', other: 'gramos' }, en: { one: 'gram', other: 'grams' } }) },
lb: { symbol: { es: 'lb', en: 'lb' }, name: p({ es: { one: 'libra', other: 'libras' }, en: { one: 'pound', other: 'pounds' } }) },
oz: { symbol: { es: 'oz', en: 'oz' }, name: p({ es: { one: 'onza', other: 'onzas' }, en: { one: 'ounce', other: 'ounces' } }) },
t: { symbol: { es: 't', en: 't' }, name: p({ es: { one: 'tonelada', other: 'toneladas' }, en: { one: 'tonne', other: 'tonnes' } }) },
},
length: {
km: { symbol: { es: 'km', en: 'km' }, name: p({ es: { one: 'kilómetro', other: 'kilómetros' }, en: { one: 'kilometer', other: 'kilometers' } }) },
m: { symbol: { es: 'm', en: 'm' }, name: p({ es: { one: 'metro', other: 'metros' }, en: { one: 'meter', other: 'meters' } }) },
cm: { symbol: { es: 'cm', en: 'cm' }, name: p({ es: { one: 'centímetro', other: 'centímetros' }, en: { one: 'centimeter', other: 'centimeters' } }) },
mm: { symbol: { es: 'mm', en: 'mm' }, name: p({ es: { one: 'milímetro', other: 'milímetros' }, en: { one: 'millimeter', other: 'millimeters' } }) },
mi: { symbol: { es: 'mi', en: 'mi' }, name: p({ es: { one: 'milla', other: 'millas' }, en: { one: 'mile', other: 'miles' } }) },
ft: { symbol: { es: 'ft', en: 'ft' }, name: p({ es: { one: 'pie', other: 'pies' }, en: { one: 'foot', other: 'feet' } }) },
in: { symbol: { es: 'in', en: 'in' }, name: p({ es: { one: 'pulgada', other: 'pulgadas' }, en: { one: 'inch', other: 'inches' } }) },
yd: { symbol: { es: 'yd', en: 'yd' }, name: p({ es: { one: 'yarda', other: 'yardas' }, en: { one: 'yard', other: 'yards' } }) },
},
temp: {
c: { symbol: { es: '°C', en: '°C' }, name: p({ es: { one: 'grado Celsius', other: 'grados Celsius' }, en: { one: 'degree Celsius', other: 'degrees Celsius' } }) },
f: { symbol: { es: '°F', en: '°F' }, name: p({ es: { one: 'grado Fahrenheit', other: 'grados Fahrenheit' }, en: { one: 'degree Fahrenheit', other: 'degrees Fahrenheit' } }) },
k: { symbol: { es: 'K', en: 'K' }, name: p({ es: { one: 'kelvin', other: 'kelvins' }, en: { one: 'kelvin', other: 'kelvins' } }) },
},
volume: {
l: { symbol: { es: 'l', en: 'l' }, name: p({ es: { one: 'litro', other: 'litros' }, en: { one: 'liter', other: 'liters' } }) },
ml: { symbol: { es: 'ml', en: 'ml' }, name: p({ es: { one: 'mililitro', other: 'mililitros' }, en: { one: 'milliliter', other: 'milliliters' } }) },
m3: { symbol: { es: 'm³', en: 'm³' }, name: p({ es: { one: 'metro cúbico', other: 'metros cúbicos'}, en: { one: 'cubic meter', other: 'cubic meters' } }) },
gal: { symbol: { es: 'gal', en: 'gal' }, name: p({ es: { one: 'galón', other: 'galones' }, en: { one: 'gallon', other: 'gallons' } }) },
floz: { symbol: { 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: { symbol: { es: 'pt', en: 'pt' }, name: p({ es: { one: 'pinta', other: 'pintas' }, en: { one: 'pint', other: 'pints' } }) },
},
area: {
m2: { symbol: { es: 'm²', en: 'm²' }, name: p({ es: { one: 'metro cuadrado', other: 'metros cuadrados' }, en: { one: 'square meter', other: 'square meters' } }) },
km2: { symbol: { es: 'km²', en: 'km²' }, name: p({ es: { one: 'kilómetro cuadrado', other: 'kilómetros cuadrados' }, en: { one: 'square kilometer', other: 'square kilometers' } }) },
ha: { symbol: { es: 'ha', en: 'ha' }, name: p({ es: { one: 'hectárea', other: 'hectáreas' }, en: { one: 'hectare', other: 'hectares' } }) },
ft2: { symbol: { es: 'ft²', en: 'ft²' }, name: p({ es: { one: 'pie cuadrado', other: 'pies cuadrados' }, en: { one: 'square foot', other: 'square feet' } }) },
mi2: { symbol: { es: 'mi²', en: 'mi²' }, name: p({ es: { one: 'milla cuadrada', other: 'millas cuadradas' }, en: { one: 'square mile', other: 'square miles' } }) },
ac: { symbol: { es: 'ac', en: 'ac' }, name: p({ es: { one: 'acre', other: 'acres' }, en: { one: 'acre', other: 'acres' } }) },
},
} satisfies LingNode;
export type UnitsSchema = typeof unitsSchema;

@ -0,0 +1,70 @@
// ==============================
// UNIT TYPES
// ==============================
import type { LingPluralFn, LingRecord } from "../ling";
import type { LocaleResolver } from "./comn_types";
/** Cómo se muestra la unidad: símbolo abreviado o nombre completo. */
export type UnitDisplay = 'symbol' | 'name';
/** Sistema de medida activo. Determina la unidad base usada en `f()`. */
export type UnitSystem = 'metric' | 'imperial';
/** Definición de una unidad dentro de una categoría. */
export interface UnitDefinition {
/** Símbolo localizado, ej. `{ es: 'kg', en: 'kg' }`. */
symbol : LingRecord;
/** Función plural bilingüe para el nombre de la unidad. */
name : LingPluralFn<Record<never, never>>;
/**
* Factor de conversión respecto a la base de la categoría.
* Fórmula: `resultado = (valor × factorOrigen) / factorDestino`
* Excepción: `temp` no usa factores (conversión no lineal).
*/
factor : number;
system : UnitSystem;
}
/** Configuración de `createUnit`. */
export interface UnitConfig {
/** Sistema activo al iniciar. Default: `'metric'`. */
selectedSystem?: UnitSystem;
/** Categorías personalizadas que sobreescriben las de `defaultCategories`. */
categories?: Partial<Record<UnitCategoryKey, UnitCategory>>;
}
/** Definición de una categoría de unidades (peso, longitud, etc.). */
export interface UnitCategory {
/** Unidad base del sistema métrico (ej. `'kg'`, `'m'`). */
base : string;
/** Unidad base del sistema imperial (ej. `'lb'`, `'mi'`). */
baseImperial : string;
units : Record<string, UnitDefinition>;
}
/** Categorías de unidades disponibles. */
export type UnitCategoryKey = 'weight' | 'length' | 'temp' | 'volume' | 'area';
export interface UnitCategoryInstance {
/** Formatea `value` en la unidad base del sistema activo. */
f : (value: number, display?: UnitDisplay, locale?: LocaleResolver) => string;
/**
* Convierte `value` desde la base métrica hacia `targetUnit` y lo formatea.
* Si se omite `targetUnit`, usa la base del sistema activo.
*/
fTo : (value: number, targetUnit?: string, display?: UnitDisplay, locale?: LocaleResolver) => string;
/** Conversión numérica pura entre dos unidades de la categoría. Sin formateo. */
convert: (value: number, from: string, to: string) => number;
}
export interface UnitInstance {
weight : UnitCategoryInstance;
length : UnitCategoryInstance;
temp : UnitCategoryInstance;
volume : UnitCategoryInstance;
area : UnitCategoryInstance;
setSystem: (system: UnitSystem) => void;
getSystem: () => UnitSystem;
}

@ -140,10 +140,6 @@ export function createLing<S extends LingNode>(
// t() solo orquesta: busca → resuelve → traduce.
const finalValue = resolveValue(rawValue, args, 0);
if (typeof finalValue === 'function') {
return tsRecord(finalValue(args[0]), path, args[0]);
}
if (isLingRecord(finalValue)) {
return tsRecord(finalValue, path, args[0]);
}
@ -242,6 +238,6 @@ export const p = (config: PluralConfig) =>
// Logger por defecto — console puro, sin dependencias externas.
// Se reemplaza con setLogger() una vez logr está inicializado.
const consoleLogger: LingLogger = {
warn : (category, message) => isDev() && console.warn (message),
error: (category, message) => isDev() && console.error(message),
warn : (_category, message) => isDev() && console.warn (message),
error: (_category, message) => isDev() && console.error(message),
};

@ -57,7 +57,7 @@ if (import.meta.env.DEV) {
Los cuatro métodos de log comparten la misma firma:
```ts
logr.debug(category, message, context?)
logr.debug(category, message, context?claude)
logr.info (category, message, context?)
logr.warn (category, message, context?)
logr.error(category, message, context?)

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLogr, LogLevel } from '@/logr';
import { createLing } from '@/ling';
import type { TranslationNode, SupportedLocale } from '@/ling';
import type { LingNode, SupportedLocale } from '@/ling';
@ -17,7 +17,7 @@ const schema = {
db: {
connectionError: { es: 'Error de conexión', en: 'Connection error' },
},
} satisfies TranslationNode;
} satisfies LingNode;
function makeI18n(locale: SupportedLocale = 'es') {
const i18n = createLing(schema, 'es');

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {callbackTransport, consoleTransport, createLogr, httpTransport, type LogEntry, LogLevel} from '@/logr';
import { createLing } from '@/ling';
import type { TranslationNode, SupportedLocale } from '@/ling';
import type { LingNode, SupportedLocale } from '@/ling';
// ============================================================================
// HELPERS
@ -11,7 +11,7 @@ const schema = {
auth: {
loginFailed: { es: 'Login fallido', en: 'Login failed' },
}
} satisfies TranslationNode;
} satisfies LingNode;
function makeI18n(locale: SupportedLocale = 'es') {
const i18n = createLing(schema, 'es');

@ -1,4 +1,5 @@
import type { Transport, ConsoleTransportOptions, HttpTransportOptions, LogEntry, LogLevel } from './types.ts';
import type { Transport, ConsoleTransportOptions, HttpTransportOptions, LogEntry } from './types.ts';
import { LogLevel } from './types.ts';
// ============================================================================
// CONSOLE TRANSPORT
@ -34,10 +35,10 @@ export function consoleTransport(options: ConsoleTransportOptions = {}): Transpo
const ctx = entry.context ?? '';
switch (entry.level) {
case 0: console.debug(...parts, ctx); break; // DEBUG
case 1: console.info (...parts, ctx); break; // INFO
case 2: console.warn (...parts, ctx); break; // WARN
case 3: console.error(...parts, ctx); break; // ERROR
case LogLevel.DEBUG: console.debug(...parts, ctx); break;
case LogLevel.INFO: console.info (...parts, ctx); break;
case LogLevel.WARN: console.warn (...parts, ctx); break;
case LogLevel.ERROR: console.error(...parts, ctx); break;
}
}
};

Loading…
Cancel
Save

Powered by TurnKey Linux.