Compare commits
126 Commits
main
...
morfo-runt
| Author | SHA1 | Date |
|---|---|---|
|
|
217d6969e4 | 5 months ago |
|
|
9009cbfe8c | 5 months ago |
|
|
83b841a7ca | 5 months ago |
|
|
5898cc58c7 | 5 months ago |
|
|
4bf6dea420 | 5 months ago |
|
|
d9b6521020 | 5 months ago |
|
|
9b07cead6b | 5 months ago |
|
|
a6e2c1e443 | 5 months ago |
|
|
3faa4473f2 | 5 months ago |
|
|
930d4c8c16 | 5 months ago |
|
|
86f01f5d8e | 5 months ago |
|
|
9338be2662 | 5 months ago |
|
|
a7adfc43b1 | 5 months ago |
|
|
746ce8dbe8 | 5 months ago |
|
|
c3dbd47f5b | 5 months ago |
|
|
b532b3a1e4 | 5 months ago |
|
|
62eb00fe8a | 5 months ago |
|
|
800e794cea | 5 months ago |
|
|
b5453af9c4 | 5 months ago |
|
|
106b972075 | 5 months ago |
|
|
d76f4eaf11 | 5 months ago |
|
|
5f6373e200 | 5 months ago |
|
|
d31bbc23f0 | 5 months ago |
|
|
abf197a37c | 5 months ago |
|
|
b096237d3e | 5 months ago |
|
|
bb8c8657e1 | 5 months ago |
|
|
084322d3db | 5 months ago |
|
|
a50b18d1f1 | 5 months ago |
|
|
fa6b01c319 | 5 months ago |
|
|
b8dec62b35 | 5 months ago |
|
|
e48f227f89 | 5 months ago |
|
|
0ebc9a9d83 | 5 months ago |
|
|
e4d131b58d | 5 months ago |
|
|
57590f2d60 | 5 months ago |
|
|
25dbce936b | 5 months ago |
|
|
dce320d7ff | 5 months ago |
|
|
d682506df2 | 5 months ago |
|
|
375e769428 | 5 months ago |
|
|
14233af4a7 | 5 months ago |
|
|
286191339e | 5 months ago |
|
|
e69e0cba80 | 5 months ago |
|
|
b66039adb1 | 5 months ago |
|
|
4f0108f839 | 5 months ago |
|
|
3d9a834886 | 5 months ago |
|
|
b4ceb1bb09 | 6 months ago |
|
|
5871cbca6e | 6 months ago |
|
|
e664b59c96 | 6 months ago |
|
|
44a499d052 | 6 months ago |
|
|
760575c419 | 6 months ago |
|
|
28f93ab089 | 6 months ago |
|
|
2e062f216b | 6 months ago |
|
|
aba18117ba | 6 months ago |
|
|
bf394873e0 | 6 months ago |
|
|
e7e32a8355 | 6 months ago |
|
|
55a5ad2647 | 6 months ago |
|
|
eb0f128c3e | 6 months ago |
|
|
6d805a4529 | 6 months ago |
|
|
ddde4601b0 | 6 months ago |
|
|
14aa218658 | 6 months ago |
|
|
2b39a2423d | 6 months ago |
|
|
53d6539abe | 6 months ago |
|
|
62bcb1268f | 6 months ago |
|
|
8ede535305 | 6 months ago |
|
|
02fa963cb8 | 6 months ago |
|
|
13caaf215f | 6 months ago |
|
|
d87421580d | 6 months ago |
|
|
a0a1485b9f | 6 months ago |
|
|
f62ede4a23 | 6 months ago |
|
|
51dba711e9 | 6 months ago |
|
|
0d8de2869f | 6 months ago |
|
|
db3ad3497f | 6 months ago |
|
|
b68b831320 | 6 months ago |
|
|
1e8500bfac | 6 months ago |
|
|
545d47148c | 6 months ago |
|
|
575748f079 | 6 months ago |
|
|
61e4ba6d26 | 6 months ago |
|
|
6efbfeb795 | 6 months ago |
|
|
a722d443b2 | 6 months ago |
|
|
8a4bbb4aa6 | 6 months ago |
|
|
2627dc2094 | 6 months ago |
|
|
50821aae44 | 6 months ago |
|
|
8d4f53a73f | 6 months ago |
|
|
e1837e8727 | 6 months ago |
|
|
91257e8526 | 6 months ago |
|
|
0222af7a0b | 6 months ago |
|
|
8a5b402dd3 | 6 months ago |
|
|
8c16ffac81 | 6 months ago |
|
|
3b704286ba | 6 months ago |
|
|
72a32e0176 | 6 months ago |
|
|
16f706439e | 6 months ago |
|
|
f07addf07b | 6 months ago |
|
|
69a8e8d4b5 | 6 months ago |
|
|
ca768f068a | 6 months ago |
|
|
a8498770f0 | 6 months ago |
|
|
a1f03b4626 | 6 months ago |
|
|
bc9b8246fc | 6 months ago |
|
|
015d5e258d | 6 months ago |
|
|
a1d0bee655 | 6 months ago |
|
|
8a88ad577a | 6 months ago |
|
|
1fd0b46b2e | 6 months ago |
|
|
dad3d26139 | 6 months ago |
|
|
c26e26f7c1 | 6 months ago |
|
|
94c499db96 | 6 months ago |
|
|
98e3c741a1 | 6 months ago |
|
|
bb0cdeb554 | 6 months ago |
|
|
193bfcdca2 | 6 months ago |
|
|
9cb5bf8dce | 6 months ago |
|
|
1655871619 | 6 months ago |
|
|
b0d5407478 | 6 months ago |
|
|
b6d9f51c47 | 6 months ago |
|
|
7b65026982 | 6 months ago |
|
|
574f9165ad | 6 months ago |
|
|
265fc9afc6 | 6 months ago |
|
|
42c3055bf4 | 6 months ago |
|
|
cb3fa2a3c7 | 6 months ago |
|
|
b31bc2f055 | 6 months ago |
|
|
6748851a88 | 6 months ago |
|
|
bb18edc6fe | 6 months ago |
|
|
5eda0c6aef | 6 months ago |
|
|
b30092dd06 | 6 months ago |
|
|
9a9c4d4f14 | 6 months ago |
|
|
19711efa84 | 6 months ago |
|
|
786658f81f | 6 months ago |
|
|
a86ebdcf69 | 6 months ago |
|
|
0766cfe49f | 6 months ago |
|
|
978a6f3463 | 6 months ago |
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
---
|
||||
name: css_documentation_progress
|
||||
description: Progreso de documentación de atributos CSS en componentes Terra UI
|
||||
type: project
|
||||
---
|
||||
|
||||
# Documentación de Atributos CSS - COMPLETADO ✅
|
||||
|
||||
## Resumen
|
||||
|
||||
Agregar documentación completa de los atributos `data-*` para CSS en todos los componentes de Terra UI. Incluyendo:
|
||||
|
||||
- Eliminar el sufijo `-root` de todos los atributos data-_-root → data-_
|
||||
- Documentar TODOS los atributos para estilos en cada componente
|
||||
|
||||
## Cambios Completados ✅
|
||||
|
||||
### 1. Sistema de atributos (core)
|
||||
|
||||
- [x] **src/uix/terra/utils/attrs.ts** - Modificado `getAttr()` para retornar `data-component` en lugar de `data-component-root` para la parte "root"
|
||||
- [x] **src/uix/terra/utils/contracts.ts** - Todos los contratos actualizados sin `-root`
|
||||
- [x] **src/uix/terra/README.md** - Documentación actualizada
|
||||
|
||||
### 2. Componentes con documentación CSS completa
|
||||
|
||||
**Form Controls:**
|
||||
|
||||
- [x] accordion (root, trigger, content, item, header)
|
||||
- [x] button
|
||||
- [x] switch (root, thumb)
|
||||
- [x] checkbox (root, group, group-label)
|
||||
- [x] radio-group (root, item)
|
||||
- [x] toggle
|
||||
- [x] toggle-group (root, item)
|
||||
- [x] slider (root, thumb, range)
|
||||
|
||||
**Disclosure:**
|
||||
|
||||
- [x] collapsible (root, trigger, content)
|
||||
|
||||
**Overlays/Dialogs:**
|
||||
|
||||
- [x] dialog (root, content, trigger, overlay, title, description, close)
|
||||
- [x] drawer (root, content)
|
||||
- [x] popover (root, content, trigger)
|
||||
- [x] tooltip (root)
|
||||
- [x] alert-dialog (root, content, action, cancel)
|
||||
|
||||
**Selection:**
|
||||
|
||||
- [x] select (root, trigger, item)
|
||||
- [x] tabs (root, trigger, content)
|
||||
- [x] menu (root, content, item)
|
||||
- [x] menubar (root, menu)
|
||||
|
||||
**Date/Time:**
|
||||
|
||||
- [x] calendar (root, cell)
|
||||
- [x] date-picker (root, trigger)
|
||||
- [x] date-field (root, input, segment)
|
||||
- [x] date-range-field (root, input, label)
|
||||
- [x] date-range-picker (root, trigger)
|
||||
- [x] time-field (root, input, label)
|
||||
- [x] time-range-field (root, label)
|
||||
- [x] range-calendar (root)
|
||||
|
||||
**Progress/Loading:**
|
||||
|
||||
- [x] progress
|
||||
- [x] meter
|
||||
- [x] spinner
|
||||
|
||||
**Otros:**
|
||||
|
||||
- [x] avatar (root, image, fallback)
|
||||
- [x] aspect-ratio
|
||||
- [x] label
|
||||
- [x] separator
|
||||
- [x] toolbar
|
||||
- [x] scroll-area
|
||||
- [x] pagination
|
||||
- [x] rating-group
|
||||
- [x] toast (ya tenía documentación previa)
|
||||
- [x] context-menu (root, content, trigger)
|
||||
- [x] dropdown-menu (root, content)
|
||||
- [x] command (root, input, item, list, empty)
|
||||
- [x] combobox (root, input)
|
||||
- [x] editable (area, preview, input, edit-trigger, cancel-trigger)
|
||||
- [x] field (Input, Label, Error, HelperText)
|
||||
- [x] file-upload (dropzone, item-name)
|
||||
- [x] form (Label, Error, Control)
|
||||
- [x] link-preview (root, trigger, content)
|
||||
- [x] pin-input (root, cell)
|
||||
- [x] stepper (root)
|
||||
- [x] splitter (root)
|
||||
- [x] calendar (day, header, heading, grid, grid-row, grid-head, head-cell, month-select, year-select, next-button, prev-button)
|
||||
- [x] color-picker (root, trigger, content, area, input, swatches)
|
||||
|
||||
## Componentes Pendientes ❌
|
||||
|
||||
Para continuar, ejecutar:
|
||||
|
||||
```bash
|
||||
cd "g:\dev\svelte\vicen - copia\src\uix\terra"
|
||||
grep -L "Selectores CSS" */component/*.svelte 2>/dev/null
|
||||
```
|
||||
|
||||
Posibles componentes pendientes (sin confirmar):
|
||||
|
||||
- menu-sub, menu-sub-content, menu-sub-trigger
|
||||
- menu-checkbox-group, menu-checkbox-item
|
||||
- menu-radio-group, menu-radio-item
|
||||
- menu-separator, menu-group, menu-group-heading
|
||||
- menu-arrow
|
||||
- drawer (trigger, overlay, title, description, close)
|
||||
- menubar (trigger, content)
|
||||
- select (content, content-static, group, group-heading, scroll-up-button, scroll-down-button, viewport)
|
||||
- tabs-list
|
||||
- toast (trigger, title, description, close)
|
||||
- color-picker (alpha-slider, area-thumb, eyedropper, hue-slider, swatch, trigger)
|
||||
- dialog (trigger)
|
||||
- form (Field, Description)
|
||||
- range-calendar (cell, day)
|
||||
- date-range-picker (calendar, trigger)
|
||||
- date-picker (calendar)
|
||||
- scroll-area (viewport, scrollbar, thumb, corner)
|
||||
- command (loading, separator, viewport, link-item, checkbox-group, checkbox-item, radio-group, radio-item)
|
||||
- stepper (increment-button, decrement-button)
|
||||
- rating-group (item)
|
||||
- file-upload (file-list, item-progress, item-remove)
|
||||
- dropdown-menu (checkbox-item, radio-item, separator)
|
||||
- context-menu (separator)
|
||||
- virtual-list
|
||||
|
||||
## Formato de Documentación a Usar
|
||||
|
||||
````svelte
|
||||
/** * ## Selectores CSS * ``` * [data-component] → Descripción * [data-state="open"] → Estado
|
||||
abierto * [data-state="closed"] → Estado cerrado * [data-disabled] → Presente cuando disabled=true *
|
||||
... * ``` */
|
||||
````
|
||||
|
||||
## Importante
|
||||
|
||||
- NO usar `-root` en los atributos (ej: usar `data-switch`, no `data-switch-root`)
|
||||
- Documentar TODOS los atributos data-\* que genera cada componente
|
||||
- Incluir descripción en español para cada selector
|
||||
|
||||
---
|
||||
|
||||
## ESTADO: COMPLETADO ✅
|
||||
|
||||
**Fecha de finalización:** 2026-04-02
|
||||
|
||||
**Commit:** 0766cfe - "Add comprehensive CSS documentation to all Terra UI components"
|
||||
|
||||
**Total de archivos modificados:** 276 archivos
|
||||
|
||||
Todos los componentes de Terra UI ahora tienen documentación completa de sus atributos CSS.
|
||||
@ -0,0 +1,203 @@
|
||||
# Plan: Tema "pretty" — Basado en Chakra UI
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear un nuevo tema llamado **"pretty"** para el sistema Air, inspirado en los design tokens de Chakra UI v3. Incluye variantes **light** y **dark**.
|
||||
|
||||
---
|
||||
|
||||
## Mapeo Chakra UI → Air Tokens
|
||||
|
||||
### Colores primarios
|
||||
|
||||
Chakra usa **purple** como color de acento principal (`#faf5ff` → `#1a032e`). Se mapea a `--air-primitive-primary-*`.
|
||||
|
||||
### Paleta neutral
|
||||
|
||||
Chakra usa **gray** (`#fafafa` → `#111111`). Se mapea a `--air-primitive-neutral-*`.
|
||||
|
||||
### Paletas semánticas
|
||||
|
||||
| Chakra | Air |
|
||||
| -------------------------------- | --------------------------- |
|
||||
| `red` (`#fef2f2` → `#1f0808`) | `--air-primitive-danger-*` |
|
||||
| `green` (`#f0fdf4` → `#03190c`) | `--air-primitive-success-*` |
|
||||
| `orange` (`#fff7ed` → `#220a04`) | `--air-primitive-warning-*` |
|
||||
| `blue` (`#eff6ff` → `#0c142e`) | `--air-primitive-info-*` |
|
||||
|
||||
### Tipografía
|
||||
|
||||
- **Sans**: Chakra usa Inter → se cambia `--air-font-sans` a Inter con fallbacks del sistema
|
||||
- **Serif**: Se mantiene Lora (ya en el sistema Air)
|
||||
- **Mono**: Se mantiene Azeret Mono
|
||||
- Escala de 12 pasos: Se ajustan sizes/line-heights para coincidir con escala Chakra
|
||||
|
||||
### Border radius
|
||||
|
||||
Chakra es más redondeado: `sm: 4px`, `md: 6px`, `lg: 8px`, `xl: 12px`, `2xl: 16px`, `3xl: 24px`
|
||||
→ Se ajustan `--air-radius-*` para reflejar la personalidad "pretty" (más suave/redondeado)
|
||||
|
||||
### Sombras
|
||||
|
||||
Se adaptan las sombras semánticas de Chakra (light/dark con `gray.900/10` y `black/64`)
|
||||
|
||||
### Spacing
|
||||
|
||||
Chakra tiene una escala más granular (con `.5`). Se mantiene la escala Air existente ya que los componentes la consumen.
|
||||
|
||||
### Motion
|
||||
|
||||
Se usan los easings y duraciones de Chakra: `moderate: 200ms`, `ease-in-smooth: cubic-bezier(0.32, 0.72, 0, 1)`
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear
|
||||
|
||||
### 1. `src/uix/air/themes/pretty/_static.css`
|
||||
|
||||
Overrides estáticos del tema pretty:
|
||||
|
||||
- `--air-font-sans` → Inter con fallbacks
|
||||
- `--air-radius-*` → valores más redondeados (estilo Chakra)
|
||||
- `--air-motion-distance-*` → distancias de Chakra
|
||||
- `--air-duration-*` → duraciones de Chakra
|
||||
- `--air-ease-*` → easings de Chakra
|
||||
|
||||
### 2. `src/uix/air/themes/pretty/light.css`
|
||||
|
||||
Tokens de color para variante light:
|
||||
|
||||
- Primitivos de color: neutral (gray Chakra), primary (purple), info (blue), success (green), warning (orange), danger (red)
|
||||
- Tokens semánticos: surfaces, content, borders, paletas por categoría
|
||||
- Sombras light
|
||||
|
||||
### 3. `src/uix/air/themes/pretty/dark.css`
|
||||
|
||||
Tokens de color para variante dark:
|
||||
|
||||
- Primitivos de color: versiones dark de cada escala
|
||||
- Tokens semánticos: surfaces dark, content dark, borders dark
|
||||
- Sombras dark
|
||||
|
||||
### 4. `src/uix/air/themes/pretty/index.css`
|
||||
|
||||
Barrel CSS: importa `_static.css`, `light.css`, `dark.css`
|
||||
|
||||
### 5. `src/uix/air/themes/pretty/index.ts`
|
||||
|
||||
Exporta `PRETTY_LIGHT_THEME` y `PRETTY_DARK_THEME` como objetos `AirTheme`
|
||||
|
||||
## Archivos a modificar
|
||||
|
||||
### 6. `src/uix/air/themes/index.css`
|
||||
|
||||
Añadir `@import './pretty/index.css';` después de earth
|
||||
|
||||
### 7. `src/uix/air/themes/index.ts`
|
||||
|
||||
- Añadir `'pretty-light' | 'pretty-dark'` al union type `AirThemeId`
|
||||
- Añadir exports de `PRETTY_LIGHT_THEME` y `PRETTY_DARK_THEME`
|
||||
|
||||
---
|
||||
|
||||
## Valores concretos (light)
|
||||
|
||||
### Primitivos neutral (gray Chakra)
|
||||
|
||||
```
|
||||
1: #fafafa 2: #f5f5f5 3: #e5e5e5 4: #d4d4d4
|
||||
5: #a3a3a3 6: #737373 7: #525252 8: #404040
|
||||
9: #262626 10: #171717 11: #111111 12: #09090b
|
||||
```
|
||||
|
||||
### Primitivos primary (purple Chakra)
|
||||
|
||||
```
|
||||
1: #faf5ff 2: #f3e8ff 3: #e9d5ff 4: #d8b4fe
|
||||
5: #c084fc 6: #a855f7 7: #9333ea 8: #7e22ce
|
||||
9: #6b21a8 10: #581c87 11: #3b0764 12: #1a032e
|
||||
```
|
||||
|
||||
### Primitivos info (blue Chakra)
|
||||
|
||||
```
|
||||
1: #eff6ff 2: #dbeafe 3: #bfdbfe 4: #93c5fd
|
||||
5: #60a5fa 6: #3b82f6 7: #2563eb 8: #1d4ed8
|
||||
9: #1e40af 10: #1e3a8a 11: #172554 12: #0c142e
|
||||
```
|
||||
|
||||
### Primitivos success (green Chakra)
|
||||
|
||||
```
|
||||
1: #f0fdf4 2: #dcfce7 3: #bbf7d0 4: #86efac
|
||||
5: #4ade80 6: #22c55e 7: #16a34a 8: #15803d
|
||||
9: #166534 10: #14532d 11: #052e16 12: #03190c
|
||||
```
|
||||
|
||||
### Primitivos warning (orange Chakra)
|
||||
|
||||
```
|
||||
1: #fff7ed 2: #ffedd5 3: #fed7aa 4: #fdba74
|
||||
5: #fb923c 6: #f97316 7: #ea580c 8: #c2410c
|
||||
9: #9a3412 10: #7c2d12 11: #431407 12: #220a04
|
||||
```
|
||||
|
||||
### Primitivos danger (red Chakra)
|
||||
|
||||
```
|
||||
1: #fef2f2 2: #fee2e2 3: #fecaca 4: #fca5a5
|
||||
5: #f87171 6: #ef4444 7: #dc2626 8: #b91c1c
|
||||
9: #991b1b 10: #7f1d1d 11: #450a0a 12: #1f0808
|
||||
```
|
||||
|
||||
### Primitivos dark (versiones invertidas)
|
||||
|
||||
Los primitivos dark se generan invirtiendo las escalas de Chakra (claro→oscuro) manteniendo la misma lógica que base-dark.
|
||||
|
||||
### Radius (pretty = más redondeado)
|
||||
|
||||
```
|
||||
sm: 6px md: 8px lg: 12px xl: 16px xxl: 24px
|
||||
```
|
||||
|
||||
### Sombras light
|
||||
|
||||
```
|
||||
subtle: 0 1px 2px rgb(9 9 11 / 0.1), 0 0 0 1px rgb(9 9 11 / 0.2)
|
||||
raised: 0 2px 4px rgb(9 9 11 / 0.1), 0 0 0 1px rgb(9 9 11 / 0.3)
|
||||
overlay: 0 4px 8px rgb(9 9 11 / 0.1), 0 0 0 1px rgb(9 9 11 / 0.3)
|
||||
```
|
||||
|
||||
### Sombras dark
|
||||
|
||||
```
|
||||
subtle: 0 1px 1px rgb(0 0 0 / 0.64), 0 0 0 1px inset rgb(163 163 163 / 0.2)
|
||||
raised: 0 2px 4px rgb(0 0 0 / 0.64), 0 0 0 1px inset rgb(163 163 163 / 0.3)
|
||||
overlay: 0 4px 8px rgb(0 0 0 / 0.64), 0 0 0 1px inset rgb(163 163 163 / 0.3)
|
||||
```
|
||||
|
||||
### Motion (overrides Chakra)
|
||||
|
||||
```
|
||||
duration-fast: 150ms
|
||||
duration-normal: 200ms
|
||||
duration-moderate: 300ms
|
||||
duration-slow: 400ms
|
||||
ease-default: cubic-bezier(0.4, 0, 0.2, 1)
|
||||
ease-out: cubic-bezier(0, 0, 0.58, 1)
|
||||
ease-in: cubic-bezier(0.42, 0, 1, 1)
|
||||
ease-spring: cubic-bezier(0.32, 0.72, 0, 1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Orden de implementación
|
||||
|
||||
1. Crear `src/uix/air/themes/pretty/_static.css` con overrides de radius, motion, tipografía
|
||||
2. Crear `src/uix/air/themes/pretty/light.css` con primitivos + semánticos + sombras light
|
||||
3. Crear `src/uix/air/themes/pretty/dark.css` con primitivos + semánticos + sombras dark
|
||||
4. Crear `src/uix/air/themes/pretty/index.css` (barrel CSS)
|
||||
5. Crear `src/uix/air/themes/pretty/index.ts` (exports TS)
|
||||
6. Modificar `src/uix/air/themes/index.css` — añadir import pretty
|
||||
7. Modificar `src/uix/air/themes/index.ts` — añadir tipos y exports
|
||||
8. Verificar con `npm run check` y `npm run build`
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Script para agregar assertDataContract a todos los componentes de Terra
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Archivos a procesar (rutas relativas al proyecto)
|
||||
const files = [
|
||||
'accordion/accordion.svelte.ts',
|
||||
'alert-dialog/alert-dialog.svelte.ts',
|
||||
'aspect-ratio/aspect-ratio.svelte.ts',
|
||||
'avatar/avatar.svelte.ts',
|
||||
'calendar/calendar.svelte.ts',
|
||||
'collapsible/collapsible.svelte.ts',
|
||||
'color-picker/color-picker.svelte.ts',
|
||||
'command/command.svelte.ts',
|
||||
'date-field/date-field.svelte.ts',
|
||||
'date-picker/date-picker.svelte.ts',
|
||||
'date-range-field/date-range-field.svelte.ts',
|
||||
'date-range-picker/date-range-picker.svelte.ts',
|
||||
'dialog/dialog.svelte.ts',
|
||||
'drawer/drawer.svelte.ts',
|
||||
'dropdown-menu/dropdown-menu.svelte.ts',
|
||||
'editable/editable.svelte.ts',
|
||||
'file-upload/file-upload.svelte.ts',
|
||||
'form/form.svelte.ts',
|
||||
'link-preview/link-preview.svelte.ts',
|
||||
'menu/menu.svelte.ts',
|
||||
'menubar/menubar.svelte.ts',
|
||||
'meter/meter.svelte.ts',
|
||||
'number-field/number-field.svelte.ts',
|
||||
'pagination/pagination.svelte.ts',
|
||||
'pin-input/pin-input.svelte.ts',
|
||||
'popover/popover.svelte.ts',
|
||||
'progress/progress.svelte.ts',
|
||||
'range-calendar/range-calendar.svelte.ts',
|
||||
'rating-group/rating-group.svelte.ts',
|
||||
'scroll-area/scroll-area.svelte.ts',
|
||||
'select/select.svelte.ts',
|
||||
'separator/separator.svelte.ts',
|
||||
'slider/slider.svelte.ts',
|
||||
'spinner/spinner.svelte.ts',
|
||||
'splitter/splitter.svelte.ts',
|
||||
'stepper/stepper.svelte.ts',
|
||||
'tabs/tabs.svelte.ts',
|
||||
'tags-input/tags-input.svelte.ts',
|
||||
'thumb/thumb.svelte.ts',
|
||||
'time-field/time-field.svelte.ts',
|
||||
'time-range-field/time-range-field.svelte.ts',
|
||||
'toggle-group/toggle-group.svelte.ts',
|
||||
'toolbar/toolbar.svelte.ts',
|
||||
'tooltip/tooltip.svelte.ts',
|
||||
'virtual-list/virtual-list.svelte.ts'
|
||||
].map((f) => resolve(__dirname, '../src/uix/terra', f));
|
||||
|
||||
let modified = 0;
|
||||
let skipped = 0;
|
||||
let notFound = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (!existsSync(file)) {
|
||||
notFound++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = readFileSync(file, 'utf-8');
|
||||
const original = content;
|
||||
|
||||
// Ya tiene el import
|
||||
if (content.includes('assertDataContract')) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Buscar la última línea de import desde utils/attrs o ../utils/attrs
|
||||
const attrsImportMatch = content.match(/from ["'].*?\/utils\/attrs["']/);
|
||||
if (!attrsImportMatch) {
|
||||
console.log(`⚠️ No se encontró import de attrs en: ${file}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastAttrsLineEnd = content.indexOf(
|
||||
'\n',
|
||||
attrsImportMatch.index + attrsImportMatch[0].length
|
||||
);
|
||||
if (lastAttrsLineEnd === -1) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insertar el nuevo import después del import de attrs
|
||||
const importStatement = `\nimport { assertDataContract } from "$terra/utils/contracts";`;
|
||||
content =
|
||||
content.slice(0, lastAttrsLineEnd + 1) +
|
||||
importStatement +
|
||||
content.slice(lastAttrsLineEnd + 1);
|
||||
|
||||
if (content !== original) {
|
||||
writeFileSync(file, content, 'utf-8');
|
||||
modified++;
|
||||
const componentName = file.replace(/.*src\\uix\\terra\\/, '').replace(/\.svelte\.ts$/, '');
|
||||
console.log(`✅ ${componentName}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`❌ Error procesando ${file}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Resumen:`);
|
||||
console.log(` Modificados: ${modified}`);
|
||||
console.log(` Skippeados (ya tenían import): ${skipped}`);
|
||||
console.log(` No encontrados: ${notFound}`);
|
||||
console.log(` Total procesados: ${modified + skipped + notFound}`);
|
||||
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* One-shot catalog pass: walks every morfo file under
|
||||
* `src/uix/morfo/components/` and inserts `archetype: 'xxx',` after the
|
||||
* `kebab:` line of each part whose kebab maps to a canonical archetype.
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx/esm scripts/catalog-archetypes.ts
|
||||
*
|
||||
* Idempotent — skips parts that already declare archetype. Reports counts
|
||||
* per file at the end.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Mapping of part `kebab` → canonical archetype. Conservative: only maps
|
||||
* kebabs whose role is unambiguously cross-component. Genuinely-unique
|
||||
* parts (e.g. `area-background`, `transparency-grid`) get nothing.
|
||||
*/
|
||||
const KEBAB_TO_ARCHETYPE: Record<string, string> = {
|
||||
// 'provider' defaults to 'provider' archetype (root container).
|
||||
// Components where Provider IS the interactive button (Toggle, Switch,
|
||||
// Checkbox, …) need a manual override to 'trigger' afterwards — see the
|
||||
// trailing patch step below.
|
||||
provider: 'provider',
|
||||
trigger: 'trigger',
|
||||
content: 'content',
|
||||
overlay: 'overlay',
|
||||
viewport: 'viewport',
|
||||
item: 'item',
|
||||
option: 'option',
|
||||
indicator: 'indicator',
|
||||
thumb: 'thumb',
|
||||
track: 'track',
|
||||
label: 'label',
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
close: 'close',
|
||||
action: 'action',
|
||||
header: 'header',
|
||||
image: 'image',
|
||||
fallback: 'fallback',
|
||||
arrow: 'arrow',
|
||||
separator: 'separator',
|
||||
group: 'group',
|
||||
input: 'input',
|
||||
segment: 'segment',
|
||||
preview: 'preview',
|
||||
|
||||
// Variant aliases — same role, different spelling
|
||||
'sub-trigger': 'trigger',
|
||||
'sub-content': 'content',
|
||||
'prev-trigger': 'trigger',
|
||||
'next-trigger': 'trigger',
|
||||
'prev-button': 'trigger',
|
||||
'next-button': 'trigger',
|
||||
'swatch-trigger': 'trigger',
|
||||
'eye-dropper': 'trigger',
|
||||
'checkbox-item': 'item',
|
||||
'radio-item': 'item',
|
||||
'link-item': 'item',
|
||||
'cell': 'item',
|
||||
'day': 'item',
|
||||
'swatch': 'item',
|
||||
'link': 'trigger',
|
||||
'cancel': 'action',
|
||||
'list': 'group',
|
||||
'item-group': 'group',
|
||||
'indicator-group': 'group',
|
||||
'swatch-group': 'group',
|
||||
'radio-group': 'group',
|
||||
'group-items': 'group',
|
||||
'group-heading': 'header',
|
||||
'group-label': 'label',
|
||||
'channel-input': 'input',
|
||||
'channel-segment': 'segment',
|
||||
'channel-slider-track': 'track',
|
||||
'channel-slider-thumb': 'thumb',
|
||||
'area-thumb': 'thumb',
|
||||
'handle': 'thumb',
|
||||
'value-swatch': 'indicator',
|
||||
'swatch-indicator': 'indicator',
|
||||
'value-text': 'description',
|
||||
region: 'content',
|
||||
'head-cell': 'header',
|
||||
'week-number': 'label',
|
||||
heading: 'header',
|
||||
loading: 'indicator',
|
||||
'format-select': 'input',
|
||||
'month-select': 'input',
|
||||
'year-select': 'input',
|
||||
|
||||
// Tree / table / list-flavored containers and rows
|
||||
branch: 'item',
|
||||
row: 'item',
|
||||
'row-detail': 'content',
|
||||
'column-header': 'header',
|
||||
'grid-row': 'item',
|
||||
'grid-head': 'header',
|
||||
'file-list': 'group',
|
||||
panel: 'content',
|
||||
|
||||
// Form-flavored
|
||||
submit: 'action',
|
||||
reset: 'action',
|
||||
'submit-trigger': 'action',
|
||||
'cancel-trigger': 'action',
|
||||
'clear-trigger': 'action',
|
||||
'remove-button': 'action',
|
||||
'item-delete-trigger': 'action',
|
||||
'item-remove': 'action',
|
||||
'helper-text': 'description',
|
||||
'error-text': 'description',
|
||||
'error-summary': 'description',
|
||||
'hidden-input': 'input',
|
||||
'selection-checkbox': 'input',
|
||||
|
||||
// Generic interactive sub-triggers — anything ending `-trigger` is one
|
||||
'edit-trigger': 'trigger',
|
||||
'expand-trigger': 'trigger',
|
||||
'decrement-trigger': 'trigger',
|
||||
'increment-trigger': 'trigger',
|
||||
'resize-trigger': 'trigger',
|
||||
'row-detail-trigger': 'trigger',
|
||||
|
||||
// Tree-view auxiliaries
|
||||
'branch-content': 'content',
|
||||
'branch-indicator': 'indicator',
|
||||
|
||||
// Article / feed sub-parts
|
||||
article: 'item',
|
||||
'article-title': 'title',
|
||||
'article-description': 'description',
|
||||
|
||||
// Misc visualization bits
|
||||
tick: 'indicator',
|
||||
scrollbar: 'track',
|
||||
scrubber: 'thumb',
|
||||
'window-viewport': 'viewport',
|
||||
value: 'description',
|
||||
'item-indicator': 'indicator',
|
||||
'item-progress': 'indicator',
|
||||
'item-preview': 'preview',
|
||||
'item-name': 'description',
|
||||
'item-text': 'description',
|
||||
'item-size': 'description'
|
||||
};
|
||||
|
||||
interface Counts {
|
||||
parts: number;
|
||||
added: number;
|
||||
skippedUnmapped: number;
|
||||
skippedAlreadyDeclared: number;
|
||||
}
|
||||
|
||||
function processFile(path: string): Counts {
|
||||
const original = readFileSync(path, 'utf8');
|
||||
const lines = original.split(/\r?\n/);
|
||||
const out: string[] = [];
|
||||
const counts: Counts = {
|
||||
parts: 0,
|
||||
added: 0,
|
||||
skippedUnmapped: 0,
|
||||
skippedAlreadyDeclared: 0
|
||||
};
|
||||
|
||||
const KEBAB_RE = /^(\s*)kebab:\s*'([^']+)',?\s*$/;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
out.push(line);
|
||||
|
||||
const m = KEBAB_RE.exec(line);
|
||||
if (!m) continue;
|
||||
|
||||
const [, indent, kebab] = m;
|
||||
|
||||
// Distinguish morfo-root kebab (single tab) from part kebab (deeper).
|
||||
// The morfo root sits at indent depth 1 inside the `as const satisfies
|
||||
// Morfo` object; parts live inside `parts: [{ ... }]` so they're
|
||||
// indented at least 3 tabs. Anything at <2 tabs is the morfo root —
|
||||
// archetype belongs only on parts.
|
||||
const tabCount = (indent.match(/\t/g) ?? []).length;
|
||||
if (tabCount < 2) continue;
|
||||
|
||||
counts.parts++;
|
||||
|
||||
// Skip if archetype already on the next non-empty line.
|
||||
let next = i + 1;
|
||||
while (next < lines.length && lines[next].trim() === '') next++;
|
||||
if (lines[next]?.includes('archetype:')) {
|
||||
counts.skippedAlreadyDeclared++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const archetype = KEBAB_TO_ARCHETYPE[kebab];
|
||||
if (!archetype) {
|
||||
counts.skippedUnmapped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(`${indent}archetype: '${archetype}',`);
|
||||
counts.added++;
|
||||
}
|
||||
|
||||
const updated = out.join('\n');
|
||||
if (updated !== original) {
|
||||
writeFileSync(path, updated, 'utf8');
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
const DIR = 'src/uix/morfo/components';
|
||||
const files = readdirSync(DIR).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
||||
|
||||
let totalParts = 0;
|
||||
let totalAdded = 0;
|
||||
let totalSkippedUnmapped = 0;
|
||||
let totalSkippedAlreadyDeclared = 0;
|
||||
const unmappedKebabs = new Set<string>();
|
||||
|
||||
for (const f of files) {
|
||||
const path = join(DIR, f);
|
||||
const counts = processFile(path);
|
||||
totalParts += counts.parts;
|
||||
totalAdded += counts.added;
|
||||
totalSkippedUnmapped += counts.skippedUnmapped;
|
||||
totalSkippedAlreadyDeclared += counts.skippedAlreadyDeclared;
|
||||
|
||||
if (counts.added > 0 || counts.skippedUnmapped > 0) {
|
||||
console.log(
|
||||
`${f}: +${counts.added} added · ${counts.skippedUnmapped} unmapped · ${counts.skippedAlreadyDeclared} already declared`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: list which kebabs were unmapped so we can extend KEBAB_TO_ARCHETYPE.
|
||||
for (const f of files) {
|
||||
const path = join(DIR, f);
|
||||
const text = readFileSync(path, 'utf8');
|
||||
const matches = [...text.matchAll(/^\s*kebab:\s*'([^']+)',?\s*$/gm)];
|
||||
for (const m of matches) {
|
||||
const kebab = m[1];
|
||||
if (!KEBAB_TO_ARCHETYPE[kebab]) unmappedKebabs.add(kebab);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n────────────────────────────────────────');
|
||||
console.log(`Total parts examined: ${totalParts}`);
|
||||
console.log(`Archetypes added: ${totalAdded}`);
|
||||
console.log(`Skipped (unmapped): ${totalSkippedUnmapped}`);
|
||||
console.log(`Skipped (declared): ${totalSkippedAlreadyDeclared}`);
|
||||
|
||||
// Filter out the morfo root kebabs (component names, never archetypes).
|
||||
const morfoRoots = new Set(files.map((f) => f.replace(/\.ts$/, '')));
|
||||
const unmappedPartKebabs = [...unmappedKebabs].filter((k) => !morfoRoots.has(k)).sort();
|
||||
console.log(`\nUnmapped part kebabs (${unmappedPartKebabs.length}):`);
|
||||
for (const k of unmappedPartKebabs) console.log(` ${k}`);
|
||||
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Verifies eidos recipes render correctly in /test/eidos.
|
||||
*
|
||||
* 1. Toggle (★) — has data-archetype + focus ring on Tab.
|
||||
* 2. Switch — has data-archetype + focus ring on Tab.
|
||||
* 3. Dialog — opens via click, content + overlay visible, persists.
|
||||
*
|
||||
* Saves screenshots to /tmp/eidos-*.png so I can inspect.
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
const URL = 'http://localhost:5173/test/eidos';
|
||||
const OUT = 'g:/tmp/eidos';
|
||||
|
||||
async function probePort(): Promise<string | null> {
|
||||
for (const port of [5173, 5174, 5175]) {
|
||||
try {
|
||||
const r = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(500) });
|
||||
if (r.ok || r.status === 404) return `http://localhost:${port}`;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const base = await probePort();
|
||||
if (!base) {
|
||||
console.error('No dev server. Run npm run dev.');
|
||||
process.exit(2);
|
||||
}
|
||||
await mkdir(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 900, height: 1100 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') console.log(`[console error] ${msg.text()}`);
|
||||
});
|
||||
|
||||
// ── 1. Toggle ────────────────────────────────────────────────────────────
|
||||
await page.goto(`${base}/test/eidos/toggle`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(300);
|
||||
const toggle = page.locator('[data-toggle]').first();
|
||||
const toggleAttrs = await toggle.evaluate((el) => ({
|
||||
dataToggle: el.getAttribute('data-toggle'),
|
||||
dataArchetype: el.getAttribute('data-archetype'),
|
||||
tag: el.tagName.toLowerCase()
|
||||
}));
|
||||
console.log('Toggle attrs:', toggleAttrs);
|
||||
|
||||
// Click body first so we have a focus origin, then tab into the toggle.
|
||||
await page.locator('body').click({ position: { x: 5, y: 5 } });
|
||||
await page.waitForTimeout(50);
|
||||
// Tab until we hit the toggle button (skip the Soma layout bar if present).
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
const isToggle = await page.evaluate(() => document.activeElement?.hasAttribute('data-toggle') ?? false);
|
||||
if (isToggle) break;
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
const toggleFocusOutline = await toggle.evaluate((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
matchesFocusVisible: el.matches(':focus-visible'),
|
||||
outlineStyle: cs.outlineStyle,
|
||||
outlineWidth: cs.outlineWidth,
|
||||
boxShadow: cs.boxShadow
|
||||
};
|
||||
});
|
||||
console.log('Toggle ring (Tab focused):', toggleFocusOutline);
|
||||
await page.screenshot({ path: `${OUT}/01-toggle-focused.png`, fullPage: false });
|
||||
|
||||
// ── 2. Switch ────────────────────────────────────────────────────────────
|
||||
await page.goto(`${base}/test/eidos/switch`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(300);
|
||||
const sw = page.locator('[data-switch]').first();
|
||||
const swAttrs = await sw.evaluate((el) => ({
|
||||
dataSwitch: el.getAttribute('data-switch'),
|
||||
dataArchetype: el.getAttribute('data-archetype'),
|
||||
dataState: el.getAttribute('data-state'),
|
||||
tag: el.tagName.toLowerCase()
|
||||
}));
|
||||
console.log('Switch attrs:', swAttrs);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
const isSwitch = await page.evaluate(() => document.activeElement?.hasAttribute('data-switch') ?? false);
|
||||
if (isSwitch) break;
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
const swFocusOutline = await sw.evaluate((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
matchesFocusVisible: el.matches(':focus-visible'),
|
||||
boxShadow: cs.boxShadow,
|
||||
width: cs.width,
|
||||
height: cs.height,
|
||||
background: cs.backgroundColor
|
||||
};
|
||||
});
|
||||
console.log('Switch ring (Tab focused):', swFocusOutline);
|
||||
await page.screenshot({ path: `${OUT}/02-switch-focused.png`, fullPage: false });
|
||||
|
||||
// ── 3. Dialog ────────────────────────────────────────────────────────────
|
||||
await page.goto(`${base}/test/eidos/dialog`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(300);
|
||||
const dialogTrigger = page.locator('[data-dialog-trigger]').first();
|
||||
const triggerExists = (await dialogTrigger.count()) > 0;
|
||||
console.log('Dialog trigger exists:', triggerExists);
|
||||
if (triggerExists) {
|
||||
// Inject a sampler that records opacity at every rAF after the click.
|
||||
await page.evaluate(`(() => {
|
||||
window.__samples = [];
|
||||
const trigger = document.querySelector('[data-dialog-trigger]');
|
||||
const t0 = performance.now();
|
||||
trigger.click();
|
||||
const tick = () => {
|
||||
const t = performance.now() - t0;
|
||||
const el = document.querySelector('[data-dialog-content]');
|
||||
if (el) {
|
||||
const cs = getComputedStyle(el);
|
||||
window.__samples.push({
|
||||
t: Math.round(t),
|
||||
opacity: parseFloat(cs.opacity),
|
||||
hasStartingStyle: el.hasAttribute('data-starting-style')
|
||||
});
|
||||
}
|
||||
if (t < 500) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
})()`);
|
||||
await page.waitForTimeout(550);
|
||||
const entranceSamples = await page.evaluate('window.__samples') as Array<{
|
||||
t: number;
|
||||
opacity: number;
|
||||
hasStartingStyle: boolean;
|
||||
}>;
|
||||
const decimated = entranceSamples.filter((_, i) => i % 3 === 0 || i === entranceSamples.length - 1);
|
||||
console.log('Entrance samples (t-ms, opacity, hasStartingStyle):');
|
||||
for (const s of decimated) {
|
||||
console.log(` t=${s.t}ms opacity=${s.opacity.toFixed(3)} starting=${s.hasStartingStyle}`);
|
||||
}
|
||||
|
||||
const dialogContent = page.locator('[data-dialog-content]');
|
||||
await page.screenshot({ path: `${OUT}/03a-dialog-entrance-mid.png`, fullPage: false });
|
||||
|
||||
const dialogOverlay = page.locator('[data-dialog-overlay]');
|
||||
const overlayCount = await dialogOverlay.count();
|
||||
const contentCount = await dialogContent.count();
|
||||
console.log(`Dialog opened — overlay count: ${overlayCount}, content count: ${contentCount}`);
|
||||
|
||||
if (contentCount > 0) {
|
||||
const contentBox = await dialogContent.first().boundingBox();
|
||||
const overlayBox = await dialogOverlay.first().boundingBox();
|
||||
const overlayStyles = await dialogOverlay.first().evaluate((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
position: cs.position,
|
||||
inset: cs.inset,
|
||||
top: cs.top,
|
||||
left: cs.left,
|
||||
right: cs.right,
|
||||
bottom: cs.bottom,
|
||||
width: cs.width,
|
||||
height: cs.height,
|
||||
background: cs.backgroundColor,
|
||||
zIndex: cs.zIndex,
|
||||
display: cs.display,
|
||||
visibility: cs.visibility,
|
||||
opacity: cs.opacity,
|
||||
dataArchetype: el.getAttribute('data-archetype'),
|
||||
parentTag: el.parentElement?.tagName.toLowerCase()
|
||||
};
|
||||
});
|
||||
const contentStyles = await dialogContent.first().evaluate((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
position: cs.position,
|
||||
top: cs.top,
|
||||
left: cs.left,
|
||||
transform: cs.transform,
|
||||
background: cs.backgroundColor,
|
||||
zIndex: cs.zIndex,
|
||||
display: cs.display,
|
||||
visibility: cs.visibility,
|
||||
opacity: cs.opacity
|
||||
};
|
||||
});
|
||||
console.log('Dialog content box:', contentBox);
|
||||
console.log('Dialog overlay box:', overlayBox);
|
||||
console.log('Dialog overlay styles:', overlayStyles);
|
||||
console.log('Dialog content styles:', contentStyles);
|
||||
}
|
||||
await page.screenshot({ path: `${OUT}/03-dialog-open.png`, fullPage: false });
|
||||
|
||||
await page.evaluate(`(() => {
|
||||
window.__exitSamples = [];
|
||||
const closeBtn = document.querySelector('[data-dialog-close]');
|
||||
const t0 = performance.now();
|
||||
closeBtn.click();
|
||||
const tick = () => {
|
||||
const t = performance.now() - t0;
|
||||
const el = document.querySelector('[data-dialog-content]');
|
||||
if (el) {
|
||||
const cs = getComputedStyle(el);
|
||||
window.__exitSamples.push({
|
||||
t: Math.round(t),
|
||||
opacity: parseFloat(cs.opacity),
|
||||
hasEndingStyle: el.hasAttribute('data-ending-style'),
|
||||
lastAction: el.getAttribute('data-last-action'),
|
||||
borderTopColor: cs.borderTopColor,
|
||||
mounted: true
|
||||
});
|
||||
} else {
|
||||
window.__exitSamples.push({
|
||||
t: Math.round(t), opacity: 0, hasEndingStyle: false, lastAction: null,
|
||||
borderTopColor: '', mounted: false
|
||||
});
|
||||
}
|
||||
if (t < 500) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
})()`);
|
||||
await page.waitForTimeout(550);
|
||||
const exitSamples = await page.evaluate('window.__exitSamples') as Array<{
|
||||
t: number;
|
||||
opacity: number;
|
||||
hasEndingStyle: boolean;
|
||||
lastAction: string | null;
|
||||
borderTopColor: string;
|
||||
mounted: boolean;
|
||||
}>;
|
||||
const exitDecimated = exitSamples.filter((_, i) => i % 3 === 0 || i === exitSamples.length - 1);
|
||||
console.log('Exit samples (t-ms, opacity, hasEndingStyle, lastAction, borderTop, mounted):');
|
||||
for (const s of exitDecimated) {
|
||||
console.log(
|
||||
` t=${s.t}ms opacity=${s.opacity.toFixed(3)} ending=${s.hasEndingStyle} lastAction=${s.lastAction} border=${s.borderTopColor} mounted=${s.mounted}`
|
||||
);
|
||||
}
|
||||
await page.screenshot({ path: `${OUT}/04-dialog-closed.png`, fullPage: false });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\nScreenshots saved to ${OUT}/`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -0,0 +1,153 @@
|
||||
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const ICONS_SOURCE = join(process.cwd(), 'static/lucide/icons');
|
||||
const ICONS_TARGET = join(process.cwd(), 'src/uix/air/icons/lib');
|
||||
const INDEX_FILE = join(process.cwd(), 'src/uix/air/icons/sound.svelte.ts');
|
||||
|
||||
function getExistingIcons() {
|
||||
const files = readdirSync(ICONS_TARGET);
|
||||
return new Set(files.map((f) => f.replace('.svelte', '')));
|
||||
}
|
||||
|
||||
function parseSvgContent(svgContent) {
|
||||
// Extract all child elements (path, circle, rect, polyline, line, polygon, ellipse)
|
||||
const elements = [];
|
||||
|
||||
const patterns = [
|
||||
/<path\s+([^>]*)>/g,
|
||||
/<circle\s+([^>]*)>/g,
|
||||
/<rect\s+([^>]*)>/g,
|
||||
/<polyline\s+([^>]*)>/g,
|
||||
/<line\s+([^>]*)>/g,
|
||||
/<polygon\s+([^>]*)>/g,
|
||||
/<ellipse\s+([^>]*)>/g
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(svgContent)) !== null) {
|
||||
const tagName = pattern.source.replace('\\s+([^>]*)>', '').replace('<', '').replace('\\', '');
|
||||
const attrs = match[1];
|
||||
elements.push(`<${tagName} ${attrs}>`);
|
||||
}
|
||||
}
|
||||
|
||||
return elements.join('\n\t');
|
||||
}
|
||||
|
||||
function toPascalCase(str) {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function convertIcon(iconName) {
|
||||
const svgPath = join(ICONS_SOURCE, `${iconName}.svg`);
|
||||
|
||||
if (!existsSync(svgPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const svgContent = readFileSync(svgPath, 'utf-8');
|
||||
const innerContent = parseSvgContent(svgContent);
|
||||
|
||||
// Generate Svelte component
|
||||
const component = `<script lang="ts">
|
||||
import Icon from '../icon.svelte';
|
||||
import type { AirIconProps } from '../types';
|
||||
|
||||
let props: AirIconProps = $props();
|
||||
</script>
|
||||
|
||||
<Icon {...props}>
|
||||
${innerContent}
|
||||
</Icon>
|
||||
`;
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
function updateIndex(icons) {
|
||||
const existingIndex = readFileSync(INDEX_FILE, 'utf-8');
|
||||
const lines = existingIndex.split('\n');
|
||||
|
||||
// Find the last export line (before export type)
|
||||
const lastExportLine = lines.findIndex((l) => l.startsWith('export type'));
|
||||
|
||||
// Collect existing exports
|
||||
const existingExports = new Set();
|
||||
lines.forEach((line) => {
|
||||
const match = line.match(/export \{ default as (\w+) \}/);
|
||||
if (match) existingExports.add(match[1]);
|
||||
});
|
||||
|
||||
// Add new exports
|
||||
const newExports = [];
|
||||
for (const icon of icons) {
|
||||
const pascalName = toPascalCase(icon);
|
||||
if (!existingExports.has(pascalName)) {
|
||||
newExports.push(`export { default as ${pascalName} } from './lib/${icon}.svelte';`);
|
||||
existingExports.add(pascalName);
|
||||
}
|
||||
}
|
||||
|
||||
if (newExports.length === 0) return;
|
||||
|
||||
// Insert new exports before the export type line
|
||||
lines.splice(lastExportLine, 0, ...newExports);
|
||||
|
||||
// Sort exports (keep TooltipContext first, then alphabetically)
|
||||
const rootLine = lines[0];
|
||||
const otherExports = lines.slice(1, lastExportLine + newExports.length);
|
||||
const typeLine = lines[lastExportLine + newExports.length];
|
||||
|
||||
otherExports.sort();
|
||||
|
||||
const newContent = [
|
||||
rootLine,
|
||||
...otherExports,
|
||||
typeLine,
|
||||
...lines.slice(lastExportLine + newExports.length + 1)
|
||||
].join('\n');
|
||||
|
||||
writeFileSync(INDEX_FILE, newContent);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const existingIcons = getExistingIcons();
|
||||
const allSvgFiles = readdirSync(ICONS_SOURCE).filter((f) => f.endsWith('.svg'));
|
||||
const missingIcons = allSvgFiles
|
||||
.map((f) => f.replace('.svg', ''))
|
||||
.filter((name) => !existingIcons.has(name));
|
||||
|
||||
console.log(`Found ${missingIcons.length} icons to convert...`);
|
||||
|
||||
if (missingIcons.length === 0) {
|
||||
console.log('No new icons to convert!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit to first 100 for testing
|
||||
const toConvert = process.argv[2] === 'all' ? missingIcons : missingIcons.slice(0, 100);
|
||||
|
||||
console.log(`Converting ${toConvert.length} icons...`);
|
||||
|
||||
const converted = [];
|
||||
|
||||
for (const iconName of toConvert) {
|
||||
const component = convertIcon(iconName);
|
||||
if (component) {
|
||||
const targetPath = join(ICONS_TARGET, `${iconName}.svelte`);
|
||||
writeFileSync(targetPath, component);
|
||||
converted.push(iconName);
|
||||
console.log(`✓ ${iconName}`);
|
||||
}
|
||||
}
|
||||
|
||||
updateIndex(converted);
|
||||
console.log(`\nDone! Converted ${converted.length} icons.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Actualiza imports de contextos que ya no existen
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const terraDir = join(__dirname, '../src');
|
||||
|
||||
// Mapeo de nombres de contextos antiguos a nuevos patrones
|
||||
const contextMap: Record<string, { class: string; newPattern: string }> = {
|
||||
AccordionRootContext: { class: 'AccordionRoot', newPattern: 'AccordionRoot.ctx' },
|
||||
AccordionItemContext: { class: 'AccordionItemState', newPattern: 'AccordionItemState.ctx' },
|
||||
AvatarRootContext: { class: 'AvatarRootState', newPattern: 'AvatarRootState.ctx' },
|
||||
CalendarCellContext: { class: 'CalendarCellState', newPattern: 'CalendarCellState.ctx' },
|
||||
CalendarRootContext: { class: 'CalendarRootState', newPattern: 'CalendarRootState.ctx' },
|
||||
CheckboxGroupContext: { class: 'CheckboxGroupState', newPattern: 'CheckboxGroupState.ctx' },
|
||||
CheckboxRootContext: { class: 'CheckboxRootState', newPattern: 'CheckboxRootState.ctx' },
|
||||
CollapsibleRootContext: { class: 'CollapsibleRootState', newPattern: 'CollapsibleRootState.ctx' },
|
||||
CommandGroupContainerContext: {
|
||||
class: 'CommandGroupContainerState',
|
||||
newPattern: 'CommandGroupContainerState.ctx'
|
||||
},
|
||||
CommandListContext: { class: 'CommandListState', newPattern: 'CommandListState.ctx' },
|
||||
CommandRootContext: { class: 'CommandRootState', newPattern: 'CommandRootState.ctx' },
|
||||
DateFieldRootContext: { class: 'DateFieldRootState', newPattern: 'DateFieldRootState.ctx' },
|
||||
DatePickerRootContext: { class: 'DatePickerRootState', newPattern: 'DatePickerRootState.ctx' },
|
||||
DateRangeFieldRootContext: {
|
||||
class: 'DateRangeFieldRootState',
|
||||
newPattern: 'DateRangeFieldRootState.ctx'
|
||||
},
|
||||
DateRangePickerRootContext: {
|
||||
class: 'DateRangePickerRootState',
|
||||
newPattern: 'DateRangePickerRootState.ctx'
|
||||
},
|
||||
DialogRootContext: { class: 'DialogRootState', newPattern: 'DialogRootState.ctx' },
|
||||
EditableRootContext: { class: 'EditableRootState', newPattern: 'EditableRootState.ctx' },
|
||||
FieldRootContext: { class: 'FieldRootState', newPattern: 'FieldRootState.ctx' },
|
||||
FileUploadItemContext: { class: 'FileUploadItemState', newPattern: 'FileUploadItemState.ctx' },
|
||||
FileUploadRootContext: { class: 'FileUploadRootState', newPattern: 'FileUploadRootState.ctx' },
|
||||
FormFieldContext: { class: 'FormFieldState', newPattern: 'FormFieldState.ctx' },
|
||||
FormRootContext: { class: 'FormRootState', newPattern: 'FormRootState.ctx' },
|
||||
LinkPreviewRootContext: { class: 'LinkPreviewRootState', newPattern: 'LinkPreviewRootState.ctx' },
|
||||
MenuCheckboxGroupContext: {
|
||||
class: 'MenuCheckboxGroupState',
|
||||
newPattern: 'MenuCheckboxGroupState.ctx'
|
||||
},
|
||||
MenuContentContext: { class: 'MenuContentState', newPattern: 'MenuContentState.ctx' },
|
||||
MenuMenuContext: { class: 'MenuMenuState', newPattern: 'MenuMenuState.ctx' },
|
||||
MenuRadioGroupContext: { class: 'MenuRadioGroupState', newPattern: 'MenuRadioGroupState.ctx' },
|
||||
MenuRootContext: { class: 'MenuRootState', newPattern: 'MenuRootState.ctx' },
|
||||
MenubarMenuContext: { class: 'MenubarMenuState', newPattern: 'MenubarMenuState.ctx' },
|
||||
MenubarRootContext: { class: 'MenubarRootState', newPattern: 'MenubarRootState.ctx' },
|
||||
NumberFieldRootContext: { class: 'NumberFieldRootState', newPattern: 'NumberFieldRootState.ctx' },
|
||||
PaginationRootContext: { class: 'PaginationRootState', newPattern: 'PaginationRootState.ctx' },
|
||||
PopoverRootContext: { class: 'PopoverRootState', newPattern: 'PopoverRootState.ctx' },
|
||||
RadioGroupRootContext: { class: 'RadioGroupRootState', newPattern: 'RadioGroupRootState.ctx' },
|
||||
RangeCalendarCellContext: {
|
||||
class: 'RangeCalendarCellState',
|
||||
newPattern: 'RangeCalendarCellState.ctx'
|
||||
},
|
||||
RatingGroupRootContext: { class: 'RatingGroupRootState', newPattern: 'RatingGroupRootState.ctx' },
|
||||
ScrollAreaScrollbarAxisContext: { class: 'ScrollbarAxis', newPattern: 'ScrollbarAxis.ctx' },
|
||||
ScrollAreaScrollbarSharedContext: {
|
||||
class: 'ScrollAreaScrollbarSharedState',
|
||||
newPattern: 'ScrollAreaScrollbarSharedState.ctx'
|
||||
},
|
||||
ScrollAreaScrollbarVisibleContext: {
|
||||
class: 'ScrollAreaScrollbarVisibleState',
|
||||
newPattern: 'ScrollAreaScrollbarVisibleState.ctx'
|
||||
},
|
||||
ScrollAreaScrollbarContext: {
|
||||
class: 'ScrollAreaScrollbarState',
|
||||
newPattern: 'ScrollAreaScrollbarState.ctx'
|
||||
},
|
||||
ScrollAreaRootContext: { class: 'ScrollAreaRootState', newPattern: 'ScrollAreaRootState.ctx' },
|
||||
SelectContentContext: { class: 'SelectContentState', newPattern: 'SelectContentState.ctx' },
|
||||
SelectGroupContext: { class: 'SelectGroupState', newPattern: 'SelectGroupState.ctx' },
|
||||
SelectRootContext: { class: 'SelectRoot', newPattern: 'SelectRoot.ctx' },
|
||||
SliderRootContext: { class: 'SliderRoot', newPattern: 'SliderRoot.ctx' },
|
||||
SpinnerRootContext: { class: 'SpinnerRootState', newPattern: 'SpinnerRootState.ctx' },
|
||||
SplitterRootContext: { class: 'SplitterRootState', newPattern: 'SplitterRootState.ctx' },
|
||||
StepperItemContext: { class: 'StepperItemState', newPattern: 'StepperItemState.ctx' },
|
||||
StepperRootContext: { class: 'StepperRootState', newPattern: 'StepperRootState.ctx' },
|
||||
SwitchRootContext: { class: 'SwitchRootState', newPattern: 'SwitchRootState.ctx' },
|
||||
TabsRootContext: { class: 'TabsRootState', newPattern: 'TabsRootState.ctx' },
|
||||
TagsInputRootContext: { class: 'TagsInputRootState', newPattern: 'TagsInputRootState.ctx' },
|
||||
TagsInputTagContext: { class: 'TagsInputTagState', newPattern: 'TagsInputTagState.ctx' },
|
||||
TimeFieldRootContext: { class: 'TimeFieldRootState', newPattern: 'TimeFieldRootState.ctx' },
|
||||
TimeRangeFieldRootContext: {
|
||||
class: 'TimeRangeFieldRootState',
|
||||
newPattern: 'TimeRangeFieldRootState.ctx'
|
||||
},
|
||||
ToastRootContext: { class: 'ToastRootState', newPattern: 'ToastRootState.ctx' },
|
||||
ToggleGroupRootContext: { class: 'ToggleGroupRootState', newPattern: 'ToggleGroupRootState.ctx' },
|
||||
ToolbarGroupContext: { class: 'ToolbarGroupState', newPattern: 'ToolbarGroupState.ctx' },
|
||||
ToolbarRootContext: { class: 'ToolbarRootState', newPattern: 'ToolbarRootState.ctx' },
|
||||
TooltipProviderContext: { class: 'TooltipProviderState', newPattern: 'TooltipProviderState.ctx' },
|
||||
TooltipRootContext: { class: 'TooltipRootState', newPattern: 'TooltipRootState.ctx' },
|
||||
FloatingTooltipRootContext: { class: 'FloatingRootState', newPattern: 'FloatingRootState.ctx' },
|
||||
FloatingContentContext: { class: 'FloatingContentState', newPattern: 'FloatingContentState.ctx' }
|
||||
};
|
||||
|
||||
function findAllFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
files.push(...findAllFiles(fullPath));
|
||||
} else if (entry.isFile() && (entry.name.endsWith('.svelte') || entry.name.endsWith('.ts'))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function fixFile(filePath: string): boolean {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
let modified = content;
|
||||
|
||||
// Para cada contexto en el mapa
|
||||
for (const [oldContext, info] of Object.entries(contextMap)) {
|
||||
// 1. Eliminar el contexto de los imports si no se usa elsewhere en el archivo
|
||||
const importRegex = new RegExp(`,?\\s*${oldContext}\\s*,?\\s*`, 'g');
|
||||
const hasOldImport = new RegExp(`\\b${oldContext}\\b`).test(content);
|
||||
|
||||
if (!hasOldImport) continue;
|
||||
|
||||
// 2. Reemplazar referencias al contexto con el nuevo patrón
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\b${oldContext}\\.set\\(`, 'g'),
|
||||
`${info.newPattern}.set(`
|
||||
);
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\b${oldContext}\\.get\\(`, 'g'),
|
||||
`${info.newPattern}.get(`
|
||||
);
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\b${oldContext}\\.getOr\\(`, 'g'),
|
||||
`${info.newPattern}.getOr(`
|
||||
);
|
||||
}
|
||||
|
||||
if (modified !== content) {
|
||||
writeFileSync(filePath, modified, 'utf-8');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const files = findAllFiles(terraDir);
|
||||
let modified = 0;
|
||||
|
||||
console.log('🔧 Actualizando referencias a contextos...\n');
|
||||
|
||||
for (const file of files) {
|
||||
if (fixFile(file)) {
|
||||
modified++;
|
||||
const relPath = file.replace(/.*src\\/, '').replace(/.*src\//, '');
|
||||
console.log(` ✅ ${relPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Resumen:`);
|
||||
console.log(` Modificados: ${modified}`);
|
||||
console.log(` Total procesados: ${files.length}`);
|
||||
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Repara los problemas generados por la conversión al patrón .ctx
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const terraDir = join(__dirname, '../src/uix/terra');
|
||||
|
||||
function findAllTsFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||
files.push(...findAllTsFiles(fullPath));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.svelte.ts')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function fixFile(filePath: string): boolean {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const original = content;
|
||||
let modified = content;
|
||||
|
||||
// 1. Eliminar líneas con "export ;" o ";"
|
||||
modified = modified.replace(/^\s*export\s*;\s*\n?/gm, '');
|
||||
modified = modified.replace(/^\s*;\s*\n?/gm, '');
|
||||
|
||||
// 2. Eliminar líneas duplicadas vacías consecutivas (más de 2)
|
||||
modified = modified.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// 3. Arreglar referencias a contextos antiguos que no se actualizaron
|
||||
// CalendarRootContext.get() -> CalendarRootState.ctx.get()
|
||||
modified = modified.replace(/CalendarRootContext\.get\(\)/g, 'CalendarRootState.ctx.get()');
|
||||
|
||||
if (modified !== original) {
|
||||
writeFileSync(filePath, modified, 'utf-8');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const files = findAllTsFiles(terraDir);
|
||||
let modified = 0;
|
||||
|
||||
console.log('🔧 Reparando archivos...\n');
|
||||
|
||||
for (const file of files) {
|
||||
if (fixFile(file)) {
|
||||
modified++;
|
||||
const relPath = file.replace(/.*src\\uix\\terra\\/, '').replace(/\.svelte\.ts$/, '');
|
||||
console.log(` ✅ ${relPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Resumen:`);
|
||||
console.log(` Modificados: ${modified}`);
|
||||
console.log(` Total procesados: ${files.length}`);
|
||||
@ -0,0 +1,102 @@
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const ICONS_SOURCE = join(process.cwd(), 'static/lucide/icons');
|
||||
const OUTPUT_FILE = join(process.cwd(), 'src/uix/air/icons/categories.ts');
|
||||
|
||||
function toPascalCase(str) {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
const categoryLabels = {
|
||||
arrows: 'Arrows',
|
||||
communication: 'Communication',
|
||||
design: 'Design',
|
||||
dev: 'Development',
|
||||
editors: 'Editors',
|
||||
logo: 'Logos',
|
||||
maps: 'Maps',
|
||||
media: 'Media',
|
||||
objects: 'Objects',
|
||||
people: 'People',
|
||||
science: 'Science',
|
||||
sports: 'Sports',
|
||||
text: 'Text',
|
||||
weather: 'Weather',
|
||||
ui: 'UI Elements',
|
||||
currency: 'Currency',
|
||||
animals: 'Animals',
|
||||
food: 'Food',
|
||||
health: 'Health',
|
||||
home: 'Home',
|
||||
navigation: 'Navigation',
|
||||
shopping: 'Shopping',
|
||||
social: 'Social',
|
||||
transportation: 'Transportation',
|
||||
accessibility: 'Accessibility',
|
||||
security: 'Security',
|
||||
files: 'Files',
|
||||
datetime: 'Date & Time'
|
||||
};
|
||||
|
||||
function main() {
|
||||
const allFiles = readdirSync(ICONS_SOURCE).filter((f) => f.endsWith('.json'));
|
||||
const categoryMap = new Map();
|
||||
|
||||
for (const file of allFiles) {
|
||||
const iconName = file.replace('.json', '');
|
||||
const jsonPath = join(ICONS_SOURCE, file);
|
||||
|
||||
try {
|
||||
const json = JSON.parse(readFileSync(jsonPath, 'utf-8'));
|
||||
const categories = json.categories || ['uncategorized'];
|
||||
|
||||
for (const category of categories) {
|
||||
if (!categoryMap.has(category)) {
|
||||
categoryMap.set(category, []);
|
||||
}
|
||||
categoryMap.get(category).push({
|
||||
name: iconName,
|
||||
pascal: toPascalCase(iconName)
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error parsing ${file}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort icons within each category
|
||||
for (const icons of categoryMap.values()) {
|
||||
icons.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Generate TypeScript file
|
||||
const sortedCategories = Array.from(categoryMap.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
let output = '// Auto-generated - DO NOT EDIT\n\n';
|
||||
output += 'export interface IconCategory {\n';
|
||||
output += ' name: string;\n';
|
||||
output += ' label: string;\n';
|
||||
output += ' icons: Array<{ name: string; pascal: string }>;\n';
|
||||
output += '}\n\n';
|
||||
output += 'export const iconCategories: IconCategory[] = [\n';
|
||||
|
||||
for (const [category, icons] of sortedCategories) {
|
||||
const label = categoryLabels[category] || toPascalCase(category);
|
||||
output += `\t{ name: '${category}', label: '${label}', icons: [\n`;
|
||||
for (const icon of icons) {
|
||||
output += `\t\t{ name: '${icon.name}', pascal: '${icon.pascal}' },\n`;
|
||||
}
|
||||
output += '\t] },\n';
|
||||
}
|
||||
|
||||
output += '];\n';
|
||||
|
||||
writeFileSync(OUTPUT_FILE, output, 'utf-8');
|
||||
console.log(`Generated ${OUTPUT_FILE} with ${categoryMap.size} categories`);
|
||||
}
|
||||
|
||||
main();
|
||||
@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Morfo DOM-validation check.
|
||||
*
|
||||
* For each morfo at `src/uix/morfo/components/*.ts`:
|
||||
* 1. Navigate to the matching demo route at `/test/soma/{kebab}`.
|
||||
* 2. For every `public` part declared in the morfo, query the DOM for
|
||||
* `[data-{component}-{part}]` (or `[data-{component}]` for root).
|
||||
* 3. For each matching element, verify:
|
||||
* - Every data-attr the morfo declares with `severity: 'required'`
|
||||
* is present.
|
||||
* - Every data-attr with `values: [...]` has a value in that set.
|
||||
* - No `data-{component}-*` attr is present that isn't declared in
|
||||
* the morfo (strict mode — excludes `data-_*` privates).
|
||||
*
|
||||
* Runs after `npm run smoke` passes. Requires `npm run dev` running.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all morfos validate against their rendered DOM.
|
||||
* 1 — at least one morfo-vs-DOM discrepancy.
|
||||
* 2 — no dev server / couldn't load morfos.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Morfo, MorfoPart, MorfoData } from '../src/uix/morfo/types';
|
||||
import { validateMorfo } from '../src/uix/morfo/schema';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
|
||||
|
||||
async function probePort(start: number, end: number): Promise<string | null> {
|
||||
for (let port = start; port <= end; port++) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/`, {
|
||||
signal: AbortSignal.timeout(500)
|
||||
});
|
||||
if (res.ok || res.status === 404 || res.status === 500) {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadMorfos(): Promise<Morfo[]> {
|
||||
const files = readdirSync(MORFOS_DIR).filter(
|
||||
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
|
||||
);
|
||||
const out: Morfo[] = [];
|
||||
for (const f of files) {
|
||||
// Use file:// URL on Windows — absolute paths starting with "g:" are
|
||||
// rejected by Node's ESM loader.
|
||||
const url = pathToFileURL(join(MORFOS_DIR, f)).href;
|
||||
const mod = (await import(url)) as Record<string, unknown>;
|
||||
for (const v of Object.values(mod)) {
|
||||
if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) {
|
||||
out.push(validateMorfo(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Issue = { kind: 'missing' | 'bad-value' | 'undeclared'; message: string };
|
||||
|
||||
/** Flatten morfo parts into a flat array for validation lookup. */
|
||||
function flatParts(parts: readonly MorfoPart[]): MorfoPart[] {
|
||||
const out: MorfoPart[] = [];
|
||||
for (const p of parts) {
|
||||
out.push(p);
|
||||
if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dataAttrFor(kebab: string, part: MorfoPart): string {
|
||||
return part.kebab === 'provider' ? `data-${kebab}` : `data-${kebab}-${part.kebab}`;
|
||||
}
|
||||
|
||||
async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise<Issue[]> {
|
||||
const issues: Issue[] = [];
|
||||
const parts = flatParts(morfo.parts).filter((p) => p.kind === 'public');
|
||||
|
||||
// Collect every part-identifying attr across the morfo so a part can
|
||||
// legitimately carry a sibling's identifying attr (e.g. tag-group Link
|
||||
// is a variant of Item, the <a> element carries both data-tag-group-link
|
||||
// and data-tag-group-item).
|
||||
const allPartAttrs = new Set(parts.map((p) => dataAttrFor(morfo.kebab, p)));
|
||||
|
||||
for (const part of parts) {
|
||||
const partAttr = dataAttrFor(morfo.kebab, part);
|
||||
|
||||
// All elements on the page that carry this part's identifying attr.
|
||||
const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) =>
|
||||
nodes.map((el) =>
|
||||
Array.from(el.attributes)
|
||||
.filter((a) => a.name.startsWith('data-'))
|
||||
.map((a) => ({ name: a.name, value: a.value }))
|
||||
)
|
||||
);
|
||||
|
||||
if (elementsAttrs.length === 0) {
|
||||
// Not on the demo page — skip validation entirely. Morfo "optional"
|
||||
// vs "required" is about composition validity (whether a consumer
|
||||
// MAY omit the part), not about demo-page presence. Dialog.Content
|
||||
// is required in a valid composition but only mounts when
|
||||
// `open=true`, which the static demo page may not exercise.
|
||||
//
|
||||
// A dedicated permutation-matrix script (future) will exercise
|
||||
// each component through its state space and validate part
|
||||
// presence per permutation. For now, demo presence is advisory.
|
||||
continue;
|
||||
}
|
||||
|
||||
const declared = new Set(part.data.map((d) => d.attr));
|
||||
declared.add(partAttr); // the part-identifying attr itself
|
||||
// Sibling parts' identifying attrs are legitimate (Link-is-Item pattern).
|
||||
for (const siblingAttr of allPartAttrs) declared.add(siblingAttr);
|
||||
const declaredByAttr = new Map<string, MorfoData>(
|
||||
part.data.map((d) => [d.attr, d])
|
||||
);
|
||||
|
||||
for (const elAttrs of elementsAttrs) {
|
||||
// (a) required attrs present
|
||||
for (const data of part.data) {
|
||||
if ((data.severity ?? 'required') !== 'required') continue;
|
||||
const found = elAttrs.find((a) => a.name === data.attr);
|
||||
if (!found) {
|
||||
issues.push({
|
||||
kind: 'missing',
|
||||
message: `${morfo.kebab}.${part.kebab}: required attr "${data.attr}" not emitted`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (b) enum-valued attrs have valid values
|
||||
for (const { name, value } of elAttrs) {
|
||||
const decl = declaredByAttr.get(name);
|
||||
if (decl?.values && !decl.values.includes(value)) {
|
||||
issues.push({
|
||||
kind: 'bad-value',
|
||||
message: `${morfo.kebab}.${part.kebab}: "${name}" has value "${value}", morfo declares [${decl.values.join(', ')}]`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (c) no undeclared public data-{component}-* attrs
|
||||
for (const { name } of elAttrs) {
|
||||
if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue;
|
||||
if (name.startsWith(`data-_`)) continue; // private escape hatch
|
||||
if (declared.has(name)) continue;
|
||||
// Allow other components' part attrs (nested composition).
|
||||
if (!name.startsWith(`data-${morfo.kebab}`)) continue;
|
||||
issues.push({
|
||||
kind: 'undeclared',
|
||||
message: `${morfo.kebab}.${part.kebab}: undeclared attr "${name}" emitted on element (not in morfo)`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE = process.argv[2] ?? (await probePort(5173, 5180));
|
||||
if (!BASE) {
|
||||
console.error('Could not find a running dev server on 5173-5180.');
|
||||
console.error('Start it with `npm run dev` in another terminal.');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Using dev server at ${BASE}`);
|
||||
|
||||
const morfos = await loadMorfos();
|
||||
if (morfos.length === 0) {
|
||||
console.error('No morfos found under src/uix/morfo/components/');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Loaded ${morfos.length} morfo${morfos.length === 1 ? '' : 's'}`);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
const failures: Array<{ morfo: string; issues: Issue[] }> = [];
|
||||
|
||||
for (const morfo of morfos) {
|
||||
const route = `/test/soma/${morfo.kebab}`;
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
await page.waitForTimeout(500);
|
||||
const issues = await validateMorfoAgainstDom(page, morfo);
|
||||
if (issues.length === 0) {
|
||||
console.log(`PASS ${morfo.kebab.padEnd(24)} ${route}`);
|
||||
} else {
|
||||
console.log(`FAIL ${morfo.kebab.padEnd(24)} ${route}`);
|
||||
issues.forEach((i) => console.log(` [${i.kind}] ${i.message}`));
|
||||
failures.push({ morfo: morfo.kebab, issues });
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`ERROR ${morfo.kebab.padEnd(24)} ${(e as Error).message}`);
|
||||
failures.push({
|
||||
morfo: morfo.kebab,
|
||||
issues: [{ kind: 'missing', message: `navigation error: ${(e as Error).message}` }]
|
||||
});
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('');
|
||||
if (failures.length === 0) {
|
||||
console.log(`All ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} validate against their demo DOM.`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
const totalIssues = failures.reduce((sum, f) => sum + f.issues.length, 0);
|
||||
console.log(
|
||||
`${failures.length}/${morfos.length} morfos failed (${totalIssues} total issues): ${failures.map((f) => f.morfo).join(', ')}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Morfo vocabulary consistency check.
|
||||
*
|
||||
* Walks every morfo's `data` declarations and reports any attr with
|
||||
* `values: string[]` whose set is NOT one of the canonical vocabularies
|
||||
* declared in `CANONICAL_VOCABULARIES` (exported from morfo/schema.ts).
|
||||
*
|
||||
* Rationale (see sema_pre.md §5 + study.md §10):
|
||||
* Sema selectors rely on consistent state vocabularies across components.
|
||||
* If Accordion uses `data-state: ['open', 'closed']` then Dialog and Drawer
|
||||
* must use the same tokens — not `visible|hidden` or `expanded|collapsed`.
|
||||
*
|
||||
* Strategy: a whitelist of known vocabularies. Any enum that doesn't exactly
|
||||
* match one of them is flagged as a WARNING, not a hard error. Promote to
|
||||
* error only when the vocabulary registry is mature enough that divergence
|
||||
* is always a bug.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all enums match a canonical vocabulary (or are novel but acceptable).
|
||||
* 1 — at least one enum diverges and should be investigated.
|
||||
*/
|
||||
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Morfo, MorfoPart } from '../src/uix/morfo/types';
|
||||
import { validateMorfo, CANONICAL_VOCABULARIES } from '../src/uix/morfo/schema';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
|
||||
|
||||
async function loadMorfos(): Promise<Morfo[]> {
|
||||
const files = readdirSync(MORFOS_DIR).filter(
|
||||
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
|
||||
);
|
||||
const out: Morfo[] = [];
|
||||
for (const f of files) {
|
||||
const url = pathToFileURL(join(MORFOS_DIR, f)).href;
|
||||
const mod = (await import(url)) as Record<string, unknown>;
|
||||
for (const v of Object.values(mod)) {
|
||||
if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) {
|
||||
out.push(validateMorfo(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function flatParts(parts: readonly MorfoPart[]): MorfoPart[] {
|
||||
const out: MorfoPart[] = [];
|
||||
for (const p of parts) {
|
||||
out.push(p);
|
||||
if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Classification =
|
||||
| { kind: 'match'; vocabulary: string }
|
||||
| { kind: 'subset'; vocabulary: string; extra: string[] }
|
||||
| { kind: 'superset'; vocabulary: string; missing: string[] }
|
||||
| { kind: 'unknown' };
|
||||
|
||||
function classify(values: readonly string[]): Classification {
|
||||
const valueSet = new Set(values);
|
||||
for (const [name, canonical] of Object.entries(CANONICAL_VOCABULARIES)) {
|
||||
const canonicalSet = new Set(canonical);
|
||||
if (
|
||||
canonicalSet.size === valueSet.size &&
|
||||
[...valueSet].every((v) => canonicalSet.has(v))
|
||||
) {
|
||||
return { kind: 'match', vocabulary: name };
|
||||
}
|
||||
// Enum extends a canonical (added values)
|
||||
if ([...canonicalSet].every((v) => valueSet.has(v))) {
|
||||
const extra = [...valueSet].filter((v) => !canonicalSet.has(v));
|
||||
if (extra.length > 0 && extra.length <= 3) {
|
||||
return { kind: 'subset', vocabulary: name, extra };
|
||||
}
|
||||
}
|
||||
// Enum shrinks a canonical (removed values)
|
||||
if ([...valueSet].every((v) => canonicalSet.has(v))) {
|
||||
const missing = [...canonicalSet].filter((v) => !valueSet.has(v));
|
||||
if (missing.length > 0 && missing.length <= 3) {
|
||||
return { kind: 'superset', vocabulary: name, missing };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const morfos = await loadMorfos();
|
||||
console.error(`Scanning ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} for vocabulary divergence...`);
|
||||
|
||||
type Finding = {
|
||||
morfo: string;
|
||||
part: string;
|
||||
attr: string;
|
||||
values: readonly string[];
|
||||
classification: Classification;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attrs whose value sets are per-component by design, not shared vocabulary.
|
||||
* Skipped by the consistency check — Dialog's `saved|cancelled|…` is correctly
|
||||
* different from Toast's `dismissed|auto-timeout|action`.
|
||||
*/
|
||||
const PER_COMPONENT_ATTRS = new Set(['data-last-action']);
|
||||
|
||||
const findings: Finding[] = [];
|
||||
|
||||
for (const morfo of morfos) {
|
||||
for (const part of flatParts(morfo.parts)) {
|
||||
for (const data of part.data) {
|
||||
if (!data.values) continue;
|
||||
if (PER_COMPONENT_ATTRS.has(data.attr)) continue;
|
||||
const classification = classify(data.values);
|
||||
if (classification.kind !== 'match') {
|
||||
findings.push({
|
||||
morfo: morfo.kebab,
|
||||
part: part.kebab,
|
||||
attr: data.attr,
|
||||
values: data.values,
|
||||
classification
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.length === 0) {
|
||||
console.log(`All enum values match canonical vocabularies.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Found ${findings.length} divergent enum${findings.length === 1 ? '' : 's'}:`);
|
||||
console.log('');
|
||||
|
||||
for (const f of findings) {
|
||||
const loc = `${f.morfo}.${f.part}.${f.attr}`;
|
||||
const values = `[${f.values.join(', ')}]`;
|
||||
switch (f.classification.kind) {
|
||||
case 'subset':
|
||||
console.log(
|
||||
`WARN ${loc} ${values} extends "${f.classification.vocabulary}" with: ${f.classification.extra.join(', ')}`
|
||||
);
|
||||
break;
|
||||
case 'superset':
|
||||
console.log(
|
||||
`WARN ${loc} ${values} shrinks "${f.classification.vocabulary}" missing: ${f.classification.missing.join(', ')}`
|
||||
);
|
||||
break;
|
||||
case 'unknown':
|
||||
console.log(
|
||||
`WARN ${loc} ${values} no canonical vocabulary matches. Consider if this should use one of: ${Object.keys(CANONICAL_VOCABULARIES).join(', ')}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
`These are WARNINGS — novel vocabularies may be legitimate. Review each above: if the value set SHOULD match a canonical vocabulary, align it. If the component introduces a NEW canonical vocabulary, add it to CANONICAL_VOCABULARIES in src/uix/morfo/schema.ts.`
|
||||
);
|
||||
|
||||
process.exit(0); // WARN only — don't fail CI yet (until vocabulary registry is mature)
|
||||
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Normaliza todos los componentes al patrño .ctx
|
||||
* Convierte: const XxxContext = createTerraContext(...)
|
||||
* A: class XxxState { static readonly ctx = createTerraContext(...) }
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const terraDir = resolve(__dirname, '../src/uix/terra');
|
||||
|
||||
interface ContextDef {
|
||||
name: string;
|
||||
className: string;
|
||||
fullMatch: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
function findContextDefinitions(content: string): ContextDef[] {
|
||||
const contexts: ContextDef[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Patrón para: const XxxContext = createTerraContext<XxxState>("...")
|
||||
const regex =
|
||||
/const\s+(\w+Context)\s*=\s*createTerraContext\s*<\s*(\w+)\s*\>\s*\(\s*["']([^"']+)["']\s*\)/g;
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const contextName = match[1];
|
||||
const className = match[2];
|
||||
const fullMatch = match[0];
|
||||
|
||||
// Encontrar número de línea
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineNumber = beforeMatch.split('\n').length;
|
||||
|
||||
contexts.push({
|
||||
name: contextName,
|
||||
className,
|
||||
fullMatch,
|
||||
lineNumber
|
||||
});
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
function findClassDeclaration(
|
||||
content: string,
|
||||
className: string
|
||||
): { start: number; end: number } | null {
|
||||
const classRegex = new RegExp(`export\\s+class\\s+${className}\\s*\\{`, 'g');
|
||||
const match = classRegex.exec(content);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const start = match.index;
|
||||
const openBraces = [start];
|
||||
let end = start + 1;
|
||||
let depth = 1;
|
||||
|
||||
// Encontrar el cierre de la clase
|
||||
while (end < content.length && depth > 0) {
|
||||
if (content[end] === '{') depth++;
|
||||
else if (content[end] === '}') depth--;
|
||||
end++;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function convertToCtxPattern(content: string, contexts: ContextDef[]): string {
|
||||
let modified = content;
|
||||
|
||||
// Ordenar por número de línea descendente (para modificar de atrás hacia adelante)
|
||||
contexts.sort((a, b) => b.lineNumber - a.lineNumber);
|
||||
|
||||
for (const ctx of contexts) {
|
||||
console.log(` Procesando: ${ctx.name} -> ${ctx.className}.ctx`);
|
||||
|
||||
// 1. Eliminar la definición del contexto externo
|
||||
modified = modified.replace(ctx.fullMatch, '');
|
||||
|
||||
// 2. Encontrar la clase y agregar static readonly ctx
|
||||
const classInfo = findClassDeclaration(modified, ctx.className);
|
||||
if (!classInfo) {
|
||||
console.log(` ⚠️ No se encontró la clase ${ctx.className}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Buscar el primer { de la clase
|
||||
const classStart = modified.indexOf('{', classInfo.start);
|
||||
if (classStart === -1 || classStart > classInfo.end) continue;
|
||||
|
||||
// Insertar static readonly ctx después de la llave de apertura
|
||||
const indent = ' '; // Usar tabs como el resto del archivo
|
||||
const ctxProperty = `${indent}static readonly ctx = createTerraContext<${ctx.className}>('${ctx.className}');\n`;
|
||||
|
||||
modified =
|
||||
modified.slice(0, classStart + 1) + '\n' + ctxProperty + modified.slice(classStart + 1);
|
||||
|
||||
// 3. Reemplazar XxxContext.get() por XxxState.ctx.get()
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\b${ctx.name}\\.get\\(\\)`, 'g'),
|
||||
`${ctx.className}.ctx.get()`
|
||||
);
|
||||
|
||||
// 4. Reemplazar XxxContext.set() por XxxState.ctx.set()
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\b${ctx.name}\\.set\\(`, 'g'),
|
||||
`${ctx.className}.ctx.set(`
|
||||
);
|
||||
|
||||
// 5. Reemplazar XxxContext.getOr() por XxxState.ctx.getOr()
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\b${ctx.name}\\.getOr\\(`, 'g'),
|
||||
`${ctx.className}.ctx.getOr(`
|
||||
);
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
function processFile(filePath: string): boolean {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const original = content;
|
||||
|
||||
const contexts = findContextDefinitions(content);
|
||||
if (contexts.length === 0) return false;
|
||||
|
||||
console.log(`📄 ${filePath}`);
|
||||
const modified = convertToCtxPattern(content, contexts);
|
||||
|
||||
if (modified !== original) {
|
||||
writeFileSync(filePath, modified, 'utf-8');
|
||||
console.log(` ✅ ${contexts.length} contexto(s) convertido(s)\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Buscar todos los archivos .svelte.ts en terra/
|
||||
function findAllTsFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||
files.push(...findAllTsFiles(fullPath));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.svelte.ts')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = findAllTsFiles(terraDir);
|
||||
let modified = 0;
|
||||
let skipped = 0;
|
||||
|
||||
console.log('🔧 Normalizando al patrón .ctx...\n');
|
||||
|
||||
for (const file of files) {
|
||||
if (processFile(file)) {
|
||||
modified++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Resumen:`);
|
||||
console.log(` Modificados: ${modified}`);
|
||||
console.log(` Skippeados: ${skipped}`);
|
||||
console.log(` Total: ${modified + skipped}`);
|
||||
@ -0,0 +1,589 @@
|
||||
/**
|
||||
* Permutation runner — cycles soma components through the state space declared
|
||||
* by their demo pages and validates morfo compliance after every transition.
|
||||
*
|
||||
* Closes the gap left by `morfo:check` (single state) and `smoke` (load-time
|
||||
* errors only): reactivity bugs like toolbar's A35 loop and form's A36
|
||||
* microtask loop manifest during state transitions, not on first paint.
|
||||
*
|
||||
* Opt-in per demo: tag interactive controls with `data-perm-step="N"`. The
|
||||
* runner discovers them, executes them in order, and re-validates morfo after
|
||||
* each settle. See `src/uix/morfo/PERMUTATION_RUNNER.md` for the full
|
||||
* authoring convention.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — every demo with `data-perm-step` passes all permutations.
|
||||
* 1 — at least one permutation failed.
|
||||
* 2 — no dev server / no morfos found.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Morfo, MorfoPart, MorfoData } from '../src/uix/morfo/types';
|
||||
import { validateMorfo } from '../src/uix/morfo/schema';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
|
||||
|
||||
// ── Shared with morfo-check.ts ──────────────────────────────────────────────
|
||||
|
||||
type Issue = { kind: 'missing' | 'bad-value' | 'undeclared'; message: string };
|
||||
|
||||
function flatParts(parts: readonly MorfoPart[]): MorfoPart[] {
|
||||
const out: MorfoPart[] = [];
|
||||
for (const p of parts) {
|
||||
out.push(p);
|
||||
if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dataAttrFor(kebab: string, part: MorfoPart): string {
|
||||
return part.kebab === 'provider' ? `data-${kebab}` : `data-${kebab}-${part.kebab}`;
|
||||
}
|
||||
|
||||
async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise<Issue[]> {
|
||||
const issues: Issue[] = [];
|
||||
const parts = flatParts(morfo.parts).filter((p) => p.kind === 'public');
|
||||
const allPartAttrs = new Set(parts.map((p) => dataAttrFor(morfo.kebab, p)));
|
||||
|
||||
for (const part of parts) {
|
||||
const partAttr = dataAttrFor(morfo.kebab, part);
|
||||
const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) =>
|
||||
nodes.map((el) =>
|
||||
Array.from(el.attributes)
|
||||
.filter((a) => a.name.startsWith('data-'))
|
||||
.map((a) => ({ name: a.name, value: a.value }))
|
||||
)
|
||||
);
|
||||
|
||||
if (elementsAttrs.length === 0) continue;
|
||||
|
||||
const declared = new Set(part.data.map((d) => d.attr));
|
||||
declared.add(partAttr);
|
||||
for (const siblingAttr of allPartAttrs) declared.add(siblingAttr);
|
||||
const declaredByAttr = new Map<string, MorfoData>(part.data.map((d) => [d.attr, d]));
|
||||
|
||||
for (const elAttrs of elementsAttrs) {
|
||||
for (const data of part.data) {
|
||||
if ((data.severity ?? 'required') !== 'required') continue;
|
||||
const found = elAttrs.find((a) => a.name === data.attr);
|
||||
if (!found) {
|
||||
issues.push({
|
||||
kind: 'missing',
|
||||
message: `${morfo.kebab}.${part.kebab}: required attr "${data.attr}" not emitted`
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const { name, value } of elAttrs) {
|
||||
const decl = declaredByAttr.get(name);
|
||||
if (decl?.values && !decl.values.includes(value)) {
|
||||
issues.push({
|
||||
kind: 'bad-value',
|
||||
message: `${morfo.kebab}.${part.kebab}: "${name}" has value "${value}", morfo declares [${decl.values.join(', ')}]`
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const { name } of elAttrs) {
|
||||
if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue;
|
||||
if (name.startsWith(`data-_`)) continue;
|
||||
if (declared.has(name)) continue;
|
||||
if (!name.startsWith(`data-${morfo.kebab}`)) continue;
|
||||
issues.push({
|
||||
kind: 'undeclared',
|
||||
message: `${morfo.kebab}.${part.kebab}: undeclared attr "${name}" (not in morfo)`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ── Probe port + load morfos (copied from morfo-check) ──────────────────────
|
||||
|
||||
async function probePort(start: number, end: number): Promise<string | null> {
|
||||
for (let port = start; port <= end; port++) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/`, {
|
||||
signal: AbortSignal.timeout(500)
|
||||
});
|
||||
if (res.ok || res.status === 404 || res.status === 500) {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadMorfos(): Promise<Morfo[]> {
|
||||
const files = readdirSync(MORFOS_DIR).filter(
|
||||
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
|
||||
);
|
||||
const out: Morfo[] = [];
|
||||
for (const f of files) {
|
||||
const url = pathToFileURL(join(MORFOS_DIR, f)).href;
|
||||
const mod = (await import(url)) as Record<string, unknown>;
|
||||
for (const v of Object.values(mod)) {
|
||||
if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) {
|
||||
out.push(validateMorfo(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Permutation step schema ─────────────────────────────────────────────────
|
||||
|
||||
interface PermStep {
|
||||
index: number;
|
||||
label: string;
|
||||
mode: { kind: 'click' } | { kind: 'focus' } | { kind: 'type'; value: string } | { kind: 'key'; key: string };
|
||||
skipValidate: boolean;
|
||||
settle: number;
|
||||
}
|
||||
|
||||
/** Parse a `data-perm-mode` attribute value. */
|
||||
function parseMode(raw: string | null): PermStep['mode'] {
|
||||
if (!raw || raw === 'click') return { kind: 'click' };
|
||||
if (raw === 'focus') return { kind: 'focus' };
|
||||
const typeMatch = /^type="([^"]*)"$/.exec(raw);
|
||||
if (typeMatch) return { kind: 'type', value: typeMatch[1] };
|
||||
const keyMatch = /^key="([^"]+)"$/.exec(raw);
|
||||
if (keyMatch) return { kind: 'key', key: keyMatch[1] };
|
||||
return { kind: 'click' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect perm steps CURRENTLY present in the DOM, sorted by index. Called
|
||||
* before every step execution because new `data-perm-step` elements may
|
||||
* mount as earlier steps fire (e.g. a Dialog.Close inside a portal that
|
||||
* only exists after `open = true`).
|
||||
*/
|
||||
async function collectSteps(page: Page): Promise<PermStep[]> {
|
||||
const raw = await page.$$eval('[data-perm-step]', (els) =>
|
||||
els.map((el) => {
|
||||
const step = Number(el.getAttribute('data-perm-step') ?? '0');
|
||||
return {
|
||||
step,
|
||||
label:
|
||||
el.getAttribute('data-perm-label') ??
|
||||
(el.textContent ?? '').trim().slice(0, 40) ??
|
||||
`step ${step}`,
|
||||
mode: el.getAttribute('data-perm-mode'),
|
||||
skipValidate: el.hasAttribute('data-perm-skip-validate'),
|
||||
settle: Number(el.getAttribute('data-perm-settle') ?? '300')
|
||||
};
|
||||
})
|
||||
);
|
||||
// Deduplicate by step index — if multiple elements share an index, keep the
|
||||
// first (demos should not do this, but portals sometimes mirror content).
|
||||
const byIndex = new Map<number, (typeof raw)[number]>();
|
||||
for (const r of raw) if (!byIndex.has(r.step)) byIndex.set(r.step, r);
|
||||
return [...byIndex.values()]
|
||||
.sort((a, b) => a.step - b.step)
|
||||
.map((r) => ({
|
||||
index: r.step,
|
||||
label: r.label || `step ${r.step}`,
|
||||
mode: parseMode(r.mode),
|
||||
skipValidate: r.skipValidate,
|
||||
settle: Number.isFinite(r.settle) && r.settle > 0 ? r.settle : 300
|
||||
}));
|
||||
}
|
||||
|
||||
/** Execute one step against the live page. Returns the label for logs. */
|
||||
async function executeStep(page: Page, step: PermStep): Promise<void> {
|
||||
const selector = `[data-perm-step="${step.index}"]`;
|
||||
const locator = page.locator(selector).first();
|
||||
switch (step.mode.kind) {
|
||||
case 'click':
|
||||
await locator.click({ timeout: 3000 });
|
||||
break;
|
||||
case 'focus':
|
||||
await locator.focus({ timeout: 3000 });
|
||||
break;
|
||||
case 'type':
|
||||
await locator.focus({ timeout: 3000 });
|
||||
await page.keyboard.type(step.mode.value);
|
||||
break;
|
||||
case 'key':
|
||||
await locator.focus({ timeout: 3000 });
|
||||
await page.keyboard.press(step.mode.key);
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(step.settle);
|
||||
}
|
||||
|
||||
// ── URL variant matrix (v2 + v3 axis-at-a-time) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Universal baseline: every instrumented demo runs at least under `ltr`
|
||||
* (plain URL) and `rtl` (`?perm.dir=rtl`). Additional per-component axes
|
||||
* are discovered at runtime from the demo's `<meta name="perm-axes">` tag;
|
||||
* each declared axis adds ONE variant that flips that axis from baseline
|
||||
* while keeping everything else at the default — linear, not cartesian,
|
||||
* so the matrix stays bounded.
|
||||
*
|
||||
* Supported axes (v3 initial set):
|
||||
* dir — ltr (baseline) + rtl
|
||||
* orientation — horizontal (baseline) + vertical
|
||||
* disabled — unset + true
|
||||
* readonly — unset + true
|
||||
* invalid — unset + true
|
||||
* loading — unset + true
|
||||
*
|
||||
* Demos opt in by listing the axes their `<script>` seed actually honours:
|
||||
*
|
||||
* ```svelte
|
||||
* <svelte:head>
|
||||
* <meta name="perm-axes" content="dir,orientation,disabled" />
|
||||
* </svelte:head>
|
||||
* ```
|
||||
*
|
||||
* Without the meta tag, only the universal `dir` axis runs. With the tag,
|
||||
* the runner adds one variant per listed axis.
|
||||
*/
|
||||
type Variant = { suffix: string; label: string };
|
||||
|
||||
const AXIS_FLIP: Record<string, Variant> = {
|
||||
dir: { suffix: '?perm.dir=rtl', label: 'rtl' },
|
||||
orientation: { suffix: '?perm.orientation=vertical', label: 'orientation=vertical' },
|
||||
disabled: { suffix: '?perm.disabled=true', label: 'disabled' },
|
||||
readonly: { suffix: '?perm.readonly=true', label: 'readonly' },
|
||||
invalid: { suffix: '?perm.invalid=true', label: 'invalid' },
|
||||
loading: { suffix: '?perm.loading=true', label: 'loading' }
|
||||
};
|
||||
|
||||
/** Read `<meta name="perm-axes" content="dir,orientation,...">` from the page. */
|
||||
async function readDeclaredAxes(page: Page): Promise<string[]> {
|
||||
try {
|
||||
const content = await page.$eval(
|
||||
'meta[name="perm-axes"]',
|
||||
(el) => (el as HTMLMetaElement).content
|
||||
);
|
||||
return content
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
// Meta not present — demo opted out of per-axis coverage.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-morfo variant list: baseline `ltr` + `rtl` (universal) +
|
||||
* one flipped variant per axis the demo declared.
|
||||
*/
|
||||
function buildVariants(declaredAxes: string[]): Variant[] {
|
||||
const base: Variant[] = [{ suffix: '', label: 'ltr' }, AXIS_FLIP.dir];
|
||||
const extra: Variant[] = [];
|
||||
for (const axis of declaredAxes) {
|
||||
if (axis === 'dir') continue; // already in base
|
||||
const v = AXIS_FLIP[axis];
|
||||
if (v) extra.push(v);
|
||||
}
|
||||
return [...base, ...extra];
|
||||
}
|
||||
|
||||
// ── Main loop ───────────────────────────────────────────────────────────────
|
||||
|
||||
type StepResult =
|
||||
| { kind: 'pass'; label: string }
|
||||
| { kind: 'fail'; label: string; reason: string }
|
||||
| { kind: 'error'; label: string; err: string }
|
||||
| { kind: 'skipped'; label: string };
|
||||
|
||||
type VariantResult = {
|
||||
variant: string;
|
||||
url: string;
|
||||
stepsTotal: number;
|
||||
steps: StepResult[];
|
||||
pageErrors: string[];
|
||||
};
|
||||
|
||||
type RouteResult = {
|
||||
morfo: string;
|
||||
variants: VariantResult[];
|
||||
};
|
||||
|
||||
const BASE = process.argv[2] ?? (await probePort(5173, 5180));
|
||||
if (!BASE) {
|
||||
console.error('Could not find a running dev server on 5173-5180.');
|
||||
console.error('Start it with `npm run dev` in another terminal.');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Using dev server at ${BASE}`);
|
||||
|
||||
const morfos = await loadMorfos();
|
||||
if (morfos.length === 0) {
|
||||
console.error('No morfos found under src/uix/morfo/components/');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Loaded ${morfos.length} morfo${morfos.length === 1 ? '' : 's'}`);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
const results: RouteResult[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
/** Run one URL variant: load, cycle click-driven steps, report. */
|
||||
async function runVariant(
|
||||
base: string,
|
||||
url: string,
|
||||
variantLabel: string,
|
||||
morfo: Morfo
|
||||
): Promise<VariantResult | 'no-steps'> {
|
||||
const page = await ctx.newPage();
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', (e) => pageErrors.push(e.message));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') pageErrors.push(m.text());
|
||||
});
|
||||
|
||||
const variantResult: VariantResult = {
|
||||
variant: variantLabel,
|
||||
url,
|
||||
stepsTotal: 0,
|
||||
steps: [],
|
||||
pageErrors: []
|
||||
};
|
||||
|
||||
try {
|
||||
await page.goto(base + url, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Meta-only demos (no perm-steps) still get passive `[initial seed]`
|
||||
// validation at each URL variant — morfo compliance under RTL /
|
||||
// orientation flips is worth catching even without a click cycle.
|
||||
|
||||
// Any errors from the URL-seeded initial state count against the variant.
|
||||
if (pageErrors.length > 0) {
|
||||
const captured = pageErrors.splice(0, pageErrors.length).join(' | ');
|
||||
variantResult.steps.push({
|
||||
kind: 'fail',
|
||||
label: '[initial seed]',
|
||||
reason: `pageerror: ${captured.slice(0, 200)}`
|
||||
});
|
||||
} else {
|
||||
// Validate morfo at the URL-seeded state BEFORE any clicks so v2
|
||||
// state-injection alone (no click sequence) is exercised.
|
||||
try {
|
||||
const seedIssues = await validateMorfoAgainstDom(page, morfo);
|
||||
if (seedIssues.length === 0) {
|
||||
variantResult.steps.push({ kind: 'pass', label: '[initial seed]' });
|
||||
} else {
|
||||
variantResult.steps.push({
|
||||
kind: 'fail',
|
||||
label: '[initial seed]',
|
||||
reason: seedIssues.map((i) => `[${i.kind}] ${i.message}`).join(' · ')
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
variantResult.steps.push({
|
||||
kind: 'error',
|
||||
label: '[initial seed]',
|
||||
err: `validation: ${(e as Error).message.slice(0, 120)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const executedIndices = new Set<number>();
|
||||
let safety = 50;
|
||||
while (safety-- > 0) {
|
||||
const current = await collectSteps(page);
|
||||
const next = current
|
||||
.filter((s) => !executedIndices.has(s.index))
|
||||
.sort((a, b) => a.index - b.index)[0];
|
||||
if (!next) break;
|
||||
|
||||
const step = next;
|
||||
executedIndices.add(step.index);
|
||||
|
||||
try {
|
||||
await executeStep(page, step);
|
||||
} catch (e) {
|
||||
variantResult.steps.push({
|
||||
kind: 'error',
|
||||
label: step.label,
|
||||
err: (e as Error).message.slice(0, 120)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
const captured = pageErrors.splice(0, pageErrors.length).join(' | ');
|
||||
variantResult.steps.push({
|
||||
kind: 'fail',
|
||||
label: step.label,
|
||||
reason: `pageerror: ${captured.slice(0, 200)}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.skipValidate) {
|
||||
variantResult.steps.push({ kind: 'pass', label: step.label + ' (no-validate)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await validateMorfoAgainstDom(page, morfo);
|
||||
if (issues.length === 0) {
|
||||
variantResult.steps.push({ kind: 'pass', label: step.label });
|
||||
} else {
|
||||
variantResult.steps.push({
|
||||
kind: 'fail',
|
||||
label: step.label,
|
||||
reason: issues.map((i) => `[${i.kind}] ${i.message}`).join(' · ')
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
variantResult.steps.push({
|
||||
kind: 'error',
|
||||
label: step.label,
|
||||
err: `validation: ${(e as Error).message.slice(0, 120)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
variantResult.pageErrors = pageErrors;
|
||||
variantResult.stepsTotal = variantResult.steps.length;
|
||||
} catch (e) {
|
||||
variantResult.steps.push({
|
||||
kind: 'error',
|
||||
label: '[navigation]',
|
||||
err: (e as Error).message.slice(0, 120)
|
||||
});
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return variantResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover opt-in signals from the demo page: the declared axes (`perm-axes`
|
||||
* meta) and whether any `data-perm-step` element exists. The demo is
|
||||
* considered "instrumented" if it declares at least one of these.
|
||||
*/
|
||||
async function probeDemo(
|
||||
base: string,
|
||||
morfoKebab: string
|
||||
): Promise<{ axes: string[]; hasSteps: boolean } | 'no-demo'> {
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const res = await page.goto(`${base}/test/soma/${morfoKebab}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 20000
|
||||
});
|
||||
if (!res || res.status() >= 400) return 'no-demo';
|
||||
await page.waitForTimeout(200);
|
||||
const axes = await readDeclaredAxes(page);
|
||||
const hasSteps = (await collectSteps(page)).length > 0;
|
||||
return { axes, hasSteps };
|
||||
} catch {
|
||||
return 'no-demo';
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
for (const morfo of morfos) {
|
||||
const variantResults: VariantResult[] = [];
|
||||
|
||||
const probe = await probeDemo(BASE, morfo.kebab);
|
||||
if (probe === 'no-demo') {
|
||||
skipped.push(morfo.kebab);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Opt-in signal: either a `perm-axes` meta OR at least one
|
||||
// `data-perm-step` element. Meta-only demos get passive `[initial seed]`
|
||||
// validation at each URL variant; step-only demos get the v1 click cycle
|
||||
// at baseline; declaring both is the full v1+v2+v3 treatment.
|
||||
if (probe.axes.length === 0 && !probe.hasSteps) {
|
||||
skipped.push(morfo.kebab);
|
||||
continue;
|
||||
}
|
||||
|
||||
const variants = buildVariants(probe.axes);
|
||||
|
||||
for (const v of variants) {
|
||||
const url = `/test/soma/${morfo.kebab}${v.suffix}`;
|
||||
const r = await runVariant(BASE, url, v.label, morfo);
|
||||
if (r === 'no-steps') continue;
|
||||
variantResults.push(r);
|
||||
}
|
||||
|
||||
if (variantResults.length === 0) {
|
||||
skipped.push(morfo.kebab);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ morfo: morfo.kebab, variants: variantResults });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('');
|
||||
let passedRoutes = 0;
|
||||
let failedRoutes = 0;
|
||||
let totalSteps = 0;
|
||||
let totalFailed = 0;
|
||||
for (const r of results) {
|
||||
const bad = r.variants
|
||||
.flatMap((v) => v.steps)
|
||||
.filter((s) => s.kind !== 'pass');
|
||||
totalSteps += r.variants.reduce((acc, v) => acc + v.steps.length, 0);
|
||||
totalFailed += bad.length;
|
||||
|
||||
if (bad.length === 0) {
|
||||
const variantCount = r.variants.length;
|
||||
const stepCount = r.variants.reduce((acc, v) => acc + v.steps.length, 0);
|
||||
console.log(
|
||||
`PASS ${r.morfo.padEnd(20)} ${variantCount} variant${variantCount === 1 ? '' : 's'} · ${stepCount} step${stepCount === 1 ? '' : 's'}`
|
||||
);
|
||||
for (const v of r.variants) {
|
||||
console.log(` [${v.variant}] ${v.url}`);
|
||||
for (const s of v.steps) console.log(` · ${s.label}`);
|
||||
}
|
||||
passedRoutes++;
|
||||
} else {
|
||||
console.log(`FAIL ${r.morfo.padEnd(20)} ${bad.length} step${bad.length === 1 ? '' : 's'} failed`);
|
||||
for (const v of r.variants) {
|
||||
console.log(` [${v.variant}] ${v.url}`);
|
||||
for (const s of v.steps) {
|
||||
if (s.kind === 'pass') console.log(` · ${s.label}`);
|
||||
else if (s.kind === 'skipped') console.log(` · ${s.label} (skipped)`);
|
||||
else if (s.kind === 'fail') console.log(` ✖ ${s.label} ${s.reason}`);
|
||||
else console.log(` ✖ ${s.label} ERROR ${s.err}`);
|
||||
}
|
||||
}
|
||||
failedRoutes++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (skipped.length > 0) {
|
||||
console.log(
|
||||
`SKIPPED (${skipped.length}): ${skipped.slice(0, 10).join(', ')}${skipped.length > 10 ? ', …' : ''}`
|
||||
);
|
||||
console.log(' (demo has no `data-perm-step` annotations yet — see src/uix/morfo/PERMUTATION_RUNNER.md)');
|
||||
}
|
||||
console.log('');
|
||||
const totalVariants = results.reduce((acc, r) => acc + r.variants.length, 0);
|
||||
const summary =
|
||||
failedRoutes === 0
|
||||
? `All ${passedRoutes} instrumented demo${passedRoutes === 1 ? '' : 's'} passed. (${totalSteps} step${totalSteps === 1 ? '' : 's'} across ${totalVariants} URL variant${totalVariants === 1 ? '' : 's'}.)`
|
||||
: `${failedRoutes} demo${failedRoutes === 1 ? '' : 's'} failed (${totalFailed}/${totalSteps} steps): ${results
|
||||
.filter((r) => r.variants.some((v) => v.steps.some((s) => s.kind !== 'pass')))
|
||||
.map((r) => r.morfo)
|
||||
.join(', ')}`;
|
||||
console.log(summary);
|
||||
process.exit(failedRoutes === 0 ? 0 : 1);
|
||||
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Script para eliminar todos los campos privados con # de los componentes Terra
|
||||
* Motivo: Los campos marcados con # se exponen vía data-* attrs, creando ambigüedad
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const terraDir = resolve(__dirname, '../src/uix/terra');
|
||||
|
||||
// Obtener todos los archivos .svelte.ts y .ts recursivamente
|
||||
function getAllTsFiles(dir: string, baseDir = dir): string[] {
|
||||
const files: string[] = [];
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = resolve(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
// Skip node_modules and similar
|
||||
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
files.push(...getAllTsFiles(fullPath, baseDir));
|
||||
}
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(entry.name.endsWith('.svelte.ts') || entry.name.endsWith('.ts'))
|
||||
) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
let modified = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
const files = getAllTsFiles(terraDir);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
let content = readFileSync(file, 'utf-8');
|
||||
const original = content;
|
||||
|
||||
// Skip NAMING_CONVENTIONS.md and other non-code files
|
||||
if (file.includes('NAMING_CONVENTIONS.md') || file.includes('README.md')) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Reemplazar declaraciones de campos privados con readonly
|
||||
// readonly #foo => readonly foo
|
||||
content = content.replace(/readonly\s+#(\w+)/g, 'readonly $1');
|
||||
|
||||
// 2. Reemplazar declaraciones de campos privados sin readonly
|
||||
// #foo = $state(...) => foo = $state(...)
|
||||
content = content.replace(/^(\s*)#(\w+)\s*=/gm, '$1$2 =');
|
||||
|
||||
// 3. Reemplazar uso de campos privados
|
||||
// this.#foo => this.foo
|
||||
content = content.replace(/this\.#(\w+)/g, 'this.$1');
|
||||
|
||||
// 4. Reemplazar métodos privados (opcional - convertir a públicos)
|
||||
// #methodName() => methodName()
|
||||
// Solo si no es seguido por "private" keyword (que no usamos)
|
||||
content = content.replace(/(\s+)#/g, '$1');
|
||||
|
||||
if (content !== original) {
|
||||
writeFileSync(file, content, 'utf-8');
|
||||
modified++;
|
||||
const relativePath = file
|
||||
.replace(/.*src\\uix\\terra\\/, '')
|
||||
.replace(/\.svelte\.ts$/, '')
|
||||
.replace(/\.ts$/, '');
|
||||
console.log(`✅ ${relativePath}`);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors++;
|
||||
console.log(`❌ Error procesando ${file}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Resumen:`);
|
||||
console.log(` Modificados: ${modified}`);
|
||||
console.log(` Skippeados (sin cambios): ${skipped}`);
|
||||
console.log(` Errores: ${errors}`);
|
||||
console.log(` Total procesados: ${modified + skipped + errors}`);
|
||||
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Playwright smoke check for the /test/soma/* demo pages.
|
||||
*
|
||||
* What it verifies per route (beyond HTTP 200):
|
||||
* - No uncaught `pageerror` (thrown during component init / hydration).
|
||||
* - No `console.error` (runtime exceptions that didn't throw synchronously).
|
||||
* - No translation key missing warnings (`[lang] Translation key not found`).
|
||||
* - No soma context-not-found warnings (`Context "X" not found`).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/smoke-check.mjs [base-url]
|
||||
* npm run smoke
|
||||
*
|
||||
* Requires `npm run dev` running in another terminal (or passes an explicit
|
||||
* base URL). The script tries 5173-5180 if no URL is given.
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROUTES_DIR = join(__dirname, '..', 'src', 'routes', 'test', 'soma');
|
||||
|
||||
/** Find every `{route}/+page.svelte` under /test/soma recursively. */
|
||||
function discoverRoutes(dir, prefix) {
|
||||
const routes = [];
|
||||
const entries = readdirSync(dir);
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
routes.push(...discoverRoutes(full, `${prefix}/${name}`));
|
||||
} else if (name === '+page.svelte') {
|
||||
routes.push(prefix || '/test/soma');
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
async function probePort(startPort, endPort) {
|
||||
for (let port = startPort; port <= endPort; port++) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/`, {
|
||||
signal: AbortSignal.timeout(500)
|
||||
});
|
||||
if (res.ok || res.status === 404 || res.status === 500) {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
} catch {
|
||||
// connection refused — try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const BASE = process.argv[2] ?? (await probePort(5173, 5180));
|
||||
if (!BASE) {
|
||||
console.error('Could not find a running dev server on 5173-5180.');
|
||||
console.error('Start it with `npm run dev` in another terminal.');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Using dev server at ${BASE}`);
|
||||
|
||||
const routes = discoverRoutes(ROUTES_DIR, '/test/soma').sort();
|
||||
if (routes.length === 0) {
|
||||
console.error('No +page.svelte files found under /test/soma.');
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`Checking ${routes.length} routes...`);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
const failures = [];
|
||||
const MAX_PARALLEL = 4;
|
||||
|
||||
async function checkRoute(route) {
|
||||
const page = await ctx.newPage();
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
page.on('pageerror', (e) => errors.push(` pageerror: ${e.message}`));
|
||||
page.on('console', (m) => {
|
||||
const t = m.text();
|
||||
if (m.type() === 'error') {
|
||||
// Filter out some noisy devtools messages unrelated to correctness.
|
||||
if (t.includes('Download the React DevTools')) return;
|
||||
errors.push(` console.error: ${t}`);
|
||||
}
|
||||
if (m.type() === 'warning' || m.type() === 'warn') {
|
||||
if (
|
||||
t.includes('Translation key not found') ||
|
||||
t.includes('Context') ||
|
||||
t.includes('[soma]') ||
|
||||
t.includes('soma context')
|
||||
) {
|
||||
warnings.push(` console.warning: ${t}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
const res = await page.goto(BASE + route, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 20000
|
||||
});
|
||||
const status = res?.status() ?? 0;
|
||||
// Give reactive effects + lazy demos a moment to settle.
|
||||
await page.waitForTimeout(500);
|
||||
if (status !== 200 || errors.length > 0 || warnings.length > 0) {
|
||||
const lines = [`FAIL ${route} (HTTP ${status})`];
|
||||
errors.forEach((e) => lines.push(e));
|
||||
warnings.forEach((w) => lines.push(w));
|
||||
return { ok: false, lines };
|
||||
}
|
||||
return { ok: true, lines: [`PASS ${route}`] };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
lines: [`FAIL ${route} (navigation error: ${e.message})`]
|
||||
};
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks with bounded concurrency so the dev server doesn't get hammered.
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < routes.length) {
|
||||
const idx = cursor++;
|
||||
const route = routes[idx];
|
||||
const result = await checkRoute(route);
|
||||
result.lines.forEach((l) => console.log(l));
|
||||
if (!result.ok) failures.push(route);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: MAX_PARALLEL }, () => worker()));
|
||||
|
||||
await browser.close();
|
||||
console.log('');
|
||||
console.log(
|
||||
failures.length === 0
|
||||
? `All ${routes.length} routes passed.`
|
||||
: `${failures.length}/${routes.length} routes failed: ${failures.join(', ')}`
|
||||
);
|
||||
process.exit(failures.length === 0 ? 0 : 1);
|
||||
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Errors for `arts/active-app`.
|
||||
*
|
||||
* Two classes of errors live here:
|
||||
*
|
||||
* - **Schema validation** — thrown synchronously during
|
||||
* `createActiveApp()` when the service schema is malformed
|
||||
* (`AappServiceNameMismatchError`,
|
||||
* `AappServiceDependencyCycleError`,
|
||||
* `AappServiceConstructionFailedError`).
|
||||
* - **App-event safety** — thrown by `publishAppDisposeStarting` and
|
||||
* friends when an event payload carries a sensitive key or fires in
|
||||
* an unsupported runtime (`AappInvalidEventRuntimeError`,
|
||||
* `AappUnsafeEventPayloadError`).
|
||||
*
|
||||
* The Svelte-context bridge `setBus` / `getBus` lives in `arts/bus/`
|
||||
* (its semantic owner is the bus, not App). The corresponding error
|
||||
* `BusNoContextError` lives in `libs/bus/errors.ts`.
|
||||
*
|
||||
* The legacy `AappAlreadyCreatedError` is gone: the declarative service
|
||||
* schema makes "factory called twice" structurally impossible — a
|
||||
* duplicate service key is a JavaScript object-literal error, not a
|
||||
* runtime concern.
|
||||
*/
|
||||
|
||||
import {
|
||||
CodeError,
|
||||
errCode,
|
||||
moduleSeed,
|
||||
type ErrCode,
|
||||
type ErrorMessages,
|
||||
type ModuleSeed
|
||||
} from '$libs/errs';
|
||||
import { APP_MODULE } from './consts.ts';
|
||||
|
||||
// ── Error codes ────────────────────────────────────────────────────────
|
||||
|
||||
export const APP_ERR: ModuleSeed = moduleSeed(APP_MODULE);
|
||||
|
||||
export const APP_ERR_SERVICE_NAME_MISMATCH: ErrCode = errCode(APP_ERR, 'service_name_mismatch');
|
||||
export const APP_ERR_SERVICE_DEPENDENCY_CYCLE: ErrCode = errCode(
|
||||
APP_ERR,
|
||||
'service_dependency_cycle'
|
||||
);
|
||||
export const APP_ERR_SERVICE_CONSTRUCTION_FAILED: ErrCode = errCode(
|
||||
APP_ERR,
|
||||
'service_construction_failed'
|
||||
);
|
||||
export const APP_ERR_EVENT: ErrCode = errCode(APP_ERR, 'event');
|
||||
export const APP_ERR_EVENT_INVALID_RUNTIME: ErrCode = errCode(APP_ERR_EVENT, 'invalid_runtime');
|
||||
export const APP_ERR_EVENT_UNSAFE_PAYLOAD: ErrCode = errCode(APP_ERR_EVENT, 'unsafe_payload');
|
||||
export const APP_ERR_NO_CONTEXT: ErrCode = errCode(APP_ERR, 'no_context');
|
||||
|
||||
// ── Error message builders ─────────────────────────────────────────────
|
||||
|
||||
export const appServiceNameMismatchMessage = (key: string, factoryName: string): string =>
|
||||
`[${APP_MODULE}] service factory name "${factoryName}" must match schema key "${key}"`;
|
||||
|
||||
export const appServiceDependencyCycleMessage = (cycle: readonly string[]): string =>
|
||||
`[${APP_MODULE}] dependency cycle detected: ${cycle.join(' -> ')}`;
|
||||
|
||||
export const appServiceConstructionFailedMessage = (name: string): string =>
|
||||
`[${APP_MODULE}] service "${name}" failed to construct`;
|
||||
|
||||
export const appInvalidEventRuntimeMessage = (
|
||||
type: string,
|
||||
allowed: string,
|
||||
where: string
|
||||
): string =>
|
||||
`[${APP_MODULE}] event cannot fire in this runtime (type=${type}, allowed=${allowed}, where=${where})`;
|
||||
|
||||
export const appUnsafeEventPayloadMessage = (type: string, path: string): string =>
|
||||
`[${APP_MODULE}] app event payload contains a sensitive field (type=${type}, path=${path})`;
|
||||
|
||||
export const appNoContextMessage = (): string =>
|
||||
`[${APP_MODULE}] No ActiveApp found in Svelte context. Call setActiveApp(App) at the layout root.`;
|
||||
|
||||
// ── Error messages ─────────────────────────────────────────────────────
|
||||
|
||||
export const APP_ERROR_MESSAGES: ErrorMessages = {
|
||||
[APP_ERR_SERVICE_NAME_MISMATCH]: appServiceNameMismatchMessage,
|
||||
[APP_ERR_SERVICE_DEPENDENCY_CYCLE]: appServiceDependencyCycleMessage,
|
||||
[APP_ERR_SERVICE_CONSTRUCTION_FAILED]: appServiceConstructionFailedMessage,
|
||||
[APP_ERR_EVENT_INVALID_RUNTIME]: appInvalidEventRuntimeMessage,
|
||||
[APP_ERR_EVENT_UNSAFE_PAYLOAD]: appUnsafeEventPayloadMessage,
|
||||
[APP_ERR_NO_CONTEXT]: appNoContextMessage
|
||||
};
|
||||
|
||||
// ── Error classes ──────────────────────────────────────────────────────
|
||||
|
||||
export class AappServiceNameMismatchError extends CodeError {
|
||||
readonly key: string;
|
||||
readonly factoryName: string;
|
||||
constructor(key: string, factoryName: string) {
|
||||
super(APP_ERR_SERVICE_NAME_MISMATCH, {
|
||||
message: appServiceNameMismatchMessage(key, factoryName)
|
||||
});
|
||||
this.key = key;
|
||||
this.factoryName = factoryName;
|
||||
}
|
||||
}
|
||||
|
||||
export class AappServiceDependencyCycleError extends CodeError {
|
||||
readonly cycle: readonly string[];
|
||||
constructor(cycle: readonly string[]) {
|
||||
super(APP_ERR_SERVICE_DEPENDENCY_CYCLE, {
|
||||
message: appServiceDependencyCycleMessage(cycle)
|
||||
});
|
||||
this.cycle = cycle;
|
||||
}
|
||||
}
|
||||
|
||||
export class AappServiceConstructionFailedError extends CodeError {
|
||||
readonly serviceName: string;
|
||||
constructor(serviceName: string, options?: { cause?: unknown }) {
|
||||
super(APP_ERR_SERVICE_CONSTRUCTION_FAILED, {
|
||||
message: appServiceConstructionFailedMessage(serviceName),
|
||||
cause: options?.cause
|
||||
});
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
}
|
||||
|
||||
export class AappInvalidEventRuntimeError extends CodeError {
|
||||
readonly type: string;
|
||||
readonly allowed: string;
|
||||
readonly where: string;
|
||||
constructor(type: string, allowed: string, where: string) {
|
||||
super(APP_ERR_EVENT_INVALID_RUNTIME, {
|
||||
message: appInvalidEventRuntimeMessage(type, allowed, where)
|
||||
});
|
||||
this.type = type;
|
||||
this.allowed = allowed;
|
||||
this.where = where;
|
||||
}
|
||||
}
|
||||
|
||||
export class AappNoContextError extends CodeError {
|
||||
constructor() {
|
||||
super(APP_ERR_NO_CONTEXT, { message: appNoContextMessage() });
|
||||
}
|
||||
}
|
||||
|
||||
export class AappUnsafeEventPayloadError extends CodeError {
|
||||
readonly type: string;
|
||||
readonly path: string;
|
||||
constructor(type: string, path: string) {
|
||||
super(APP_ERR_EVENT_UNSAFE_PAYLOAD, {
|
||||
message: appUnsafeEventPayloadMessage(type, path)
|
||||
});
|
||||
this.type = type;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Type guards ────────────────────────────────────────────────────────
|
||||
|
||||
export function isAappServiceNameMismatchError(
|
||||
error: unknown
|
||||
): error is AappServiceNameMismatchError {
|
||||
return error instanceof AappServiceNameMismatchError;
|
||||
}
|
||||
|
||||
export function isAappServiceDependencyCycleError(
|
||||
error: unknown
|
||||
): error is AappServiceDependencyCycleError {
|
||||
return error instanceof AappServiceDependencyCycleError;
|
||||
}
|
||||
|
||||
export function isAappServiceConstructionFailedError(
|
||||
error: unknown
|
||||
): error is AappServiceConstructionFailedError {
|
||||
return error instanceof AappServiceConstructionFailedError;
|
||||
}
|
||||
|
||||
export function isAappInvalidEventRuntimeError(
|
||||
error: unknown
|
||||
): error is AappInvalidEventRuntimeError {
|
||||
return error instanceof AappInvalidEventRuntimeError;
|
||||
}
|
||||
|
||||
export function isAappUnsafeEventPayloadError(
|
||||
error: unknown
|
||||
): error is AappUnsafeEventPayloadError {
|
||||
return error instanceof AappUnsafeEventPayloadError;
|
||||
}
|
||||
|
||||
export function isAappNoContextError(error: unknown): error is AappNoContextError {
|
||||
return error instanceof AappNoContextError;
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Public entry point of `arts/active-app`.
|
||||
*
|
||||
* Three import paths exist:
|
||||
*
|
||||
* - `$active-app` — `createActiveApp`, types, errors. What every app
|
||||
* needs to compose its runtime.
|
||||
* - `$active-app/services` — `defineActive*` / `defineEngine*`
|
||||
* factories for the declarative service schema. Loaded only by
|
||||
* apps that declare services.
|
||||
* - `$active-app/presets` — orchestration presets registered on
|
||||
* `App.orca`. Loaded only by apps that opt into the standard
|
||||
* reactions.
|
||||
*
|
||||
* Splitting the entry points lets the bundler tree-shake each layer
|
||||
* independently. An app that only consumes the core never pulls in
|
||||
* service factories or presets.
|
||||
*
|
||||
* The Svelte-context bridge (`setBus` / `getBus`) lives in `$bus`, not
|
||||
* here. It is owned by the bus, which is the semantic origin of the
|
||||
* pattern; App is a consumer that just calls `setBus(App.bus)` once at
|
||||
* the layout root.
|
||||
*/
|
||||
|
||||
export { createActiveApp } from './active-app.svelte.ts';
|
||||
|
||||
export { APP_MODULE } from './consts.ts';
|
||||
|
||||
export {
|
||||
APP_EVENT_DISPOSE_STARTING,
|
||||
publishAppDisposeStarting,
|
||||
onAppDisposeStarting,
|
||||
type AppDisposeStartingPayload,
|
||||
type AppEventBus,
|
||||
type AppEventMap,
|
||||
type AppEventRuntime
|
||||
} from './events.ts';
|
||||
|
||||
export {
|
||||
AappInvalidEventRuntimeError,
|
||||
AappNoContextError,
|
||||
AappServiceConstructionFailedError,
|
||||
AappServiceDependencyCycleError,
|
||||
AappServiceNameMismatchError,
|
||||
AappUnsafeEventPayloadError,
|
||||
isAappInvalidEventRuntimeError,
|
||||
isAappNoContextError,
|
||||
isAappServiceConstructionFailedError,
|
||||
isAappServiceDependencyCycleError,
|
||||
isAappServiceNameMismatchError,
|
||||
isAappUnsafeEventPayloadError
|
||||
} from './errors.ts';
|
||||
|
||||
export { setActiveApp, getActiveApp, tryGetActiveApp } from './svelte/context.svelte.ts';
|
||||
|
||||
export type {
|
||||
ActiveApp,
|
||||
ActiveAppBusEvents,
|
||||
ActiveAppCore,
|
||||
ActiveAppOptions,
|
||||
ActiveAppPrefsOptions,
|
||||
ActiveAppServicesIntrospection
|
||||
} from './types.ts';
|
||||
|
||||
export type {
|
||||
AppServiceFactory,
|
||||
AppServiceSchema,
|
||||
CoreServices,
|
||||
CoreServiceKey,
|
||||
ResolveServiceInstances,
|
||||
ServiceInitMode,
|
||||
ServiceStatus
|
||||
} from './services.ts';
|
||||
|
||||
export { buildServiceBuilders } from './service-builder.ts';
|
||||
export type { ServiceBuilders } from './service-builder.ts';
|
||||
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Svelte context bridge for the App.
|
||||
*
|
||||
* `setActiveApp(app)` writes the App into the component-tree's context
|
||||
* once near the layout root; `getActiveApp()` retrieves it from anywhere
|
||||
* deeper without threading it through props. Mirrors the `setBus` /
|
||||
* `getBus` pattern from `$bus`.
|
||||
*
|
||||
* `getActiveApp()` throws `AappNoContextError` if no App is in scope —
|
||||
* forgetting to call `setActiveApp()` is always a wiring bug, not a
|
||||
* degraded mode. Components that need to degrade gracefully use
|
||||
* `tryGetActiveApp()` instead.
|
||||
*/
|
||||
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { AappNoContextError } from '../errors.ts';
|
||||
import type { ActiveApp } from '../types.ts';
|
||||
import type { AppServiceSchema } from '../services.ts';
|
||||
import type { PrefsSchema } from '$libs/prefs';
|
||||
|
||||
const APP_CONTEXT = Symbol('arts.active-app.context');
|
||||
|
||||
export function setActiveApp<
|
||||
TSchema extends AppServiceSchema = AppServiceSchema,
|
||||
TPrefsSchema extends PrefsSchema = PrefsSchema
|
||||
>(app: ActiveApp<TSchema, TPrefsSchema>): ActiveApp<TSchema, TPrefsSchema> {
|
||||
setContext(APP_CONTEXT, app);
|
||||
return app;
|
||||
}
|
||||
|
||||
export function getActiveApp<
|
||||
TSchema extends AppServiceSchema = AppServiceSchema,
|
||||
TPrefsSchema extends PrefsSchema = PrefsSchema
|
||||
>(): ActiveApp<TSchema, TPrefsSchema> {
|
||||
const app = getContext<ActiveApp<TSchema, TPrefsSchema> | undefined>(APP_CONTEXT);
|
||||
if (!app) throw new AappNoContextError();
|
||||
return app;
|
||||
}
|
||||
|
||||
export function tryGetActiveApp<
|
||||
TSchema extends AppServiceSchema = AppServiceSchema,
|
||||
TPrefsSchema extends PrefsSchema = PrefsSchema
|
||||
>(): ActiveApp<TSchema, TPrefsSchema> | undefined {
|
||||
return getContext<ActiveApp<TSchema, TPrefsSchema> | undefined>(APP_CONTEXT);
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
# `src/lib` — DEPRECATED
|
||||
|
||||
> **STATUS:** Legacy ecosystem. Slated for removal once all consumers
|
||||
> migrate to the new `src/arts` + `src/libs` + `src/svrs` ecosystem.
|
||||
>
|
||||
> **DO NOT** add new code here. **DO NOT** add new aliases pointing here.
|
||||
> If you find yourself reaching for one of these modules, the
|
||||
> replacement already exists in the new ecosystem — see the migration
|
||||
> map below.
|
||||
|
||||
The new ecosystem is documented at [`src/arts/README.md`](../arts/README.md).
|
||||
Each top-level artifact ships its own README.
|
||||
|
||||
---
|
||||
|
||||
## Migration map
|
||||
|
||||
| Legacy module | Replacement | Notes |
|
||||
| --- | --- | --- |
|
||||
| `src/lib/ext/app` (`App.create`) | [`$active-app`](../arts/active-app/README.md) (`createActiveApp`) | Factory signature changes — services declared explicitly. |
|
||||
| `src/lib/ext/lang` (`createLang`, `LangNode`, `p()`) | [`$lang`](../arts/lang/README.md) (`EngineLang` / `ActiveLang`) + `$libs/lang` types | Reactive wrapper lives in `$lang/active-lang.svelte.ts`. |
|
||||
| `src/lib/ext/dates` | [`$libs/days`](../libs/days) (vendored dias) | Drop-in path change. |
|
||||
| `src/lib/ext/money` | [`$format`](../arts/format/README.md) `currency` | API differs. |
|
||||
| `src/lib/ext/nums` | [`$format`](../arts/format/README.md) `numbers` | API differs. |
|
||||
| `src/lib/ext/units` | [`$format`](../arts/format/README.md) `units` | API differs. |
|
||||
| `src/lib/ext/presentation` | [`$frontend`](../arts/frontend/README.md) (`ActiveFrontend`) | dir / locale / density. |
|
||||
| `src/lib/sium/core` | [`$libs/standard-schema`](../libs/standard-schema.ts) + `$libs/sium/types` | Pure schema contracts. |
|
||||
| `src/lib/sium/svelte` (`SiumProvider`) | [`$sium`](../arts/sium/README.md) (`EngineSium`) | Service injection — no Context provider. |
|
||||
| `src/lib/sium/langs` | [`$sium`](../arts/sium/README.md) (`EngineSium.langs`) | Accessed via engine instance. |
|
||||
| `src/lib/util/dias` | [`$libs/days`](../libs/days) | Drop-in. |
|
||||
| `src/lib/util/colors` | [`$libs/color`](../libs/color) | Drop-in. |
|
||||
| `src/lib/util/reactive` | [`$libs/reactive`](../libs/reactive) | Drop-in. The `$reactive` alias already points here. |
|
||||
| `src/lib/util/{arrays,strings,funcs,objs,numbers}` | [`$libs/{arrays,strings,funcs,objs,numbers}`](../libs) | Drop-in. |
|
||||
| `@/ling` (`createLing`) | [`$lang`](../arts/lang/README.md) (`createEngineLang`) | Plurals + reactive split. |
|
||||
| `@/logr` (`createLogr`) | [`$logger`](../arts/logger/README.md) (`EngineLogger`) | Diagnostic events catalogados. |
|
||||
| `@/glob` | [`$format`](../arts/format/README.md) engines | Per CLAUDE.md "do not use." |
|
||||
| `@/actx` | _under review_ | Audit before deletion. |
|
||||
|
||||
---
|
||||
|
||||
## Removal plan
|
||||
|
||||
1. **Phase 0 (THIS COMMIT):** mark every top-level barrel with `@deprecated`. Aliases stay wired for backwards-compat. Zero behavior change.
|
||||
2. **Phase 1:** wire the new ecosystem aliases in `vite.config.ts` + `svelte.config.js`. Already done in this commit.
|
||||
3. **Phase 2:** mechanical sweep — migrate `src/lib/util/*` imports to `$libs/*` everywhere. Drop-in. Solo paths.
|
||||
4. **Phase 3:** migrate `morfo/schema.ts` to `$libs/standard-schema`. One file, three imports.
|
||||
5. **Phase 4:** migrate soma's `App.create()` composition to `createActiveApp` with explicit service slots (incluido `semantic` para sema). Only architecturally substantive step.
|
||||
6. **Phase 5:** sweep test pages (~82 files) to use the new aliases.
|
||||
7. **Phase 6:** remove `src/lib/{glob,ling,actx}` (no consumers). Investigate `src/lib/sium` and `src/lib/util/{dates,times,timers}` for any remaining consumers; remove if clean.
|
||||
|
||||
After Phase 6, this folder should be empty or contain only files awaiting one final consumer.
|
||||
@ -1,2 +1,7 @@
|
||||
/**
|
||||
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
|
||||
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
|
||||
* See `src/lib/README.md` for the migration map.
|
||||
*/
|
||||
export * from './types.ts';
|
||||
export * from './engine.svelte.ts';
|
||||
@ -0,0 +1,878 @@
|
||||
# App Layer — Architecture & Design Document
|
||||
|
||||
## Context
|
||||
|
||||
The current service architecture has soma owning application-level services (translator, logger, formatters, dir). This creates coupling: soma dictates what services exist, requires adapter functions, and creates phantom instances when no context exists (F12). The app layer extracts service ownership to where it belongs — the application.
|
||||
|
||||
### New layer hierarchy
|
||||
|
||||
```
|
||||
app → service provider (translator, format, logger, dir, portalTo, user, ...)
|
||||
soma → headless behavior (components, ARIA, keyboard, state)
|
||||
sema → semantic intent (motion meaning, sound meaning, interaction semantics)
|
||||
eidos → visual form (tokens, themes, CSS recipes, styled components)
|
||||
```
|
||||
|
||||
Dependencies flow one way: `app → soma → sema → eidos`. Each layer reads from the one above, never imports from below.
|
||||
|
||||
### What changes
|
||||
|
||||
- `app` is NEW — lives at `src/lib/ext/app/`
|
||||
- `soma` is MODIFIED — receives services from app instead of owning them
|
||||
- `sema` is NEW — lives at `src/uix/sema/` (semantic layer extracted from air/semantics)
|
||||
- `eidos` is FUTURE — eventually replaces `air/` (not in this phase)
|
||||
- `air` stays for now — gradually delegates to sema
|
||||
|
||||
### Naming
|
||||
|
||||
| Layer | Greek | English | Responsibility |
|
||||
| ----- | ------------ | ----------- | ------------------------------------------------ |
|
||||
| app | — | application | Services, config, domain |
|
||||
| soma | σῶμα (body) | behavior | Components, ARIA, keyboard, state |
|
||||
| sema | σῆμα (sign) | semantics | Motion intent, sound intent, interaction meaning |
|
||||
| eidos | εἶδος (form) | appearance | Tokens, themes, CSS, visual styling |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: App Layer (`src/lib/ext/app/`)
|
||||
|
||||
### 1.1 Directory structure
|
||||
|
||||
```
|
||||
src/lib/ext/app/
|
||||
├── index.ts ← barrel exports
|
||||
├── types.ts ← AppServices, AppLangs, AppLogger, AppFormat interfaces
|
||||
├── app.svelte.ts ← App class (reactive service holder)
|
||||
├── context.ts ← createApp, getApp, useApp
|
||||
└── defaults.ts ← fallback services (mock langs, console logger)
|
||||
```
|
||||
|
||||
### 1.2 Core interface: `AppServices`
|
||||
|
||||
```ts
|
||||
// src/lib/ext/app/types.ts
|
||||
|
||||
export interface AppServices {
|
||||
/** i18n runtime — locale, translation, interpolation, module extension, reactive subscriptions */
|
||||
readonly langs: AppLangs;
|
||||
/** Text direction */
|
||||
readonly dir: Direction;
|
||||
/** Logging */
|
||||
readonly logger?: AppLogger;
|
||||
/** Number/currency/unit/date formatting */
|
||||
readonly format?: AppFormat;
|
||||
/** Default portal target for overlays */
|
||||
readonly portalTo?: string | HTMLElement;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2.1 `AppLangs` — i18n contract
|
||||
|
||||
The interface mirrors lang's `LangInstance` API exactly. Lang fulfills it directly (no adapter). If the consumer doesn't use lang, they implement the interface or provide a mock.
|
||||
|
||||
```ts
|
||||
export interface AppLangs {
|
||||
/** Resolve a dot-path key to a localized string */
|
||||
t(path: string, params?: Record<string, unknown>): string;
|
||||
/** Resolve a LangRecord, LangRef, or descriptor to a string */
|
||||
ts(value: unknown, locale?: string): string;
|
||||
/** Get current locale */
|
||||
getLocale(): string;
|
||||
/** Set active locale — triggers onLocaleChange subscribers */
|
||||
setLocale(locale: string): void;
|
||||
/** Extend with a translation module at runtime (lazy loading) */
|
||||
extend(namespace: string, module: unknown): void;
|
||||
/** Subscribe to locale changes. Returns unsubscribe function. */
|
||||
onLocaleChange(fn: (locale: string) => void): () => void;
|
||||
/** Subscribe to schema changes (extend calls). Returns unsubscribe function. */
|
||||
onSchemaChange(fn: () => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- `locale` is NOT a separate field on `AppServices` — it lives in `langs.getLocale()`. Single source of truth.
|
||||
- `setLocale()` is on `langs`, not on `App`. The i18n runtime owns locale state.
|
||||
- `onLocaleChange` / `onSchemaChange` provide the reactivity hooks. Components subscribe to know when to re-render.
|
||||
- lang implements this interface natively. Assignment is direct: `langs: myLangInstance`.
|
||||
- For testing or SSR without lang, a mock implements `AppLangs` returning paths as-is.
|
||||
|
||||
### 1.2.2 Domain service interfaces
|
||||
|
||||
Each domain owns its data, its formatting, and its state. No central `fmts` library — each domain formats itself because it knows its own rules.
|
||||
|
||||
#### `AppNums` — number formatting & parsing
|
||||
|
||||
```ts
|
||||
export interface AppNums {
|
||||
/** Format a number with locale-aware separators */
|
||||
format(value: number, options?: Intl.NumberFormatOptions): string;
|
||||
/** Parse a locale-formatted string back to a number */
|
||||
parse(value: string): number | undefined;
|
||||
|
||||
// Preferences (derived from locale, overridable by user)
|
||||
getDecimalSeparator(): string;
|
||||
getGroupSeparator(): string;
|
||||
getGrouping(): boolean;
|
||||
|
||||
setDecimalSeparator(sep: string | 'auto'): void;
|
||||
setGroupSeparator(sep: string | 'auto'): void;
|
||||
setGrouping(enabled: boolean | 'auto'): void;
|
||||
|
||||
/** Subscribe to preference changes. Returns unsubscribe. */
|
||||
onPreferenceChange(fn: () => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Refactored from the existing `createNumr` in `@/glob/lib`. Lives at `src/lib/ext/nums/`. Owns numeric formatting preferences (separators, grouping) — derived from locale, overridable by user. The base building block — `money` and `units` use `nums` internally for the numeric part of their formatting.
|
||||
|
||||
#### `AppMoney` — currency & monetary values
|
||||
|
||||
```ts
|
||||
export interface AppMoney {
|
||||
/** Active currency code (e.g., 'EUR', 'USD') */
|
||||
getCurrency(): string;
|
||||
/** Set active currency */
|
||||
setCurrency(currency: string): void;
|
||||
/** Create an exact monetary value (integer minor units internally) */
|
||||
create(amount: number, currency?: string): MoneyValue;
|
||||
/** Format a monetary value in the active currency */
|
||||
format(value: number | MoneyValue, options?: MoneyFormatOptions): string;
|
||||
/** Format in a specific currency */
|
||||
formatAs(value: number | MoneyValue, currency: string, options?: MoneyFormatOptions): string;
|
||||
/** Subscribe to currency changes. Returns unsubscribe. */
|
||||
onCurrencyChange(fn: (currency: string) => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Lives at `src/lib/ext/money/`. Uses integer/bigint representation internally (like dinero.js) — never stores `10.99` as float. Owns currency state like `langs` owns locale.
|
||||
|
||||
#### `AppDates` — date & time
|
||||
|
||||
```ts
|
||||
export interface AppDates {
|
||||
/** Format a date */
|
||||
format(value: Date, options?: Intl.DateTimeFormatOptions): string;
|
||||
/** Format time only */
|
||||
formatTime(value: Date, options?: Intl.DateTimeFormatOptions): string;
|
||||
/** Get date field order */
|
||||
getDateOrder(): DateOrder;
|
||||
/** Set date field order preference */
|
||||
setDateOrder(order: DateOrder): void;
|
||||
/** Get hour cycle */
|
||||
getHourCycle(): HourCycle;
|
||||
/** Set hour cycle preference */
|
||||
setHourCycle(cycle: HourCycle): void;
|
||||
/** Subscribe to preference changes. Returns unsubscribe. */
|
||||
onPreferenceChange(fn: () => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Lives at `src/lib/ext/dates/`. Owns date/time preferences (dateOrder, hourCycle) like `money` owns currency and `langs` owns locale. Defaults derived from locale, overridable by user.
|
||||
|
||||
#### `AppUnits` — units & measurement
|
||||
|
||||
```ts
|
||||
export interface AppUnits {
|
||||
/** Format a value with a unit */
|
||||
format(value: number, unit: string, options?: Intl.NumberFormatOptions): string;
|
||||
/** Get the active measurement system */
|
||||
getSystem(): 'metric' | 'imperial';
|
||||
/** Set measurement system preference */
|
||||
setSystem(system: 'metric' | 'imperial'): void;
|
||||
/** Convert between units */
|
||||
convert(value: number, from: string, to: string): number;
|
||||
/** Subscribe to preference changes. Returns unsubscribe. */
|
||||
onPreferenceChange(fn: () => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Lives at `src/lib/ext/units/`. Owns measurement system preference like `money` owns currency.
|
||||
|
||||
#### `AppPresentation` — visual & sensorial preferences
|
||||
|
||||
```ts
|
||||
export interface AppPresentation {
|
||||
/** Text direction (derived from locale, overridable) */
|
||||
getDir(): Direction;
|
||||
setDir(dir: Direction | 'auto'): void;
|
||||
|
||||
/** Color theme */
|
||||
getTheme(): string;
|
||||
setTheme(theme: string): void;
|
||||
|
||||
/** Color mode — light, dark, or system-derived */
|
||||
getMode(): 'light' | 'dark';
|
||||
setMode(mode: 'light' | 'dark' | 'auto'): void;
|
||||
|
||||
/** User prefers reduced motion (respects OS setting, overridable) */
|
||||
getReducedMotion(): boolean;
|
||||
setReducedMotion(reduced: boolean | 'auto'): void;
|
||||
|
||||
/** User prefers reduced sound */
|
||||
getReducedSound(): boolean;
|
||||
setReducedSound(reduced: boolean): void;
|
||||
|
||||
/** UI density */
|
||||
getDensity(): 'compact' | 'normal' | 'comfortable';
|
||||
setDensity(density: 'compact' | 'normal' | 'comfortable'): void;
|
||||
|
||||
/** Subscribe to any presentation change. Returns unsubscribe. */
|
||||
onPreferenceChange(fn: () => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Lives at `src/lib/ext/presentation/`. Owns ALL visual/sensorial preferences. Follows the same override/derive pattern:
|
||||
|
||||
- `dir` → derived from locale unless overridden
|
||||
- `mode` → derived from `prefers-color-scheme` media query unless overridden ('auto')
|
||||
- `reducedMotion` → derived from `prefers-reduced-motion` media query unless overridden ('auto')
|
||||
- `reducedSound` → explicit only (no OS signal)
|
||||
- `theme` → explicit (e.g., 'base', 'brand', 'midnight')
|
||||
- `density` → explicit, defaults to 'normal'
|
||||
|
||||
This is the service that sema and eidos will read from. sema checks `reducedMotion`/`reducedSound` before playing motion/sound. eidos checks `mode`/`theme`/`density` for CSS token resolution.
|
||||
|
||||
#### `AppLogger`
|
||||
|
||||
```ts
|
||||
export interface AppLogger {
|
||||
debug(message: string, context?: unknown): void;
|
||||
info(message: string, context?: unknown): void;
|
||||
warn(message: string, context?: unknown): void;
|
||||
error(message: string, context?: unknown): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2.3 Updated `AppServices`
|
||||
|
||||
```ts
|
||||
export interface AppServices {
|
||||
/** i18n — locale, translation, interpolation, module extension */
|
||||
readonly langs: AppLangs;
|
||||
/** Numbers — pure numeric formatting/parsing */
|
||||
readonly nums?: AppNums;
|
||||
/** Money — currency, exact decimal values, monetary formatting */
|
||||
readonly money?: AppMoney;
|
||||
/** Dates — date/time formatting, dateOrder, hourCycle */
|
||||
readonly dates?: AppDates;
|
||||
/** Units — measurement formatting, conversion, system */
|
||||
readonly units?: AppUnits;
|
||||
/** Presentation — dir, theme, mode, reducedMotion, reducedSound, density */
|
||||
readonly presentation: AppPresentation;
|
||||
/** Logging */
|
||||
readonly logger?: AppLogger;
|
||||
/** Default portal target for overlays */
|
||||
readonly portalTo?: string | HTMLElement;
|
||||
}
|
||||
```
|
||||
|
||||
**Service ownership — each domain owns its state and formatting:**
|
||||
|
||||
| Service | Name | Owns (state + preferences) | Formats | Library |
|
||||
| -------------- | ---------------------- | ------------------------------------------------------ | ------------------------ | --------------------------- |
|
||||
| `langs` | plural | locale, translations, schema | — | `src/lib/ext/lang/` |
|
||||
| `nums` | plural | decimal/group separators, grouping | `1234.5` → `'1.234,5'` | `src/lib/ext/nums/` |
|
||||
| `money` | singular (uncountable) | currency, exact values | `1099` → `'10,99 €'` | `src/lib/ext/money/` |
|
||||
| `dates` | plural | dateOrder, hourCycle | `Date` → `'14 abr 2026'` | `src/lib/ext/dates/` |
|
||||
| `units` | plural | measurement system | `5, 'km'` → `'5 km'` | `src/lib/ext/units/` |
|
||||
| `presentation` | singular | dir, theme, mode, reducedMotion, reducedSound, density | — | `src/lib/ext/presentation/` |
|
||||
| `app` | — | nothing — composes services | — | `src/lib/ext/app/` |
|
||||
|
||||
**Key design principles:**
|
||||
|
||||
- **Each domain owns its state AND formats itself.** No central `fmts`. Each service has `get*`/`set*` for its preferences and `format()` for display.
|
||||
- **`locale` lives in `langs`** — single source of truth
|
||||
- **`currency` lives in `money`** — single source of truth
|
||||
- **`dateOrder`/`hourCycle` live in `dates`** — single source of truth
|
||||
- **`measurement system` lives in `units`** — single source of truth
|
||||
- **`dir`/`theme`/`mode`/`reducedMotion`/`reducedSound`/`density` live in `presentation`** — visual/sensorial preferences
|
||||
- **App owns NO preferences directly** — it composes services and provides convenience getters/setters
|
||||
- **Each library implements its `App*` interface directly** — no adapters
|
||||
- **Money uses integer representation** — never `10.99` as float
|
||||
- **Subscription pattern** — each stateful service exposes `on*Change()` for reactivity
|
||||
- **ALL services are stateful** — each owns preferences derived from locale, overridable by user
|
||||
|
||||
### 1.3 Reactive App class — override/derive pattern
|
||||
|
||||
Every preference follows the same pattern: **`userOverride ?? deriveFromLocale(locale)`**. When the user changes locale, all non-overridden preferences recalculate. When the user sets an explicit preference, it sticks regardless of locale changes.
|
||||
|
||||
```ts
|
||||
// src/lib/ext/app/app.svelte.ts
|
||||
|
||||
export class App {
|
||||
// ── Services (stable references) ────────────────────────────────────
|
||||
readonly langs: AppLangs;
|
||||
readonly nums: AppNums | undefined;
|
||||
readonly money: AppMoney | undefined;
|
||||
readonly dates: AppDates | undefined;
|
||||
readonly units: AppUnits | undefined;
|
||||
readonly presentation: AppPresentation;
|
||||
readonly logger: AppLogger;
|
||||
readonly portalTo: string | HTMLElement | undefined;
|
||||
|
||||
// App holds NO preferences directly. Each service owns its own state:
|
||||
// - locale → langs
|
||||
// - currency → money
|
||||
// - dateOrder, hourCycle → dates
|
||||
// - unit system → units
|
||||
// - dir, theme, mode, reducedMotion, etc. → presentation
|
||||
|
||||
constructor(opts: AppOptions) {
|
||||
this.langs = opts.langs;
|
||||
this.nums = opts.nums;
|
||||
this.money = opts.money;
|
||||
this.dates = opts.dates;
|
||||
this.units = opts.units;
|
||||
this.presentation = opts.presentation;
|
||||
this.logger = opts.logger ?? consoleLogger;
|
||||
this.portalTo = opts.portalTo;
|
||||
}
|
||||
|
||||
// ── Convenience getters (delegate to services) ──────────────────────
|
||||
|
||||
get locale(): string {
|
||||
return this.langs.getLocale();
|
||||
}
|
||||
get dir(): Direction {
|
||||
return this.presentation.getDir();
|
||||
}
|
||||
get currency(): string | undefined {
|
||||
return this.money?.getCurrency();
|
||||
}
|
||||
get dateOrder(): DateOrder | undefined {
|
||||
return this.dates?.getDateOrder();
|
||||
}
|
||||
get hourCycle(): HourCycle | undefined {
|
||||
return this.dates?.getHourCycle();
|
||||
}
|
||||
get theme(): string {
|
||||
return this.presentation.getTheme();
|
||||
}
|
||||
get mode(): 'light' | 'dark' {
|
||||
return this.presentation.getMode();
|
||||
}
|
||||
|
||||
// ── Convenience setters (delegate to services) ──────────────────────
|
||||
|
||||
setLocale(locale: string) {
|
||||
this.langs.setLocale(locale);
|
||||
}
|
||||
setCurrency(currency: string) {
|
||||
this.money?.setCurrency(currency);
|
||||
}
|
||||
setDir(dir: Direction | 'auto') {
|
||||
this.presentation.setDir(dir);
|
||||
}
|
||||
setTheme(theme: string) {
|
||||
this.presentation.setTheme(theme);
|
||||
}
|
||||
setMode(mode: 'light' | 'dark' | 'auto') {
|
||||
this.presentation.setMode(mode);
|
||||
}
|
||||
|
||||
// ── Convenience ─────────────────────────────────────────────────────
|
||||
|
||||
/** Translate with fallback */
|
||||
translate(path: string, fallback: string): string {
|
||||
return this.langs.t(path) || fallback;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reactivity model — locale change propagation:**
|
||||
|
||||
Every service receives `langs` at construction and subscribes to `onLocaleChange` internally. When locale changes, each service recalculates its defaults — unless the user has set an explicit override.
|
||||
|
||||
```ts
|
||||
// Internal pattern inside each service (e.g., createPresentation)
|
||||
function createPresentation(langs: AppLangs): AppPresentation {
|
||||
let _dir: Direction | null = null; // null = derive from locale
|
||||
|
||||
// Subscribe to locale changes — recalculate derived defaults
|
||||
langs.onLocaleChange((locale) => {
|
||||
// Only recalculates if no user override (_dir === null)
|
||||
// Getters read: _dir ?? resolveDir(locale)
|
||||
});
|
||||
|
||||
return {
|
||||
getDir: () => _dir ?? resolveDir(langs.getLocale()),
|
||||
setDir: (dir) => {
|
||||
_dir = dir === 'auto' ? null : dir;
|
||||
}
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
|
||||
- `override ?? deriveFromLocale(locale)` — every preference follows this pattern
|
||||
- `'auto'` clears the override, returning to locale-derived default
|
||||
- Each service manages its own subscription — App does NOT orchestrate
|
||||
- Locale change → all non-overridden preferences recalculate automatically
|
||||
- User-set overrides are preserved across locale changes
|
||||
|
||||
### 1.4 Context distribution
|
||||
|
||||
```ts
|
||||
// src/lib/ext/app/context.ts
|
||||
import { Context } from 'runed';
|
||||
|
||||
const AppContext = new Context<App>('App');
|
||||
|
||||
export function createApp(opts: AppOptions): App {
|
||||
const app = new App(opts);
|
||||
return AppContext.set(app);
|
||||
}
|
||||
|
||||
export function getApp(): App {
|
||||
return AppContext.getOr(fallbackApp);
|
||||
}
|
||||
|
||||
export function useApp(): App {
|
||||
return AppContext.get(); // throws if no App ancestor
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 Supporting libraries
|
||||
|
||||
#### `lang` (`src/lib/ext/lang/`) — i18n runtime
|
||||
|
||||
Already exists. `LangInstance` implements `AppLangs` directly. No adapter.
|
||||
|
||||
#### Domain libraries
|
||||
|
||||
Each domain is an independent library in `src/lib/ext/`. Each implements its `App*` interface directly — no adapters.
|
||||
|
||||
```
|
||||
src/lib/ext/
|
||||
├── lang/ ← exists, implements AppLangs
|
||||
├── nums/ ← NEW, refactored from @/glob createNumr. Implements AppNums
|
||||
├── money/ ← NEW, integer-based money values. Implements AppMoney
|
||||
├── dates/ ← NEW, refactored from @/glob date utils. Implements AppDates
|
||||
├── units/ ← NEW, measurement formatting + conversion. Implements AppUnits
|
||||
├── presentation/ ← NEW, visual/sensorial preferences. Implements AppPresentation
|
||||
├── app/ ← NEW, composes all services
|
||||
└── table/ ← exists (Table createTable utility)
|
||||
```
|
||||
|
||||
**Locale → preference derivation** lives in each service:
|
||||
|
||||
- `presentation` derives `dir` from locale internally
|
||||
- `dates` derives `dateOrder`/`hourCycle` from locale internally
|
||||
- `units` derives `system` from locale internally
|
||||
- `money` derives default currency from locale internally
|
||||
|
||||
Each service knows how to derive its defaults from locale. The derivation logic is NOT centralized in app.
|
||||
|
||||
**Independence from glob:** Each library in `src/lib/ext/` is self-contained. No imports from `@/glob`. Code that currently lives in `glob` (createNumr, date utils, number formatting) is refactored into the corresponding `ext/` library. `glob` continues to exist — terra and air still use it — but soma, sema, eidos, and all `ext/` libraries do NOT import from glob.
|
||||
|
||||
### 1.6 Integration
|
||||
|
||||
lang's `LangInstance` already implements `AppLangs` directly. No adapter.
|
||||
|
||||
```ts
|
||||
import { createLang } from '$lib/ext/lang';
|
||||
|
||||
const lang = createLang(translations, 'es');
|
||||
|
||||
const app = createApp({
|
||||
langs: lang, // direct — lang IS AppLangs
|
||||
dir: 'ltr'
|
||||
});
|
||||
```
|
||||
|
||||
For testing without lang, use the fallback mock from `defaults.ts`:
|
||||
|
||||
```ts
|
||||
// src/lib/ext/app/defaults.ts
|
||||
|
||||
export const fallbackLangs: AppLangs = {
|
||||
t: (path) => path,
|
||||
ts: (value) => String(value ?? ''),
|
||||
getLocale: () => '',
|
||||
setLocale: () => {},
|
||||
extend: () => {},
|
||||
onLocaleChange: () => () => {},
|
||||
onSchemaChange: () => () => {}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Soma consumes App services
|
||||
|
||||
### 2.1 New `<Soma>` wrapper
|
||||
|
||||
```svelte
|
||||
<!-- src/uix/soma/components/internal/soma.svelte -->
|
||||
<script lang="ts">
|
||||
import { getApp } from '$lib/ext/app';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Read services from App context (set by consumer above)
|
||||
const app = getApp();
|
||||
|
||||
// Create Soma context, delegates to App for services
|
||||
Soma.create(app);
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
```
|
||||
|
||||
### 2.2 Soma simplification
|
||||
|
||||
The `Soma` class stops holding services. Instead:
|
||||
|
||||
```ts
|
||||
// src/uix/soma/core/soma.svelte.ts
|
||||
|
||||
export class Soma {
|
||||
static readonly ctx = new Context<Soma>('Soma');
|
||||
|
||||
readonly app: App;
|
||||
|
||||
static create(app: App): Soma {
|
||||
return Soma.ctx.set(new Soma(app));
|
||||
}
|
||||
|
||||
static get(): Soma {
|
||||
return Soma.ctx.get();
|
||||
}
|
||||
|
||||
static getOr(): Soma | undefined {
|
||||
return Soma.ctx.getOr(undefined);
|
||||
}
|
||||
|
||||
private constructor(app: App) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
// Convenience — components call soma.translate() directly
|
||||
translate(path: string, fallback: string): string {
|
||||
return this.app.translator.t(path) || fallback;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Component migration
|
||||
|
||||
Before:
|
||||
|
||||
```ts
|
||||
readonly config = getSoma();
|
||||
const translator = this.config.translator.current;
|
||||
resolveSomaTranslationPath('soma.dialog.close', translator, 'Close');
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
readonly app = getApp();
|
||||
this.app.langs.t('soma.dialog.close') || 'Close';
|
||||
// or via convenience:
|
||||
this.app.translate('soma.dialog.close', 'Close');
|
||||
```
|
||||
|
||||
**What changes per component:**
|
||||
|
||||
- `getSoma()` → `getApp()`
|
||||
- `config.translator.current` → `app.langs`
|
||||
- `resolveSomaTranslationPath(path, translator, fallback)` → `app.langs.t(path) || fallback`
|
||||
- `config.presentation.current?.dir` → `app.dir`
|
||||
- `Soma.resolve()` → `prop ?? app.dir ?? 'ltr'` (plain nullish coalescing)
|
||||
- Prop resolvers (`resolveLocaleProp`, `resolveDirectionProp`) → eliminated, replaced by direct reads from `app`
|
||||
|
||||
### 2.4 What gets deleted from soma
|
||||
|
||||
- `src/uix/soma/core/translator.svelte.ts` — replaced by app adapter
|
||||
- `src/uix/soma/core/prop-resolvers.ts` — simplified, reads from App
|
||||
- `src/uix/soma/core/number-formatter.svelte.ts` — moved to app format
|
||||
- `src/uix/soma/core/currency-formatter.svelte.ts` — moved to app format
|
||||
- `src/uix/soma/core/unit-formatter.svelte.ts` — moved to app format
|
||||
- `src/uix/soma/core/date-time-formatter.svelte.ts` — moved to app format
|
||||
- `createSomaTranslator()`, `extendSomaTranslationModule()` — no longer needed
|
||||
- `SomaTranslator`, `SomaLogger`, `SomaFormatter` interfaces — replaced by `AppLangs`, `AppLogger`, `AppFormat`
|
||||
- `SomaPresentationConfig` — replaced by `AppServices.dir` + `AppServices.format`
|
||||
- `Soma.resolve()` — eliminated, replaced by plain `??` coalescing from `app`
|
||||
|
||||
### 2.5 Backward compatibility
|
||||
|
||||
During transition:
|
||||
|
||||
- `getSoma()` still works but reads from `App` under the hood
|
||||
- `<Soma>` wrapper still exists but delegates to `<App>`
|
||||
- Components gradually migrate from `getSoma()` to `getApp()`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: sema layer (`src/uix/sema/`) — future
|
||||
|
||||
The sema layer extracts semantic intent from `air/semantics/`:
|
||||
|
||||
```
|
||||
src/uix/sema/
|
||||
├── index.ts ← public API
|
||||
├── types.ts ← Semantic (12 types), SemanticPhase
|
||||
├── props.ts ← SemanticBehaviorProps
|
||||
├── intents/
|
||||
│ ├── feedback.ts ← action semantic
|
||||
│ ├── selection.ts
|
||||
│ ├── revelation.ts ← transition semantic
|
||||
│ ├── context.ts
|
||||
│ ├── expansion.ts
|
||||
│ ├── attention.ts
|
||||
│ ├── emphasis.ts
|
||||
│ ├── completion.ts
|
||||
│ ├── destruction.ts
|
||||
│ ├── persistence.ts
|
||||
│ └── navigation.ts
|
||||
├── motion/
|
||||
│ ├── presets.ts ← default motion presets per semantic
|
||||
│ └── types.ts ← MotionPresets, MotionConfig
|
||||
└── sound/
|
||||
├── presets.ts ← default sound presets per semantic
|
||||
├── engine.ts ← Web Audio synthesis engine
|
||||
└── types.ts ← SoundPresets, SoundDefinition
|
||||
```
|
||||
|
||||
**sema reads from App:**
|
||||
|
||||
- `app.dir` for RTL-aware motion
|
||||
- `app.locale` for locale-aware sounds (future)
|
||||
|
||||
**sema does NOT depend on soma or air.** It's a pure semantic layer.
|
||||
|
||||
**eidos (air) reads from sema** for motion/sound presets and from soma for data-\* attrs.
|
||||
|
||||
---
|
||||
|
||||
## Consumer usage (final state)
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script>
|
||||
import { createApp } from '$lib/ext/app';
|
||||
import { createLang } from '$lib/ext/lang';
|
||||
import { createNums } from '$lib/ext/nums';
|
||||
import { createMoney } from '$lib/ext/money';
|
||||
import { createDates } from '$lib/ext/dates';
|
||||
import { createPresentation } from '$lib/ext/presentation';
|
||||
import { Soma } from '$soma/components/internal';
|
||||
|
||||
// langs is the root — all other services depend on it for locale
|
||||
const langs = createLang(translations, 'es');
|
||||
|
||||
const app = createApp({
|
||||
langs, // root service — owns locale
|
||||
nums: createNums(langs), // subscribes to locale changes
|
||||
money: createMoney(langs), // subscribes to locale changes, owns currency
|
||||
dates: createDates(langs), // subscribes to locale changes, owns dateOrder/hourCycle
|
||||
presentation: createPresentation(langs) // subscribes to locale changes, owns dir/theme/mode
|
||||
});
|
||||
</script>
|
||||
|
||||
<Soma>
|
||||
<slot />
|
||||
</Soma>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- Any component -->
|
||||
<script>
|
||||
import { getApp } from '$lib/ext/app';
|
||||
const app = getApp();
|
||||
|
||||
// Translation
|
||||
const label = app.langs.t('soma.dialog.close') || 'Close';
|
||||
|
||||
// Numbers
|
||||
app.nums?.format(1234.5); // '1.234,5'
|
||||
|
||||
// Money (owns currency)
|
||||
app.money?.format(1099); // '10,99 €'
|
||||
app.money?.getCurrency(); // 'EUR'
|
||||
|
||||
// Dates (owns dateOrder, hourCycle)
|
||||
app.dates?.format(new Date()); // '14 abr 2026'
|
||||
app.dates?.getDateOrder(); // 'DMY'
|
||||
app.dates?.getHourCycle(); // '24h'
|
||||
|
||||
// Presentation (owns dir, theme, mode, motion/sound prefs)
|
||||
app.presentation.getDir(); // 'ltr'
|
||||
app.presentation.getMode(); // 'light' (from OS)
|
||||
app.presentation.getReducedMotion(); // false (from OS)
|
||||
app.presentation.getTheme(); // 'base'
|
||||
|
||||
// Convenience via App
|
||||
app.dir; // 'ltr'
|
||||
app.mode; // 'light'
|
||||
app.theme; // 'base'
|
||||
</script>
|
||||
```
|
||||
|
||||
```ts
|
||||
// Locale switching — each service derives its defaults
|
||||
app.setLocale('ar');
|
||||
app.dir; // 'rtl' (presentation derived from 'ar')
|
||||
app.dates?.getDateOrder(); // recalculated for 'ar'
|
||||
app.money?.getCurrency(); // unchanged (money owns currency independently)
|
||||
|
||||
// Presentation overrides
|
||||
app.presentation.setDir('ltr'); // force LTR despite 'ar' locale
|
||||
app.presentation.setMode('dark'); // force dark mode
|
||||
app.presentation.setReducedMotion(true); // disable motion
|
||||
|
||||
// Reset presentation to derived defaults
|
||||
app.presentation.setDir('auto'); // back to 'rtl' (from 'ar')
|
||||
app.presentation.setMode('auto'); // back to OS preference
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **Create `src/lib/ext/app/`** — types, App class, context, defaults
|
||||
2. **Create lang adapter** — `langAsTranslator()` (trivial if interfaces match)
|
||||
3. **Update `<Soma>`** — accept App from context instead of raw props
|
||||
4. **Update `getSoma()`** — delegate to `getApp()`
|
||||
5. **Migrate test layout** — use `createApp()` + `<Soma>`
|
||||
6. **Migrate components** (25) — `getSoma().translator.current` → `getApp().translator`
|
||||
7. **Delete soma service code** — translator.svelte.ts, formatters, prop-resolvers
|
||||
8. **Create `src/uix/sema/`** — extract from air/semantics (separate PR)
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npm run check` — 0 errors after each phase
|
||||
2. Test pages still work (all 25 components)
|
||||
3. Locale switching works reactively
|
||||
4. Dir switching works reactively
|
||||
5. Translation fallbacks work without App context (fallbackApp)
|
||||
6. No phantom Soma instances (F12 resolved)
|
||||
|
||||
---
|
||||
|
||||
## Files affected
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------- | ------------------------------------------------ |
|
||||
| `src/lib/ext/app/index.ts` | Barrel |
|
||||
| `src/lib/ext/app/types.ts` | AppServices, AppTranslator, AppLogger, AppFormat |
|
||||
| `src/lib/ext/app/app.svelte.ts` | App reactive class |
|
||||
| `src/lib/ext/app/context.ts` | createApp, getApp, useApp |
|
||||
| `src/lib/ext/app/defaults.ts` | Fallback services |
|
||||
| `src/lib/ext/app/defaults.ts` | Fallback langs mock + console logger |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Change |
|
||||
| ---------------------------------------------- | ------------------------------------------- |
|
||||
| `src/uix/soma/core/soma.svelte.ts` | Simplified — delegates to App |
|
||||
| `src/uix/soma/components/internal/soma.svelte` | Reads App from context |
|
||||
| `src/routes/test/soma/+layout.svelte` | Uses createApp() |
|
||||
| 25 provider files | `getSoma()` → `getApp()` for service access |
|
||||
|
||||
### Deleted files (after migration)
|
||||
|
||||
| File | Reason |
|
||||
| ------------------------------------------------- | -------------------------- |
|
||||
| `src/uix/soma/core/translator.svelte.ts` | Replaced by app translator |
|
||||
| `src/uix/soma/core/prop-resolvers.ts` | Simplified into App reads |
|
||||
| `src/uix/soma/core/number-formatter.svelte.ts` | Moved to app format |
|
||||
| `src/uix/soma/core/currency-formatter.svelte.ts` | Moved to app format |
|
||||
| `src/uix/soma/core/unit-formatter.svelte.ts` | Moved to app format |
|
||||
| `src/uix/soma/core/date-time-formatter.svelte.ts` | Moved to app format |
|
||||
|
||||
---
|
||||
|
||||
## Auditoría y opinión
|
||||
|
||||
### A favor
|
||||
|
||||
1. **Separación de responsabilidades correcta.** Soma no debería poseer servicios de aplicación. Las auditorías de los 26 componentes revelaron que muchos (accordion, tabs, stepper, switch, slider, toolbar, splitter) tienen `config = getSoma()` que nunca usan — símbolo de que la clase Soma hace demasiado.
|
||||
|
||||
2. **`AppServices` bien diseñada.** Interfaces limpias, `readonly`, sin sobre-ingeniería. El adapter `langAsTranslator` es trivial (casi innecesario si lang ya implementa la interface).
|
||||
|
||||
3. **El modelo reactivo es correcto.** `$state` en `locale`/`dir` + servicios estables como `readonly` — las piezas que cambian son reactivas, las que no cambian son estables.
|
||||
|
||||
4. **Migration path sensata.** Fases incrementales, backward compat, deletions al final.
|
||||
|
||||
### Concerns
|
||||
|
||||
**1. `Soma` se vuelve redundante**
|
||||
|
||||
Después de Phase 2, `Soma` es un proxy de una línea a `App`:
|
||||
|
||||
```ts
|
||||
class Soma {
|
||||
readonly app: App;
|
||||
translate(path, fallback) {
|
||||
return this.app.translator.t(path) || fallback;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dos contextos (App + Soma) significan dos caminos al mismo lugar: `Soma.get().app.translator.t()` o `getApp().translator.t()`. Se recomienda eliminar `Soma` como clase y que los componentes llamen `getApp()` directamente. Si se necesita el convenience method `translate()`, moverlo a `App`:
|
||||
|
||||
```ts
|
||||
class App {
|
||||
translate(path: string, fallback: string): string {
|
||||
return this.translator.t(path) || fallback;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. `Soma.resolve()` queda huérfano**
|
||||
|
||||
El pattern `Soma.resolve(prop, somaConfig, fallback)` se usa en varios componentes para resolver props por capas. El doc no menciona qué pasa con este mecanismo. ¿Se migra a `App.resolve()`? ¿Se elimina? Necesita decisión explícita.
|
||||
|
||||
**3. Fallback chain sin App context**
|
||||
|
||||
El doc menciona `fallbackApp` pero no lo define. Actualmente `Soma.getOr()` retorna `undefined` y los componentes manejan el caso. Con `getApp()` retornando un fallback, los tests unitarios que no montan contexto necesitan poder funcionar sin App. El `defaults.ts` (no-op translator, console logger) es la respuesta correcta pero debería detallarse más.
|
||||
|
||||
**4. `setLocale` es incompleto**
|
||||
|
||||
```ts
|
||||
setLocale(locale: string) {
|
||||
this.locale = locale;
|
||||
// If translator supports locale switching
|
||||
}
|
||||
```
|
||||
|
||||
El comentario suelto indica que `translator` y `locale` pueden desincronizarse. Si lang maneja su propio locale internamente, `app.locale` es redundante o contradictorio. Debería ser:
|
||||
|
||||
```ts
|
||||
setLocale(locale: string) {
|
||||
this.locale = locale;
|
||||
this.translator.setLocale?.(locale);
|
||||
}
|
||||
```
|
||||
|
||||
O mejor: `locale` derivado de `translator.getLocale()`, no estado independiente.
|
||||
|
||||
**5. Phase 3 (sema) está muy verde**
|
||||
|
||||
No hay suficiente detalle para evaluar. La estructura de directorios parece razonable pero los 12 intents (`feedback`, `selection`, `revelation`, etc.) son especulativos. No se comenta más hasta que haya una implementación concreta.
|
||||
|
||||
### Resoluciones
|
||||
|
||||
**Concern 1 — Soma redundante:** Agreed. Soma as a class is eliminated. `<Soma>` wrapper remains as a scope boundary (verifies App context exists) but holds no services. Components call `getApp()` directly.
|
||||
|
||||
**Concern 2 — `Soma.resolve()` orphaned:** Eliminated. Replaced by plain `prop ?? app.dir ?? 'ltr'` coalescing. No generic resolver needed.
|
||||
|
||||
**Concern 3 — Fallback chain:** `defaults.ts` defines `fallbackLangs` (returns path as-is, no-op setLocale) and `consoleLogger`. `getApp()` returns a fallback `App` with these defaults.
|
||||
|
||||
**Concern 4 — `setLocale` incomplete:** Resolved. `locale` is derived from `langs.getLocale()`, not independent state. `App.setLocale()` delegates to `langs.setLocale()`. Single source of truth.
|
||||
|
||||
**Concern 5 — sema is green:** Acknowledged. sema design deferred to implementation phase.
|
||||
|
||||
### Veredicto
|
||||
|
||||
Phase 1 y 2: Adelante. Concerns resolved.
|
||||
@ -0,0 +1,144 @@
|
||||
import { Context } from 'runed';
|
||||
import { createActiveDom } from '$adom';
|
||||
import { EngineSemantic } from '$uix/sema';
|
||||
import type {
|
||||
AppOptions,
|
||||
AppDom,
|
||||
AppSemantic,
|
||||
AppLangs,
|
||||
AppNums,
|
||||
AppMoney,
|
||||
AppDates,
|
||||
AppUnits,
|
||||
AppPresentation,
|
||||
AppLogger,
|
||||
Direction,
|
||||
DateOrder,
|
||||
HourCycle
|
||||
} from './types';
|
||||
import {
|
||||
fallbackDom,
|
||||
fallbackLangs,
|
||||
fallbackPresentation,
|
||||
fallbackSemantic,
|
||||
consoleLogger
|
||||
} from './defaults';
|
||||
|
||||
/**
|
||||
* App — root service compositor.
|
||||
*
|
||||
* Holds references to all domain services. Owns no preferences directly —
|
||||
* each service owns its own state. App provides convenience getters/setters
|
||||
* that delegate to the appropriate service.
|
||||
*
|
||||
* All services that depend on locale receive `langs` at construction and
|
||||
* subscribe to `onLocaleChange` internally. When locale changes, each
|
||||
* recalculates its defaults unless the user has set an explicit override.
|
||||
*/
|
||||
export class App {
|
||||
// ── Services ────────────────────────────────────────────────────────
|
||||
readonly dom: AppDom;
|
||||
readonly semantic: AppSemantic;
|
||||
readonly langs: AppLangs;
|
||||
readonly nums: AppNums | undefined;
|
||||
readonly money: AppMoney | undefined;
|
||||
readonly dates: AppDates | undefined;
|
||||
readonly units: AppUnits | undefined;
|
||||
readonly presentation: AppPresentation;
|
||||
readonly logger: AppLogger;
|
||||
|
||||
constructor(opts: AppOptions) {
|
||||
this.dom = opts.dom ?? createActiveDom();
|
||||
this.semantic = opts.semantic ?? new EngineSemantic();
|
||||
this.langs = opts.langs;
|
||||
this.nums = opts.nums;
|
||||
this.money = opts.money;
|
||||
this.dates = opts.dates;
|
||||
this.units = opts.units;
|
||||
this.presentation = opts.presentation;
|
||||
this.logger = opts.logger ?? consoleLogger;
|
||||
}
|
||||
|
||||
// ── Convenience getters (delegate to services) ──────────────────────
|
||||
|
||||
get currency(): string | undefined {
|
||||
return this.money?.getCurrency();
|
||||
}
|
||||
|
||||
get dateOrder(): DateOrder | undefined {
|
||||
return this.dates?.getDateOrder();
|
||||
}
|
||||
|
||||
get hourCycle(): HourCycle | undefined {
|
||||
return this.dates?.getHourCycle();
|
||||
}
|
||||
|
||||
get theme(): string {
|
||||
return this.presentation.getTheme();
|
||||
}
|
||||
|
||||
get mode(): 'light' | 'dark' {
|
||||
return this.presentation.getMode();
|
||||
}
|
||||
|
||||
// ── Convenience setters (delegate to services) ──────────────────────
|
||||
|
||||
setLocale(locale: string) {
|
||||
this.langs.setLocale(locale);
|
||||
}
|
||||
|
||||
setCurrency(currency: string) {
|
||||
this.money?.setCurrency(currency);
|
||||
}
|
||||
|
||||
setDir(dir: Direction | 'auto') {
|
||||
this.presentation.setDir(dir);
|
||||
}
|
||||
|
||||
setTheme(theme: string) {
|
||||
this.presentation.setTheme(theme);
|
||||
}
|
||||
|
||||
setMode(mode: 'light' | 'dark' | 'auto') {
|
||||
this.presentation.setMode(mode);
|
||||
}
|
||||
|
||||
// ── Static distribution methods ─────────────────────────────────────
|
||||
|
||||
static create(opts: AppOptions): App {
|
||||
const app = new App(opts);
|
||||
return _ctx.set(app);
|
||||
}
|
||||
|
||||
static get(): App {
|
||||
return _ctx.getOr(_fallback);
|
||||
}
|
||||
|
||||
static require(): App {
|
||||
const app = _ctx.getOr(null as unknown as App);
|
||||
if (!app) {
|
||||
throw new Error(
|
||||
'[app] No App found. Make sure this component is rendered inside a App.create() scope.'
|
||||
);
|
||||
}
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Module-level context ────────────────────────────────────────────────────
|
||||
|
||||
const _ctx = new Context<App>('App');
|
||||
|
||||
/**
|
||||
* Fallback App used when no <App> ancestor exists.
|
||||
* Only provides core services (langs, presentation, logger).
|
||||
* Optional services (nums, money, dates, units) are undefined —
|
||||
* components must handle their absence gracefully.
|
||||
*/
|
||||
const _fallback = new App({
|
||||
dom: fallbackDom,
|
||||
semantic: fallbackSemantic,
|
||||
langs: fallbackLangs,
|
||||
presentation: fallbackPresentation,
|
||||
logger: consoleLogger
|
||||
});
|
||||
@ -0,0 +1,45 @@
|
||||
import { createActiveDom } from '$adom';
|
||||
import { EngineSemantic } from '$uix/sema';
|
||||
import type { AppLangs, AppPresentation, AppLogger, AppSemantic } from './types';
|
||||
|
||||
/** No-op langs: returns path as-is, no locale switching */
|
||||
export const fallbackLangs: AppLangs = {
|
||||
t: (path) => path,
|
||||
ts: (value) => String(value ?? ''),
|
||||
getLocale: () => '',
|
||||
setLocale: () => {},
|
||||
extend: () => {},
|
||||
onLocaleChange: () => () => {},
|
||||
onSchemaChange: () => () => {}
|
||||
};
|
||||
|
||||
/** Console-based logger (matches logr's API: category + message + context) */
|
||||
export const consoleLogger: AppLogger = {
|
||||
debug: (cat, msg, ctx) => console.debug(`[${cat}]`, msg, ctx ?? ''),
|
||||
info: (cat, msg, ctx) => console.info(`[${cat}]`, msg, ctx ?? ''),
|
||||
warn: (cat, msg, ctx) => console.warn(`[${cat}]`, msg, ctx ?? ''),
|
||||
error: (cat, msg, ctx) => console.error(`[${cat}]`, msg, ctx ?? '')
|
||||
};
|
||||
|
||||
/** Minimal presentation: all defaults, no OS queries */
|
||||
export const fallbackPresentation: AppPresentation = {
|
||||
getDir: () => 'ltr',
|
||||
setDir: () => {},
|
||||
getTheme: () => 'base',
|
||||
setTheme: () => {},
|
||||
getMode: () => 'light',
|
||||
setMode: () => {},
|
||||
getReducedMotion: () => false,
|
||||
setReducedMotion: () => {},
|
||||
getReducedSound: () => false,
|
||||
setReducedSound: () => {},
|
||||
getDensity: () => 'normal',
|
||||
setDensity: () => {},
|
||||
onPreferenceChange: () => () => {}
|
||||
};
|
||||
|
||||
/** Minimal dom runtime: default breakpoints + responsive helpers. */
|
||||
export const fallbackDom = createActiveDom({});
|
||||
|
||||
/** Default semantic engine wired to the fallback dom. */
|
||||
export const fallbackSemantic: AppSemantic = new EngineSemantic();
|
||||
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
|
||||
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
|
||||
* See `src/lib/README.md` for the migration map.
|
||||
*/
|
||||
export { App } from './app.svelte';
|
||||
export {
|
||||
fallbackDom,
|
||||
fallbackLangs,
|
||||
fallbackPresentation,
|
||||
fallbackSemantic,
|
||||
consoleLogger
|
||||
} from './defaults';
|
||||
|
||||
export type {
|
||||
AppServices,
|
||||
AppOptions,
|
||||
AppDom,
|
||||
AppSemantic,
|
||||
AppLangs,
|
||||
AppNums,
|
||||
AppMoney,
|
||||
AppDates,
|
||||
AppUnits,
|
||||
AppPresentation,
|
||||
AppLogger,
|
||||
MoneyFormatOptions,
|
||||
Direction,
|
||||
DateOrder,
|
||||
HourCycle
|
||||
} from './types';
|
||||
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* App layer — test suite
|
||||
*
|
||||
* Covers:
|
||||
* - types (AppServices, AppLangs, AppNums, AppMoney, AppDates, AppUnits, AppPresentation, AppLogger)
|
||||
* - defaults (fallbackLangs, consoleLogger, fallbackPresentation)
|
||||
* - app (App class — getters, setters, delegation)
|
||||
* - context (createApp, getApp — requires Svelte context, tested separately)
|
||||
* - nums (createNums — format, parse, preference overrides, locale reactivity)
|
||||
* - money (createMoney — format, currency state, locale reactivity)
|
||||
* - dates (createDates — format, dateOrder, hourCycle, locale reactivity)
|
||||
* - units (createUnits — format, convert, system preference, locale reactivity)
|
||||
* - presentation (createPresentation — dir, theme, mode, reducedMotion, locale reactivity)
|
||||
* - conversions (convert — pure unit conversion)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AppLangs } from '../types';
|
||||
import { fallbackLangs, consoleLogger, fallbackPresentation } from '../defaults';
|
||||
import { App } from '../app.svelte';
|
||||
import { convert } from '../../units/conversions';
|
||||
|
||||
// ── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Creates a mock AppLangs with controllable locale and subscriptions */
|
||||
function mockLangs(initialLocale = 'es'): AppLangs & { _setLocale: (l: string) => void } {
|
||||
let locale = initialLocale;
|
||||
const localeListeners = new Set<(l: string) => void>();
|
||||
const schemaListeners = new Set<() => void>();
|
||||
|
||||
return {
|
||||
t: (path) => `[${locale}:${path}]`,
|
||||
ts: (value) => String(value ?? ''),
|
||||
getLocale: () => locale,
|
||||
setLocale: (l) => {
|
||||
locale = l;
|
||||
for (const fn of localeListeners) fn(l);
|
||||
},
|
||||
extend: () => {},
|
||||
onLocaleChange: (fn) => {
|
||||
localeListeners.add(fn);
|
||||
return () => localeListeners.delete(fn);
|
||||
},
|
||||
onSchemaChange: (fn) => {
|
||||
schemaListeners.add(fn);
|
||||
return () => schemaListeners.delete(fn);
|
||||
},
|
||||
_setLocale: (l) => {
|
||||
locale = l;
|
||||
for (const fn of localeListeners) fn(l);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DEFAULTS
|
||||
// ============================================================================
|
||||
|
||||
describe('fallbackLangs', () => {
|
||||
it('t returns path as-is', () => {
|
||||
expect(fallbackLangs.t('some.path')).toBe('some.path');
|
||||
});
|
||||
|
||||
it('ts returns stringified value', () => {
|
||||
expect(fallbackLangs.ts('hello')).toBe('hello');
|
||||
expect(fallbackLangs.ts(null)).toBe('');
|
||||
expect(fallbackLangs.ts(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('getLocale returns empty string', () => {
|
||||
expect(fallbackLangs.getLocale()).toBe('');
|
||||
});
|
||||
|
||||
it('setLocale is no-op', () => {
|
||||
expect(() => fallbackLangs.setLocale('en')).not.toThrow();
|
||||
});
|
||||
|
||||
it('onLocaleChange returns unsubscribe', () => {
|
||||
const unsub = fallbackLangs.onLocaleChange(() => {});
|
||||
expect(typeof unsub).toBe('function');
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
describe('consoleLogger', () => {
|
||||
it('has all four log methods', () => {
|
||||
expect(typeof consoleLogger.debug).toBe('function');
|
||||
expect(typeof consoleLogger.info).toBe('function');
|
||||
expect(typeof consoleLogger.warn).toBe('function');
|
||||
expect(typeof consoleLogger.error).toBe('function');
|
||||
});
|
||||
|
||||
it('logs with category prefix', () => {
|
||||
const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
consoleLogger.info('auth', 'user logged in');
|
||||
expect(spy).toHaveBeenCalledWith('[auth]', 'user logged in', '');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs with context', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleLogger.warn('db', 'slow query', { ms: 2000 });
|
||||
expect(spy).toHaveBeenCalledWith('[db]', 'slow query', { ms: 2000 });
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallbackPresentation', () => {
|
||||
it('returns safe defaults', () => {
|
||||
expect(fallbackPresentation.getDir()).toBe('ltr');
|
||||
expect(fallbackPresentation.getTheme()).toBe('base');
|
||||
expect(fallbackPresentation.getMode()).toBe('light');
|
||||
expect(fallbackPresentation.getReducedMotion()).toBe(false);
|
||||
expect(fallbackPresentation.getReducedSound()).toBe(false);
|
||||
expect(fallbackPresentation.getDensity()).toBe('normal');
|
||||
});
|
||||
|
||||
it('setters are no-ops', () => {
|
||||
expect(() => fallbackPresentation.setDir('rtl')).not.toThrow();
|
||||
expect(fallbackPresentation.getDir()).toBe('ltr');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// APP CLASS
|
||||
// ============================================================================
|
||||
|
||||
describe('App', () => {
|
||||
it('delegates locale to langs', () => {
|
||||
const langs = mockLangs('es');
|
||||
const app = new App({ langs, presentation: fallbackPresentation });
|
||||
expect(app.langs.getLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('setLocale delegates to langs', () => {
|
||||
const langs = mockLangs('es');
|
||||
const app = new App({ langs, presentation: fallbackPresentation });
|
||||
app.setLocale('en');
|
||||
expect(langs.getLocale()).toBe('en');
|
||||
expect(app.langs.getLocale()).toBe('en');
|
||||
});
|
||||
|
||||
it('delegates dir to presentation', () => {
|
||||
const langs = mockLangs('es');
|
||||
const app = new App({ langs, presentation: fallbackPresentation });
|
||||
expect(app.presentation.getDir()).toBe('ltr');
|
||||
});
|
||||
|
||||
it('currency delegates to money', () => {
|
||||
const langs = mockLangs('es');
|
||||
const app = new App({ langs, presentation: fallbackPresentation });
|
||||
expect(app.currency).toBeUndefined();
|
||||
});
|
||||
|
||||
it('optional services are undefined when not provided', () => {
|
||||
const langs = mockLangs('es');
|
||||
const app = new App({ langs, presentation: fallbackPresentation });
|
||||
expect(app.nums).toBeUndefined();
|
||||
expect(app.money).toBeUndefined();
|
||||
expect(app.dates).toBeUndefined();
|
||||
expect(app.units).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses consoleLogger when no logger provided', () => {
|
||||
const langs = mockLangs('es');
|
||||
const app = new App({ langs, presentation: fallbackPresentation });
|
||||
expect(app.logger).toBe(consoleLogger);
|
||||
});
|
||||
|
||||
it('provides a dom runtime even when none is injected', () => {
|
||||
const langs = mockLangs('es');
|
||||
const app = new App({ langs, presentation: fallbackPresentation });
|
||||
expect(app.dom.currentBreakpoint.current).toBe('base');
|
||||
expect(app.dom.resolve({ base: 'stack', lg: 'inline' })).toBe('stack');
|
||||
});
|
||||
|
||||
it('uses provided logger', () => {
|
||||
const langs = mockLangs('es');
|
||||
const customLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
};
|
||||
const app = new App({ langs, presentation: fallbackPresentation, logger: customLogger });
|
||||
expect(app.logger).toBe(customLogger);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// UNIT CONVERSIONS (pure, no Svelte runes)
|
||||
// ============================================================================
|
||||
|
||||
describe('convert', () => {
|
||||
it('converts km to miles', () => {
|
||||
const result = convert(1, 'kilometer', 'mile');
|
||||
expect(result).toBeCloseTo(0.621371, 4);
|
||||
});
|
||||
|
||||
it('converts miles to km', () => {
|
||||
const result = convert(1, 'mile', 'kilometer');
|
||||
expect(result).toBeCloseTo(1.60934, 4);
|
||||
});
|
||||
|
||||
it('converts celsius to fahrenheit', () => {
|
||||
expect(convert(0, 'celsius', 'fahrenheit')).toBeCloseTo(32, 1);
|
||||
expect(convert(100, 'celsius', 'fahrenheit')).toBeCloseTo(212, 1);
|
||||
});
|
||||
|
||||
it('converts fahrenheit to celsius', () => {
|
||||
expect(convert(32, 'fahrenheit', 'celsius')).toBeCloseTo(0, 1);
|
||||
expect(convert(212, 'fahrenheit', 'celsius')).toBeCloseTo(100, 1);
|
||||
});
|
||||
|
||||
it('converts kg to pounds', () => {
|
||||
expect(convert(1, 'kilogram', 'pound')).toBeCloseTo(2.20462, 4);
|
||||
});
|
||||
|
||||
it('converts liters to gallons', () => {
|
||||
expect(convert(1, 'liter', 'gallon')).toBeCloseTo(0.264172, 4);
|
||||
});
|
||||
|
||||
it('identity conversion returns same value', () => {
|
||||
expect(convert(5, 'meter', 'meter')).toBeCloseTo(5, 10);
|
||||
});
|
||||
|
||||
it('throws for incompatible units', () => {
|
||||
expect(() => convert(1, 'meter', 'kilogram')).toThrow('Incompatible units');
|
||||
});
|
||||
|
||||
it('throws for unknown unit', () => {
|
||||
expect(() => convert(1, 'parsec', 'meter')).toThrow('Unknown unit');
|
||||
});
|
||||
|
||||
it('converts meters to feet', () => {
|
||||
expect(convert(1, 'meter', 'foot')).toBeCloseTo(3.28084, 4);
|
||||
});
|
||||
|
||||
it('converts speed units', () => {
|
||||
expect(convert(100, 'kilometer-per-hour', 'mile-per-hour')).toBeCloseTo(62.1371, 3);
|
||||
});
|
||||
|
||||
it('celsius to kelvin', () => {
|
||||
expect(convert(0, 'celsius', 'kelvin')).toBeCloseTo(273.15, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LOCALE DEFAULTS
|
||||
// ============================================================================
|
||||
|
||||
describe('locale-defaults', () => {
|
||||
it('resolveCurrency maps locales to currencies', async () => {
|
||||
const { resolveCurrency } = await import('../../money/locale-defaults');
|
||||
expect(resolveCurrency('es')).toBe('EUR');
|
||||
expect(resolveCurrency('en-US')).toBe('USD');
|
||||
expect(resolveCurrency('en-GB')).toBe('GBP');
|
||||
expect(resolveCurrency('ja')).toBe('JPY');
|
||||
expect(resolveCurrency('unknown')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolveDir maps locales to direction', async () => {
|
||||
const { resolveDir } = await import('../../presentation/locale-defaults');
|
||||
expect(resolveDir('es')).toBe('ltr');
|
||||
expect(resolveDir('en')).toBe('ltr');
|
||||
expect(resolveDir('ar')).toBe('rtl');
|
||||
expect(resolveDir('he')).toBe('rtl');
|
||||
expect(resolveDir('fa')).toBe('rtl');
|
||||
});
|
||||
|
||||
it('resolveDateOrder maps locales', async () => {
|
||||
const { resolveDateOrder } = await import('../../dates/locale-defaults');
|
||||
expect(resolveDateOrder('es')).toBe('DMY');
|
||||
expect(resolveDateOrder('en-US')).toBe('MDY');
|
||||
expect(resolveDateOrder('ja')).toBe('YMD');
|
||||
expect(resolveDateOrder('zh')).toBe('YMD');
|
||||
});
|
||||
|
||||
it('resolveHourCycle maps locales', async () => {
|
||||
const { resolveHourCycle } = await import('../../dates/locale-defaults');
|
||||
expect(resolveHourCycle('es')).toBe(24);
|
||||
expect(resolveHourCycle('en')).toBe(12);
|
||||
expect(resolveHourCycle('en-GB')).toBe(24);
|
||||
});
|
||||
|
||||
it('resolveUnitSystem maps locales', async () => {
|
||||
const { resolveUnitSystem } = await import('../../units/locale-defaults');
|
||||
expect(resolveUnitSystem('es')).toBe('metric');
|
||||
expect(resolveUnitSystem('en-US')).toBe('imperial');
|
||||
expect(resolveUnitSystem('en-GB')).toBe('metric');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,670 @@
|
||||
/**
|
||||
* Service factories — test suite
|
||||
*
|
||||
* Tests createNums, createMoney, createDates, createUnits, createPresentation
|
||||
* These use $state internally (Svelte runes) but the public API is plain JS.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AppLangs } from '../types';
|
||||
|
||||
// ── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function mockLangs(initialLocale = 'es'): AppLangs & { _fire: () => void } {
|
||||
let locale = initialLocale;
|
||||
const listeners = new Set<(l: string) => void>();
|
||||
|
||||
return {
|
||||
t: (path) => `[${locale}:${path}]`,
|
||||
ts: (value) => String(value ?? ''),
|
||||
getLocale: () => locale,
|
||||
setLocale: (l) => {
|
||||
locale = l;
|
||||
for (const fn of listeners) fn(l);
|
||||
},
|
||||
extend: () => {},
|
||||
onLocaleChange: (fn) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
onSchemaChange: () => () => {},
|
||||
_fire: () => {
|
||||
for (const fn of listeners) fn(locale);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NUMS
|
||||
// ============================================================================
|
||||
|
||||
describe('createNums', () => {
|
||||
it('formats numbers with locale', async () => {
|
||||
const { createNums } = await import('../../nums/nums.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const nums = createNums(langs);
|
||||
|
||||
const result = nums.format(1234.5);
|
||||
expect(result).toContain('1');
|
||||
expect(result).toContain('234');
|
||||
});
|
||||
|
||||
it('parses locale-formatted numbers', async () => {
|
||||
const { createNums } = await import('../../nums/nums.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const nums = createNums(langs);
|
||||
|
||||
expect(nums.parse('1,234.5')).toBeCloseTo(1234.5);
|
||||
});
|
||||
|
||||
it('returns undefined for invalid parse input', async () => {
|
||||
const { createNums } = await import('../../nums/nums.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const nums = createNums(langs);
|
||||
|
||||
expect(nums.parse('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles NaN/Infinity gracefully', async () => {
|
||||
const { createNums } = await import('../../nums/nums.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const nums = createNums(langs);
|
||||
|
||||
expect(nums.format(NaN)).toBe('NaN');
|
||||
expect(nums.format(Infinity)).toBe('Infinity');
|
||||
});
|
||||
|
||||
it('notifies on preference change', async () => {
|
||||
const { createNums } = await import('../../nums/nums.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const nums = createNums(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
nums.onPreferenceChange(spy);
|
||||
nums.setDecimalSeparator(',');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('notifies on locale change', async () => {
|
||||
const { createNums } = await import('../../nums/nums.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const nums = createNums(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
nums.onPreferenceChange(spy);
|
||||
langs.setLocale('es');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unsubscribe works', async () => {
|
||||
const { createNums } = await import('../../nums/nums.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const nums = createNums(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
const unsub = nums.onPreferenceChange(spy);
|
||||
unsub();
|
||||
nums.setDecimalSeparator(',');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// MONEY
|
||||
// ============================================================================
|
||||
|
||||
describe('createMoney', () => {
|
||||
it('derives currency from locale', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const money = createMoney(langs);
|
||||
|
||||
expect(money.getCurrency()).toBe('EUR');
|
||||
});
|
||||
|
||||
it('accepts currency override', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const money = createMoney(langs, { currency: 'USD' });
|
||||
|
||||
expect(money.getCurrency()).toBe('USD');
|
||||
});
|
||||
|
||||
it('setCurrency changes active currency', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const money = createMoney(langs);
|
||||
|
||||
money.setCurrency('GBP');
|
||||
expect(money.getCurrency()).toBe('GBP');
|
||||
});
|
||||
|
||||
it('formats monetary values', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('en-US');
|
||||
const money = createMoney(langs, { currency: 'USD' });
|
||||
|
||||
const result = money.format(10.99);
|
||||
expect(result).toContain('10');
|
||||
expect(result).toContain('99');
|
||||
});
|
||||
|
||||
it('formatAs uses specified currency', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('en-US');
|
||||
const money = createMoney(langs, { currency: 'USD' });
|
||||
|
||||
const result = money.formatAs(10.99, 'EUR');
|
||||
expect(result).toContain('10');
|
||||
});
|
||||
|
||||
it('notifies on currency change', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const money = createMoney(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
money.onCurrencyChange(spy);
|
||||
money.setCurrency('GBP');
|
||||
expect(spy).toHaveBeenCalledWith('GBP');
|
||||
});
|
||||
|
||||
it('recalculates currency on locale change when no override', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const money = createMoney(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
expect(money.getCurrency()).toBe('EUR');
|
||||
money.onCurrencyChange(spy);
|
||||
langs.setLocale('en-US');
|
||||
// Notifies because currency would change (EUR → USD)
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves override across locale changes', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const money = createMoney(langs, { currency: 'GBP' });
|
||||
|
||||
langs.setLocale('en-US');
|
||||
expect(money.getCurrency()).toBe('GBP'); // override sticks
|
||||
});
|
||||
|
||||
it('ratesSupported is false when no provider', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const money = createMoney(langs);
|
||||
|
||||
expect(money.ratesSupported).toBe(false);
|
||||
});
|
||||
|
||||
it('convert returns undefined when no rates provider', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const money = createMoney(langs);
|
||||
|
||||
expect(await money.convert(100, 'USD')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('converts with static rates', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const { createRates } = await import('../../money/rates');
|
||||
const langs = mockLangs('es');
|
||||
|
||||
const rates = createRates({
|
||||
initial: { base: 'USD', rates: { EUR: 0.92, GBP: 0.79 } }
|
||||
});
|
||||
const money = createMoney(langs, { currency: 'EUR', rates });
|
||||
|
||||
expect(money.ratesSupported).toBe(true);
|
||||
|
||||
// EUR → USD: rate = 1/0.92 ≈ 1.087
|
||||
const result = await money.convert(100, 'USD');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!).toBeCloseTo(108.7, 0);
|
||||
});
|
||||
|
||||
it('convertAs converts between any two currencies', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const { createRates } = await import('../../money/rates');
|
||||
const langs = mockLangs('es');
|
||||
|
||||
const rates = createRates({
|
||||
initial: { base: 'USD', rates: { EUR: 0.92, GBP: 0.79 } }
|
||||
});
|
||||
const money = createMoney(langs, { rates });
|
||||
|
||||
const result = await money.convertAs(100, 'EUR', 'GBP');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!).toBeCloseTo(85.87, 0);
|
||||
});
|
||||
|
||||
it('convert same currency returns same value', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const { createRates } = await import('../../money/rates');
|
||||
const langs = mockLangs('es');
|
||||
|
||||
const rates = createRates({
|
||||
initial: { base: 'USD', rates: { EUR: 0.92 } }
|
||||
});
|
||||
const money = createMoney(langs, { currency: 'EUR', rates });
|
||||
|
||||
expect(await money.convert(100, 'EUR')).toBe(100);
|
||||
});
|
||||
|
||||
it('convert returns undefined for unknown pair without fetcher', async () => {
|
||||
const { createMoney } = await import('../../money/money.svelte');
|
||||
const { createRates } = await import('../../money/rates');
|
||||
const langs = mockLangs('es');
|
||||
|
||||
const rates = createRates({
|
||||
initial: { base: 'USD', rates: { EUR: 0.92 } }
|
||||
});
|
||||
const money = createMoney(langs, { currency: 'EUR', rates });
|
||||
|
||||
expect(await money.convert(100, 'JPY')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fetches rate lazily when expired', async () => {
|
||||
const { createRates } = await import('../../money/rates');
|
||||
let fetchCount = 0;
|
||||
const provider = createRates({
|
||||
fetchRate: async (from, to) => {
|
||||
fetchCount++;
|
||||
if (from === 'USD' && to === 'EUR') return { rate: 0.92, expiresAt: Infinity };
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// No initial rates — fetches on demand
|
||||
const rate = await provider.getRate('USD', 'EUR');
|
||||
expect(fetchCount).toBe(1);
|
||||
expect(rate).toBeCloseTo(0.92);
|
||||
|
||||
// Second call uses cache (not expired)
|
||||
const rate2 = await provider.getRate('USD', 'EUR');
|
||||
expect(fetchCount).toBe(1); // no new fetch
|
||||
expect(rate2).toBeCloseTo(0.92);
|
||||
});
|
||||
|
||||
it('setRate populates cache directly', async () => {
|
||||
const { createRates } = await import('../../money/rates');
|
||||
const provider = createRates();
|
||||
|
||||
provider.setRate('USD', 'EUR', 0.92);
|
||||
expect(await provider.getRate('USD', 'EUR')).toBeCloseTo(0.92);
|
||||
expect(await provider.getRate('EUR', 'USD')).toBeCloseTo(1 / 0.92, 4);
|
||||
expect(provider.hasRates()).toBe(true);
|
||||
});
|
||||
|
||||
it('initial rates available immediately', async () => {
|
||||
const { createRates } = await import('../../money/rates');
|
||||
const provider = createRates({
|
||||
initial: { base: 'USD', rates: { EUR: 0.92 } }
|
||||
});
|
||||
|
||||
expect(await provider.getRate('USD', 'EUR')).toBeCloseTo(0.92);
|
||||
expect(provider.hasRates()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DATES
|
||||
// ============================================================================
|
||||
|
||||
describe('createDates', () => {
|
||||
it('derives dateOrder from locale', async () => {
|
||||
const { createDates } = await import('../../dates/dates.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const dates = createDates(langs);
|
||||
|
||||
expect(dates.getDateOrder()).toBe('DMY');
|
||||
});
|
||||
|
||||
it('derives hourCycle from locale', async () => {
|
||||
const { createDates } = await import('../../dates/dates.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const dates = createDates(langs);
|
||||
|
||||
expect(dates.getHourCycle()).toBe(24);
|
||||
});
|
||||
|
||||
it('formats dates', async () => {
|
||||
const { createDates } = await import('../../dates/dates.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const dates = createDates(langs);
|
||||
|
||||
const result = dates.format(new Date(2026, 3, 14));
|
||||
expect(result).toContain('2026');
|
||||
});
|
||||
|
||||
it('formats time', async () => {
|
||||
const { createDates } = await import('../../dates/dates.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const dates = createDates(langs);
|
||||
|
||||
const result = dates.formatTime(new Date(2026, 3, 14, 15, 30));
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('setDateOrder overrides', async () => {
|
||||
const { createDates } = await import('../../dates/dates.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const dates = createDates(langs);
|
||||
|
||||
dates.setDateOrder('YMD');
|
||||
expect(dates.getDateOrder()).toBe('YMD');
|
||||
});
|
||||
|
||||
it('setDateOrder auto resets to locale default', async () => {
|
||||
const { createDates } = await import('../../dates/dates.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const dates = createDates(langs);
|
||||
|
||||
dates.setDateOrder('YMD');
|
||||
dates.setDateOrder('auto');
|
||||
expect(dates.getDateOrder()).toBe('DMY');
|
||||
});
|
||||
|
||||
it('setHourCycle overrides', async () => {
|
||||
const { createDates } = await import('../../dates/dates.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const dates = createDates(langs);
|
||||
|
||||
dates.setHourCycle(12);
|
||||
expect(dates.getHourCycle()).toBe(12);
|
||||
});
|
||||
|
||||
it('notifies on preference change', async () => {
|
||||
const { createDates } = await import('../../dates/dates.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const dates = createDates(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
dates.onPreferenceChange(spy);
|
||||
dates.setDateOrder('MDY');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('notifies on locale change', async () => {
|
||||
const { createDates } = await import('../../dates/dates.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const dates = createDates(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
dates.onPreferenceChange(spy);
|
||||
langs.setLocale('en-US');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// UNITS
|
||||
// ============================================================================
|
||||
|
||||
describe('createUnits', () => {
|
||||
it('derives system from locale', async () => {
|
||||
const { createUnits } = await import('../../units/units.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const units = createUnits(langs);
|
||||
|
||||
expect(units.getSystem()).toBe('metric');
|
||||
});
|
||||
|
||||
it('en-US defaults to imperial', async () => {
|
||||
const { createUnits } = await import('../../units/units.svelte');
|
||||
const langs = mockLangs('en-US');
|
||||
const units = createUnits(langs);
|
||||
|
||||
expect(units.getSystem()).toBe('imperial');
|
||||
});
|
||||
|
||||
it('formats units', async () => {
|
||||
const { createUnits } = await import('../../units/units.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const units = createUnits(langs);
|
||||
|
||||
const result = units.format(5, 'kilometer');
|
||||
expect(result).toContain('5');
|
||||
expect(result).toContain('km');
|
||||
});
|
||||
|
||||
it('converts between units', async () => {
|
||||
const { createUnits } = await import('../../units/units.svelte');
|
||||
const langs = mockLangs('en');
|
||||
const units = createUnits(langs);
|
||||
|
||||
expect(units.convert(1, 'kilometer', 'meter')).toBeCloseTo(1000);
|
||||
});
|
||||
|
||||
it('setSystem overrides', async () => {
|
||||
const { createUnits } = await import('../../units/units.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const units = createUnits(langs);
|
||||
|
||||
units.setSystem('imperial');
|
||||
expect(units.getSystem()).toBe('imperial');
|
||||
});
|
||||
|
||||
it('setSystem auto resets', async () => {
|
||||
const { createUnits } = await import('../../units/units.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const units = createUnits(langs);
|
||||
|
||||
units.setSystem('imperial');
|
||||
units.setSystem('auto');
|
||||
expect(units.getSystem()).toBe('metric');
|
||||
});
|
||||
|
||||
it('notifies on locale change when no override', async () => {
|
||||
const { createUnits } = await import('../../units/units.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const units = createUnits(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
units.onPreferenceChange(spy);
|
||||
langs.setLocale('en-US');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not notify on locale change when overridden', async () => {
|
||||
const { createUnits } = await import('../../units/units.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const units = createUnits(langs, { system: 'imperial' });
|
||||
const spy = vi.fn();
|
||||
|
||||
units.onPreferenceChange(spy);
|
||||
langs.setLocale('en-US');
|
||||
// Should NOT notify because system is explicitly overridden
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PRESENTATION
|
||||
// ============================================================================
|
||||
|
||||
describe('createPresentation', () => {
|
||||
it('derives dir from locale', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
expect(pres.getDir()).toBe('ltr');
|
||||
});
|
||||
|
||||
it('derives rtl for arabic', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('ar');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
expect(pres.getDir()).toBe('rtl');
|
||||
});
|
||||
|
||||
it('setDir overrides', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
pres.setDir('rtl');
|
||||
expect(pres.getDir()).toBe('rtl');
|
||||
});
|
||||
|
||||
it('setDir auto resets to locale-derived', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
pres.setDir('rtl');
|
||||
pres.setDir('auto');
|
||||
expect(pres.getDir()).toBe('ltr');
|
||||
});
|
||||
|
||||
it('defaults theme to base', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
expect(pres.getTheme()).toBe('base');
|
||||
});
|
||||
|
||||
it('setTheme changes theme', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
pres.setTheme('midnight');
|
||||
expect(pres.getTheme()).toBe('midnight');
|
||||
});
|
||||
|
||||
it('defaults mode to light (no window in Node)', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
expect(pres.getMode()).toBe('light');
|
||||
});
|
||||
|
||||
it('setMode overrides', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
pres.setMode('dark');
|
||||
expect(pres.getMode()).toBe('dark');
|
||||
});
|
||||
|
||||
it('setMode auto resets to OS default', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
pres.setMode('dark');
|
||||
pres.setMode('auto');
|
||||
// In Node, OS dark mode is false → light
|
||||
expect(pres.getMode()).toBe('light');
|
||||
});
|
||||
|
||||
it('defaults reducedMotion to false', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
expect(pres.getReducedMotion()).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults reducedSound to false', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
expect(pres.getReducedSound()).toBe(false);
|
||||
});
|
||||
|
||||
it('setReducedSound changes preference', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
pres.setReducedSound(true);
|
||||
expect(pres.getReducedSound()).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults density to normal', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
expect(pres.getDensity()).toBe('normal');
|
||||
});
|
||||
|
||||
it('setDensity changes density', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
pres.setDensity('compact');
|
||||
expect(pres.getDensity()).toBe('compact');
|
||||
});
|
||||
|
||||
it('notifies on preference change', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
pres.onPreferenceChange(spy);
|
||||
pres.setDir('rtl');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('notifies on locale change', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
const spy = vi.fn();
|
||||
|
||||
pres.onPreferenceChange(spy);
|
||||
langs.setLocale('ar');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dir recalculates on locale change', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
expect(pres.getDir()).toBe('ltr');
|
||||
langs.setLocale('ar');
|
||||
expect(pres.getDir()).toBe('rtl');
|
||||
});
|
||||
|
||||
it('dir override preserved across locale change', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs);
|
||||
|
||||
pres.setDir('rtl');
|
||||
langs.setLocale('en');
|
||||
expect(pres.getDir()).toBe('rtl'); // override sticks
|
||||
});
|
||||
|
||||
it('accepts initial options', async () => {
|
||||
const { createPresentation } = await import('../../presentation/presentation.svelte');
|
||||
const langs = mockLangs('es');
|
||||
const pres = createPresentation(langs, {
|
||||
dir: 'rtl',
|
||||
theme: 'midnight',
|
||||
mode: 'dark',
|
||||
density: 'compact',
|
||||
reducedSound: true
|
||||
});
|
||||
|
||||
expect(pres.getDir()).toBe('rtl');
|
||||
expect(pres.getTheme()).toBe('midnight');
|
||||
expect(pres.getMode()).toBe('dark');
|
||||
expect(pres.getDensity()).toBe('compact');
|
||||
expect(pres.getReducedSound()).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,239 @@
|
||||
import type { ActiveDom } from '$adom';
|
||||
import type { SemanticSignal } from '$uix/sema';
|
||||
|
||||
// ── Direction ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Direction = 'ltr' | 'rtl';
|
||||
|
||||
// ── Dates ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `DateOrder` and `HourCycle` are canonical across the framework. Defined in
|
||||
// `$lib/util/dias`, re-exported here as convenience for App-layer consumers.
|
||||
// `HourCycle` uses the numeric form `12 | 24` — same as `Intl.DateTimeFormat`'s
|
||||
// resolved `hour12` option (12 → true, 24 → false).
|
||||
|
||||
import type { DateOrder, HourCycle } from '$lib/util/dias';
|
||||
export type { DateOrder, HourCycle };
|
||||
|
||||
// ── AppLangs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** i18n runtime contract. lang implements this directly. */
|
||||
export interface AppLangs {
|
||||
/** Resolve a dot-path key to a localized string */
|
||||
t(path: string, params?: Record<string, unknown>): string;
|
||||
/** Resolve a LangRecord, LangRef, or descriptor to a string */
|
||||
ts(value: unknown, locale?: string): string;
|
||||
/** Get current locale */
|
||||
getLocale(): string;
|
||||
/** Set active locale — triggers onLocaleChange subscribers */
|
||||
setLocale(locale: string): void;
|
||||
/** Extend with a translation module at runtime */
|
||||
extend(namespace: string, module: unknown): void;
|
||||
/** Subscribe to locale changes. Returns unsubscribe. */
|
||||
onLocaleChange(fn: (locale: string) => void): () => void;
|
||||
/** Subscribe to schema changes (extend calls). Returns unsubscribe. */
|
||||
onSchemaChange(fn: () => void): () => void;
|
||||
}
|
||||
|
||||
// ── AppNums ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Number formatting/parsing contract. nums implements this directly. */
|
||||
export interface AppNums {
|
||||
/** Format a number with locale-aware separators */
|
||||
format(value: number, options?: Intl.NumberFormatOptions): string;
|
||||
/** Parse a locale-formatted string back to a number */
|
||||
parse(value: string): number | undefined;
|
||||
/** Get decimal separator */
|
||||
getDecimalSeparator(): string;
|
||||
/** Get group (thousands) separator */
|
||||
getGroupSeparator(): string;
|
||||
/** Whether grouping is enabled */
|
||||
getGrouping(): boolean;
|
||||
/** Override decimal separator ('auto' resets to locale default) */
|
||||
setDecimalSeparator(sep: string | 'auto'): void;
|
||||
/** Override group separator ('auto' resets to locale default) */
|
||||
setGroupSeparator(sep: string | 'auto'): void;
|
||||
/** Override grouping ('auto' resets to locale default) */
|
||||
setGrouping(enabled: boolean | 'auto'): void;
|
||||
/** Subscribe to preference changes. Returns unsubscribe. */
|
||||
onPreferenceChange(fn: () => void): () => void;
|
||||
}
|
||||
|
||||
// ── AppMoney ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Currency & monetary value contract. money implements this directly. */
|
||||
export interface AppMoney {
|
||||
/** Get active currency code (e.g., 'EUR', 'USD') */
|
||||
getCurrency(): string;
|
||||
/** Set active currency */
|
||||
setCurrency(currency: string): void;
|
||||
/** Format a monetary amount in the active currency */
|
||||
format(value: number, options?: MoneyFormatOptions): string;
|
||||
/** Format in a specific currency (ignoring active) */
|
||||
formatAs(value: number, currency: string, options?: MoneyFormatOptions): string;
|
||||
/**
|
||||
* Convert an amount from active currency to target currency.
|
||||
* Async — may fetch rate if cached entry is expired.
|
||||
* Returns undefined if rates provider is not configured or pair unavailable.
|
||||
*/
|
||||
convert(value: number, to: string): Promise<number | undefined>;
|
||||
/**
|
||||
* Convert between any two currencies.
|
||||
* Async — may fetch rate if cached entry is expired.
|
||||
* Returns undefined if rates provider is not configured or pair unavailable.
|
||||
*/
|
||||
convertAs(value: number, from: string, to: string): Promise<number | undefined>;
|
||||
/** Whether this money instance supports currency conversion */
|
||||
readonly ratesSupported: boolean;
|
||||
/** Subscribe to currency changes. Returns unsubscribe. */
|
||||
onCurrencyChange(fn: (currency: string) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MoneyFormatOptions {
|
||||
/** How to display the currency: symbol, narrowSymbol, code, name */
|
||||
currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name';
|
||||
/** Sign convention for negative amounts */
|
||||
currencySign?: 'standard' | 'accounting';
|
||||
/** Minimum fraction digits */
|
||||
minDecimals?: number;
|
||||
/** Maximum fraction digits */
|
||||
maxDecimals?: number;
|
||||
}
|
||||
|
||||
// ── AppDates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Date/time formatting contract. dates implements this directly. */
|
||||
export interface AppDates {
|
||||
/** Format a date */
|
||||
format(value: Date, options?: Intl.DateTimeFormatOptions): string;
|
||||
/** Format time only */
|
||||
formatTime(value: Date, options?: Intl.DateTimeFormatOptions): string;
|
||||
/** Get date field order */
|
||||
getDateOrder(): DateOrder;
|
||||
/** Set date field order preference */
|
||||
setDateOrder(order: DateOrder | 'auto'): void;
|
||||
/** Get hour cycle */
|
||||
getHourCycle(): HourCycle;
|
||||
/** Set hour cycle preference */
|
||||
setHourCycle(cycle: HourCycle | 'auto'): void;
|
||||
/** Subscribe to preference changes. Returns unsubscribe. */
|
||||
onPreferenceChange(fn: () => void): () => void;
|
||||
}
|
||||
|
||||
// ── AppUnits ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Unit formatting & conversion contract. units implements this directly. */
|
||||
export interface AppUnits {
|
||||
/** Format a value with a unit */
|
||||
format(value: number, unit: string, options?: Intl.NumberFormatOptions): string;
|
||||
/** Get the active measurement system */
|
||||
getSystem(): 'metric' | 'imperial';
|
||||
/** Set measurement system preference */
|
||||
setSystem(system: 'metric' | 'imperial' | 'auto'): void;
|
||||
/** Convert between units */
|
||||
convert(value: number, from: string, to: string): number;
|
||||
/** Subscribe to preference changes. Returns unsubscribe. */
|
||||
onPreferenceChange(fn: () => void): () => void;
|
||||
}
|
||||
|
||||
// ── AppPresentation ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Visual & sensorial preferences contract. presentation implements this directly. */
|
||||
export interface AppPresentation {
|
||||
/** Text direction (derived from locale, overridable) */
|
||||
getDir(): Direction;
|
||||
setDir(dir: Direction | 'auto'): void;
|
||||
/** Color theme name */
|
||||
getTheme(): string;
|
||||
setTheme(theme: string): void;
|
||||
/** Color mode — light, dark, or auto (OS preference) */
|
||||
getMode(): 'light' | 'dark';
|
||||
setMode(mode: 'light' | 'dark' | 'auto'): void;
|
||||
/** User prefers reduced motion (auto = respect OS prefers-reduced-motion) */
|
||||
getReducedMotion(): boolean;
|
||||
setReducedMotion(reduced: boolean | 'auto'): void;
|
||||
/** User prefers reduced/no sound */
|
||||
getReducedSound(): boolean;
|
||||
setReducedSound(reduced: boolean): void;
|
||||
/** UI density */
|
||||
getDensity(): 'compact' | 'normal' | 'comfortable';
|
||||
setDensity(density: 'compact' | 'normal' | 'comfortable'): void;
|
||||
/** Subscribe to any presentation change. Returns unsubscribe. */
|
||||
onPreferenceChange(fn: () => void): () => void;
|
||||
}
|
||||
|
||||
// ── AppDom ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** DOM runtime contract. Implements viewport + breakpoints + responsive helpers. */
|
||||
export type AppDom = ActiveDom;
|
||||
|
||||
// ── AppSemantic ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Semantic engine contract. Responsible for orchestrating perceptual signals
|
||||
* (`data-event*`) on a target with the agreed lifecycle: write the signal,
|
||||
* await one rAF so CSS / Eidos can react, resolve the Promise, hold N frames,
|
||||
* then clean up. The provider/Soma decides when to emit; the engine never
|
||||
* decides what occurred.
|
||||
*
|
||||
* Implementations may also publish to other channels (sound, vibra) by
|
||||
* subscribing to their own internal bus — that's an engine detail, not the
|
||||
* contract.
|
||||
*/
|
||||
export interface AppSemantic {
|
||||
emit(signal: SemanticSignal): Promise<void>;
|
||||
}
|
||||
|
||||
// ── AppLogger ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Logging contract. logr implements this directly. */
|
||||
export interface AppLogger {
|
||||
/** Log a debug message */
|
||||
debug(category: string, message: unknown, context?: Record<string, unknown>): void;
|
||||
/** Log an info message */
|
||||
info(category: string, message: unknown, context?: Record<string, unknown>): void;
|
||||
/** Log a warning */
|
||||
warn(category: string, message: unknown, context?: Record<string, unknown>): void;
|
||||
/** Log an error */
|
||||
error(category: string, message: unknown, context?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
// ── AppServices ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AppServices {
|
||||
/** DOM runtime — viewport, breakpoints, responsive helpers */
|
||||
readonly dom: AppDom;
|
||||
/** Semantic engine — emits perceptual signals as `data-event*` on a target */
|
||||
readonly semantic: AppSemantic;
|
||||
/** i18n — locale, translation, interpolation, module extension */
|
||||
readonly langs: AppLangs;
|
||||
/** Numbers — formatting, parsing, separator preferences */
|
||||
readonly nums?: AppNums;
|
||||
/** Money — currency, monetary formatting */
|
||||
readonly money?: AppMoney;
|
||||
/** Dates — date/time formatting, dateOrder, hourCycle */
|
||||
readonly dates?: AppDates;
|
||||
/** Units — measurement formatting, conversion, system */
|
||||
readonly units?: AppUnits;
|
||||
/** Presentation — dir, theme, mode, reducedMotion, reducedSound, density */
|
||||
readonly presentation: AppPresentation;
|
||||
/** Logging */
|
||||
readonly logger?: AppLogger;
|
||||
/** Default portal target for overlays */
|
||||
readonly portalTo?: string | HTMLElement;
|
||||
}
|
||||
|
||||
// ── AppOptions (constructor input) ───────────────────────────────────────────
|
||||
|
||||
export interface AppOptions {
|
||||
dom?: AppDom;
|
||||
semantic?: AppSemantic;
|
||||
langs: AppLangs;
|
||||
nums?: AppNums;
|
||||
money?: AppMoney;
|
||||
dates?: AppDates;
|
||||
units?: AppUnits;
|
||||
presentation: AppPresentation;
|
||||
logger?: AppLogger;
|
||||
portalTo?: string | HTMLElement;
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import type { AppLangs, AppDates, DateOrder, HourCycle } from '../app/types';
|
||||
import { resolveDateOrder, resolveHourCycle } from './locale-defaults';
|
||||
import { getCachedDateFormat } from '$lib/util/dias';
|
||||
|
||||
// ── Factory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DatesOptions {
|
||||
/** Initial dateOrder override (null = derive from locale) */
|
||||
dateOrder?: DateOrder;
|
||||
/** Initial hourCycle override (null = derive from locale) */
|
||||
hourCycle?: HourCycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive dates service.
|
||||
* Subscribes to locale changes from langs.
|
||||
* Owns dateOrder and hourCycle preferences.
|
||||
*
|
||||
* Formatting itself is delegated to `$lib/util/dias`: this module adds
|
||||
* nothing but reactive preference state on top of it.
|
||||
*/
|
||||
export function createDates(langs: AppLangs, opts: DatesOptions = {}): AppDates {
|
||||
let _dateOrder = $state<DateOrder | null>(opts.dateOrder ?? null);
|
||||
let _hourCycle = $state<HourCycle | null>(opts.hourCycle ?? null);
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
langs.onLocaleChange(() => notify());
|
||||
|
||||
function locale(): string {
|
||||
return langs.getLocale() || 'en';
|
||||
}
|
||||
|
||||
return {
|
||||
format(value: Date, options?: Intl.DateTimeFormatOptions): string {
|
||||
const mergedOpts: Intl.DateTimeFormatOptions = {
|
||||
...(options ?? { dateStyle: 'medium' })
|
||||
};
|
||||
return getCachedDateFormat(locale(), mergedOpts).format(value);
|
||||
},
|
||||
|
||||
formatTime(value: Date, options?: Intl.DateTimeFormatOptions): string {
|
||||
const cycle = _hourCycle ?? resolveHourCycle(locale());
|
||||
const mergedOpts: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hourCycle: cycle === 12 ? 'h12' : 'h23',
|
||||
...options
|
||||
};
|
||||
return getCachedDateFormat(locale(), mergedOpts).format(value);
|
||||
},
|
||||
|
||||
getDateOrder: () => _dateOrder ?? resolveDateOrder(locale()),
|
||||
setDateOrder(order) {
|
||||
_dateOrder = order === 'auto' ? null : order;
|
||||
notify();
|
||||
},
|
||||
|
||||
getHourCycle: () => _hourCycle ?? resolveHourCycle(locale()),
|
||||
setHourCycle(cycle) {
|
||||
_hourCycle = cycle === 'auto' ? null : cycle;
|
||||
notify();
|
||||
},
|
||||
|
||||
onPreferenceChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
|
||||
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
|
||||
* See `src/lib/README.md` for the migration map.
|
||||
*/
|
||||
export { createDates, type DatesOptions } from './dates.svelte';
|
||||
export { resolveDateOrder, resolveHourCycle } from './locale-defaults';
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Thin re-export of locale-default helpers from `$lib/util/dias`. Exists for
|
||||
* historical reasons — `ext/dates` used to convert between App-layer string
|
||||
* hour-cycle (`'12h'|'24h'`) and the canonical numeric form. Since both
|
||||
* layers now use the same numeric form, this file is a passthrough and can
|
||||
* be removed; consumers should import directly from `$lib/util/dias`.
|
||||
*/
|
||||
|
||||
export { resolveDateOrder, resolveHourCycle } from '$lib/util/dias';
|
||||
@ -0,0 +1,381 @@
|
||||
# lang
|
||||
|
||||
Libreria i18n type-safe para SvelteKit. Zero dependencias externas. Resolucion reactiva de traducciones con cadena de fallback, pluralizacion via `Intl.PluralRules`, referencias entre claves, y serializacion JSON.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
lang/
|
||||
├── lib/
|
||||
│ ├── index.ts Barrel de exports
|
||||
│ ├── types.ts Tipos: LangRecord, LangNode, LangInstance, etc.
|
||||
│ ├── engine.ts Factory createLang() — nucleo de la libreria
|
||||
│ ├── plural.ts Helper p() con WeakMap para serializacion
|
||||
│ ├── plural_rules.ts Wrapper sobre Intl.PluralRules
|
||||
│ ├── guards.ts Type guards: isLangRef, isLangRecord, isLangString
|
||||
│ ├── helpers.ts Utilidades: interpolateTemplate, resolveRecordFallback, deepMerge
|
||||
│ ├── json.ts Serializacion/deserializacion: langNodeToJSON, JSONToLangNode
|
||||
│ ├── consts.ts Constantes: ID_PREFIX (#?), separadores, limits
|
||||
│ └── errors.ts Mensajes de error centralizados
|
||||
└── test/
|
||||
└── lang.test.ts 136 tests
|
||||
```
|
||||
|
||||
### Principios de diseno
|
||||
|
||||
1. **Factory pattern** — `createLang()` devuelve una instancia con estado cerrado sobre el schema y locale. No hay singletons globales.
|
||||
2. **Resolucion pura** — `tsRecord` y `resolveValue` reciben locale como parametro. Sin mutacion de estado para resolver en otro locale.
|
||||
3. **Cadena de fallback** — `[currentLocale, ...fallbackChain, defaultLocale]`. Configurable en `createLang()`.
|
||||
4. **Type-safe paths** — `t('common.ok')` autocompleta y valida tipos. Overload `string` para paths dinamicos.
|
||||
5. **Datos desacoplados** — `p()` vive en `plural.ts`, no en el engine. Los datos de traducciones no importan del engine.
|
||||
|
||||
---
|
||||
|
||||
## Schema de traducciones
|
||||
|
||||
Un schema es un arbol de `LangNode` con 4 tipos de hoja:
|
||||
|
||||
### LangRecord — traduccion estatica
|
||||
|
||||
```ts
|
||||
{ es: 'Aceptar', en: 'OK' }
|
||||
```
|
||||
|
||||
Todos los locales son opcionales. No hay locale obligatorio — ningun `LangRecord` te exige tener `es`, `en`, o cualquier otro.
|
||||
|
||||
### LangFn — interpolacion con parametros
|
||||
|
||||
```ts
|
||||
(params: { name: string }) => ({
|
||||
es: `Hola, {{name}}`,
|
||||
en: `Hello, {{name}}`
|
||||
});
|
||||
```
|
||||
|
||||
Los parametros se interpolan con `{{key}}`. La funcion recibe un objeto y devuelve un `LangRecord`.
|
||||
|
||||
### LangPluralFn — pluralizacion con `p()`
|
||||
|
||||
```ts
|
||||
import { p } from '@/ext/lang';
|
||||
|
||||
p({
|
||||
es: { one: '{{count}} mensaje', other: '{{count}} mensajes' },
|
||||
en: { one: '{{count}} message', other: '{{count}} messages' },
|
||||
ar: { zero: '...', one: '...', two: '...', few: '...', many: '...', other: '...' }
|
||||
});
|
||||
```
|
||||
|
||||
`p()` usa `Intl.PluralRules` internamente. Formas: `zero`, `one`, `two`, `few`, `many`, `other`. Solo `other` es obligatoria. Si una forma no existe, cae a `other`.
|
||||
|
||||
### LangRef — referencia a otra clave
|
||||
|
||||
```ts
|
||||
'#?common.ok'; // alias → resuelve el valor de common.ok
|
||||
'#?common.ok|fallback'; // alias con fallback literal si la clave no existe
|
||||
```
|
||||
|
||||
Prefijo `#?`, separador `|` para fallback. Maximo 3 niveles de indireccion. Si se detecta un ciclo, lanza error.
|
||||
|
||||
### Ejemplo completo de schema
|
||||
|
||||
```ts
|
||||
import { p } from '@/ext/lang';
|
||||
import type { LangNode } from '@/ext/lang';
|
||||
|
||||
export const translations = {
|
||||
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'
|
||||
} satisfies LangNode;
|
||||
|
||||
export type TranslationSchema = typeof translations;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uso
|
||||
|
||||
### Crear una instancia
|
||||
|
||||
```ts
|
||||
import { createLang } from '@/ext/lang';
|
||||
import { translations } from './translations';
|
||||
|
||||
const lang = createLang(translations, 'es');
|
||||
```
|
||||
|
||||
Parametros de `createLang`:
|
||||
|
||||
```ts
|
||||
createLang(
|
||||
schema: LangNode, // arbol de traducciones
|
||||
defaultLocale: SupportedLocale = 'es', // locale por defecto
|
||||
fallbackChain?: SupportedLocale[] // cadena intermedia
|
||||
)
|
||||
```
|
||||
|
||||
Con cadena de fallback:
|
||||
|
||||
```ts
|
||||
// Resolucion: currentLocale → en → es(default)
|
||||
const lang = createLang(translations, 'es', ['en']);
|
||||
|
||||
lang.setLocale('fr');
|
||||
lang.t('common.ok'); // fr no existe → busca en → 'OK'
|
||||
```
|
||||
|
||||
Con cualquier locale como default:
|
||||
|
||||
```ts
|
||||
const langEN = createLang(translations, 'en');
|
||||
const langFR = createLang(translations, 'fr', ['en', 'es']);
|
||||
```
|
||||
|
||||
### Traducir por path — `t()`
|
||||
|
||||
```ts
|
||||
const lang = createLang(translations, 'es');
|
||||
|
||||
// Path tipado (autocompleta)
|
||||
lang.t('common.ok'); // → 'Aceptar'
|
||||
lang.t('common.ok', undefined, 'en'); // → 'OK' (sin cambiar locale activo)
|
||||
|
||||
// Con parametros de interpolacion
|
||||
lang.t('greet', { name: 'Ana' }); // → 'Hola, Ana'
|
||||
lang.t('greet', { name: 'Ana' }, 'en'); // → 'Hello, Ana'
|
||||
|
||||
// Pluralizacion
|
||||
lang.t('messages.unread', { count: 1 }); // → '1 mensaje sin leer'
|
||||
lang.t('messages.unread', { count: 5 }, 'en'); // → '5 unread messages'
|
||||
|
||||
// Referencia
|
||||
lang.t('ref'); // → 'Aceptar' (resuelve #?common.ok)
|
||||
```
|
||||
|
||||
### Traducir un valor directo — `ts()`
|
||||
|
||||
```ts
|
||||
const lang = createLang(translations, 'es');
|
||||
|
||||
// LangRecord
|
||||
lang.ts({ es: 'Hola', en: 'Hello' }); // → 'Hola'
|
||||
lang.ts({ es: 'Hola', en: 'Hello' }, 'en'); // → 'Hello'
|
||||
|
||||
// LangRef
|
||||
lang.ts('#?common.ok'); // → 'Aceptar'
|
||||
lang.ts('#?common.ok|fallback'); // → 'Aceptar' (clave existe, ignora fallback)
|
||||
lang.ts('#?missing.key|valor por defecto'); // → 'valor por defecto'
|
||||
|
||||
// String plano — no hace lookup
|
||||
lang.ts('texto plano'); // → 'texto plano'
|
||||
```
|
||||
|
||||
### Cambiar locale
|
||||
|
||||
```ts
|
||||
const lang = createLang(translations, 'es');
|
||||
|
||||
lang.setLocale('en');
|
||||
lang.getLocale(); // → 'en'
|
||||
lang.t('common.ok'); // → 'OK'
|
||||
|
||||
// Suscribirse a cambios
|
||||
const unsubscribe = lang.onLocaleChange((locale) => {
|
||||
console.log('Locale cambiado a', locale);
|
||||
});
|
||||
lang.setLocale('es'); // dispara el callback
|
||||
unsubscribe(); // dejar de escuchar
|
||||
```
|
||||
|
||||
### Modulos lazy — `extend()`
|
||||
|
||||
Anade traducciones al schema en runtime. Soporta namespaces con puntos y deep-merge:
|
||||
|
||||
```ts
|
||||
const lang = createLang(translations, 'es');
|
||||
|
||||
lang.extend('shop', {
|
||||
product: { es: 'Producto', en: 'Product' },
|
||||
cart: { es: 'Carrito', en: 'Cart' }
|
||||
});
|
||||
|
||||
lang.t('shop.product'); // → 'Producto'
|
||||
|
||||
// Namespace con puntos — crea la jerarquia anidada
|
||||
lang.extend('app.settings', {
|
||||
title: { es: 'Ajustes', en: 'Settings' }
|
||||
});
|
||||
lang.t('app.settings.title'); // → 'Ajustes'
|
||||
|
||||
// Deep-merge — no borra claves existentes
|
||||
lang.extend('common', {
|
||||
extra: { es: 'Extra', en: 'Extra' }
|
||||
});
|
||||
lang.t('common.ok'); // → 'Aceptar' (sigue existiendo)
|
||||
|
||||
// Suscribirse a cambios de schema
|
||||
const unsub = lang.onSchemaChange(() => {
|
||||
// Re-render cuando se anade un modulo
|
||||
});
|
||||
```
|
||||
|
||||
### Instancias hijas — `register()`
|
||||
|
||||
Crea una nueva instancia con el modulo anadido. La hija sincroniza locale con la padre. Llamar `dispose()` para desconectar:
|
||||
|
||||
```ts
|
||||
const lang = createLang(translations, 'es');
|
||||
|
||||
const child = lang.register('admin', {
|
||||
dashboard: { es: 'Panel', en: 'Dashboard' }
|
||||
});
|
||||
|
||||
child.t('admin.dashboard'); // → 'Panel'
|
||||
child.t('common.ok'); // → 'Aceptar' (hereda el schema padre)
|
||||
|
||||
lang.setLocale('en'); // hijo se sincroniza automaticamente
|
||||
child.t('admin.dashboard'); // → 'Dashboard'
|
||||
|
||||
child.dispose(); // desconecta del padre
|
||||
lang.setLocale('es'); // hijo ya no recibe el cambio
|
||||
child.getLocale(); // → 'en' (se quedo en el ultimo)
|
||||
```
|
||||
|
||||
La hija hereda el `defaultLocale` y la `fallbackChain` del padre.
|
||||
|
||||
### Logger personalizado
|
||||
|
||||
```ts
|
||||
const lang = createLang(translations, 'es');
|
||||
|
||||
lang.setLogger({
|
||||
warn: (category, message) => myLogger.warn(message),
|
||||
error: (category, message) => myLogger.error(message)
|
||||
});
|
||||
// Solo se puede llamar una vez — posteriores llamadas se ignoran
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cadena de fallback
|
||||
|
||||
Cuando se traduce una clave, se busca en este orden:
|
||||
|
||||
```
|
||||
1. currentLocale
|
||||
2. fallbackChain[0]
|
||||
3. fallbackChain[1]
|
||||
4. ...
|
||||
5. defaultLocale
|
||||
```
|
||||
|
||||
Ejemplo con `createLang(schema, 'es', ['en'])` y `lang.setLocale('fr')`:
|
||||
|
||||
```
|
||||
clave: common.ok → { es: 'Aceptar', en: 'OK' }
|
||||
|
||||
1. fr → no existe
|
||||
2. en → 'OK' ✓
|
||||
```
|
||||
|
||||
Si ningun locale tiene la traduccion, `t()` devuelve el path y `ts()` devuelve string vacio. En DEV se emite un warning cuando se usa un fallback.
|
||||
|
||||
---
|
||||
|
||||
## API — LangInstance
|
||||
|
||||
| Metodo | Descripcion |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `t(path, params?, locale?)` | Traduce por path del schema. `params` para interpolacion/plural. `locale` para resolver en un locale concreto sin cambiar el activo. |
|
||||
| `ts(value, locale?)` | Traduce un `LangString` directo (record, string, ref). `locale` opcional. |
|
||||
| `setLocale(locale)` | Cambia el locale activo y notifica listeners. |
|
||||
| `getLocale()` | Devuelve el locale activo. |
|
||||
| `onLocaleChange(fn)` | Subscribe a cambios de locale. Devuelve funcion de unsubscribe. |
|
||||
| `onSchemaChange(fn)` | Subscribe a cambios de schema (`extend`). Devuelve funcion de unsubscribe. |
|
||||
| `extend(namespace, module)` | Anade un modulo al schema en runtime via deep-merge. Soporta namespaces con puntos. |
|
||||
| `register(namespace, module)` | Crea una instancia hija con el modulo anadido y locale sincronizado. |
|
||||
| `setLogger(logger)` | Inyecta un logger personalizado. Solo puede llamarse una vez. |
|
||||
| `dispose()` | Desconecta una instancia hija de su padre. |
|
||||
|
||||
---
|
||||
|
||||
## Tipos principales
|
||||
|
||||
| Tipo | Descripcion |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `SupportedLocale` | Union de locales: `es \| en \| ar \| de \| fr \| it \| pt \| ca \| eu \| gl` |
|
||||
| `LangRecord` | `{ [K in SupportedLocale]?: string }` — todos opcionales |
|
||||
| `LangString` | `string \| LangRecord \| LangRef` |
|
||||
| `LangNode` | Arbol recursivo: `LangRecord \| LangFn \| LangPluralFn \| LangRef \| { [key: string]: LangNode }` |
|
||||
| `LangRef` | Template literal: `` `#?${string}` `` |
|
||||
| `LangFn<P>` | `(params: P) => LangRecord` |
|
||||
| `LangPluralFn<P>` | `(params: { count: number } & P) => LangRecord` |
|
||||
| `PluralConfig` | `{ [K in SupportedLocale]?: PluralForms }` |
|
||||
| `PluralForms` | `{ other: string } & Partial<Record<PluralCategory, string>>` |
|
||||
| `LangInstance<S>` | Interface completa de la instancia devuelta por `createLang()` |
|
||||
| `LangLogger` | `{ warn, error }` — interface del logger inyectable |
|
||||
|
||||
---
|
||||
|
||||
## Serializacion JSON
|
||||
|
||||
```ts
|
||||
import { langNodeToJSON, JSONToLangNode } from '@/ext/lang';
|
||||
import { translations } from './translations';
|
||||
|
||||
const json = langNodeToJSON(translations);
|
||||
// LangFn → { es: 'Hola {{name}}', en: 'Hello {{name}}' }
|
||||
// LangPluralFn → { __type: 'plural', config: {...} }
|
||||
// LangRef → '#?common.ok' (string plano)
|
||||
// LangRecord → { es: 'Aceptar', en: 'OK' } (sin cambios)
|
||||
|
||||
const restored = JSONToLangNode(json);
|
||||
// Reconstruye funciones de interpolacion y pluralizacion
|
||||
// Las referencias #? se conservan como strings — isLangRef() las reconoce
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Helpers publicos
|
||||
|
||||
Disponibles para adapters y consumidores externos:
|
||||
|
||||
```ts
|
||||
import {
|
||||
interpolateTemplate,
|
||||
resolveRecordFallback,
|
||||
resolvePath,
|
||||
parseLangRef,
|
||||
makeLangRecord,
|
||||
asLangString,
|
||||
deepMerge
|
||||
} from '@/ext/lang';
|
||||
|
||||
interpolateTemplate('Hola {{name}}', { name: 'Ana' }); // → 'Hola Ana'
|
||||
resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'en', 'es']); // → 'Hello'
|
||||
makeLangRecord('Hello', 'en'); // → { en: 'Hello' }
|
||||
parseLangRef('#?common.ok|fallback'); // → { path: 'common.ok', fallback: 'fallback' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npx vitest run src/lib/ext/lang/test/lang.test.ts
|
||||
```
|
||||
|
||||
136 tests cubriendo: guards, helpers, plural rules, `p()` con WeakMap, engine completo (t, ts, locale param, fallback chain, extend, register), JSON round-trip, y verificacion de que no hay locale hardcoded.
|
||||
@ -0,0 +1,77 @@
|
||||
import { createLang, type LangNode, type SupportedLocale, type LangInstance } from './lib';
|
||||
|
||||
export type { LangNode, SupportedLocale, LangInstance };
|
||||
|
||||
/**
|
||||
* Reactive wrapper around lang for Svelte 5.
|
||||
*
|
||||
* Mirrors the lang instance but overrides `getLocale` and `t` to touch
|
||||
* a `$state` variable, so any `$derived` / `$effect` that calls them
|
||||
* re-evaluates when the locale changes.
|
||||
*/
|
||||
export function createLangs<S extends LangNode>(
|
||||
schema: S,
|
||||
defaultLocale?: SupportedLocale,
|
||||
fallbackChain?: SupportedLocale[]
|
||||
) {
|
||||
const lang = createLang(schema, defaultLocale, fallbackChain);
|
||||
|
||||
let _locale = $state<SupportedLocale>(lang.getLocale());
|
||||
let _schemaVersion = $state(0);
|
||||
|
||||
lang.onLocaleChange((locale) => {
|
||||
_locale = locale;
|
||||
});
|
||||
|
||||
lang.onSchemaChange(() => {
|
||||
_schemaVersion++;
|
||||
});
|
||||
|
||||
return {
|
||||
t(path: string, params?: Record<string, any>, locale?: SupportedLocale): string {
|
||||
void _locale;
|
||||
void _schemaVersion;
|
||||
return lang.t(path, params, locale);
|
||||
},
|
||||
|
||||
ts(value: any, locale?: SupportedLocale): string {
|
||||
void _locale;
|
||||
void _schemaVersion;
|
||||
return lang.ts(value, locale);
|
||||
},
|
||||
|
||||
getLocale(): SupportedLocale {
|
||||
return _locale;
|
||||
},
|
||||
|
||||
setLocale(locale: SupportedLocale): void {
|
||||
lang.setLocale(locale);
|
||||
},
|
||||
|
||||
extend(namespace: string, module: LangNode): void {
|
||||
lang.extend(namespace, module);
|
||||
},
|
||||
|
||||
register<NS extends string, M extends LangNode>(namespace: NS, module: M) {
|
||||
return lang.register(namespace, module);
|
||||
},
|
||||
|
||||
onLocaleChange(fn: (locale: SupportedLocale) => void): () => void {
|
||||
return lang.onLocaleChange(fn);
|
||||
},
|
||||
|
||||
onSchemaChange(fn: () => void): () => void {
|
||||
return lang.onSchemaChange(fn);
|
||||
},
|
||||
|
||||
setLogger(logger: any): void {
|
||||
lang.setLogger(logger);
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
lang.dispose();
|
||||
},
|
||||
|
||||
__dispose: lang.__dispose
|
||||
} as unknown as LangInstance<S>;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
|
||||
export const ID_PREFIX = '#?';
|
||||
export const ID_FALLBACK_SEPARATOR = '|';
|
||||
export const MAX_RESOLVE_DEEP = 3;
|
||||
export const LOGGER_CATEGORY = 'lang';
|
||||
@ -0,0 +1,265 @@
|
||||
import type {
|
||||
SupportedLocale,
|
||||
LangRecord,
|
||||
LangString,
|
||||
LangInstance,
|
||||
LangLogger,
|
||||
LangNode
|
||||
} from './types.ts';
|
||||
import { LANG_ERRORS } from './errors.ts';
|
||||
import { ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP } from './consts.ts';
|
||||
import { isLangRef, isLangRecord } from './guards.ts';
|
||||
import {
|
||||
interpolateTemplate,
|
||||
resolvePath,
|
||||
parseLangRef,
|
||||
parsePathFallback,
|
||||
deepMerge
|
||||
} from './helpers.ts';
|
||||
|
||||
const DEV: boolean =
|
||||
typeof import.meta !== 'undefined' && import.meta.env != null
|
||||
? import.meta.env.DEV === true
|
||||
: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
|
||||
|
||||
export function createLang<S extends LangNode>(
|
||||
schema: S,
|
||||
defaultLocale: SupportedLocale = 'es',
|
||||
fallbackChain?: SupportedLocale[]
|
||||
): LangInstance<S> {
|
||||
let currentSchema: LangNode = schema;
|
||||
let currentLocale: SupportedLocale = defaultLocale;
|
||||
let logger: LangLogger = consoleLogger;
|
||||
let loggerSet: boolean = false;
|
||||
|
||||
const listeners = new Set<(locale: SupportedLocale) => void>();
|
||||
const schemaListeners = new Set<() => void>();
|
||||
|
||||
let __dispose: (() => void) | undefined;
|
||||
|
||||
function setLogger(external: LangLogger): void {
|
||||
if (loggerSet) {
|
||||
if (DEV) console.warn(LANG_ERRORS.LOGGER_ALREADY_SET);
|
||||
return;
|
||||
}
|
||||
logger = external;
|
||||
loggerSet = true;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function buildChain(locale: SupportedLocale): SupportedLocale[] {
|
||||
if (locale === defaultLocale) return [defaultLocale];
|
||||
const chain: SupportedLocale[] = [locale];
|
||||
for (const loc of fallbackChain ?? []) {
|
||||
if (loc !== locale && loc !== defaultLocale) chain.push(loc);
|
||||
}
|
||||
chain.push(defaultLocale);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function tsRecord(
|
||||
record: LangRecord,
|
||||
locale: SupportedLocale,
|
||||
path?: string,
|
||||
params?: any
|
||||
): string {
|
||||
const chain = buildChain(locale);
|
||||
let translation: string | undefined;
|
||||
let usedLocale: SupportedLocale | undefined;
|
||||
|
||||
for (const loc of chain) {
|
||||
const candidate = (record as any)[loc];
|
||||
if (candidate !== undefined) {
|
||||
translation = candidate;
|
||||
usedLocale = loc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (translation === undefined) translation = path ?? '';
|
||||
|
||||
translation = interpolateTemplate(translation, params);
|
||||
|
||||
if (DEV && usedLocale !== undefined && usedLocale !== locale) {
|
||||
const msg = path
|
||||
? LANG_ERRORS.MISSING_TRANSLATION(path, locale, usedLocale)
|
||||
: LANG_ERRORS.MISSING_TRANSLATION_RECORD(locale, usedLocale);
|
||||
logger.warn(LOGGER_CATEGORY, msg);
|
||||
}
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
function resolveValue(
|
||||
value: any,
|
||||
args: any[],
|
||||
locale: SupportedLocale,
|
||||
depth: number,
|
||||
visited?: Set<string>
|
||||
): any {
|
||||
if (depth > MAX_RESOLVE_DEEP) {
|
||||
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(String(value)));
|
||||
throw new Error('Circular reference in lang');
|
||||
}
|
||||
|
||||
if (isLangRef(value)) {
|
||||
const parsed = parseLangRef(value);
|
||||
const path = parsed?.path ?? value.substring(ID_PREFIX.length);
|
||||
const seen = visited ?? new Set<string>();
|
||||
if (seen.has(path)) {
|
||||
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(path));
|
||||
throw new Error('Circular reference in lang');
|
||||
}
|
||||
seen.add(path);
|
||||
const resolved = resolvePath(currentSchema, path);
|
||||
|
||||
if (resolved === undefined) {
|
||||
if (parsed?.fallback !== undefined) {
|
||||
if (DEV)
|
||||
logger.warn(
|
||||
LOGGER_CATEGORY,
|
||||
`${LANG_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".`
|
||||
);
|
||||
return parsed.fallback;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolveValue(resolved, args, locale, depth + 1, seen);
|
||||
}
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return value(args[0]);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function extractArgs(args: any[]): { params: any; locale: SupportedLocale | undefined } {
|
||||
if (args.length === 0) return { params: undefined, locale: undefined };
|
||||
|
||||
const last = args[args.length - 1];
|
||||
if (typeof last === 'string' && args.length > 1) {
|
||||
return { params: args[0], locale: last as SupportedLocale };
|
||||
}
|
||||
if (typeof last === 'string' && args.length === 1) {
|
||||
return { params: undefined, locale: last as SupportedLocale };
|
||||
}
|
||||
|
||||
if (args.length === 2 && typeof args[1] === 'string') {
|
||||
return { params: args[0], locale: args[1] as SupportedLocale };
|
||||
}
|
||||
|
||||
return { params: args[0], locale: undefined };
|
||||
}
|
||||
|
||||
const t: LangInstance<S>['t'] = (path: string, ...args: any[]): string => {
|
||||
const { params, locale: argLocale } = extractArgs(args);
|
||||
const locale = argLocale ?? currentLocale;
|
||||
|
||||
const { path: cleanPath, fallback: pathFallback } = parsePathFallback(path);
|
||||
const rawValue = resolvePath(currentSchema, cleanPath);
|
||||
|
||||
if (rawValue === undefined) {
|
||||
if (pathFallback !== undefined) {
|
||||
return interpolateTemplate(pathFallback, params);
|
||||
}
|
||||
if (DEV) logger.error(LOGGER_CATEGORY, LANG_ERRORS.KEY_NOT_FOUND(cleanPath));
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
const finalValue = resolveValue(rawValue, [params], locale, 0);
|
||||
|
||||
if (isLangRecord(finalValue)) {
|
||||
return tsRecord(finalValue, locale, cleanPath, params);
|
||||
}
|
||||
|
||||
return String(finalValue);
|
||||
};
|
||||
|
||||
function ts(value: LangString, locale?: SupportedLocale): string {
|
||||
const loc = locale ?? currentLocale;
|
||||
if (value == null) return '';
|
||||
const finalValue = resolveValue(value, [], loc, 0);
|
||||
if (isLangRecord(finalValue)) return tsRecord(finalValue, loc, undefined, undefined);
|
||||
return typeof finalValue === 'string' ? finalValue : String(finalValue);
|
||||
}
|
||||
|
||||
function extend(namespace: string, module: LangNode): 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());
|
||||
}
|
||||
|
||||
function register<NS extends string, M extends LangNode>(
|
||||
namespace: NS,
|
||||
module: M
|
||||
): LangInstance<S & { [K in NS]: M }> {
|
||||
const newSchema = {
|
||||
...(currentSchema as Record<string, any>),
|
||||
[namespace]: module
|
||||
} as S & { [K in NS]: M };
|
||||
|
||||
const extended = createLang(newSchema, defaultLocale, fallbackChain);
|
||||
extended.setLocale(currentLocale);
|
||||
|
||||
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,
|
||||
ts,
|
||||
setLocale,
|
||||
getLocale,
|
||||
onLocaleChange,
|
||||
onSchemaChange,
|
||||
extend,
|
||||
register,
|
||||
setLogger,
|
||||
dispose,
|
||||
get __dispose() {
|
||||
return __dispose;
|
||||
},
|
||||
set __dispose(fn) {
|
||||
__dispose = fn;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const consoleLogger: LangLogger = {
|
||||
warn: (_category: string, message: string) => DEV && console.warn(message),
|
||||
error: (_category: string, message: string) => DEV && console.error(message)
|
||||
};
|
||||
@ -0,0 +1,15 @@
|
||||
import type { SupportedLocale } from './types.ts';
|
||||
|
||||
export const LANG_ERRORS = {
|
||||
KEY_NOT_FOUND: (path: string): string => `[lang] Translation key not found: "${path}"`,
|
||||
|
||||
CIRCULAR_REFERENCE: (path: string): string => `[lang] Circular reference in "${path}".`,
|
||||
|
||||
MISSING_TRANSLATION: (path: string, locale: string, usedLocale: string): string =>
|
||||
`[lang] Missing translation for "${path}" in "${locale}". Falling back to "${usedLocale}".`,
|
||||
|
||||
MISSING_TRANSLATION_RECORD: (locale: string, usedLocale: string): string =>
|
||||
`[lang] Missing translation in "${locale}". Falling back to "${usedLocale}".`,
|
||||
|
||||
LOGGER_ALREADY_SET: '[lang] Logger already set. setLogger() can only be called once.'
|
||||
} as const;
|
||||
@ -0,0 +1,19 @@
|
||||
import type { LangRef, LangRecord, LangString } from './types.ts';
|
||||
import { ID_PREFIX } from './consts.ts';
|
||||
|
||||
export function isLangRef(value: unknown): value is LangRef {
|
||||
return typeof value === 'string' && value.startsWith(ID_PREFIX) && value.length > 2;
|
||||
}
|
||||
|
||||
export function isLangRecord(value: unknown): value is LangRecord {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
if (Array.isArray(value)) return false;
|
||||
if (typeof value === 'function') return false;
|
||||
const vals = Object.values(value as Record<string, unknown>);
|
||||
if (vals.length === 0) return false;
|
||||
return vals.every((v) => typeof v === 'string' || v === undefined);
|
||||
}
|
||||
|
||||
export function isLangString(value: unknown): value is LangString {
|
||||
return isLangRef(value) || isLangRecord(value) || typeof value === 'string';
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import type { LangRecord, LangString, SupportedLocale } from './types.ts';
|
||||
import { ID_FALLBACK_SEPARATOR, ID_PREFIX } from './consts.ts';
|
||||
import { isLangString } from './guards.ts';
|
||||
|
||||
export function resolvePath(obj: any, path: string): any {
|
||||
return path.split('.').reduce((acc, key) => acc?.[key], obj);
|
||||
}
|
||||
|
||||
export function parseLangRef(value: string): { path: string; fallback?: string } | null {
|
||||
if (!value.startsWith(ID_PREFIX) || value.length <= ID_PREFIX.length) return null;
|
||||
|
||||
const raw = value.slice(ID_PREFIX.length);
|
||||
const separatorIndex = raw.indexOf(ID_FALLBACK_SEPARATOR);
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
return { path: raw };
|
||||
}
|
||||
|
||||
return {
|
||||
path: raw.slice(0, separatorIndex),
|
||||
fallback: raw.slice(separatorIndex + 1)
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePathFallback(path: string): { path: string; fallback?: string } {
|
||||
const separatorIndex = path.indexOf(ID_FALLBACK_SEPARATOR);
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
return { path };
|
||||
}
|
||||
|
||||
return {
|
||||
path: path.slice(0, separatorIndex),
|
||||
fallback: path.slice(separatorIndex + 1)
|
||||
};
|
||||
}
|
||||
|
||||
export function makeLangRecord(text: string, locale: SupportedLocale): LangRecord {
|
||||
return { [locale]: text } as LangRecord;
|
||||
}
|
||||
|
||||
export function asLangString(value: unknown): LangString {
|
||||
if (value == null) return '';
|
||||
if (isLangString(value)) return value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function interpolateTemplate(text: string, params?: Record<string, any>): string {
|
||||
if (!params) return text;
|
||||
let result = text;
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
result = result.replaceAll(`{{${key}}}`, String(val));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveRecordFallback(
|
||||
record: Partial<Record<SupportedLocale, string>>,
|
||||
chain: SupportedLocale[]
|
||||
): string | undefined {
|
||||
for (const locale of chain) {
|
||||
const val = record[locale];
|
||||
if (val !== undefined) return val;
|
||||
}
|
||||
return Object.values(record).find((v) => typeof v === 'string');
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
|
||||
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
|
||||
* See `src/lib/README.md` for the migration map.
|
||||
*/
|
||||
export * from './consts.ts';
|
||||
export * from './guards.ts';
|
||||
export * from './types.ts';
|
||||
export * from './plural_rules.ts';
|
||||
export * from './plural.ts';
|
||||
export * from './helpers.ts';
|
||||
export * from './engine.ts';
|
||||
export * from './json.ts';
|
||||
export * from './errors.ts';
|
||||
@ -0,0 +1,58 @@
|
||||
import type { LangNode } from './types.ts';
|
||||
import { p } from './plural.ts';
|
||||
import { getPluralConfig } from './plural.ts';
|
||||
import { isLangRecord } from './guards.ts';
|
||||
|
||||
export function langNodeToJSON(node: LangNode | any): any {
|
||||
if (typeof node === 'string') return node;
|
||||
|
||||
if (typeof node === 'function') {
|
||||
const config = getPluralConfig(node);
|
||||
if (config) {
|
||||
return {
|
||||
__type: 'plural',
|
||||
config: config
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const proxyParams = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, prop) => `{{${String(prop)}}}`
|
||||
}
|
||||
);
|
||||
return langNodeToJSON(node(proxyParams));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof node === 'object' && node !== null) {
|
||||
const result: Record<string, any> = {};
|
||||
for (const key in node) {
|
||||
result[key] = langNodeToJSON(node[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export function JSONToLangNode(json: any): LangNode {
|
||||
if (typeof json === 'string') return json as LangNode;
|
||||
|
||||
if (typeof json === 'object' && json !== null) {
|
||||
if (json.__type === 'plural' && json.config) {
|
||||
return p(json.config) as unknown as LangNode;
|
||||
}
|
||||
|
||||
const result: any = {};
|
||||
for (const key in json) {
|
||||
result[key] = JSONToLangNode(json[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return json as LangNode;
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import type { PluralConfig, LangRecord, SupportedLocale } from './types.ts';
|
||||
import type { PluralCategory } from './plural_rules.ts';
|
||||
import { pluralRule } from './plural_rules.ts';
|
||||
import type { PluralForms } from './types.ts';
|
||||
|
||||
const pluralConfigs = new WeakMap<Function, PluralConfig>();
|
||||
|
||||
export const p = (config: PluralConfig) => {
|
||||
const cache = new Map<string, LangRecord>();
|
||||
|
||||
const fn = (params: { count: number } & Record<string, any>): LangRecord => {
|
||||
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: PluralCategory = pluralRule(locale, params.count);
|
||||
const typedForms = forms as PluralForms;
|
||||
result[locale] = typedForms[rule] || typedForms.other;
|
||||
}
|
||||
|
||||
cache.set(key, result as LangRecord);
|
||||
return result as LangRecord;
|
||||
};
|
||||
|
||||
pluralConfigs.set(fn, config);
|
||||
return fn;
|
||||
};
|
||||
|
||||
export function getPluralConfig(fn: Function): PluralConfig | undefined {
|
||||
return pluralConfigs.get(fn);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
|
||||
|
||||
export function pluralRule(locale: string, n: number): PluralCategory {
|
||||
if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') {
|
||||
try {
|
||||
return new Intl.PluralRules(locale).select(n) as PluralCategory;
|
||||
} catch {
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import type { ID_PREFIX } from './consts.ts';
|
||||
import type { PluralCategory } from './plural_rules.ts';
|
||||
|
||||
export type SupportedLocale = 'es' | 'en' | 'ar' | 'de' | 'fr' | 'it' | 'pt' | 'ca' | 'eu' | 'gl';
|
||||
|
||||
export type LangRef = `${typeof ID_PREFIX}${string}`;
|
||||
|
||||
export type LangRecord = {
|
||||
[K in SupportedLocale]?: string;
|
||||
};
|
||||
|
||||
export type LangFn<P = any> = (params: P) => LangRecord;
|
||||
|
||||
export type LangPluralFn<P = any> = (params: { count: number } & P) => LangRecord;
|
||||
|
||||
export type LangValue<P = any> = LangRecord | LangFn<P> | LangPluralFn<P> | LangRef;
|
||||
|
||||
export type LangNode = LangValue | { [key: string]: LangNode };
|
||||
|
||||
export type LangString = string | LangRecord | LangRef;
|
||||
|
||||
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
export type LeafPaths<T, D extends number = 6> = [D] extends [never]
|
||||
? never
|
||||
: T extends LangValue
|
||||
? ''
|
||||
: T extends object
|
||||
? {
|
||||
[K in keyof T & string]: T[K] extends LangValue
|
||||
? 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;
|
||||
|
||||
export type ParamsFor<T> = T extends (params: infer P) => any ? P : never;
|
||||
|
||||
export type HasParams<T> = T extends (params: any) => any ? true : false;
|
||||
|
||||
export type PluralForms = Partial<Record<PluralCategory, string>> & { other: string };
|
||||
|
||||
export type PluralConfig = {
|
||||
[K in SupportedLocale]?: PluralForms;
|
||||
};
|
||||
|
||||
export type LangInstance<S extends LangNode = LangNode> = {
|
||||
t: {
|
||||
<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
|
||||
path: P,
|
||||
params?: HasParams<TType> extends true ? ParamsFor<TType> : Record<string, any>,
|
||||
locale?: SupportedLocale
|
||||
): string;
|
||||
(path: string, params?: Record<string, any>, locale?: SupportedLocale): string;
|
||||
};
|
||||
|
||||
ts: (value: LangString, locale?: SupportedLocale) => string;
|
||||
|
||||
setLocale: (locale: SupportedLocale) => void;
|
||||
getLocale: () => SupportedLocale;
|
||||
onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void;
|
||||
onSchemaChange: (fn: () => void) => () => void;
|
||||
extend: (namespace: string, module: LangNode) => void;
|
||||
register: <NS extends string, M extends LangNode>(
|
||||
namespace: NS,
|
||||
module: M
|
||||
) => LangInstance<S & { [K in NS]: M }>;
|
||||
setLogger: (logger: LangLogger) => void;
|
||||
dispose: () => void;
|
||||
__dispose: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export interface LangLogger {
|
||||
warn: (category: string, message: string) => void;
|
||||
error: (category: string, message: string) => void;
|
||||
}
|
||||
@ -0,0 +1,880 @@
|
||||
/**
|
||||
* lang — test suite
|
||||
*
|
||||
* Covers:
|
||||
* - guards (isLangRef, isLangRecord, isLangString)
|
||||
* - helpers (resolvePath, parseLangRef, makeLangRecord, interpolateTemplate, resolveRecordFallback, deepMerge)
|
||||
* - plural-rules (pluralRule via Intl.PluralRules)
|
||||
* - plural (p, getPluralConfig)
|
||||
* - engine (createLang: t, ts, fallback chain, locale param, extend, register, setLocale)
|
||||
* - json (langNodeToJSON, JSONToLangNode)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { isLangRef, isLangRecord, isLangString } from '../lib/guards';
|
||||
import {
|
||||
parseLangRef,
|
||||
parsePathFallback,
|
||||
resolvePath,
|
||||
makeLangRecord,
|
||||
interpolateTemplate,
|
||||
resolveRecordFallback,
|
||||
deepMerge
|
||||
} from '../lib/helpers';
|
||||
import { pluralRule } from '../lib/plural_rules';
|
||||
import { p, getPluralConfig } from '../lib/plural';
|
||||
import { createLang } from '../lib/engine';
|
||||
import { langNodeToJSON, JSONToLangNode } from '../lib/json';
|
||||
|
||||
// ============================================================================
|
||||
// GUARDS
|
||||
// ============================================================================
|
||||
|
||||
describe('isLangRef', () => {
|
||||
it('returns true for a valid reference', () => {
|
||||
expect(isLangRef('#?common.ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for prefix only without path', () => {
|
||||
expect(isLangRef('#?')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for normal strings', () => {
|
||||
expect(isLangRef('hola')).toBe(false);
|
||||
expect(isLangRef('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-strings', () => {
|
||||
expect(isLangRef(null)).toBe(false);
|
||||
expect(isLangRef(42)).toBe(false);
|
||||
expect(isLangRef({})).toBe(false);
|
||||
expect(isLangRef(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for references with nested paths', () => {
|
||||
expect(isLangRef('#?a.b.c')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLangRecord', () => {
|
||||
it('returns true for a record with es and en', () => {
|
||||
expect(isLangRecord({ es: 'Aceptar', en: 'OK' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with only en — no hardcoded locale required', () => {
|
||||
expect(isLangRecord({ en: 'Hello' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with only fr', () => {
|
||||
expect(isLangRecord({ fr: 'Bonjour' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with only es', () => {
|
||||
expect(isLangRecord({ es: 'Hola' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty objects', () => {
|
||||
expect(isLangRecord({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if values are not strings', () => {
|
||||
expect(isLangRecord({ es: 42 })).toBe(false);
|
||||
expect(isLangRecord({ es: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for arrays', () => {
|
||||
expect(isLangRecord(['es', 'Hola'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for functions', () => {
|
||||
expect(isLangRecord(() => ({ es: 'x' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for primitives', () => {
|
||||
expect(isLangRecord(null)).toBe(false);
|
||||
expect(isLangRecord('hola')).toBe(false);
|
||||
expect(isLangRecord(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLangString', () => {
|
||||
it('accepts plain strings', () => {
|
||||
expect(isLangString('hola')).toBe(true);
|
||||
expect(isLangString('')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts LangRecord', () => {
|
||||
expect(isLangString({ en: 'Hello' })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts LangRef', () => {
|
||||
expect(isLangString('#?common.ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects null and undefined', () => {
|
||||
expect(isLangString(null)).toBe(false);
|
||||
expect(isLangString(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects numbers', () => {
|
||||
expect(isLangString(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
describe('parseLangRef', () => {
|
||||
it('extracts path without fallback', () => {
|
||||
expect(parseLangRef('#?common.ok')).toEqual({ path: 'common.ok' });
|
||||
});
|
||||
|
||||
it('extracts path and fallback literal', () => {
|
||||
expect(parseLangRef('#?terra.calendar.month|month')).toEqual({
|
||||
path: 'terra.calendar.month',
|
||||
fallback: 'month'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for non-ref strings', () => {
|
||||
expect(parseLangRef('terra.calendar.month')).toBeNull();
|
||||
expect(parseLangRef('#?')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePathFallback', () => {
|
||||
it('extracts path without fallback', () => {
|
||||
expect(parsePathFallback('common.ok')).toEqual({ path: 'common.ok' });
|
||||
});
|
||||
|
||||
it('extracts path and fallback', () => {
|
||||
expect(parsePathFallback('home.options|Opciones')).toEqual({
|
||||
path: 'home.options',
|
||||
fallback: 'Opciones'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns full string as path when no separator', () => {
|
||||
expect(parsePathFallback('noseparator')).toEqual({ path: 'noseparator' });
|
||||
});
|
||||
|
||||
it('handles empty fallback', () => {
|
||||
expect(parsePathFallback('path|')).toEqual({ path: 'path', fallback: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePath', () => {
|
||||
const obj = { a: { b: { c: 'valor' } } };
|
||||
|
||||
it('resolves nested paths', () => {
|
||||
expect(resolvePath(obj, 'a.b.c')).toBe('valor');
|
||||
});
|
||||
|
||||
it('resolves one level', () => {
|
||||
expect(resolvePath(obj, 'a')).toEqual({ b: { c: 'valor' } });
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent paths', () => {
|
||||
expect(resolvePath(obj, 'a.x.y')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty path', () => {
|
||||
expect(resolvePath(obj, '')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not throw with null intermediate objects', () => {
|
||||
expect(resolvePath({ a: null }, 'a.b')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeLangRecord', () => {
|
||||
it('creates a record with specified locale', () => {
|
||||
const record = makeLangRecord('Hola', 'es');
|
||||
expect(record.es).toBe('Hola');
|
||||
});
|
||||
|
||||
it('creates a record with en locale', () => {
|
||||
const record = makeLangRecord('Hello', 'en');
|
||||
expect(record.en).toBe('Hello');
|
||||
expect((record as any).es).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateTemplate', () => {
|
||||
it('replaces {{key}} with value', () => {
|
||||
expect(interpolateTemplate('Hello {{name}}', { name: 'World' })).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('replaces multiple keys', () => {
|
||||
expect(interpolateTemplate('{{greeting}} {{name}}', { greeting: 'Hi', name: 'Ana' })).toBe(
|
||||
'Hi Ana'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns text unchanged if no params', () => {
|
||||
expect(interpolateTemplate('Hello')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('handles special regex chars in keys', () => {
|
||||
expect(interpolateTemplate('Code: {{code}}', { code: 'A+B' })).toBe('Code: A+B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRecordFallback', () => {
|
||||
it('returns value for first locale in chain', () => {
|
||||
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['en', 'es'])).toBe('Hello');
|
||||
});
|
||||
|
||||
it('falls back through chain', () => {
|
||||
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'en', 'es'])).toBe('Hello');
|
||||
});
|
||||
|
||||
it('falls back to last resort', () => {
|
||||
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'de', 'es'])).toBe('Hola');
|
||||
});
|
||||
|
||||
it('returns undefined for empty record', () => {
|
||||
expect(resolveRecordFallback({}, ['es'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses Object.values fallback when chain misses', () => {
|
||||
expect(resolveRecordFallback({ fr: 'Bonjour' }, ['de'])).toBe('Bonjour');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMerge', () => {
|
||||
it('merges flat objects', () => {
|
||||
expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('deep merges nested objects', () => {
|
||||
expect(deepMerge({ a: { x: 1, y: 2 } }, { a: { y: 3, z: 4 } })).toEqual({
|
||||
a: { x: 1, y: 3, z: 4 }
|
||||
});
|
||||
});
|
||||
|
||||
it('overwrites primitives with source', () => {
|
||||
expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PLURAL RULES (Intl.PluralRules)
|
||||
// ============================================================================
|
||||
|
||||
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'));
|
||||
});
|
||||
|
||||
describe('inglés (en) — one/other', () => {
|
||||
it('1 → one', () => expect(pluralRule('en', 1)).toBe('one'));
|
||||
it('2 → other', () => expect(pluralRule('en', 2)).toBe('other'));
|
||||
});
|
||||
|
||||
describe('árabe (ar) — 6 forms', () => {
|
||||
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/many/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 → many', () => expect(pluralRule('ru', 5)).toBe('many'));
|
||||
it('0 → many', () => expect(pluralRule('ru', 0)).toBe('many'));
|
||||
it('11 → many', () => expect(pluralRule('ru', 11)).toBe('many'));
|
||||
});
|
||||
|
||||
describe('locale desconocido — Intl.PluralRules uses root rules', () => {
|
||||
it('xx, 1 → one (Intl falls back to root)', () => {
|
||||
const result = pluralRule('xx', 1);
|
||||
expect(['one', 'other']).toContain(result);
|
||||
});
|
||||
it('xx, 5 → other', () => expect(pluralRule('xx', 5)).toBe('other'));
|
||||
});
|
||||
|
||||
describe('normalización con región', () => {
|
||||
it('es-ES → one', () => expect(pluralRule('es-ES', 1)).toBe('one'));
|
||||
it('en-US → one', () => expect(pluralRule('en-US', 1)).toBe('one'));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PLURAL — p() with WeakMap
|
||||
// ============================================================================
|
||||
|
||||
describe('p()', () => {
|
||||
const plural = p({
|
||||
es: { one: '{{count}} elemento', other: '{{count}} elementos' },
|
||||
en: { one: '{{count}} item', other: '{{count}} items' }
|
||||
});
|
||||
|
||||
it('selects singular form for 1', () => {
|
||||
const record = plural({ count: 1 });
|
||||
expect(record.es).toBe('{{count}} elemento');
|
||||
});
|
||||
|
||||
it('selects plural form for 2', () => {
|
||||
const record = plural({ count: 2 });
|
||||
expect(record.es).toBe('{{count}} elementos');
|
||||
});
|
||||
|
||||
it('caches by count', () => {
|
||||
const r1 = plural({ count: 5 });
|
||||
const r2 = plural({ count: 5 });
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
|
||||
it('caches floats correctly', () => {
|
||||
const r1 = plural({ count: 1.5 });
|
||||
const r2 = plural({ count: 1.5 });
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
|
||||
it('uses other as fallback when form missing', () => {
|
||||
const fallback = p({ es: { other: 'varios' } });
|
||||
const record = fallback({ count: 1 });
|
||||
expect(record.es).toBe('varios');
|
||||
});
|
||||
|
||||
it('getPluralConfig retrieves config via WeakMap', () => {
|
||||
expect(getPluralConfig(plural)).toBeDefined();
|
||||
expect(getPluralConfig(plural)!.es).toBeDefined();
|
||||
});
|
||||
|
||||
it('getPluralConfig returns undefined for non-plural functions', () => {
|
||||
const regular = () => ({ es: 'x' });
|
||||
expect(getPluralConfig(regular)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('no __pluralConfig on the function — uses WeakMap instead', () => {
|
||||
expect((plural as any).__pluralConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — createLang
|
||||
// ============================================================================
|
||||
|
||||
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' }
|
||||
}
|
||||
},
|
||||
onlyEn: { en: 'English only' },
|
||||
onlyFr: { fr: 'Francais seulement' }
|
||||
} as const;
|
||||
|
||||
describe('createLang — t()', () => {
|
||||
let lang: ReturnType<typeof createLang<typeof schema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
lang = createLang(schema, 'es');
|
||||
});
|
||||
|
||||
it('translates a simple key to default locale', () => {
|
||||
expect(lang.t('common.ok')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('translates to active locale after setLocale', () => {
|
||||
lang.setLocale('en');
|
||||
expect(lang.t('common.ok')).toBe('OK');
|
||||
});
|
||||
|
||||
it('returns path if key does not exist', () => {
|
||||
expect((lang.t as any)('no.existe')).toBe('no.existe');
|
||||
});
|
||||
|
||||
it('translates with interpolation', () => {
|
||||
expect((lang.t as any)('greet', { name: 'Ana' })).toBe('Hola, Ana');
|
||||
});
|
||||
|
||||
it('translates with interpolation in alternative locale', () => {
|
||||
lang.setLocale('en');
|
||||
expect((lang.t as any)('greet', { name: 'Ana' })).toBe('Hello, Ana');
|
||||
});
|
||||
|
||||
it('resolves LangRef references (#?)', () => {
|
||||
expect((lang.t as any)('ref')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('translates deep nested paths', () => {
|
||||
expect(lang.t('nested.deep.value')).toBe('Profundo');
|
||||
});
|
||||
|
||||
it('translates plurals — one', () => {
|
||||
expect((lang.t as any)('messages.unread', { count: 1 })).toBe('1 mensaje sin leer');
|
||||
});
|
||||
|
||||
it('translates plurals — other', () => {
|
||||
expect((lang.t as any)('messages.unread', { count: 5 })).toBe('5 mensajes sin leer');
|
||||
});
|
||||
|
||||
it('translates plurals in alternative locale', () => {
|
||||
lang.setLocale('en');
|
||||
expect((lang.t as any)('messages.unread', { count: 1 })).toBe('1 unread message');
|
||||
expect((lang.t as any)('messages.unread', { count: 3 })).toBe('3 unread messages');
|
||||
});
|
||||
|
||||
it('falls back to default locale when translation missing', () => {
|
||||
lang.setLocale('en');
|
||||
expect(lang.t('common.ok')).toBe('OK');
|
||||
});
|
||||
|
||||
it('returns fallback text when path has | and key not found', () => {
|
||||
expect((lang.t as any)('missing.key|Texto alternativo')).toBe('Texto alternativo');
|
||||
});
|
||||
|
||||
it('returns resolved value when path has | and key exists', () => {
|
||||
expect((lang.t as any)('common.ok|Fallback')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('interpolates params in fallback text', () => {
|
||||
expect((lang.t as any)('missing.greet|Hola {{name}}', { name: 'Ana' })).toBe('Hola Ana');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — t() with locale parameter (replaces tForLocale)
|
||||
// ============================================================================
|
||||
|
||||
describe('createLang — t() with explicit locale parameter', () => {
|
||||
let lang: ReturnType<typeof createLang<typeof schema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
lang = createLang(schema, 'es');
|
||||
});
|
||||
|
||||
it('translates to explicit locale without changing active locale', () => {
|
||||
lang.setLocale('es');
|
||||
expect((lang.t as any)('common.ok', undefined, 'en')).toBe('OK');
|
||||
expect(lang.getLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('translates with params and explicit locale', () => {
|
||||
expect((lang.t as any)('greet', { name: 'Ana' }, 'en')).toBe('Hello, Ana');
|
||||
expect(lang.getLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('is reentrant — does not corrupt state on nested calls', () => {
|
||||
lang.setLocale('es');
|
||||
expect((lang.t as any)('common.ok', undefined, 'en')).toBe('OK');
|
||||
expect((lang.t as any)('common.ok')).toBe('Aceptar');
|
||||
expect(lang.getLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('restores state after circular reference throws', () => {
|
||||
const circular = { a: '#?a' } as any;
|
||||
const l = createLang(circular, 'es');
|
||||
expect(() => (l.t as any)('a', undefined, 'en')).toThrow();
|
||||
expect(l.getLocale()).toBe('es');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — ts() with locale parameter
|
||||
// ============================================================================
|
||||
|
||||
describe('createLang — ts()', () => {
|
||||
let lang: ReturnType<typeof createLang<typeof schema>>;
|
||||
|
||||
beforeEach(() => {
|
||||
lang = createLang(schema, 'es');
|
||||
});
|
||||
|
||||
it('translates a LangRecord directly', () => {
|
||||
expect(lang.ts({ es: 'Directo', en: 'Direct' })).toBe('Directo');
|
||||
});
|
||||
|
||||
it('translates a LangRecord with explicit locale', () => {
|
||||
expect(lang.ts({ es: 'Directo', en: 'Direct' }, 'en')).toBe('Direct');
|
||||
});
|
||||
|
||||
it('translates with explicit locale without changing active locale', () => {
|
||||
lang.setLocale('es');
|
||||
expect(lang.ts({ es: 'Hola', en: 'Hello' }, 'en')).toBe('Hello');
|
||||
expect(lang.getLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('returns plain strings as-is', () => {
|
||||
expect(lang.ts('texto plano')).toBe('texto plano');
|
||||
});
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(lang.ts(null as any)).toBe('');
|
||||
});
|
||||
|
||||
it('resolves LangRef references', () => {
|
||||
expect(lang.ts('#?common.ok')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('uses fallback literal when ref key does not exist', () => {
|
||||
expect(lang.ts('#?terra.calendar.month|month')).toBe('month');
|
||||
});
|
||||
|
||||
it('prioritizes schema key over fallback literal', () => {
|
||||
expect(lang.ts('#?common.ok|fallback')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('keeps normal strings as literals — no implicit lookup', () => {
|
||||
expect(lang.ts('terra.calendar.month')).toBe('terra.calendar.month');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — FALLBACK CHAIN
|
||||
// ============================================================================
|
||||
|
||||
describe('createLang — fallback chain', () => {
|
||||
it('falls back through chain: current → fallback → default', () => {
|
||||
const lang = createLang(schema, 'es', ['en']);
|
||||
lang.setLocale('fr');
|
||||
expect(lang.t('common.ok')).toBe('OK');
|
||||
});
|
||||
|
||||
it('uses current locale when available', () => {
|
||||
const lang = createLang(schema, 'es', ['en']);
|
||||
lang.setLocale('en');
|
||||
expect(lang.t('common.ok')).toBe('OK');
|
||||
});
|
||||
|
||||
it('skips chain locales already equal to current or default', () => {
|
||||
const lang = createLang(schema, 'es', ['es']);
|
||||
lang.setLocale('en');
|
||||
expect(lang.t('common.ok')).toBe('OK');
|
||||
});
|
||||
|
||||
it('falls back to default when chain and current miss', () => {
|
||||
const lang = createLang(schema, 'es', ['de']);
|
||||
lang.setLocale('fr');
|
||||
expect(lang.t('common.ok')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('key with only en: fr → en(chain) → es(default)', () => {
|
||||
const lang = createLang(schema, 'es', ['en']);
|
||||
lang.setLocale('fr');
|
||||
expect((lang.t as any)('onlyEn')).toBe('English only');
|
||||
});
|
||||
|
||||
it('key with only fr: en → es(default) — fr not in chain', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
lang.setLocale('en');
|
||||
expect((lang.t as any)('onlyFr')).toBe('onlyFr');
|
||||
});
|
||||
|
||||
it('explicit locale parameter uses chain', () => {
|
||||
const lang = createLang(schema, 'es', ['en']);
|
||||
expect((lang.t as any)('common.ok', undefined, 'fr')).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — setLocale / getLocale / onLocaleChange
|
||||
// ============================================================================
|
||||
|
||||
describe('createLang — setLocale / getLocale / onLocaleChange', () => {
|
||||
it('getLocale returns initial locale', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
expect(lang.getLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('setLocale notifies listeners', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
const spy = vi.fn();
|
||||
lang.onLocaleChange(spy);
|
||||
lang.setLocale('en');
|
||||
expect(spy).toHaveBeenCalledWith('en');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unsubscribe stops notifications', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
const spy = vi.fn();
|
||||
const unsub = lang.onLocaleChange(spy);
|
||||
unsub();
|
||||
lang.setLocale('en');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — extend()
|
||||
// ============================================================================
|
||||
|
||||
describe('createLang — extend()', () => {
|
||||
it('adds a simple namespace', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
lang.extend('shop', { product: { es: 'Producto', en: 'Product' } });
|
||||
expect((lang.t as any)('shop.product')).toBe('Producto');
|
||||
});
|
||||
|
||||
it('adds with dotted namespace', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
lang.extend('app.settings', { title: { es: 'Ajustes', en: 'Settings' } });
|
||||
expect((lang.t as any)('app.settings.title')).toBe('Ajustes');
|
||||
});
|
||||
|
||||
it('deep-merges without overwriting existing keys', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
lang.extend('common', { extra: { es: 'Extra', en: 'Extra' } });
|
||||
expect(lang.t('common.ok')).toBe('Aceptar');
|
||||
expect((lang.t as any)('common.extra')).toBe('Extra');
|
||||
});
|
||||
|
||||
it('notifies schema listeners', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
const spy = vi.fn();
|
||||
lang.onSchemaChange(spy);
|
||||
lang.extend('shop', { product: { es: 'Producto' } });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unsubscribe stops schema notifications', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
const spy = vi.fn();
|
||||
const unsub = lang.onSchemaChange(spy);
|
||||
unsub();
|
||||
lang.extend('shop', { product: { es: 'Producto' } });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — register()
|
||||
// ============================================================================
|
||||
|
||||
describe('createLang — register()', () => {
|
||||
it('returns a new instance with namespace', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
const extended = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
|
||||
expect((extended.t as any)('shop.product')).toBe('Producto');
|
||||
});
|
||||
|
||||
it('child syncs locale with parent', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
const child = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
|
||||
lang.setLocale('en');
|
||||
expect((child.t as any)('shop.product')).toBe('Product');
|
||||
});
|
||||
|
||||
it('dispose() removes parent listener', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
const child = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
|
||||
child.dispose();
|
||||
lang.setLocale('en');
|
||||
expect(child.getLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('original instance is not affected by register', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
lang.register('shop', { product: { es: 'Producto' } });
|
||||
expect((lang.t as any)('shop.product')).toBe('shop.product');
|
||||
});
|
||||
|
||||
it('child inherits fallback chain from parent', () => {
|
||||
const lang = createLang(schema, 'es', ['en']);
|
||||
const child = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
|
||||
lang.setLocale('fr');
|
||||
expect((child.t as any)('shop.product')).toBe('Product');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — setLogger()
|
||||
// ============================================================================
|
||||
|
||||
describe('createLang — setLogger()', () => {
|
||||
it('replaces default logger', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
const warn = vi.fn();
|
||||
const error = vi.fn();
|
||||
lang.setLogger({ warn, error });
|
||||
expect(lang.getLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('setLogger() can only be called once', () => {
|
||||
const lang = createLang(schema, 'es');
|
||||
const firstWarn = vi.fn();
|
||||
const secondWarn = vi.fn();
|
||||
lang.setLogger({ warn: firstWarn, error: vi.fn() });
|
||||
lang.setLogger({ warn: secondWarn, error: vi.fn() });
|
||||
expect(lang.getLocale()).toBe('es');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — NO RIGIDEZ: records sin locale obligatorio
|
||||
// ============================================================================
|
||||
|
||||
describe('createLang — no hardcoded default locale', () => {
|
||||
it('works with en as default locale', () => {
|
||||
const enSchema = {
|
||||
greeting: { en: 'Hello' }
|
||||
} as const;
|
||||
const lang = createLang(enSchema, 'en');
|
||||
expect(lang.t('greeting')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('works with fr as default locale', () => {
|
||||
const frSchema = {
|
||||
greeting: { fr: 'Bonjour' }
|
||||
} as const;
|
||||
const lang = createLang(frSchema, 'fr');
|
||||
expect(lang.t('greeting')).toBe('Bonjour');
|
||||
});
|
||||
|
||||
it('PluralConfig without es — only en', () => {
|
||||
const plural = p({
|
||||
en: { one: '{{count}} item', other: '{{count}} items' }
|
||||
});
|
||||
const record = plural({ count: 1 });
|
||||
expect(record.en).toBe('{{count}} item');
|
||||
});
|
||||
|
||||
it('createLang with en default and fallback chain to fr', () => {
|
||||
const s = {
|
||||
msg: { en: 'Hello', fr: 'Bonjour' }
|
||||
} as const;
|
||||
const lang = createLang(s, 'en', ['fr']);
|
||||
expect(lang.t('msg')).toBe('Hello');
|
||||
lang.setLocale('fr');
|
||||
expect(lang.t('msg')).toBe('Bonjour');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// JSON — langNodeToJSON / JSONToLangNode
|
||||
// ============================================================================
|
||||
|
||||
describe('langNodeToJSON', () => {
|
||||
it('serializes plain strings', () => {
|
||||
expect(langNodeToJSON('hola')).toBe('hola');
|
||||
});
|
||||
|
||||
it('serializes LangRef as strings', () => {
|
||||
expect(langNodeToJSON('#?common.ok')).toBe('#?common.ok');
|
||||
});
|
||||
|
||||
it('serializes LangRecord as plain object', () => {
|
||||
const result = langNodeToJSON({ en: 'Hello' });
|
||||
expect(result).toEqual({ en: 'Hello' });
|
||||
});
|
||||
|
||||
it('serializes namespaces recursively', () => {
|
||||
const node = { common: { ok: { es: 'Aceptar', en: 'OK' } } };
|
||||
const result = langNodeToJSON(node);
|
||||
expect(result.common.ok).toEqual({ es: 'Aceptar', en: 'OK' });
|
||||
});
|
||||
|
||||
it('serializes interpolation functions with {{tokens}}', () => {
|
||||
const fn = (p: any) => ({ es: `Hola ${p.name}`, en: `Hello ${p.name}` });
|
||||
const result = langNodeToJSON(fn);
|
||||
expect(result.es).toBe('Hola {{name}}');
|
||||
expect(result.en).toBe('Hello {{name}}');
|
||||
});
|
||||
|
||||
it('serializes plurals with __type: plural via WeakMap', () => {
|
||||
const plural = p({
|
||||
en: { one: '1 item', other: '{{count}} items' }
|
||||
});
|
||||
const result = langNodeToJSON(plural);
|
||||
expect(result.__type).toBe('plural');
|
||||
expect(result.config).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns {} if interpolation function throws', () => {
|
||||
const broken = () => {
|
||||
throw new Error('broken');
|
||||
};
|
||||
expect(langNodeToJSON(broken)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSONToLangNode', () => {
|
||||
it('reconstructs plain strings', () => {
|
||||
expect(JSONToLangNode('hola')).toBe('hola');
|
||||
});
|
||||
|
||||
it('reconstructs LangRecord', () => {
|
||||
const result = JSONToLangNode({ en: 'Hello' });
|
||||
expect(result).toEqual({ en: 'Hello' });
|
||||
});
|
||||
|
||||
it('reconstructs plurals as functions', () => {
|
||||
const config = {
|
||||
en: { one: '{{count}} item', other: '{{count}} items' }
|
||||
};
|
||||
const result = JSONToLangNode({ __type: 'plural', config }) as any;
|
||||
expect(typeof result).toBe('function');
|
||||
expect(result({ count: 1 }).en).toBe('{{count}} item');
|
||||
});
|
||||
|
||||
it('reconstructs namespaces recursively', () => {
|
||||
const json = { common: { ok: { en: 'OK' } } };
|
||||
const result = JSONToLangNode(json) as any;
|
||||
expect(result.common.ok.en).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip langNodeToJSON → JSONToLangNode', () => {
|
||||
it('LangRecord survives round-trip', () => {
|
||||
const original = { en: 'Hello' };
|
||||
const json = langNodeToJSON(original);
|
||||
const restored = JSONToLangNode(json) as any;
|
||||
expect(restored.en).toBe('Hello');
|
||||
});
|
||||
|
||||
it('plural survives round-trip and works', () => {
|
||||
const original = p({ en: { one: '1 item', other: '{{count}} items' } });
|
||||
const json = langNodeToJSON(original);
|
||||
const restored = JSONToLangNode(json) as any;
|
||||
expect(typeof restored).toBe('function');
|
||||
expect(restored({ count: 1 }).en).toBe('1 item');
|
||||
expect(restored({ count: 5 }).en).toBe('{{count}} items');
|
||||
});
|
||||
|
||||
it('LangRef survives round-trip', () => {
|
||||
const json = langNodeToJSON('#?common.ok');
|
||||
const restored = JSONToLangNode(json);
|
||||
expect(restored).toBe('#?common.ok');
|
||||
expect(isLangRef(restored)).toBe(true);
|
||||
});
|
||||
|
||||
it('full tree survives round-trip', () => {
|
||||
const node = {
|
||||
common: { ok: { es: 'Aceptar', en: 'OK' } },
|
||||
ref: '#?common.ok',
|
||||
items: p({ en: { one: '1 item', other: '{{count}} items' } })
|
||||
};
|
||||
const json = langNodeToJSON(node);
|
||||
const restored = JSONToLangNode(json) as any;
|
||||
|
||||
expect(restored.common.ok.en).toBe('OK');
|
||||
expect(restored.ref).toBe('#?common.ok');
|
||||
expect(typeof restored.items).toBe('function');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
|
||||
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
|
||||
* See `src/lib/README.md` for the migration map.
|
||||
*/
|
||||
export { createMoney, type MoneyOptions } from './money.svelte';
|
||||
export { createRates, type RateFetcher, type RatesOptions } from './rates';
|
||||
export { resolveCurrency } from './locale-defaults';
|
||||
export type { MoneyRatesProvider, RateEntry } from './types';
|
||||
@ -0,0 +1,28 @@
|
||||
const LOCALE_CURRENCY: Record<string, string> = {
|
||||
es: 'EUR',
|
||||
en: 'USD',
|
||||
'en-US': 'USD',
|
||||
'en-GB': 'GBP',
|
||||
'en-AU': 'AUD',
|
||||
'en-CA': 'CAD',
|
||||
de: 'EUR',
|
||||
fr: 'EUR',
|
||||
it: 'EUR',
|
||||
pt: 'EUR',
|
||||
'pt-BR': 'BRL',
|
||||
ar: 'SAR',
|
||||
'ar-AE': 'AED',
|
||||
'ar-EG': 'EGP',
|
||||
ja: 'JPY',
|
||||
ko: 'KRW',
|
||||
zh: 'CNY',
|
||||
'zh-TW': 'TWD',
|
||||
ca: 'EUR',
|
||||
eu: 'EUR',
|
||||
gl: 'EUR'
|
||||
};
|
||||
|
||||
/** Resolve default currency from locale. Returns undefined if locale is not in the table. */
|
||||
export function resolveCurrency(locale: string): string | undefined {
|
||||
return LOCALE_CURRENCY[locale] ?? LOCALE_CURRENCY[locale.split('-')[0]];
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
import type { AppLangs, AppMoney, MoneyFormatOptions } from '../app/types';
|
||||
import type { MoneyRatesProvider } from './types';
|
||||
import { resolveCurrency } from './locale-defaults';
|
||||
|
||||
// ── Intl cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
const formatCache = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
function getCachedCurrencyFormat(
|
||||
locale: string,
|
||||
currency: string,
|
||||
opts?: MoneyFormatOptions
|
||||
): Intl.NumberFormat {
|
||||
const key = `${locale}:${currency}:${JSON.stringify(opts ?? {})}`;
|
||||
let fmt = formatCache.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.NumberFormat(locale || undefined, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
currencyDisplay: opts?.currencyDisplay ?? 'symbol',
|
||||
currencySign: opts?.currencySign ?? 'standard',
|
||||
minimumFractionDigits: opts?.minDecimals,
|
||||
maximumFractionDigits: opts?.maxDecimals
|
||||
});
|
||||
formatCache.set(key, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
// ── Factory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MoneyOptions {
|
||||
/** Initial/default currency. Used when locale has no mapping. */
|
||||
currency?: string;
|
||||
/** Exchange rate provider for currency conversion */
|
||||
rates?: MoneyRatesProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive money service.
|
||||
* Subscribes to locale changes from langs.
|
||||
* Owns currency state. Conversion requires a rates provider.
|
||||
*/
|
||||
export function createMoney(langs: AppLangs, opts: MoneyOptions = {}): AppMoney {
|
||||
let _currency = $state<string | null>(opts.currency ?? null);
|
||||
const _rates = opts.rates;
|
||||
|
||||
const listeners = new Set<(currency: string) => void>();
|
||||
|
||||
function notify() {
|
||||
const cur = getCurrency();
|
||||
for (const fn of listeners) fn(cur);
|
||||
}
|
||||
|
||||
langs.onLocaleChange(() => {
|
||||
if (_currency === null) notify();
|
||||
});
|
||||
|
||||
function locale(): string {
|
||||
return langs.getLocale() || 'en';
|
||||
}
|
||||
|
||||
const defaultCurrency = opts.currency ?? 'USD';
|
||||
|
||||
function getCurrency(): string {
|
||||
return _currency ?? resolveCurrency(locale()) ?? defaultCurrency;
|
||||
}
|
||||
|
||||
return {
|
||||
getCurrency,
|
||||
|
||||
setCurrency(currency: string) {
|
||||
_currency = currency;
|
||||
notify();
|
||||
},
|
||||
|
||||
format(value: number, options?: MoneyFormatOptions): string {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
return getCachedCurrencyFormat(locale(), getCurrency(), options).format(value);
|
||||
},
|
||||
|
||||
formatAs(value: number, currency: string, options?: MoneyFormatOptions): string {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
return getCachedCurrencyFormat(locale(), currency, options).format(value);
|
||||
},
|
||||
|
||||
async convert(value: number, to: string): Promise<number | undefined> {
|
||||
if (!_rates) return undefined;
|
||||
const from = getCurrency();
|
||||
if (from === to) return value;
|
||||
const rate = await _rates.getRate(from, to);
|
||||
return rate !== undefined ? value * rate : undefined;
|
||||
},
|
||||
|
||||
async convertAs(value: number, from: string, to: string): Promise<number | undefined> {
|
||||
if (!_rates) return undefined;
|
||||
if (from === to) return value;
|
||||
const rate = await _rates.getRate(from, to);
|
||||
return rate !== undefined ? value * rate : undefined;
|
||||
},
|
||||
|
||||
get ratesSupported() {
|
||||
return _rates !== undefined;
|
||||
},
|
||||
|
||||
onCurrencyChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
import type { MoneyRatesProvider, RateEntry } from './types';
|
||||
|
||||
/**
|
||||
* Function that fetches a fresh rate for a currency pair.
|
||||
* Called lazily when a cached rate has expired.
|
||||
*/
|
||||
export type RateFetcher = (
|
||||
from: string,
|
||||
to: string
|
||||
) => Promise<{ rate: number; expiresAt: number } | undefined>;
|
||||
|
||||
export interface RatesOptions {
|
||||
/** Initial rates: base currency + rate map. Optional. */
|
||||
initial?: {
|
||||
base: string;
|
||||
rates: Record<string, number>;
|
||||
/** When these initial rates expire. @default Infinity */
|
||||
expiresAt?: number;
|
||||
};
|
||||
/** Function to fetch a fresh rate when a cached entry expires. */
|
||||
fetchRate?: RateFetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rates provider backed by a cache table.
|
||||
*
|
||||
* Each rate entry stores its own expiresAt. When getRate() is called
|
||||
* and the entry is expired, fetchRate is called to get a fresh one.
|
||||
* No timers. No proactive refresh. Lazy only.
|
||||
*/
|
||||
export function createRates(opts: RatesOptions = {}): MoneyRatesProvider {
|
||||
const cache = new Map<string, RateEntry>();
|
||||
const pendingFetches = new Map<string, Promise<number | undefined>>();
|
||||
|
||||
// Populate initial rates
|
||||
if (opts.initial) {
|
||||
const { base, rates, expiresAt = Infinity } = opts.initial;
|
||||
for (const [currency, rate] of Object.entries(rates)) {
|
||||
cache.set(`${base}→${currency}`, { rate, expiresAt });
|
||||
if (rate !== 0) {
|
||||
cache.set(`${currency}→${base}`, { rate: 1 / rate, expiresAt });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedRate(from: string, to: string): number | undefined {
|
||||
const entry = cache.get(`${from}→${to}`);
|
||||
if (entry && (entry.expiresAt === Infinity || Date.now() < entry.expiresAt)) {
|
||||
return entry.rate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
async getRate(from: string, to: string): Promise<number | undefined> {
|
||||
if (from === to) return 1;
|
||||
|
||||
// Try cache (direct)
|
||||
const cached = getCachedRate(from, to);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Try computing cross rate via any shared base
|
||||
// (skip if no fetcher — only use what's in cache)
|
||||
if (!opts.fetchRate) {
|
||||
// Try cross rate from existing entries
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.expiresAt !== Infinity && Date.now() >= entry.expiresAt) continue;
|
||||
const [kFrom, kTo] = key.split('→');
|
||||
if (kFrom === from) {
|
||||
const second = getCachedRate(kTo, to);
|
||||
if (second !== undefined) return entry.rate * second;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fetch — deduplicate concurrent requests for the same pair
|
||||
const fetchKey = `${from}→${to}`;
|
||||
if (pendingFetches.has(fetchKey)) {
|
||||
return pendingFetches.get(fetchKey);
|
||||
}
|
||||
|
||||
const promise = opts
|
||||
.fetchRate(from, to)
|
||||
.then((result) => {
|
||||
pendingFetches.delete(fetchKey);
|
||||
if (!result) return undefined;
|
||||
cache.set(fetchKey, { rate: result.rate, expiresAt: result.expiresAt });
|
||||
cache.set(`${to}→${from}`, { rate: 1 / result.rate, expiresAt: result.expiresAt });
|
||||
return result.rate;
|
||||
})
|
||||
.catch(() => {
|
||||
pendingFetches.delete(fetchKey);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pendingFetches.set(fetchKey, promise);
|
||||
return promise;
|
||||
},
|
||||
|
||||
setRate(from: string, to: string, rate: number, expiresAt = Infinity) {
|
||||
cache.set(`${from}→${to}`, { rate, expiresAt });
|
||||
if (rate !== 0) {
|
||||
cache.set(`${to}→${from}`, { rate: 1 / rate, expiresAt });
|
||||
}
|
||||
},
|
||||
|
||||
setRates(base: string, rates: Record<string, number>, expiresAt = Infinity) {
|
||||
for (const [currency, rate] of Object.entries(rates)) {
|
||||
cache.set(`${base}→${currency}`, { rate, expiresAt });
|
||||
if (rate !== 0) {
|
||||
cache.set(`${currency}→${base}`, { rate: 1 / rate, expiresAt });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
hasRates(): boolean {
|
||||
return cache.size > 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
/** A single cached exchange rate with expiration */
|
||||
export interface RateEntry {
|
||||
/** Exchange rate multiplier (from → to) */
|
||||
rate: number;
|
||||
/** Timestamp when this rate expires. Infinity = never expires. */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange rate provider — injected into money at construction.
|
||||
*
|
||||
* Rates are stored in a table with per-entry expiration.
|
||||
* When a rate is requested and its entry has expired, the fetch function
|
||||
* is called to get a fresh rate. No timers, no proactive refresh.
|
||||
*/
|
||||
export interface MoneyRatesProvider {
|
||||
/**
|
||||
* Get the exchange rate from one currency to another.
|
||||
* If the cached entry is expired, calls the fetch function to refresh it.
|
||||
* Returns undefined if the pair is not available and cannot be fetched.
|
||||
*/
|
||||
getRate(from: string, to: string): Promise<number | undefined>;
|
||||
/**
|
||||
* Set a rate directly (e.g., from initial data or backend push).
|
||||
* expiresAt is the timestamp when this rate becomes stale.
|
||||
* Use Infinity for rates that never expire.
|
||||
*/
|
||||
setRate(from: string, to: string, rate: number, expiresAt?: number): void;
|
||||
/**
|
||||
* Set multiple rates at once relative to a base currency.
|
||||
*/
|
||||
setRates(base: string, rates: Record<string, number>, expiresAt?: number): void;
|
||||
/**
|
||||
* Whether any rates are available (even if some may be expired).
|
||||
*/
|
||||
hasRates(): boolean;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
|
||||
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
|
||||
* See `src/lib/README.md` for the migration map.
|
||||
*/
|
||||
export { createNums, type NumsOptions } from './nums.svelte';
|
||||
@ -0,0 +1,105 @@
|
||||
import type { AppLangs, AppNums } from '../app/types';
|
||||
|
||||
// ── Intl cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
const formatCache = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
function getCachedFormat(locale: string, opts?: Intl.NumberFormatOptions): Intl.NumberFormat {
|
||||
const key = `${locale}:${JSON.stringify(opts ?? {})}`;
|
||||
let fmt = formatCache.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.NumberFormat(locale || undefined, opts);
|
||||
formatCache.set(key, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
// ── Locale defaults ─────────────────────────────────────────────────────────
|
||||
|
||||
function resolveDecimalSeparator(locale: string): string {
|
||||
const parts = getCachedFormat(locale).formatToParts(1.1);
|
||||
return parts.find((p) => p.type === 'decimal')?.value ?? '.';
|
||||
}
|
||||
|
||||
function resolveGroupSeparator(locale: string): string {
|
||||
const parts = getCachedFormat(locale).formatToParts(1000);
|
||||
return parts.find((p) => p.type === 'group')?.value ?? ',';
|
||||
}
|
||||
|
||||
// ── Factory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface NumsOptions {
|
||||
/** Initial decimal separator override */
|
||||
decimalSeparator?: string;
|
||||
/** Initial group separator override */
|
||||
groupSeparator?: string;
|
||||
/** Initial grouping override */
|
||||
grouping?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive number formatting service.
|
||||
* Subscribes to locale changes from langs.
|
||||
*/
|
||||
export function createNums(langs: AppLangs, opts: NumsOptions = {}): AppNums {
|
||||
let _decimalSep = $state<string | null>(opts.decimalSeparator ?? null);
|
||||
let _groupSep = $state<string | null>(opts.groupSeparator ?? null);
|
||||
let _grouping = $state<boolean | null>(opts.grouping ?? null);
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
langs.onLocaleChange(() => notify());
|
||||
|
||||
function locale(): string {
|
||||
return langs.getLocale() || 'en';
|
||||
}
|
||||
|
||||
return {
|
||||
format(value: number, options?: Intl.NumberFormatOptions): string {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
return getCachedFormat(locale(), options).format(value);
|
||||
},
|
||||
|
||||
parse(value: string): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const loc = locale();
|
||||
const decSep = _decimalSep ?? resolveDecimalSeparator(loc);
|
||||
const grpSep = _groupSep ?? resolveGroupSeparator(loc);
|
||||
|
||||
// Normalize to parseable format
|
||||
let normalized = value;
|
||||
if (grpSep) normalized = normalized.replaceAll(grpSep, '');
|
||||
if (decSep !== '.') normalized = normalized.replace(decSep, '.');
|
||||
normalized = normalized.replace(/[^\d.\-+eE]/g, '');
|
||||
|
||||
const n = Number(normalized);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
},
|
||||
|
||||
getDecimalSeparator: () => _decimalSep ?? resolveDecimalSeparator(locale()),
|
||||
getGroupSeparator: () => _groupSep ?? resolveGroupSeparator(locale()),
|
||||
getGrouping: () => _grouping ?? true,
|
||||
|
||||
setDecimalSeparator(sep) {
|
||||
_decimalSep = sep === 'auto' ? null : sep;
|
||||
notify();
|
||||
},
|
||||
setGroupSeparator(sep) {
|
||||
_groupSep = sep === 'auto' ? null : sep;
|
||||
notify();
|
||||
},
|
||||
setGrouping(enabled) {
|
||||
_grouping = enabled === 'auto' ? null : enabled;
|
||||
notify();
|
||||
},
|
||||
|
||||
onPreferenceChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
|
||||
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
|
||||
* See `src/lib/README.md` for the migration map.
|
||||
*/
|
||||
export { createPresentation } from './presentation.svelte';
|
||||
export type { PresentationOptions } from './presentation.svelte';
|
||||
@ -0,0 +1,18 @@
|
||||
import type { Direction } from '../app/types';
|
||||
|
||||
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'ku', 'yi', 'dv']);
|
||||
|
||||
/**
|
||||
* Best-effort direction inferred from a BCP 47 locale.
|
||||
*/
|
||||
export function resolveDir(locale: string): Direction {
|
||||
const base = locale.split('-')[0].toLowerCase();
|
||||
return RTL_LOCALES.has(base) ? 'rtl' : 'ltr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy re-exports. The date-related defaults live in `$lib/util/dias`;
|
||||
* they were duplicated here in an earlier iteration and are kept as
|
||||
* passthroughs to preserve any lingering imports.
|
||||
*/
|
||||
export { resolveDateOrder, resolveHourCycle } from '$lib/util/dias';
|
||||
@ -0,0 +1,113 @@
|
||||
import type { AppLangs, AppPresentation, Direction } from '../app/types';
|
||||
import { resolveDir } from './locale-defaults';
|
||||
|
||||
export interface PresentationOptions {
|
||||
/** Initial dir override (null = derive from locale) */
|
||||
dir?: Direction;
|
||||
/** Initial theme name */
|
||||
theme?: string;
|
||||
/** Initial color mode */
|
||||
mode?: 'light' | 'dark' | 'auto';
|
||||
/** Initial reduced motion preference */
|
||||
reducedMotion?: boolean | 'auto';
|
||||
/** Initial reduced sound preference */
|
||||
reducedSound?: boolean;
|
||||
/** Initial density */
|
||||
density?: 'compact' | 'normal' | 'comfortable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive presentation service that subscribes to locale changes.
|
||||
*
|
||||
* Preferences follow the override/derive pattern:
|
||||
* - null override → derive from locale or OS
|
||||
* - explicit override → sticks regardless of locale changes
|
||||
* - 'auto' resets to derived
|
||||
*/
|
||||
export function createPresentation(
|
||||
langs: AppLangs,
|
||||
opts: PresentationOptions = {}
|
||||
): AppPresentation {
|
||||
// ── Overrides (null = derive) ────────────────────────────────────────
|
||||
let _dir = $state<Direction | null>(opts.dir ?? null);
|
||||
let _theme = $state(opts.theme ?? 'base');
|
||||
let _modeOverride = $state<'light' | 'dark' | null>(
|
||||
opts.mode === 'auto' || opts.mode === undefined ? null : opts.mode
|
||||
);
|
||||
let _reducedMotionOverride = $state<boolean | null>(
|
||||
opts.reducedMotion === 'auto' || opts.reducedMotion === undefined ? null : opts.reducedMotion
|
||||
);
|
||||
let _reducedSound = $state(opts.reducedSound ?? false);
|
||||
let _density = $state<'compact' | 'normal' | 'comfortable'>(opts.density ?? 'normal');
|
||||
|
||||
// ── OS media queries (SSR-safe) ─────────────────────────────────────
|
||||
let osDarkMode = $state(false);
|
||||
let osReducedMotion = $state(false);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const darkMq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const motionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
osDarkMode = darkMq.matches;
|
||||
osReducedMotion = motionMq.matches;
|
||||
darkMq.addEventListener('change', (e) => (osDarkMode = e.matches));
|
||||
motionMq.addEventListener('change', (e) => (osReducedMotion = e.matches));
|
||||
}
|
||||
|
||||
// ── Subscribers ──────────────────────────────────────────────────────
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
// Subscribe to locale changes — recalculate derived defaults
|
||||
langs.onLocaleChange(() => {
|
||||
// Derived values recalculate automatically via getters
|
||||
// But we need to notify subscribers for non-Svelte consumers
|
||||
notify();
|
||||
});
|
||||
|
||||
// ── Interface ────────────────────────────────────────────────────────
|
||||
return {
|
||||
getDir: () => _dir ?? resolveDir(langs.getLocale()),
|
||||
setDir: (dir) => {
|
||||
_dir = dir === 'auto' ? null : dir;
|
||||
notify();
|
||||
},
|
||||
|
||||
getTheme: () => _theme,
|
||||
setTheme: (theme) => {
|
||||
_theme = theme;
|
||||
notify();
|
||||
},
|
||||
|
||||
getMode: () => _modeOverride ?? (osDarkMode ? 'dark' : 'light'),
|
||||
setMode: (mode) => {
|
||||
_modeOverride = mode === 'auto' ? null : mode;
|
||||
notify();
|
||||
},
|
||||
|
||||
getReducedMotion: () => _reducedMotionOverride ?? osReducedMotion,
|
||||
setReducedMotion: (reduced) => {
|
||||
_reducedMotionOverride = reduced === 'auto' ? null : reduced;
|
||||
notify();
|
||||
},
|
||||
|
||||
getReducedSound: () => _reducedSound,
|
||||
setReducedSound: (reduced) => {
|
||||
_reducedSound = reduced;
|
||||
notify();
|
||||
},
|
||||
|
||||
getDensity: () => _density,
|
||||
setDensity: (density) => {
|
||||
_density = density;
|
||||
notify();
|
||||
},
|
||||
|
||||
onPreferenceChange: (fn) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
/** Unit conversion factors to SI base units */
|
||||
const TO_SI: Record<string, { base: string; factor: number }> = {
|
||||
// Length
|
||||
meter: { base: 'meter', factor: 1 },
|
||||
kilometer: { base: 'meter', factor: 1000 },
|
||||
centimeter: { base: 'meter', factor: 0.01 },
|
||||
millimeter: { base: 'meter', factor: 0.001 },
|
||||
mile: { base: 'meter', factor: 1609.344 },
|
||||
yard: { base: 'meter', factor: 0.9144 },
|
||||
foot: { base: 'meter', factor: 0.3048 },
|
||||
inch: { base: 'meter', factor: 0.0254 },
|
||||
// Mass
|
||||
kilogram: { base: 'kilogram', factor: 1 },
|
||||
gram: { base: 'kilogram', factor: 0.001 },
|
||||
milligram: { base: 'kilogram', factor: 0.000001 },
|
||||
pound: { base: 'kilogram', factor: 0.45359237 },
|
||||
ounce: { base: 'kilogram', factor: 0.028349523 },
|
||||
// Volume
|
||||
liter: { base: 'liter', factor: 1 },
|
||||
milliliter: { base: 'liter', factor: 0.001 },
|
||||
gallon: { base: 'liter', factor: 3.785411784 },
|
||||
quart: { base: 'liter', factor: 0.946352946 },
|
||||
pint: { base: 'liter', factor: 0.473176473 },
|
||||
'fluid-ounce': { base: 'liter', factor: 0.029573529 },
|
||||
// Temperature handled separately
|
||||
// Speed
|
||||
'kilometer-per-hour': { base: 'meter-per-second', factor: 0.277778 },
|
||||
'mile-per-hour': { base: 'meter-per-second', factor: 0.44704 },
|
||||
'meter-per-second': { base: 'meter-per-second', factor: 1 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a value between two compatible units.
|
||||
* Throws if units are incompatible (different base).
|
||||
*/
|
||||
export function convert(value: number, from: string, to: string): number {
|
||||
// Temperature special case
|
||||
if (from === 'celsius' && to === 'fahrenheit') return (value * 9) / 5 + 32;
|
||||
if (from === 'fahrenheit' && to === 'celsius') return ((value - 32) * 5) / 9;
|
||||
if (from === 'celsius' && to === 'kelvin') return value + 273.15;
|
||||
if (from === 'kelvin' && to === 'celsius') return value - 273.15;
|
||||
if (from === 'fahrenheit' && to === 'kelvin') return ((value - 32) * 5) / 9 + 273.15;
|
||||
if (from === 'kelvin' && to === 'fahrenheit') return ((value - 273.15) * 9) / 5 + 32;
|
||||
|
||||
const fromDef = TO_SI[from];
|
||||
const toDef = TO_SI[to];
|
||||
|
||||
if (!fromDef) throw new Error(`[units] Unknown unit: ${from}`);
|
||||
if (!toDef) throw new Error(`[units] Unknown unit: ${to}`);
|
||||
if (fromDef.base !== toDef.base) {
|
||||
throw new Error(
|
||||
`[units] Incompatible units: ${from} (${fromDef.base}) and ${to} (${toDef.base})`
|
||||
);
|
||||
}
|
||||
|
||||
// Convert: from → SI base → to
|
||||
const siValue = value * fromDef.factor;
|
||||
return siValue / toDef.factor;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
|
||||
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
|
||||
* See `src/lib/README.md` for the migration map.
|
||||
*/
|
||||
export { createUnits, type UnitsOptions } from './units.svelte';
|
||||
export { resolveUnitSystem } from './locale-defaults';
|
||||
export { convert } from './conversions';
|
||||
@ -0,0 +1,6 @@
|
||||
/** Locales that use the imperial measurement system */
|
||||
const IMPERIAL_LOCALES = new Set(['en-US', 'en-LR', 'en-MM']);
|
||||
|
||||
export function resolveUnitSystem(locale: string): 'metric' | 'imperial' {
|
||||
return IMPERIAL_LOCALES.has(locale) ? 'imperial' : 'metric';
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import type { AppLangs, AppUnits } from '../app/types';
|
||||
import { resolveUnitSystem } from './locale-defaults';
|
||||
import { convert as convertUnits } from './conversions';
|
||||
|
||||
// ── Intl cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
const formatCache = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
function getCachedUnitFormat(
|
||||
locale: string,
|
||||
unit: string,
|
||||
opts?: Intl.NumberFormatOptions
|
||||
): Intl.NumberFormat {
|
||||
const key = `${locale}:${unit}:${JSON.stringify(opts ?? {})}`;
|
||||
let fmt = formatCache.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.NumberFormat(locale || undefined, {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: 'short',
|
||||
...opts
|
||||
});
|
||||
formatCache.set(key, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
// ── Factory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UnitsOptions {
|
||||
/** Initial measurement system override (null = derive from locale) */
|
||||
system?: 'metric' | 'imperial';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive units service.
|
||||
* Subscribes to locale changes from langs.
|
||||
* Owns measurement system preference.
|
||||
*/
|
||||
export function createUnits(langs: AppLangs, opts: UnitsOptions = {}): AppUnits {
|
||||
let _system = $state<'metric' | 'imperial' | null>(opts.system ?? null);
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
langs.onLocaleChange(() => {
|
||||
if (_system === null) notify();
|
||||
});
|
||||
|
||||
function locale(): string {
|
||||
return langs.getLocale() || 'en';
|
||||
}
|
||||
|
||||
return {
|
||||
format(value: number, unit: string, options?: Intl.NumberFormatOptions): string {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
return getCachedUnitFormat(locale(), unit, options).format(value);
|
||||
},
|
||||
|
||||
getSystem: () => _system ?? resolveUnitSystem(locale()),
|
||||
|
||||
setSystem(system) {
|
||||
_system = system === 'auto' ? null : system;
|
||||
notify();
|
||||
},
|
||||
|
||||
convert: convertUnits,
|
||||
|
||||
onPreferenceChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,326 +1,359 @@
|
||||
import type {
|
||||
SupportedLocale,
|
||||
LeafPaths,
|
||||
GetTypeAtPath,
|
||||
ParamsFor,
|
||||
HasParams,
|
||||
PluralForms,
|
||||
LingRecord,
|
||||
LingString, LingInstance, LingLogger, LingNode, PluralConfig,
|
||||
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";
|
||||
|
||||
|
||||
|
||||
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 { parseIDLing, 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';
|
||||
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;
|
||||
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
|
||||
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 detecta un ciclo o se supera la profundidad máxima.
|
||||
*/
|
||||
function resolveValue(value: any, args: any[], depth: number, visited?: Set<string>): 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 seen = visited ?? new Set<string>();
|
||||
if (seen.has(path)) {
|
||||
logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(path));
|
||||
throw new Error("Circular reference in ling");
|
||||
}
|
||||
seen.add(path);
|
||||
const resolved = resolvePath(currentSchema, path);
|
||||
return resolveValue(resolved, args, depth + 1, seen);
|
||||
}
|
||||
|
||||
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[]): string => {
|
||||
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; } };
|
||||
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 detecta un ciclo o se supera la profundidad máxima.
|
||||
*/
|
||||
function resolveValue(value: any, args: any[], depth: number, visited?: Set<string>): 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 parsed = parseIDLing(value);
|
||||
const path = parsed?.path ?? value.substring(ID_PREFIX.length);
|
||||
const seen = visited ?? new Set<string>();
|
||||
if (seen.has(path)) {
|
||||
logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(path));
|
||||
throw new Error('Circular reference in ling');
|
||||
}
|
||||
seen.add(path);
|
||||
const resolved = resolvePath(currentSchema, path);
|
||||
|
||||
if (resolved === undefined) {
|
||||
if (parsed?.fallback !== undefined) {
|
||||
if (DEV)
|
||||
logger.warn(
|
||||
LOGGER_CATEGORY,
|
||||
`${LING_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".`
|
||||
);
|
||||
return parsed.fallback;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolveValue(resolved, args, depth + 1, seen);
|
||||
}
|
||||
|
||||
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[]): string => {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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),
|
||||
warn: (_category: string, message: string) => DEV && console.warn(message),
|
||||
error: (_category: string, message: string) => DEV && console.error(message)
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue