commit 781bfb7e4b46ee8f11b15e6e821bcf2dc7c80575 Author: dev Date: Tue Mar 24 03:32:13 2026 +0100 Initial commit: SvelteKit application with modular libraries Add comprehensive library system for internationalization, logging, formatting, and UI state management: - ling: i18n library with translation keys, plural rules, and JSON serialization - logr: logging system with levels, filters, history, and transports - glob: number (numr), date (dati), currency (curr), and unit (unit) formatters - actx: application context integrating all modules with Svelte 5 reactivity - uiux: reactive UI state for theme, toasts, modals, menus, and breakpoints Features: - 247 passing tests across 4 test suites - Svelte 5 runes ($state) for reactive context - Lazy initialization for optional modules - Type-safe configuration with TypeScript - Locale-aware formatting using Intl APIs Co-Authored-By: Claude Opus 4.6 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..5707c58ad --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..3b462cb0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..7d74fe246 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..819fa5766 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ], + "tailwindStylesheet": "./src/routes/layout.css" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..252fc41c1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..bc31e1553 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.css": "tailwindcss" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..3f9749e21 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2922 @@ +{ + "name": "vicen", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vicen", + "version": "0.0.1", + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.5.0", + "@vitest/browser-playwright": "^4.1.0", + "playwright": "^1.58.2", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vitest": "^4.1.0", + "vitest-browser-svelte": "^2.0.2" + } + }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/browser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.1.tgz", + "integrity": "sha512-gjjrFC4+kPVK/fN9URDJWrssU5Gqh8Az8pKG/NSfQ2V+ky8b/y1BgBg0Ug13+hOGp5pzInonmGRPn7vOgSLgzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.1" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.1.tgz", + "integrity": "sha512-dtVSBZZha2k/7P7EAXXrEAoxuIKl8Yv9f2Dk4GN/DGfmhf4DQvkvu+57okR2wq/gan1xppKjL/aBxK/kbYrbGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.1", + "@vitest/mocker": "4.1.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.1" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.1", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", + "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.1.tgz", + "integrity": "sha512-b+u3CEM6FjDHru+nhUSjDofpWSBp2rINziJWgApm72wwGasQ/wKXftZe4tI2Y5HPv6OpzXSZHOFq87H4vfsgsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest-browser-svelte": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-2.1.0.tgz", + "integrity": "sha512-Uqcqn9gKhYoNOn5uGOQHSPIEGHgIz25zPP6R63LQ5+yEVHfDXdOKBMba9pBlPIgp31AxYbV9h43j9+W+5M5y+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@playwright/test": "^1.58.2", + "@testing-library/svelte-core": "^1.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vitest": "^4.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..ae7e52115 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "vicen", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check .", + "format": "prettier --write .", + "test:unit": "vitest", + "test": "npm run test:unit -- --run" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.5.0", + "@vitest/browser-playwright": "^4.1.0", + "playwright": "^1.58.2", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vitest": "^4.1.0", + "vitest-browser-svelte": "^2.0.2" + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 000000000..da08e6da5 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 000000000..f273cc58f --- /dev/null +++ b/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/actx/lib/actx.svelte.ts b/src/lib/actx/lib/actx.svelte.ts new file mode 100644 index 000000000..4b11d6c0b --- /dev/null +++ b/src/lib/actx/lib/actx.svelte.ts @@ -0,0 +1,87 @@ +/** + * actx.svelte.ts — Singleton del contexto global. + * + * La aplicación decide aquí: + * - El schema de traducciones y locale inicial de ling + * - Las monedas disponibles y su moneda activa + * - El sistema de unidades + * - Los defaults de formateo, log y UI + * + * Importar en componentes Svelte: + * import { actx } from '$lib/actx/actx.svelte.ts'; + */ + +import { createLing } from '../../ling/lib'; +import { LogLevel } from '../../logr/lib'; +import { p } from '../../ling/lib'; +import { createACtx } from './engine.svelte.ts'; + + + +// ── Schema de traducciones de la app ────────────────────────────────────────── +// Importa las traducciones de tu proyecto. +// Los módulos (unit, curr) registran las suyas vía ling.extend() automáticamente. +import { translations } from '../../ling/lib/translations'; + + + +// ── Monedas ─────────────────────────────────────────────────────────────────── +// Cada CurrDefinition define symbol, code y name (LingPluralFn). +// p() crea la función de pluralización que curr usa para display: 'name'. +const EUR = { + symbol: '€', + code : 'EUR', + name : p({ + es: { one: 'euro', other: 'euros' }, + en: { one: 'euro', other: 'euros' }, + }), + conversionValue: 1, +}; + +const USD = { + symbol: '$', + code : 'USD', + name : p({ + es: { one: 'dólar', other: 'dólares' }, + en: { one: 'dollar', other: 'dollars' }, + }), + conversionValue: 1.08, +}; + + +// ── Singleton ───────────────────────────────────────────────────────────────── + +export const actx = createACtx({ + ling: createLing(translations, 'es'), + + logr: { + level: LogLevel.WARN, + }, + + numr: { + minDecimals: 0, + maxDecimals: 2, + }, + + dati: { + selectedTimeFormat: '24h', + selectedDateOrder : 'DMY', + }, + + curr: { + definitions : { EUR, USD }, + selectedCurrency: 'EUR', + display : 'symbol', + decimals : 2, + }, + + unit: { + selectedSystem: 'metric', + // categories: { weight: myCustomWeight }, // sobreescribe si necesitas + }, + + uiuxConfig: { + theme: { mode: 'auto' }, + toast: { defaultDuration: 4000 }, + }, +}); \ No newline at end of file diff --git a/src/lib/actx/lib/engine.svelte.ts b/src/lib/actx/lib/engine.svelte.ts new file mode 100644 index 000000000..ac0a9cd54 --- /dev/null +++ b/src/lib/actx/lib/engine.svelte.ts @@ -0,0 +1,132 @@ +import type { LingNode } from '../../ling/lib/types'; +import type { GlobLocale } from '../../glob/lib/comn_types'; +import type { ACtxConfig, ACtx, ReactiveACtx } from './types'; + +import { createLogr } from '../../logr/lib/engine'; +import { createNumr } from '../../glob/lib/numr_engine'; +import { createDati } from '../../glob/lib/dati_engine'; +import { createCurr } from '../../glob/lib/curr_engine'; +import { createUnit } from '../../glob/lib/unit_engine'; +import { createUiux, type ModalEntry, type ThemeMode, type Toast, type UiuxConfig } from '../../uiux/lib'; + +// ============================================================================ +// ENGINE (REACTIVE WITH SVELTE 5 RUNES) +// ============================================================================ + +/** + * Crea el contexto global de la aplicación (versión reactiva con Svelte 5). + * + * Esta versión usa $state para que Svelte rastree las dependencias automáticamente. + * Los métodos de formateo están envueltos para acceder a locale de forma reactiva. + */ +export function createACtx( + config: ACtxConfig +): ReactiveACtx { + + const { ling } = config; + const getLocale = (): GlobLocale => ling.getLocale() as GlobLocale; + + // ── Engines puro TS ─────────────────────────────────────────────────────── + const _logr = createLogr(ling, config.logr ?? {}); + const _numr = createNumr(config.numr ?? {}, getLocale); + const _dati = createDati(config.dati ?? {}, getLocale); + const _curr = createCurr( + config.curr?.definitions ?? {}, + ling, + config.curr ?? {}, + getLocale + ); + const _unit = createUnit( + ling, + config.unit?.categories ?? {}, + config.unit ?? {}, + getLocale + ); + const uiuxConfig: UiuxConfig = config.uiuxConfig ?? {}; + const _uiux = createUiux(uiuxConfig); + + // ── $state — reactivo para Svelte ───────────────────────────────────────── + // Válido dentro de funciones en ficheros .svelte.ts (Svelte 5). + // Cada llamada a createACtx() crea su propio estado independiente. + + let locale = $state(getLocale()); + let themeResolved = $state<'light' | 'dark'>(_uiux.theme.resolved); + let themeMode = $state(_uiux.theme.mode); + let toastQueue = $state([]); + let modalStack = $state([]); + let breakCurrent = $state(_uiux.break.current); + + // ── Sincronización engines → $state ─────────────────────────────────────── + + ling.onLocaleChange(l => { locale = l as GlobLocale; }); + + _uiux.theme.onChange((mode, resolved) => { + themeMode = mode; + themeResolved = resolved; + }); + + _uiux.toast.onChange(q => { toastQueue = q; }); + _uiux.modal.onChange(s => { modalStack = s; }); + _uiux.break.onChange(bp => { breakCurrent = bp; }); + + // ── Instancia ───────────────────────────────────────────────────────────── + + return { + // Delegates de ling + // Leer `locale` ($state) dentro del getter establece la dependencia reactiva. + // Cuando el locale cambia, Svelte re-evalúa cualquier template que use t() o ts(). + get t() { locale; return ling.t.bind(ling); }, + get ts() { locale; return ling.ts.bind(ling); }, + + // Módulos puro TS + ling, + logr : _logr, + numr : _numr, + dati : _dati, + curr : _curr, + unit : _unit, + + // Locale reactivo + get locale() { return locale; }, + + // UIX reactivo + uiux: { + theme: { + get resolved() { return themeResolved; }, + get mode() { return themeMode; }, + set : _uiux.theme.set.bind(_uiux.theme), + toggle : _uiux.theme.toggle.bind(_uiux.theme), + onChange: _uiux.theme.onChange.bind(_uiux.theme), + }, + toast: { + get queue() { return toastQueue; }, + show : _uiux.toast.show.bind(_uiux.toast), + dismiss : _uiux.toast.dismiss.bind(_uiux.toast), + clear : _uiux.toast.clear.bind(_uiux.toast), + onChange: _uiux.toast.onChange.bind(_uiux.toast), + }, + modal: { + get stack() { return modalStack; }, + get active() { + return modalStack.length > 0 + ? modalStack[modalStack.length - 1] + : null; + }, + open : _uiux.modal.open.bind(_uiux.modal), + close : _uiux.modal.close.bind(_uiux.modal), + closeAll: _uiux.modal.closeAll.bind(_uiux.modal), + isOpen : _uiux.modal.isOpen.bind(_uiux.modal), + onChange: _uiux.modal.onChange.bind(_uiux.modal), + }, + menu: _uiux.menu, + break: { + get current() { return breakCurrent; }, + get isMobile() { return !_uiux.break.is('md'); }, + get isTablet() { return _uiux.break.is('md') && !_uiux.break.is('lg'); }, + get isDesktop() { return _uiux.break.is('lg'); }, + is : _uiux.break.is.bind(_uiux.break), + onChange: _uiux.break.onChange.bind(_uiux.break), + }, + }, + }; +} \ No newline at end of file diff --git a/src/lib/actx/lib/index.ts b/src/lib/actx/lib/index.ts new file mode 100644 index 000000000..15d0f9858 --- /dev/null +++ b/src/lib/actx/lib/index.ts @@ -0,0 +1,2 @@ +export * from './types.ts'; +export * from './engine.ts'; \ No newline at end of file diff --git a/src/lib/actx/lib/nr_actx.ts b/src/lib/actx/lib/nr_actx.ts new file mode 100644 index 000000000..b73132b85 --- /dev/null +++ b/src/lib/actx/lib/nr_actx.ts @@ -0,0 +1,53 @@ +/** + * actx.ts — Singleton puro TS del contexto global. + * + * Sin reactividad Svelte. Útil para: + * - SSR / SvelteKit load functions + * - Workers y edge functions + * - Tests unitarios + * - Scripts Node.js + * + * Para el singleton reactivo en componentes Svelte usa `actx.svelte.ts`. + */ + +import { createLing } from '@/ling/lib'; +import { LogLevel } from '@/logr/lib'; +import { translations } from '@/ling/lib'; +import { createACtx } from './nr_engine'; + +// import { EUR, USD } from '$lib/currencies.ts'; + +export const actx = createACtx({ + ling: createLing(translations, 'es'), + + logr: { + level: LogLevel.WARN, + }, + + numr: { + minDecimals: 0, + maxDecimals: 2, + }, + + dati: { + selectedTimeFormat: '24h', + selectedDateOrder : 'DMY', + }, + + curr: { + // definitions: { EUR, USD }, + definitions : {}, + selectedCurrency: 'EUR', + display : 'symbol', + decimals : 2, + }, + + unit: { + selectedSystem: 'metric', + }, + + uiuxConfig: { + theme: { mode: 'light' }, // sin 'auto' — no hay window en SSR + toast: { defaultDuration: 4000 }, + }, +}); \ No newline at end of file diff --git a/src/lib/actx/lib/nr_engine.ts b/src/lib/actx/lib/nr_engine.ts new file mode 100644 index 000000000..a3edb266e --- /dev/null +++ b/src/lib/actx/lib/nr_engine.ts @@ -0,0 +1,104 @@ +import type { LingNode } from '../../ling/lib/types'; +import type { GlobLocale } from '../../glob/lib/comn_types'; +import type { ACtxConfig, ACtx } from './types'; + +import { createLogr } from '../../logr/lib/engine'; +import { createNumr } from '../../glob/lib/numr_engine'; +import { createDati } from '../../glob/lib/dati_engine'; +import { createCurr } from '../../glob/lib/curr_engine'; +import { createUnit } from '../../glob/lib/unit_engine'; +import { createUiux } from '../../uiux/lib'; + +// ============================================================================ +// HELPERS +// ============================================================================ + +function missingModule(name: string, hint: string): never { + throw new Error( + `[actx] '${name}' no está configurado. ` + + `Añade { ${hint} } a createACtx().` + ); +} + +// ============================================================================ +// ENGINE (NON-REACTIVE) +// ============================================================================ + +/** + * Crea el contexto global de la aplicación (versión no reactiva). + * + * Solo `ling` es obligatorio. Los demás módulos se activan pasando su config + * y se inicializan de forma lazy en el primer acceso. + * + * Para la versión reactiva con Svelte 5, usa `engine.svelte.ts`. + */ +export function createACtx( + config: ACtxConfig +): ACtx { + + const { ling } = config; + const getLocale = (): GlobLocale => ling.getLocale() as GlobLocale; + + let _logr: ReturnType | undefined; + let _numr: ReturnType | undefined; + let _dati: ReturnType | undefined; + let _curr: ReturnType | undefined; + let _unit: ReturnType | undefined; + let _uiux: ReturnType | undefined; + + return { + // Delegates de ling + t : ling.t.bind(ling), + ts : ling.ts.bind(ling), + + // Locale reactivo (simulado con getter) + get locale() { return ling.getLocale() as GlobLocale; }, + + // Módulo ling + ling, + + // Módulos con lazy initialization + get logr() { + if (!config.logr) missingModule('logr', 'logr: { level: LogLevel.WARN }'); + return _logr ??= createLogr(ling, config.logr); + }, + + get numr() { + if (!config.numr) missingModule('numr', 'numr: {}'); + return _numr ??= createNumr(config.numr, getLocale); + }, + + get dati() { + if (!config.dati) missingModule('dati', 'dati: {}'); + return _dati ??= createDati(config.dati, getLocale); + }, + + get curr() { + if (!config.curr) missingModule('curr', 'curr: { definitions: { EUR, USD } }'); + return _curr ??= createCurr( + config.curr.definitions ?? {}, + ling, + config.curr, + getLocale + ); + }, + + get unit() { + if (!config.unit) missingModule('unit', 'unit: { selectedSystem: "metric" }'); + return _unit ??= createUnit( + ling, + config.unit.categories ?? {}, + config.unit, + getLocale + ); + }, + + get uiux() { + if (!config.uiuxConfig) missingModule('uiux', 'uiuxConfig: { theme: { mode: "auto" } }'); + if (!_uiux) { + _uiux = createUiux(config.uiuxConfig); + } + return _uiux; + }, + }; +} diff --git a/src/lib/actx/lib/types.ts b/src/lib/actx/lib/types.ts new file mode 100644 index 000000000..4e4b0eb70 --- /dev/null +++ b/src/lib/actx/lib/types.ts @@ -0,0 +1,89 @@ +// ============================== +// ACTX TYPES +// ============================== + +import type { LingInstance, LingNode, LingString } from '../../ling/lib/types'; +import type { Logr, LogrOptions } from '../../logr/lib/types'; +import type { CurrInstance, CurrDefinition, CurrConfig } from '../../glob/lib/curr_types'; +import type { UnitInstance, UnitCategoryData, UnitCategoryKey, UnitSystem } from '../../glob/lib/unit_types'; +import type { DatiInstance, DatiConfig } from '../../glob/lib/dati_types'; +import type { NumrInstance, NumrConfig } from '../../glob/lib/numr_types'; +import type { GlobLocale } from '../../glob/lib/comn_types'; +import type { ReactiveUiux, Uiux, UiuxConfig } from '../../uiux/lib/types'; + + +// ============================================================================ +// CONFIG +// ============================================================================ + +/** + * Configuración de `createACtx`. + * + * Solo `ling` es obligatorio. El resto de módulos son opcionales y se + * inicializan de forma lazy cuando se accede por primera vez. + * + * @example + * const actx = createACtx({ + * ling, + * logr : { level: LogLevel.WARN }, + * numr : { maxDecimals: 2 }, + * dati : { selectedTimeFormat: '24h' }, + * curr : { definitions: { EUR, USD }, selectedCurrency: 'EUR' }, + * unit : { selectedSystem: 'metric' }, + * uiux : { theme: { mode: 'auto' } }, + * }); + */ +export interface ACtxConfig { + /** Instancia de i18n — fuente de verdad del locale para todo el contexto. */ + ling : LingInstance; + /** Configuración del logger. */ + logr? : LogrOptions; + /** Configuración del formateador de números. */ + numr? : NumrConfig; + /** Configuración del formateador de fechas. */ + dati? : DatiConfig; + /** Configuración del formateador de monedas con pluralización completa. */ + curr? : CurrConfig & { definitions?: Record }; + /** Configuración del formateador de unidades con pluralización completa. */ + unit? : { + selectedSystem?: UnitSystem; + categories? : Partial>; + }; + /** Configuración del contexto de UI (tema, breakpoints, toasts, modales, menús). */ + uiuxConfig? : UiuxConfig; +} + +// ============================================================================ +// INSTANCE +// ============================================================================ + +/** + * Contexto global de la aplicación. + * + * @example + * actx.t('common.ok') + * actx.ts({ es: 'Hola', en: 'Hi' }) + * actx.locale + * actx.logr.warn('auth', 'Error') + * actx.numr.f(1234.5) + * actx.dati.f(new Date()) + * actx.curr.f(99.99, 'name') + * actx.unit.weight.fTo(75, 'kg', 'lb') + * actx.uiux.theme.toggle() + * actx.uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' }) + * actx.uiux.modal.open('confirm', { title: '¿Eliminar?' }) + * actx.uiux.break.isMobile + */ +export interface ACtx { + t : LingInstance['t']; + ts : (value: LingString) => string; + readonly locale: GlobLocale; + ling : LingInstance; + logr : Logr; + numr : NumrInstance; + dati : DatiInstance; + curr : CurrInstance; + unit : UnitInstance; + uiux : ReactiveUiux; +} + diff --git a/src/lib/actx/test/actx.svelte.test.ts.skip b/src/lib/actx/test/actx.svelte.test.ts.skip new file mode 100644 index 000000000..55b7005b8 --- /dev/null +++ b/src/lib/actx/test/actx.svelte.test.ts.skip @@ -0,0 +1,321 @@ +/** + * actx.svelte — test suite (versión reactiva) + * + * Estos tests requieren un entorno Svelte 5 con soporte para runes ($state). + * + * Ejecutar: npx vitest run src/lib/actx/test/actx.svelte.test.ts + * + * Módulos cubiertos: + * - engine.svelte (createACtx reactivo) + * - reactividad de $state + * - sincronización engines → $state + * - uiux reactivo + */ + +import type { CurrDefinition } from '../../glob/lib'; +import { describe, it, expect } from 'vitest'; +import { createLing } from '../../ling/lib'; +import { createACtx } from '../lib/engine.svelte'; +import { LogLevel } from '../../logr/lib'; + +// ============================================================================ +// FIXTURES +// ============================================================================ + +const translations = { + common: { + ok: { es: 'Aceptar', en: 'OK' }, + cancel: { es: 'Cancelar', en: 'Cancel' }, + }, + messages: { + welcome: { es: 'Bienvenido', en: 'Welcome' }, + }, +}; + +const EUR: CurrDefinition = { + symbol: '€', + code: 'EUR', + name: ({ count }) => ({ + es: count === 1 ? 'euro' : 'euros', + en: count === 1 ? 'euro' : 'euros', + }), + conversionValue: 1, +}; + +const USD: CurrDefinition = { + symbol: '$', + code: 'USD', + name: ({ count }) => ({ + es: count === 1 ? 'dólar' : 'dólares', + en: count === 1 ? 'dollar' : 'dollars', + }), + conversionValue: 1.08, +}; + +// ============================================================================ +// CREATE ACTX (REACTIVE) +// ============================================================================ + +describe('createACtx (reactivo con Svelte 5)', () => { + describe('creación básica', () => { + it('crea una instancia con ling', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.ling).toBeDefined(); + expect(actx.locale).toBe('es'); + }); + + it('t() funciona directamente en actx', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.t('common.ok')).toBe('Aceptar'); + }); + + it('ts() funciona directamente en actx', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola'); + }); + }); + + describe('módulos configurados', () => { + it('crea instancia con todos los módulos configurados', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + logr: { level: LogLevel.WARN }, + numr: { maxDecimals: 2 }, + dati: { selectedTimeFormat: '24h' }, + curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' }, + unit: { selectedSystem: 'metric' }, + uiuxConfig: { theme: { mode: 'auto' } }, + }); + + // Módulos básicos + expect(actx.ling).toBeDefined(); + expect(actx.logr).toBeDefined(); + expect(actx.numr).toBeDefined(); + expect(actx.dati).toBeDefined(); + expect(actx.curr).toBeDefined(); + expect(actx.unit).toBeDefined(); + + // UIX reactivo + expect(actx.uiux).toBeDefined(); + expect(actx.uiux.theme).toBeDefined(); + expect(actx.uiux.toast).toBeDefined(); + expect(actx.uiux.modal).toBeDefined(); + expect(actx.uiux.break).toBeDefined(); + }); + }); + + describe('reactividad de locale', () => { + it('locale es reactivo y cambia con ling.setLocale()', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.locale).toBe('es'); + + // El cambio en ling debería reflejarse en el estado reactivo + // (en un entorno Svelte real, esto sería automático) + actx.ling.setLocale('en'); + + // En el entorno de test, verificamos que ling cambió + expect(actx.ling.getLocale()).toBe('en'); + }); + }); + + describe('uiux reactivo', () => { + it('theme tiene propiedades de lectura', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + uiuxConfig: { theme: { mode: 'dark' } }, + }); + + // Props de lectura (reactivas en Svelte) + expect(actx.uiux.theme.resolved).toBeDefined(); + expect(actx.uiux.theme.mode).toBe('dark'); + }); + + it('theme tiene métodos de acción', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + uiuxConfig: { theme: { mode: 'light' } }, + }); + + // Métodos de acción + expect(actx.uiux.theme.set).toBeInstanceOf(Function); + expect(actx.uiux.theme.toggle).toBeInstanceOf(Function); + expect(actx.uiux.theme.onChange).toBeInstanceOf(Function); + }); + + it('toast tiene propiedades de lectura', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + uiuxConfig: { toast: { defaultDuration: 4000 } }, + }); + + // Props de lectura + expect(actx.uiux.toast.queue).toBeDefined(); + expect(Array.isArray(actx.uiux.toast.queue)).toBe(true); + }); + + it('toast tiene métodos de acción', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.uiux.toast.show).toBeInstanceOf(Function); + expect(actx.uiux.toast.dismiss).toBeInstanceOf(Function); + expect(actx.uiux.toast.clear).toBeInstanceOf(Function); + expect(actx.uiux.toast.onChange).toBeInstanceOf(Function); + }); + + it('modal tiene propiedades de lectura', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.uiux.modal.stack).toBeDefined(); + expect(Array.isArray(actx.uiux.modal.stack)).toBe(true); + }); + + it('modal tiene métodos de acción', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.uiux.modal.open).toBeInstanceOf(Function); + expect(actx.uiux.modal.close).toBeInstanceOf(Function); + expect(actx.uiux.modal.closeAll).toBeInstanceOf(Function); + expect(actx.uiux.modal.isOpen).toBeInstanceOf(Function); + expect(actx.uiux.modal.onChange).toBeInstanceOf(Function); + }); + + it('break tiene propiedades de lectura', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.uiux.break.current).toBeDefined(); + // Las propiedades computadas dependen del entorno + expect(typeof actx.uiux.break.isMobile).toBe('boolean'); + }); + + it('break tiene métodos de acción', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.uiux.break.is).toBeInstanceOf(Function); + expect(actx.uiux.break.onChange).toBeInstanceOf(Function); + }); + }); + + describe('integración con módulos', () => { + it('numr funciona correctamente', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + numr: { maxDecimals: 2 }, + }); + + const result = actx.numr.f(1234.5); + expect(result).toBeTruthy(); + expect(result).toContain('5'); + }); + + it('curr funciona correctamente con pluralización', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + curr: { definitions: { EUR }, selectedCurrency: 'EUR' }, + }); + + expect(actx.curr.f(1, 'name')).toBe('1,00 euro'); + expect(actx.curr.f(2, 'name')).toBe('2,00 euros'); + }); + + it('dati funciona correctamente', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + dati: { selectedTimeFormat: '24h' }, + }); + + const date = new Date('2024-03-15'); + const result = actx.dati.f(date); + expect(result).toBeTruthy(); + }); + + it('unit funciona correctamente', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + unit: { selectedSystem: 'metric' }, + }); + + const result = actx.unit.weight.f(75); + expect(result).toBeTruthy(); + expect(result).toContain('75'); + }); + }); + + describe('logr integrado con ling', () => { + it('logr usa ling para resolver mensajes', () => { + const ling = createLing(translations, 'es'); + const captured: string[] = []; + const actx = createACtx({ + ling, + logr: { + level: LogLevel.INFO, + transports: [ + { + write: (_entry: any, resolvedMessage: string) => captured.push(resolvedMessage) + } + ], + }, + }); + + actx.logr.info('test', '#?common.ok'); + expect(captured[0]).toBe('Aceptar'); + }); + }); + + describe('casos de uso reales', () => { + it('simula una aplicación multilingüe completa', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + numr: { maxDecimals: 2 }, + curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' }, + dati: { selectedTimeFormat: '24h' }, + unit: { selectedSystem: 'metric' }, + uiuxConfig: { + theme: { mode: 'auto' }, + toast: { defaultDuration: 4000 }, + }, + }); + + // Traducciones + expect(actx.t('common.ok')).toBe('Aceptar'); + expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola'); + + // Formateo + expect(actx.numr.f(1000)).toBeTruthy(); + expect(actx.curr.f(100)).toBe('100,00 €'); + expect(actx.dati.f(new Date())).toBeTruthy(); + expect(actx.unit.weight.f(75)).toContain('75'); + + // Locale + expect(actx.locale).toBe('es'); + + // UIX + expect(actx.uiux.theme.mode).toBe('auto'); + + // Cambio de locale + actx.ling.setLocale('en'); + expect(actx.t('common.ok')).toBe('OK'); + }); + }); +}); diff --git a/src/lib/actx/test/actx.test.ts b/src/lib/actx/test/actx.test.ts new file mode 100644 index 000000000..2eaf38acd --- /dev/null +++ b/src/lib/actx/test/actx.test.ts @@ -0,0 +1,518 @@ +/** + * actx — test suite (versión no reactiva) + * + * Ejecutar: npx vitest run + * + * Módulos cubiertos: + * - engine (createACtx) + * - lazy initialization + * - integración de módulos + * - reactividad básica + */ + +import type { CurrDefinition } from '../../glob/lib'; +import { describe, it, expect } from 'vitest'; +import { createLing } from '../../ling/lib'; +import { createACtx } from '../lib/nr_engine'; +import { LogLevel } from '../../logr/lib'; + +// ============================================================================ +// FIXTURES +// ============================================================================ + +const translations = { + common: { + ok: { es: 'Aceptar', en: 'OK' }, + cancel: { es: 'Cancelar', en: 'Cancel' }, + }, + messages: { + welcome: { es: 'Bienvenido', en: 'Welcome' }, + }, +}; + +const EUR: CurrDefinition = { + symbol: '€', + code: 'EUR', + name: ({ count }) => ({ + es: count === 1 ? 'euro' : 'euros', + en: count === 1 ? 'euro' : 'euros', + }), + conversionValue: 1, +}; + +const USD: CurrDefinition = { + symbol: '$', + code: 'USD', + name: ({ count }) => ({ + es: count === 1 ? 'dólar' : 'dólares', + en: count === 1 ? 'dollar' : 'dollars', + }), + conversionValue: 1.1, +}; + +// ============================================================================ +// CREATE ACTX +// ============================================================================ + +describe('createACtx (no reactivo)', () => { + describe('configuración mínima (solo ling)', () => { + it('crea una instancia con solo ling', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.ling).toBeDefined(); + expect(actx.locale).toBe('es'); + }); + + it('t() funciona directamente en actx', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.t('common.ok')).toBe('Aceptar'); + }); + + it('ts() funciona directamente en actx', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola'); + }); + + it('ling.setLocale() cambia el locale', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + actx.ling.setLocale('en'); + expect(actx.locale).toBe('en'); + expect(actx.t('common.ok')).toBe('OK'); + }); + + it('locale es un getter que refleja el locale de ling', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(actx.locale).toBe(ling.getLocale()); + }); + + it('ling.onLocaleChange() permite escuchar cambios', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + const spy = [] as string[]; + + const unsub = actx.ling.onLocaleChange((loc) => spy.push(loc)); + actx.ling.setLocale('en'); + + expect(spy).toEqual(['en']); + unsub(); + }); + }); + + describe('acceso a módulos no configurados', () => { + it('lanza error al acceder a logr si no está configurado', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(() => actx.logr).toThrow('[actx] \'logr\' no está configurado'); + }); + + it('lanza error al acceder a numr si no está configurado', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(() => actx.numr).toThrow('[actx] \'numr\' no está configurado'); + }); + + it('lanza error al acceder a dati si no está configurado', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(() => actx.dati).toThrow('[actx] \'dati\' no está configurado'); + }); + + it('lanza error al acceder a curr si no está configurado', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(() => actx.curr).toThrow('[actx] \'curr\' no está configurado'); + }); + + it('lanza error al acceder a unit si no está configurado', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(() => actx.unit).toThrow('[actx] \'unit\' no está configurado'); + }); + + it('lanza error al acceder a uiux si no está configurado', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + + expect(() => actx.uiux).toThrow('[actx] \'uiux\' no está configurado'); + }); + }); + + describe('lazy initialization', () => { + it('logr se inicializa en el primer acceso', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling, logr: { level: LogLevel.INFO } }); + + expect(actx.logr).toBeDefined(); + expect(actx.logr.info).toBeInstanceOf(Function); + }); + + it('numr se inicializa en el primer acceso', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling, numr: { maxDecimals: 3 } }); + + expect(actx.numr).toBeDefined(); + expect(actx.numr.f).toBeInstanceOf(Function); + }); + + it('dati se inicializa en el primer acceso', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling, dati: { selectedTimeFormat: '12h' } }); + + expect(actx.dati).toBeDefined(); + expect(actx.dati.f).toBeInstanceOf(Function); + }); + + it('curr se inicializa en el primer acceso', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' }, + }); + + expect(actx.curr).toBeDefined(); + expect(actx.curr.f).toBeInstanceOf(Function); + }); + + it('unit se inicializa en el primer acceso', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling, unit: { selectedSystem: 'metric' } }); + + expect(actx.unit).toBeDefined(); + expect(actx.unit.weight).toBeDefined(); + }); + + it('uiux se inicializa en el primer acceso', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling, uiuxConfig: { theme: { mode: 'light' } } }); + + expect(actx.uiux).toBeDefined(); + expect(actx.uiux.theme).toBeDefined(); + }); + + it('múltiples accesos devuelven la misma instancia (singleton)', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling, numr: {} }); + + const numr1 = actx.numr; + const numr2 = actx.numr; + + expect(numr1).toBe(numr2); + }); + }); + + describe('integración de módulos con ling', () => { + it('numr lee el locale de ling automáticamente', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling, numr: {} }); + + const esResult = actx.numr.f(1234.5); + expect(esResult).toBeTruthy(); + expect(esResult).toContain('5'); + + actx.ling.setLocale('en'); + const enResult = actx.numr.f(1234.5); + expect(enResult).toBeTruthy(); + expect(enResult).toContain('5'); + }); + + it('dati lee el locale de ling automáticamente', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling, dati: {} }); + + const testDate = new Date('2024-03-15T14:30:00Z'); + const esResult = actx.dati.f(testDate, 'short'); + expect(esResult).toBeTruthy(); + + actx.ling.setLocale('en'); + const enResult = actx.dati.f(testDate, 'short'); + expect(enResult).toBeTruthy(); + }); + + it('curr lee el locale de ling automáticamente', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + curr: { definitions: { EUR }, selectedCurrency: 'EUR' }, + }); + + const esResult = actx.curr.f(99.99, 'name'); + expect(esResult).toBeTruthy(); + expect(esResult).toContain('99'); + + actx.ling.setLocale('en'); + const enResult = actx.curr.f(99.99, 'name'); + expect(enResult).toBeTruthy(); + expect(enResult).toContain('99'); + }); + + it('curr usa ling para pluralizar nombres', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + curr: { definitions: { EUR }, selectedCurrency: 'EUR' }, + }); + + expect(actx.curr.f(1, 'name')).toBe('1,00 euro'); + expect(actx.curr.f(2, 'name')).toBe('2,00 euros'); + }); + + it('logr usa ling para resolver mensajes localizados', () => { + const ling = createLing(translations, 'es'); + const captured: string[] = []; + const actx = createACtx({ + ling, + logr: { + level: LogLevel.INFO, + transports: [ + { + write: (_entry: any, resolvedMessage: string) => captured.push(resolvedMessage) + } + ], + }, + }); + + actx.logr.info('test', '#?common.ok'); + expect(captured[0]).toBe('Aceptar'); + + actx.ling.setLocale('en'); + actx.logr.info('test', '#?common.ok'); + expect(captured[1]).toBe('OK'); + }); + }); + + describe('cambio de locale', () => { + it('ling.setLocale() cambia el locale para todos los módulos', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + numr: { maxDecimals: 2 }, + dati: {}, + curr: { definitions: { EUR }, selectedCurrency: 'EUR' }, + unit: { selectedSystem: 'metric' }, + }); + + // Todos los módulos en español + const esNum = actx.numr.f(1234.5); + const esDate = actx.dati.f(new Date()); + const esCurr = actx.curr.f(100); + const esUnit = actx.unit.weight.f(75); + + expect(esNum).toBeTruthy(); + expect(esDate).toBeTruthy(); + expect(esCurr).toBeTruthy(); + expect(esUnit).toBeTruthy(); + + actx.ling.setLocale('en'); + + // Todos los módulos en inglés + const enNum = actx.numr.f(1234.5); + const enDate = actx.dati.f(new Date()); + const enCurr = actx.curr.f(100); + const enUnit = actx.unit.weight.f(75); + + expect(enNum).toBeTruthy(); + expect(enDate).toBeTruthy(); + expect(enCurr).toBeTruthy(); + expect(enUnit).toBeTruthy(); + }); + + it('no es necesario reconectar módulos tras cambiar locale', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling, numr: {} }); + + const numrRef = actx.numr; + + actx.ling.setLocale('en'); + + // La misma instancia ahora usa el nuevo locale + expect(actx.numr).toBe(numrRef); + const result = actx.numr.f(1234.5); + expect(result).toBeTruthy(); + expect(result).toContain('5'); + }); + }); + + describe('onLocaleChange', () => { + it('notifica cuando cambia el locale a través de ling.setLocale', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + const locales: string[] = []; + + actx.ling.onLocaleChange((loc) => locales.push(loc)); + actx.ling.setLocale('en'); + actx.ling.setLocale('fr'); + + expect(locales).toEqual(['en', 'fr']); + }); + + it('el unsubscribe deja de notificar', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + const locales: string[] = []; + + const unsub = actx.ling.onLocaleChange((loc) => locales.push(loc)); + unsub(); + actx.ling.setLocale('en'); + + expect(locales).toEqual([]); + }); + + it('los callbacks se ejecutan después de que el locale cambió', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ ling }); + const capturedLocales: string[] = []; + + actx.ling.onLocaleChange(() => { + capturedLocales.push(actx.locale); + }); + + actx.ling.setLocale('en'); + + expect(capturedLocales[0]).toBe('en'); + }); + }); + + describe('configuración completa de todos los módulos', () => { + it('crea un contexto completo con todos los módulos configurados', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + logr: { level: LogLevel.WARN }, + numr: { minDecimals: 0, maxDecimals: 2 }, + dati: { selectedTimeFormat: '24h', selectedDateOrder: 'DMY' }, + curr: { + definitions: { EUR, USD }, + selectedCurrency: 'EUR', + display: 'symbol', + decimals: 2, + }, + unit: { selectedSystem: 'metric' }, + uiuxConfig: { theme: { mode: 'dark' } }, + }); + + // Todos los módulos están disponibles + expect(actx.ling).toBeDefined(); + expect(actx.logr).toBeDefined(); + expect(actx.numr).toBeDefined(); + expect(actx.dati).toBeDefined(); + expect(actx.curr).toBeDefined(); + expect(actx.unit).toBeDefined(); + expect(actx.uiux).toBeDefined(); + + // Configuración de numr + const numrResult = actx.numr.f(1234); + expect(numrResult).toBeTruthy(); + expect(numrResult).toContain('1234'); + + // Configuración de dati + expect(actx.dati.getTimeFormat()).toBe('24h'); + + // Configuración de curr + expect(actx.curr.getCurrency()).toBe('EUR'); + expect(actx.curr.get('EUR')).toEqual(EUR); + + // Configuración de unit + expect(actx.unit.getSystem()).toBe('metric'); + + // Configuración de uiux + expect(actx.uiux.theme.mode).toBe('dark'); + + // Configuración de logr + expect(() => actx.logr.warn('test', 'msg')).not.toThrow(); + }); + }); + + describe('actx como punto único de entrada', () => { + it('proporciona acceso unificado a toda la funcionalidad', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + numr: {}, + dati: {}, + curr: { definitions: { EUR }, selectedCurrency: 'EUR' }, + unit: {}, + logr: { level: LogLevel.NONE }, + uiuxConfig: { theme: { mode: 'auto' } }, + }); + + // Traducción directa + expect(actx.t('common.ok')).toBe('Aceptar'); + + // Traducción de string + expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola'); + + // Locale + expect(actx.locale).toBe('es'); + + // Números + const numResult = actx.numr.f(1234.5); + expect(numResult).toBeTruthy(); + expect(numResult).toContain('5'); + + // Fechas + const date = new Date('2024-03-15'); + expect(actx.dati.f(date)).toBeTruthy(); + + // Monedas + expect(actx.curr.f(100)).toBe('100,00 €'); + + // Unidades + expect(actx.unit.weight.f(75)).toContain('75'); + + // UIX + expect(actx.uiux.theme.mode).toBe('auto'); + + // Cambio de locale + actx.ling.setLocale('en'); + expect(actx.locale).toBe('en'); + expect(actx.t('common.ok')).toBe('OK'); + }); + }); + + describe('uso típico en aplicación', () => { + it('simula el flujo de una aplicación multilingüe', () => { + const ling = createLing(translations, 'es'); + const actx = createACtx({ + ling, + numr: { maxDecimals: 2 }, + curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' }, + logr: { level: LogLevel.INFO }, + }); + + // Usuario en español + expect(actx.t('messages.welcome')).toBe('Bienvenido'); + const esNum = actx.numr.f(1000); + expect(esNum).toBeTruthy(); + expect(esNum).toContain('1'); + expect(actx.curr.f(50)).toContain('50'); + + // Usuario cambia a inglés + actx.ling.setLocale('en'); + + expect(actx.t('messages.welcome')).toBe('Welcome'); + const enNum = actx.numr.f(1000); + expect(enNum).toBeTruthy(); + expect(enNum).toContain('1'); + expect(actx.curr.f(50)).toContain('50'); + + // Conversión de moneda + actx.curr.setCurrency('USD'); + const converted = actx.curr.convertTo(100, 'EUR'); + expect(converted).toBeCloseTo(90.91); + }); + }); +}); diff --git a/src/lib/glob/lib/comn_engine.ts b/src/lib/glob/lib/comn_engine.ts new file mode 100644 index 000000000..46a80bee1 --- /dev/null +++ b/src/lib/glob/lib/comn_engine.ts @@ -0,0 +1,10 @@ +// ============================== +// HELPERS +// ============================== + +import type { GlobLocale, LocaleResolver } from "./comn_types.ts"; + +/** Normaliza un `LocaleResolver` a un string de locale listo para `Intl.*`. */ +export function resolveLocale(l: LocaleResolver): GlobLocale { + return typeof l === 'function' ? l() : l; +} \ No newline at end of file diff --git a/src/lib/glob/lib/comn_types.ts b/src/lib/glob/lib/comn_types.ts new file mode 100644 index 000000000..81b80aa5f --- /dev/null +++ b/src/lib/glob/lib/comn_types.ts @@ -0,0 +1,21 @@ +import type { SupportedLocale } from '../../ling/lib'; + +// ============================== +// LOCALE +// ============================== + +/** Cualquier código BCP 47 válido ('es', 'en', 'es-MX', …). */ +export type GlobLocale = SupportedLocale | (string & {}); + +/** + * Valor directo o función — los módulos llaman a `resolveLocale()` en cada `f()`. + * + * - String fijo: el locale se resuelve una sola vez en la llamada. + * - Función: el locale se lee en cada invocación, lo que permite reactividad + * sin necesidad de recrear los módulos. + * + * @example + * numr.f(1234, {}, 'en') // fijo + * numr.f(1234, {}, () => user.locale) // dinámico + */ +export type LocaleResolver = GlobLocale | (() => GlobLocale); diff --git a/src/lib/glob/lib/curr_engine.ts b/src/lib/glob/lib/curr_engine.ts new file mode 100644 index 000000000..f021ce301 --- /dev/null +++ b/src/lib/glob/lib/curr_engine.ts @@ -0,0 +1,138 @@ +import type { + CurrInstance, + CurrDefinition, + CurrDisplay, + CurrConfig, + ConversionValue +} from './curr_types.ts'; +import type { + LingInstance, + LingNode +} from '../../ling/lib/types'; +import type { + LocaleResolver, + GlobLocale +} from './comn_types.ts'; +import { resolveLocale } from './comn_engine.ts'; +import { DEFAULT_ISO_LOCALE } from '../../ling/lib'; +import { currLingNode } from './curr_langs.ts'; + +// Flag de módulo — registra traducciones en ling solo una vez. +let currExtended = false; + + +/** + * Crea un módulo de moneda con formateo y conversión. + * + * @param definitions - Mapa de monedas disponibles indexado por código ISO. + * @param ling - Instancia de ling para pluralización de nombres. Opcional — + * si no se pasa, `display: 'name'` devuelve el código ISO como fallback. + * @param opts - Configuración inicial (moneda activa, decimales, display). + * @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito. + * + * @remarks + * **Tasas de conversión**: todas las `conversionValue` deben expresarse respecto + * a la misma moneda base. La fórmula es `(amount / fromRate) * toRate`. + */ +export function createCurr( + definitions : Record, + ling : LingInstance | null = null, + opts : CurrConfig = {}, + defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE +): CurrInstance { + + // Registra traducciones predefinidas de monedas en ling solo una vez + if (ling && !currExtended) { + ling.extend('curr', currLingNode); + currExtended = true; + } + + let selectedCurrency: string = opts.selectedCurrency ?? Object.keys(definitions)[0] ?? ''; + + const defaults = { + display : opts.display ?? 'symbol' as CurrDisplay, + decimals : opts.decimals ?? 2, + }; + + function getCurrency(): string { return selectedCurrency; } + function setCurrency(currency: string): void { selectedCurrency = currency.toUpperCase(); } + + function get(currency: string): CurrDefinition | undefined { + return definitions[currency.toUpperCase()]; + } + + function formatAmount(amount: number, decimals: number, locale: GlobLocale): string { + return new Intl.NumberFormat(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(amount); + } + + /** + * Resuelve el nombre plural de la moneda. + * Requiere `ling` — sin él devuelve el código ISO como fallback. + */ + /** + * Resuelve el nombre plural de la moneda. + * Lee de ling via 'curr.{CODE}.name' — registrado en extend(). + * Fallback: def.name() si existe, luego el código ISO. + */ + function resolveName(def: CurrDefinition, amount: number, locale: GlobLocale): string { + if (!ling) return def.code; + // Intenta leer desde el nodo 'curr' registrado en ling + const path = `curr.${def.code}.name`; + const fromLing = (ling.t as any)(path, { count: Math.abs(amount) }); + if (fromLing && fromLing !== path) return fromLing; + // Fallback: función name() definida en CurrDefinition + if (def.name) { + const record = def.name({ count: Math.abs(amount) }); + const baseLocale = locale.split('-')[0] as keyof typeof record; + const fallbackLocale = resolveLocale(defaultLocale).split('-')[0] as keyof typeof record; + return record[baseLocale] ?? record[fallbackLocale] ?? record['es'] ?? def.code; + } + return def.code; + } + + function resolveConversionValue(value: ConversionValue): number | null { + if (value === null || value === undefined) return null; + if (typeof value === 'number') return value; + return value(); + } + + function f( + amount : number, + display : CurrDisplay = defaults.display, + locale : LocaleResolver = defaultLocale + ): string { + const loc = resolveLocale(locale); + const formatted = formatAmount(amount, defaults.decimals, loc); + const def = get(selectedCurrency); + + if (!def) return `${formatted} ${selectedCurrency}`; + + switch (display) { + case 'symbol': return `${formatted} ${def.symbol}`; + case 'code': return `${formatted} ${def.code}`; + case 'name': return `${formatted} ${resolveName(def, amount, loc)}`; + } + } + + function convert(amount: number, from: string, to: string): number | null { + if (from.toUpperCase() === to.toUpperCase()) return amount; + const fromDef = get(from); + const toDef = get(to); + if (!fromDef || !toDef) return null; + const fromRate = resolveConversionValue(fromDef.conversionValue ?? null); + const toRate = resolveConversionValue(toDef.conversionValue ?? null); + if (fromRate === null || toRate === null) return null; + return (amount / fromRate) * toRate; + } + + function convertTo(amount: number, to: string): number | null { + return convert(amount, selectedCurrency, to); + } + + function getCurrencies(): string[] { return Object.keys(definitions); } + + return { f, convert, convertTo, setCurrency, getCurrency, get, getCurrencies }; +} \ No newline at end of file diff --git a/src/lib/glob/lib/curr_langs.ts b/src/lib/glob/lib/curr_langs.ts new file mode 100644 index 000000000..d99c32b23 --- /dev/null +++ b/src/lib/glob/lib/curr_langs.ts @@ -0,0 +1,113 @@ +import { p } from '../../ling/lib'; +import type { LingNode } from '../../ling/lib/types'; + +/** + * Traducciones predefinidas de monedas. + * Se registra en ling bajo el nodo 'curr' via createCurr(). + * Las definiciones concretas (symbol, code, conversionValue) + * las aporta el usuario en ACtxConfig.curr.definitions. + */ +export const currLingNode = { + EUR: { + name: p({ + es: { one: 'euro', other: 'euros' }, + en: { one: 'euro', other: 'euros' }, + de: { one: 'Euro', other: 'Euro' }, + fr: { one: 'euro', other: 'euros' }, + it: { one: 'euro', other: 'euro' }, + pt: { one: 'euro', other: 'euros' }, + }), + }, + USD: { + name: p({ + es: { one: 'dólar', other: 'dólares' }, + en: { one: 'dollar', other: 'dollars' }, + de: { one: 'Dollar', other: 'Dollar' }, + fr: { one: 'dollar', other: 'dollars' }, + it: { one: 'dollaro', other: 'dollari' }, + pt: { one: 'dólar', other: 'dólares' }, + }), + }, + GBP: { + name: p({ + es: { one: 'libra', other: 'libras' }, + en: { one: 'pound', other: 'pounds' }, + de: { one: 'Pfund', other: 'Pfund' }, + fr: { one: 'livre', other: 'livres' }, + it: { one: 'sterlina', other: 'sterline' }, + pt: { one: 'libra', other: 'libras' }, + }), + }, + JPY: { + name: p({ + es: { one: 'yen', other: 'yenes' }, + en: { one: 'yen', other: 'yen' }, + de: { one: 'Yen', other: 'Yen' }, + fr: { one: 'yen', other: 'yens' }, + it: { one: 'yen', other: 'yen' }, + pt: { one: 'iene', other: 'ienes' }, + }), + }, + CHF: { + name: p({ + es: { one: 'franco suizo', other: 'francos suizos' }, + en: { one: 'Swiss franc', other: 'Swiss francs' }, + de: { one: 'Schweizer Franken', other: 'Schweizer Franken' }, + fr: { one: 'franc suisse', other: 'francs suisses' }, + it: { one: 'franco svizzero', other: 'franchi svizzeri' }, + pt: { one: 'franco suíço', other: 'francos suíços' }, + }), + }, + CAD: { + name: p({ + es: { one: 'dólar canadiense', other: 'dólares canadienses' }, + en: { one: 'Canadian dollar', other: 'Canadian dollars' }, + de: { one: 'Kanadischer Dollar', other: 'Kanadische Dollar' }, + fr: { one: 'dollar canadien', other: 'dollars canadiens' }, + it: { one: 'dollaro canadese', other: 'dollari canadesi' }, + pt: { one: 'dólar canadiano', other: 'dólares canadianos' }, + }), + }, + AUD: { + name: p({ + es: { one: 'dólar australiano', other: 'dólares australianos' }, + en: { one: 'Australian dollar', other: 'Australian dollars' }, + de: { one: 'Australischer Dollar', other: 'Australische Dollar' }, + fr: { one: 'dollar australien', other: 'dollars australiens' }, + it: { one: 'dollaro australiano', other: 'dollari australiani' }, + pt: { one: 'dólar australiano', other: 'dólares australianos' }, + }), + }, + MXN: { + name: p({ + es: { one: 'peso mexicano', other: 'pesos mexicanos' }, + en: { one: 'Mexican peso', other: 'Mexican pesos' }, + de: { one: 'Mexikanischer Peso', other: 'Mexikanische Pesos' }, + fr: { one: 'peso mexicain', other: 'pesos mexicains' }, + it: { one: 'peso messicano', other: 'pesos messicani' }, + pt: { one: 'peso mexicano', other: 'pesos mexicanos' }, + }), + }, + BRL: { + name: p({ + es: { one: 'real brasileño', other: 'reales brasileños' }, + en: { one: 'Brazilian real', other: 'Brazilian reais' }, + de: { one: 'Brasilianischer Real', other: 'Brasilianische Real' }, + fr: { one: 'réal brésilien', other: 'réals brésiliens' }, + it: { one: 'real brasiliano', other: 'real brasiliani' }, + pt: { one: 'real brasileiro', other: 'reais brasileiros' }, + }), + }, + CNY: { + name: p({ + es: { one: 'yuan chino', other: 'yuanes chinos' }, + en: { one: 'Chinese yuan', other: 'Chinese yuan' }, + de: { one: 'Chinesischer Yuan', other: 'Chinesische Yuan' }, + fr: { one: 'yuan chinois', other: 'yuans chinois' }, + it: { one: 'yuan cinese', other: 'yuan cinesi' }, + pt: { one: 'yuan chinês', other: 'yuans chineses' }, + }), + }, +} satisfies LingNode; + +export type CurrLingNode = typeof currLingNode; \ No newline at end of file diff --git a/src/lib/glob/lib/curr_types.ts b/src/lib/glob/lib/curr_types.ts new file mode 100644 index 000000000..1237471a9 --- /dev/null +++ b/src/lib/glob/lib/curr_types.ts @@ -0,0 +1,68 @@ +// ============================== +// CURR TYPES +// ============================== + +import type { LingPluralFn } from "../../ling/lib"; +import type { LocaleResolver } from "./comn_types.ts"; + + +/** + * Tasa de conversión de una moneda respecto a la moneda base. + * - `number` : tasa estática. + * - `() => number | null` : tasa dinámica (ej. consultada en tiempo real). + * - `null` : conversión no disponible para esta moneda. + * + * @remarks + * Todas las tasas deben expresarse respecto a la **misma** moneda base + * (ej. todas en USD). La fórmula de conversión es `(amount / fromRate) * toRate`. + * Mezclar bases distintas produce resultados incorrectos sin aviso. + */ +export type ConversionValue = number | (() => number | null) | null; + +/** Cómo se muestra la moneda en `f()`. */ +export type CurrDisplay = 'symbol' | 'code' | 'name'; + +/** Definición completa de una moneda. */ +export interface CurrDefinition { + /** Símbolo gráfico, ej. `'€'`, `'$'`. */ + symbol : string; + /** Función plural para el nombre de la moneda (ej. "euro" / "euros"). */ + name : LingPluralFn>; + /** Código ISO 4217, ej. `'EUR'`, `'USD'`. */ + code : string; + /** Tasa respecto a la moneda base. Omitir o `null` deshabilita la conversión. */ + conversionValue?: ConversionValue; +} + +export interface CurrInstance { + /** Formatea `amount` en la moneda activa según `display` y `locale`. */ + f : (amount: number, display?: CurrDisplay, locale?: LocaleResolver) => string; + /** + * Convierte `amount` de la moneda `from` a la moneda `to`. + * Devuelve `null` si alguna de las monedas no tiene `conversionValue`. + * Fórmula: `(amount / fromRate) * toRate` + */ + convert : (amount: number, from: string, to: string) => number | null; + /** Convierte `amount` desde la moneda activa (`selectedCurrency`) hacia `to`. */ + convertTo : (amount: number, to: string) => number | null; + /** Cambia la moneda activa. Acepta mayúsculas y minúsculas. */ + setCurrency: (currency: string) => void; + /** Devuelve el código ISO de la moneda activa. */ + getCurrency: () => string; + /** Busca la definición de una moneda por código ISO (insensible a mayúsculas). */ + get : (currency: string) => CurrDefinition | undefined; + /** Devuelve todos los códigos ISO disponibles en las definiciones. */ + getCurrencies: () => string[]; +} + +/** Configuración de `createCurr`. */ +export interface CurrConfig { + /** Moneda base para los cálculos de conversión. Default: `selectedCurrency`. */ + baseCurrency? : string; + /** Moneda activa al iniciar. Default: primera moneda de `definitions`. */ + selectedCurrency?: string; + /** Modo de display por defecto. Default: `'symbol'`. */ + display? : CurrDisplay; + /** Decimales fijos para el formateo. Default: `2`. */ + decimals? : number; +} diff --git a/src/lib/glob/lib/dati_engine.ts b/src/lib/glob/lib/dati_engine.ts new file mode 100644 index 000000000..a9ddb76d2 --- /dev/null +++ b/src/lib/glob/lib/dati_engine.ts @@ -0,0 +1,80 @@ +import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder, DatiConfig } from './dati_types.ts'; +import type { LocaleResolver, GlobLocale } from './comn_types.ts'; +import { resolveLocale } from './comn_engine.ts'; +import { DEFAULT_ISO_LOCALE } from '../../ling/lib/consts.ts'; + + +// ============================== +// HELPERS +// ============================== + +/** + * Normaliza `DatiOptions` a las opciones que acepta `Intl.DateTimeFormat`. + * - Sin opciones → solo fecha larga. + * - String → solo fecha con ese estilo. + * - Objeto → combina `date` y `time` según lo indicado. + */ +function resolveOpts(opts?: DatiOptions): { + dateStyle?: DatiStyleType; + timeStyle?: DatiStyleType; +} { + if (!opts) return { dateStyle: 'long' }; + if (typeof opts === 'string') return { dateStyle: opts }; + const { date, time } = opts; + if (!date && !time) return { dateStyle: 'long' }; + return { dateStyle: date, timeStyle: time }; +} + +// ============================== +// ENGINE +// ============================== + +/** + * Crea un formateador de fechas y horas basado en `Intl.DateTimeFormat`. + * + * @param opts - Preferencias iniciales (formato de hora, orden de fecha). + * @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito. + */ +export function createDati( + opts : DatiConfig = {}, + defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE +): DatiInstance { + + let timeFormat: DatiTimeFormat = opts.selectedTimeFormat ?? '24h'; + let dateOrder : DatiDateOrder = opts.selectedDateOrder ?? 'DMY'; + + // ── Preferencias ────────────────────────────────────────────────────────── + + function getTimeFormat(): DatiTimeFormat { return timeFormat; } + function setTimeFormat(format: DatiTimeFormat): void { timeFormat = format; } + + function getDateOrder(): DatiDateOrder { return dateOrder; } + function setDateOrder(order: DatiDateOrder): void { dateOrder = order; } + + // ── Format ──────────────────────────────────────────────────────────────── + + /** + * Formatea `date` usando `Intl.DateTimeFormat`. + * Cuando se incluye hora, aplica `hour12` según `timeFormat` de la instancia. + */ + function f( + date : Date, + callOpts: DatiOptions = 'long', + locale : LocaleResolver = defaultLocale + ): string { + const { dateStyle, timeStyle } = resolveOpts(callOpts); + const loc = resolveLocale(locale); + + const intlOpts: Intl.DateTimeFormatOptions = {}; + + if (dateStyle) intlOpts.dateStyle = dateStyle; + if (timeStyle) { + intlOpts.timeStyle = timeStyle; + intlOpts.hour12 = timeFormat === '12h'; + } + + return new Intl.DateTimeFormat(loc, intlOpts).format(date); + } + + return { f, getTimeFormat, setTimeFormat, getDateOrder, setDateOrder }; +} diff --git a/src/lib/glob/lib/dati_types.ts b/src/lib/glob/lib/dati_types.ts new file mode 100644 index 000000000..12d1c0158 --- /dev/null +++ b/src/lib/glob/lib/dati_types.ts @@ -0,0 +1,42 @@ +// ============================== +// DATI +// ============================== + +import type { LocaleResolver } from "./comn_types.ts"; + +/** + * Nivel de detalle en el formateo de fecha u hora. + * Mapea directamente a los valores que acepta `Intl.DateTimeFormat`. + */ +export type DatiStyleType = 'short' | 'medium' | 'long' | 'full'; +/** Formato de horas: reloj de 12 o 24 horas. */ +export type DatiTimeFormat = '12h' | '24h'; +/** Orden preferido de los componentes de fecha (informativo para la UI). */ +export type DatiDateOrder = 'DMY' | 'MDY' | 'YMD'; + +/** + * Opciones de `f()`: + * - `'short'` / `'medium'` / `'long'` / `'full'` : solo fecha en ese estilo. + * - `{ date?, time? }` : control independiente de fecha y hora. + */ +export type DatiOptions = + | DatiStyleType + | { date?: DatiStyleType; time?: DatiStyleType }; + +/** Configuración inicial de `createDati`. */ +export interface DatiConfig { + /** Formato de horas. Default: `'24h'`. */ + selectedTimeFormat?: DatiTimeFormat; + /** Orden de los componentes de fecha. Default: `'DMY'`. */ + selectedDateOrder?: DatiDateOrder; +} + +export interface DatiInstance { + /** Formatea `date` según las opciones y el locale indicados. */ + f : (date: Date, opts?: DatiOptions, locale?: LocaleResolver) => string; + setTimeFormat: (format: DatiTimeFormat) => void; + getTimeFormat: () => DatiTimeFormat; + /** Guarda la preferencia de orden de fecha para uso de la UI. No afecta a `Intl`. */ + setDateOrder : (order: DatiDateOrder) => void; + getDateOrder : () => DatiDateOrder; +} diff --git a/src/lib/glob/lib/index.ts b/src/lib/glob/lib/index.ts new file mode 100644 index 000000000..1b05d75c4 --- /dev/null +++ b/src/lib/glob/lib/index.ts @@ -0,0 +1,11 @@ +export * from './comn_types.ts'; +export * from './curr_types.ts'; +export * from './dati_types.ts'; +export * from './numr_types.ts'; +export * from './unit_types.ts'; + +export * from './comn_engine.ts'; +export * from './curr_engine.ts'; +export * from './dati_engine.ts'; +export * from './numr_engine.ts'; +export * from './unit_engine.ts'; diff --git a/src/lib/glob/lib/numr_engine.ts b/src/lib/glob/lib/numr_engine.ts new file mode 100644 index 000000000..804672be4 --- /dev/null +++ b/src/lib/glob/lib/numr_engine.ts @@ -0,0 +1,48 @@ +import { DEFAULT_ISO_LOCALE } from '../../ling/lib'; +import { resolveLocale } from './comn_engine.ts'; +import type { LocaleResolver } from './comn_types.ts'; +import type { NumrConfig, NumrInstance } from './numr_types.ts'; + + +// ============================== +// ENGINE +// ============================== + +/** + * Crea un formateador de números basado en `Intl.NumberFormat`. + * + * @param opts - Decimales por defecto de la instancia. + * @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito. + */ +export function createNumr( + opts : NumrConfig = {}, + defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE +): NumrInstance { + + const defaults: Required = { + minDecimals: opts.minDecimals ?? 0, + maxDecimals: opts.maxDecimals ?? 2, + }; + + /** + * Formatea `n` con el locale y los decimales indicados. + * Los `callOpts` tienen prioridad sobre los defaults de la instancia. + * Si `minDecimals > maxDecimals`, `maxDecimals` se eleva al valor de `minDecimals` + * para evitar un `RangeError` de `Intl.NumberFormat`. + */ + function f( + n : number, + callOpts?: NumrConfig, + locale : LocaleResolver = defaultLocale + ): string { + const min = callOpts?.minDecimals ?? defaults.minDecimals; + const max = Math.max(callOpts?.maxDecimals ?? defaults.maxDecimals, min); + + return new Intl.NumberFormat(resolveLocale(locale), { + minimumFractionDigits: min, + maximumFractionDigits: max, + }).format(n); + } + + return { f }; +} diff --git a/src/lib/glob/lib/numr_types.ts b/src/lib/glob/lib/numr_types.ts new file mode 100644 index 000000000..7a45f77df --- /dev/null +++ b/src/lib/glob/lib/numr_types.ts @@ -0,0 +1,22 @@ +// ============================== +// NUMR TYPES +// ============================== + +import type { LocaleResolver } from "./comn_types.ts"; + +/** Defaults de decimales para la instancia. Pueden sobreescribirse en cada llamada a `f()`. */ +export interface NumrConfig { + /** Mínimo de decimales mostrados. Default: `0`. */ + minDecimals?: number; + /** Máximo de decimales mostrados. Default: `2`. */ + maxDecimals?: number; +} + +export interface NumrInstance { + /** + * Formatea un número según el locale activo. + * Los `opts` de cada llamada sobreescriben los defaults de la instancia. + * Si `minDecimals > maxDecimals`, `maxDecimals` se eleva automáticamente. + */ + f: (n: number, opts?: NumrConfig, locale?: LocaleResolver) => string; +} diff --git a/src/lib/glob/lib/unit_data.ts b/src/lib/glob/lib/unit_data.ts new file mode 100644 index 000000000..630ad218e --- /dev/null +++ b/src/lib/glob/lib/unit_data.ts @@ -0,0 +1,71 @@ +import type { UnitCategoryKey, UnitCategoryData } from './unit_types.ts'; + +/** + * Datos numéricos de las unidades — factores de conversión y sistemas. + * Las traducciones (symbol, name) viven en units_lang.ts → ling. + */ +export const unitData: Record = { + + weight: { + base : 'kg', + baseImperial: 'lb', + units: { + kg: { factor: 1, system: 'metric' }, + g: { factor: 0.001, system: 'metric' }, + lb: { factor: 0.453592, system: 'imperial' }, + oz: { factor: 0.0283495, system: 'imperial' }, + t: { factor: 1000, system: 'metric' }, + }, + }, + + length: { + base : 'm', + baseImperial: 'mi', + units: { + km: { factor: 1000, system: 'metric' }, + m: { factor: 1, system: 'metric' }, + cm: { factor: 0.01, system: 'metric' }, + mm: { factor: 0.001, system: 'metric' }, + mi: { factor: 1609.344, system: 'imperial' }, + ft: { factor: 0.3048, system: 'imperial' }, + in: { factor: 0.0254, system: 'imperial' }, + yd: { factor: 0.9144, system: 'imperial' }, + }, + }, + + temp: { + base : 'c', + baseImperial: 'f', + units: { + c: { factor: 1, system: 'metric' }, + f: { factor: 1, system: 'imperial' }, + k: { factor: 1, system: 'metric' }, + }, + }, + + volume: { + base : 'l', + baseImperial: 'gal', + units: { + l: { factor: 1, system: 'metric' }, + ml: { factor: 0.001, system: 'metric' }, + m3: { factor: 1000, system: 'metric' }, + gal: { factor: 3.78541, system: 'imperial' }, + floz: { factor: 0.029574, system: 'imperial' }, + pt: { factor: 0.473176, system: 'imperial' }, + }, + }, + + area: { + base : 'm2', + baseImperial: 'ft2', + units: { + m2: { factor: 1, system: 'metric' }, + km2: { factor: 1000000, system: 'metric' }, + ha: { factor: 10000, system: 'metric' }, + ft2: { factor: 0.092903, system: 'imperial' }, + mi2: { factor: 2589988.11, system: 'imperial' }, + ac: { factor: 4046.856, system: 'imperial' }, + }, + }, +}; \ No newline at end of file diff --git a/src/lib/glob/lib/unit_engine.ts b/src/lib/glob/lib/unit_engine.ts new file mode 100644 index 000000000..56f4d5a93 --- /dev/null +++ b/src/lib/glob/lib/unit_engine.ts @@ -0,0 +1,143 @@ +import type { LocaleResolver, GlobLocale } from "./comn_types.ts"; +import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types.ts"; +import { resolveLocale } from "./comn_engine.ts"; +import { DEFAULT_ISO_LOCALE } from "../../ling/lib/consts.ts"; +import { type LingNode, type LingInstance } from "../../ling/lib"; +import { unitData } from "./unit_data.ts"; +import { unitsLingNode } from "./unit_langs.ts"; + +// ============================== +// CONVERSIÓN DE TEMPERATURA +// ============================== + +function convertTemp(value: number, from: string, to: string): number { + if (from === to) return value; + let c: number; + switch (from) { + case 'c': c = value; break; + case 'f': c = (value - 32) * 5 / 9; break; + case 'k': c = value - 273.15; break; + default: c = value; + } + switch (to) { + case 'c': return c; + case 'f': return c * 9 / 5 + 32; + case 'k': return c + 273.15; + default: return c; + } +} + +// ============================== +// CATEGORY ENGINE +// ============================== + +function createCategory( + categoryKey : UnitCategoryKey, + category : UnitCategoryData, + getSystem : () => UnitSystem, + defaultLocale: LocaleResolver, + lingInst : LingInstance, +): UnitCategoryInstance { + + function resolveTargetUnit(): string { + return getSystem() === 'imperial' ? category.baseImperial : category.base; + } + + function formatValue(value: number, unitKey: string, display: UnitDisplay, locale: GlobLocale): string { + const formatted = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value); + + if (display === 'abbr') { + const abbr = lingInst.t(`units.${categoryKey}.${unitKey}.abbr` as any); + return `${formatted} ${abbr}`; + } + + const name = lingInst.t(`units.${categoryKey}.${unitKey}.name` as any, { count: value }); + return `${formatted} ${name}`; + } + + function convert(value: number, from: string, to: string): number { + if (from === to) return value; + if (categoryKey === 'temp') return convertTemp(value, from, to); + const fromData = category.units[from]; + const toData = category.units[to]; + if (!fromData || !toData) return value; + return (value * fromData.factor) / toData.factor; + } + + /** + * Formatea `value` en la unidad base del sistema activo. + * Asume que `value` ya está expresado en esa unidad. + * Para convertir desde otra unidad primero, usa `fTo()`. + */ + function f(value: number, display: UnitDisplay = 'abbr', locale: LocaleResolver = defaultLocale): string { + const loc = resolveLocale(locale); + const unit = resolveTargetUnit(); + return formatValue(value, unit, display, loc); + } + + /** + * Convierte `value` desde `fromUnit` a `targetUnit` y lo formatea. + * Si se omite `fromUnit`, usa la unidad base del sistema activo. + * Si se omite `targetUnit`, usa la unidad base del sistema activo. + */ + function fTo( + value : number, + fromUnit? : string, + targetUnit?: string, + display : UnitDisplay = 'abbr', + locale : LocaleResolver = defaultLocale + ): string { + const loc = resolveLocale(locale); + const from = fromUnit ?? resolveTargetUnit(); + const to = targetUnit ?? resolveTargetUnit(); + const converted = convert(value, from, to); + return formatValue(converted, to, display, loc); + } + + return { f, fTo, convert }; +} + +// ============================== +// UNIT ENGINE +// ============================== + +// Flag de módulo para registrar traducciones en ling solo una vez, +// independientemente de cuántas veces se llame a createUnit(). +let unitsExtended = false; + +export function createUnit( + lingInst : LingInstance, + categories : Partial> = {}, + opts : { selectedSystem?: UnitSystem } = {}, + defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE +): UnitInstance { + + // Registra traducciones solo en la primera llamada — idempotente y eficiente + if (!unitsExtended) { + lingInst.extend('units', unitsLingNode); + unitsExtended = true; + } + + let currentSystem: UnitSystem = opts.selectedSystem ?? 'metric'; + + function getSystem(): UnitSystem { return currentSystem; } + function setSystem(s: UnitSystem) { currentSystem = s; } + + const merged: Record = { + ...unitData, + ...categories, + }; + + const makeCategory = (key: UnitCategoryKey) => + createCategory(key, merged[key], getSystem, defaultLocale, lingInst); + + return { + getSystem, + setSystem, + weight : makeCategory('weight'), + length : makeCategory('length'), + temp : makeCategory('temp'), + volume : makeCategory('volume'), + area : makeCategory('area'), + }; +} diff --git a/src/lib/glob/lib/unit_langs.ts b/src/lib/glob/lib/unit_langs.ts new file mode 100644 index 000000000..cedf933a0 --- /dev/null +++ b/src/lib/glob/lib/unit_langs.ts @@ -0,0 +1,50 @@ +import { p } from '../../ling/lib/engine.ts'; +import type { LingNode } from '../../ling/lib/types.ts'; + +/** + * Traducciones de unidades de medida. + * Se registra en ling bajo el nodo 'units' via glob_engine. + * Los datos numéricos (factor, system, base) viven en units_data.ts. + */ +export const unitsLingNode = { + weight: { + kg: { abbr: { es: 'kg', en: 'kg' }, name: p({ es: { one: 'kilogramo', other: 'kilogramos' }, en: { one: 'kilogram', other: 'kilograms' } }) }, + g: { abbr: { es: 'g', en: 'g' }, name: p({ es: { one: 'gramo', other: 'gramos' }, en: { one: 'gram', other: 'grams' } }) }, + lb: { abbr: { es: 'lb', en: 'lb' }, name: p({ es: { one: 'libra', other: 'libras' }, en: { one: 'pound', other: 'pounds' } }) }, + oz: { abbr: { es: 'oz', en: 'oz' }, name: p({ es: { one: 'onza', other: 'onzas' }, en: { one: 'ounce', other: 'ounces' } }) }, + t: { abbr: { es: 't', en: 't' }, name: p({ es: { one: 'tonelada', other: 'toneladas' }, en: { one: 'tonne', other: 'tonnes' } }) }, + }, + length: { + km: { abbr: { es: 'km', en: 'km' }, name: p({ es: { one: 'kilómetro', other: 'kilómetros' }, en: { one: 'kilometer', other: 'kilometers' } }) }, + m: { abbr: { es: 'm', en: 'm' }, name: p({ es: { one: 'metro', other: 'metros' }, en: { one: 'meter', other: 'meters' } }) }, + cm: { abbr: { es: 'cm', en: 'cm' }, name: p({ es: { one: 'centímetro', other: 'centímetros' }, en: { one: 'centimeter', other: 'centimeters' } }) }, + mm: { abbr: { es: 'mm', en: 'mm' }, name: p({ es: { one: 'milímetro', other: 'milímetros' }, en: { one: 'millimeter', other: 'millimeters' } }) }, + mi: { abbr: { es: 'mi', en: 'mi' }, name: p({ es: { one: 'milla', other: 'millas' }, en: { one: 'mile', other: 'miles' } }) }, + ft: { abbr: { es: 'ft', en: 'ft' }, name: p({ es: { one: 'pie', other: 'pies' }, en: { one: 'foot', other: 'feet' } }) }, + in: { abbr: { es: 'in', en: 'in' }, name: p({ es: { one: 'pulgada', other: 'pulgadas' }, en: { one: 'inch', other: 'inches' } }) }, + yd: { abbr: { es: 'yd', en: 'yd' }, name: p({ es: { one: 'yarda', other: 'yardas' }, en: { one: 'yard', other: 'yards' } }) }, + }, + temp: { + c: { abbr: { es: '°C', en: '°C' }, name: p({ es: { one: 'grado Celsius', other: 'grados Celsius' }, en: { one: 'degree Celsius', other: 'degrees Celsius' } }) }, + f: { abbr: { es: '°F', en: '°F' }, name: p({ es: { one: 'grado Fahrenheit', other: 'grados Fahrenheit' }, en: { one: 'degree Fahrenheit', other: 'degrees Fahrenheit' } }) }, + k: { abbr: { es: 'K', en: 'K' }, name: p({ es: { one: 'kelvin', other: 'kelvins' }, en: { one: 'kelvin', other: 'kelvins' } }) }, + }, + volume: { + l: { abbr: { es: 'l', en: 'l' }, name: p({ es: { one: 'litro', other: 'litros' }, en: { one: 'liter', other: 'liters' } }) }, + ml: { abbr: { es: 'ml', en: 'ml' }, name: p({ es: { one: 'mililitro', other: 'mililitros' }, en: { one: 'milliliter', other: 'milliliters' } }) }, + m3: { abbr: { es: 'm³', en: 'm³' }, name: p({ es: { one: 'metro cúbico', other: 'metros cúbicos'}, en: { one: 'cubic meter', other: 'cubic meters' } }) }, + gal: { abbr: { es: 'gal', en: 'gal' }, name: p({ es: { one: 'galón', other: 'galones' }, en: { one: 'gallon', other: 'gallons' } }) }, + floz: { abbr: { es: 'fl oz', en: 'fl oz' }, name: p({ es: { one: 'onza líquida', other: 'onzas líquidas'}, en: { one: 'fluid ounce', other: 'fluid ounces' } }) }, + pt: { abbr: { es: 'pt', en: 'pt' }, name: p({ es: { one: 'pinta', other: 'pintas' }, en: { one: 'pint', other: 'pints' } }) }, + }, + area: { + m2: { abbr: { es: 'm²', en: 'm²' }, name: p({ es: { one: 'metro cuadrado', other: 'metros cuadrados' }, en: { one: 'square meter', other: 'square meters' } }) }, + km2: { abbr: { es: 'km²', en: 'km²' }, name: p({ es: { one: 'kilómetro cuadrado', other: 'kilómetros cuadrados' }, en: { one: 'square kilometer', other: 'square kilometers' } }) }, + ha: { abbr: { es: 'ha', en: 'ha' }, name: p({ es: { one: 'hectárea', other: 'hectáreas' }, en: { one: 'hectare', other: 'hectares' } }) }, + ft2: { abbr: { es: 'ft²', en: 'ft²' }, name: p({ es: { one: 'pie cuadrado', other: 'pies cuadrados' }, en: { one: 'square foot', other: 'square feet' } }) }, + mi2: { abbr: { es: 'mi²', en: 'mi²' }, name: p({ es: { one: 'milla cuadrada', other: 'millas cuadradas' }, en: { one: 'square mile', other: 'square miles' } }) }, + ac: { abbr: { es: 'ac', en: 'ac' }, name: p({ es: { one: 'acre', other: 'acres' }, en: { one: 'acre', other: 'acres' } }) }, + }, +} satisfies LingNode; + +export type UnitsLingNode = typeof unitsLingNode; \ No newline at end of file diff --git a/src/lib/glob/lib/unit_types.ts b/src/lib/glob/lib/unit_types.ts new file mode 100644 index 000000000..f90938519 --- /dev/null +++ b/src/lib/glob/lib/unit_types.ts @@ -0,0 +1,53 @@ +// ============================== +// UNIT TYPES +// ============================== +import type { LocaleResolver } from "./comn_types.ts"; + +export type UnitDisplay = 'abbr' | 'name'; +export type UnitSystem = 'metric' | 'imperial'; + +export type UnitCategoryKey = 'weight' | 'length' | 'temp' | 'volume' | 'area'; + +/** Solo datos numéricos — sin texto, sin traducciones */ +export interface UnitData { + factor : number; + system : UnitSystem; +} + +/** Datos de categoría — base y factores de conversión */ +export interface UnitCategoryData { + base : string; + baseImperial : string; + units : Record; +} + +export interface UnitCategoryInstance { + /** + * Formatea `value` en la unidad activa del sistema actual (métrico o imperial). + * Asume que `value` ya está expresado en esa unidad base. + * Para convertir desde otra unidad, usa `fTo()`. + */ + f : (value: number, display?: UnitDisplay, locale?: LocaleResolver) => string; + /** + * Convierte `value` desde `fromUnit` (o la unidad base del sistema actual si se omite) + * a `targetUnit` (o la unidad base del sistema actual si se omite) y lo formatea. + */ + fTo : (value: number, fromUnit?: string, targetUnit?: string, display?: UnitDisplay, locale?: LocaleResolver) => string; + /** Convierte `value` de la unidad `from` a la unidad `to`. */ + convert: (value: number, from: string, to: string) => number; +} + +export interface UnitConfig { + selectedSystem?: UnitSystem; + categories?: Partial>; +} + +export interface UnitInstance { + weight : UnitCategoryInstance; + length : UnitCategoryInstance; + temp : UnitCategoryInstance; + volume : UnitCategoryInstance; + area : UnitCategoryInstance; + setSystem: (system: UnitSystem) => void; + getSystem: () => UnitSystem; +} diff --git a/src/lib/glob/test/glob.test.ts b/src/lib/glob/test/glob.test.ts new file mode 100644 index 000000000..030c14b29 --- /dev/null +++ b/src/lib/glob/test/glob.test.ts @@ -0,0 +1,601 @@ +/** + * glob — test suite + * + * Ejecutar: npx vitest run + * + * Módulos cubiertos: + * - comn_engine (resolveLocale) + * - numr_engine (createNumr) + * - curr_engine (createCurr) + * - dati_engine (createDati) + * - unit_engine (createUnit) + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createLing } from '../../ling/lib'; +import { createNumr } from '../lib/numr_engine'; +import { createCurr } from '../lib/curr_engine'; +import { createDati } from '../lib/dati_engine'; +import { createUnit } from '../lib/unit_engine'; +import { resolveLocale } from '../lib/comn_engine'; +import { unitsLingNode } from '../lib/unit_langs'; +import type { NumrConfig } from '../lib/numr_types'; +import type { CurrDefinition } from '../lib/curr_types'; +import type { DatiConfig, DatiOptions } from '../lib/dati_types'; +import type { UnitSystem, UnitDisplay } from '../lib/unit_types'; + +// ============================================================================ +// COMMON +// ============================================================================ + +describe('resolveLocale', () => { + it('devuelve el string si es un locale directo', () => { + expect(resolveLocale('es')).toBe('es'); + expect(resolveLocale('en-US')).toBe('en-US'); + }); + + it('ejecuta la función y devuelve el resultado', () => { + const resolver = () => 'fr'; + expect(resolveLocale(resolver)).toBe('fr'); + }); + + it('la función se ejecuta en cada llamada (reactividad)', () => { + let current = 'es'; + const resolver = () => current; + + expect(resolveLocale(resolver)).toBe('es'); + current = 'de'; + expect(resolveLocale(resolver)).toBe('de'); + }); +}); + +// ============================================================================ +// NUMR +// ============================================================================ + +describe('createNumr', () => { + describe('f()', () => { + it('formatea números con el locale por defecto', () => { + const numr = createNumr({}, 'es'); + const result = numr.f(1234.5); + // El formato exacto depende de la implementación de Intl + expect(result).toBeTruthy(); + expect(result).toContain('5'); + }); + + it('formatea con locale inglés', () => { + const numr = createNumr({}, 'en'); + const result = numr.f(1234.5); + expect(result).toBeTruthy(); + expect(result).toContain('5'); + }); + + it('usa minDecimals por defecto (0)', () => { + const numr = createNumr({}, 'es'); + const result = numr.f(1234); + expect(result).toBeTruthy(); + expect(result).toContain('1234'); + }); + + it('usa maxDecimals por defecto (2)', () => { + const numr = createNumr({}, 'es'); + const result = numr.f(1234.5678); + expect(result).toBeTruthy(); + // Debe tener máximo 2 decimales + expect(result).toContain('57'); + }); + + it('respeta minDecimals de la instancia', () => { + const opts: NumrConfig = { minDecimals: 2 }; + const numr = createNumr(opts, 'es'); + const result = numr.f(1234); + expect(result).toBeTruthy(); + // Debe tener 2 decimales + expect(result).toContain('00'); + }); + + it('respeta maxDecimals de la instancia', () => { + const opts: NumrConfig = { maxDecimals: 4 }; + const numr = createNumr(opts, 'es'); + const result = numr.f(1234.56789); + expect(result).toBeTruthy(); + // Debe tener 4 decimales + expect(result).toContain('5679'); + }); + + it('los opts de llamada sobreescriben los defaults', () => { + const numr = createNumr({ minDecimals: 0, maxDecimals: 2 }, 'es'); + const result = numr.f(1234.5, { minDecimals: 1, maxDecimals: 3 }); + expect(result).toBeTruthy(); + // Verifica que el resultado contiene el valor principal + expect(result).toContain('1234'); + }); + + it('eleva maxDecimals si minDecimals > maxDecimals', () => { + const numr = createNumr({}, 'es'); + const result = numr.f(1234, { minDecimals: 3, maxDecimals: 1 }); + expect(result).toBeTruthy(); + // Debe tener 3 decimales (elevado al minDecimals) + expect(result).toContain('000'); + }); + + it('acepta locale como función (reactivo)', () => { + let current = 'es'; + const numr = createNumr({}, () => current); + + const esResult = numr.f(1234.5); + expect(esResult).toBeTruthy(); + + current = 'en'; + const enResult = numr.f(1234.5); + expect(enResult).toBeTruthy(); + // Los resultados deben ser diferentes (diferentes separadores) + expect(esResult).not.toBe(enResult); + }); + + it('permite override el locale en cada llamada', () => { + const numr = createNumr({}, 'es'); + const result = numr.f(1234.5, {}, 'en'); + expect(result).toBeTruthy(); + }); + }); +}); + +// ============================================================================ +// CURR +// ============================================================================ + +const EUR: CurrDefinition = { + symbol: '€', + code: 'EUR', + name: ({ count }) => ({ + es: count === 1 ? 'euro' : 'euros', + en: count === 1 ? 'euro' : 'euros', + }), + conversionValue: 1, // Base +}; + +const USD: CurrDefinition = { + symbol: '$', + code: 'USD', + name: ({ count }) => ({ + es: count === 1 ? 'dólar' : 'dólares', + en: count === 1 ? 'dollar' : 'dollars', + }), + conversionValue: 1.1, // 1 EUR = 1.1 USD (ejemplo) +}; + +const GBP: CurrDefinition = { + symbol: '£', + code: 'GBP', + name: ({ count }) => ({ + es: count === 1 ? 'libra' : 'libras', + en: count === 1 ? 'pound' : 'pounds', + }), + conversionValue: 0.85, // 1 EUR = 0.85 GBP +}; + +describe('createCurr', () => { + let ling: ReturnType; + + beforeEach(() => { + ling = createLing({}, 'es'); + }); + + describe('f() - formateo', () => { + it('formatea con display symbol por defecto', () => { + const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.f(99.99)).toBe('99,99 €'); + }); + + it('formatea con display code', () => { + const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.f(99.99, 'code')).toBe('99,99 EUR'); + }); + + it('formatea con display name (singular)', () => { + const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.f(1, 'name')).toBe('1,00 euro'); + }); + + it('formatea con display name (plural)', () => { + const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.f(2, 'name')).toBe('2,00 euros'); + }); + + it('resuelve el nombre según el locale', () => { + const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'en'); + expect(curr.f(2, 'name', 'en')).toBe('2.00 euros'); + }); + + it('usa el locale por defecto si no se especifica', () => { + const curr = createCurr({ EUR }, ling, { selectedCurrency: 'EUR' }, 'en'); + expect(curr.f(2, 'name')).toBe('2.00 euros'); + }); + + it('si la moneda no existe, devuelve importe + código', () => { + const curr = createCurr({}, ling, { selectedCurrency: 'XXX' }, 'es'); + expect(curr.f(99.99)).toBe('99,99 XXX'); + }); + }); + + describe('convert() - conversión', () => { + it('convierte entre monedas con tasas definidas', () => { + const curr = createCurr({ EUR, USD, GBP }, ling, {}, 'es'); + // 100 EUR * 1.1 = 110 USD + expect(curr.convert(100, 'EUR', 'USD')).toBeCloseTo(110); + // 100 EUR * 0.85 = 85 GBP + expect(curr.convert(100, 'EUR', 'GBP')).toBe(85); + }); + + it('convierte en sentido inverso', () => { + const curr = createCurr({ EUR, USD }, ling, {}, 'es'); + // 110 USD / 1.1 = 100 EUR + expect(curr.convert(110, 'USD', 'EUR')).toBeCloseTo(100); + }); + + it('devuelve null si una moneda no existe', () => { + const curr = createCurr({ EUR }, ling, {}, 'es'); + expect(curr.convert(100, 'EUR', 'XXX')).toBeNull(); + }); + + it('devuelve null si una moneda no tiene tasa', () => { + const curr = createCurr( + { + EUR: { ...EUR, conversionValue: 1 }, + USD: { ...USD, conversionValue: null as any }, + }, + ling, + {}, + 'es' + ); + expect(curr.convert(100, 'EUR', 'USD')).toBeNull(); + }); + + it('devuelve el mismo valor si from === to', () => { + const curr = createCurr({ EUR }, ling, {}, 'es'); + expect(curr.convert(100, 'EUR', 'EUR')).toBe(100); + }); + + it('es insensible a mayúsculas', () => { + const curr = createCurr({ EUR, USD }, ling, {}, 'es'); + expect(curr.convert(100, 'eur', 'usd')).toBeCloseTo(110); + }); + }); + + describe('convertTo() - conversión desde moneda activa', () => { + it('convierte desde la moneda activa', () => { + const curr = createCurr({ EUR, USD }, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.convertTo(100, 'USD')).toBeCloseTo(110); + }); + + it('se actualiza al cambiar la moneda activa', () => { + const curr = createCurr({ EUR, USD }, ling, { selectedCurrency: 'EUR' }, 'es'); + curr.setCurrency('USD'); + // 100 USD / 1.1 = 90.91 EUR + expect(curr.convertTo(100, 'EUR')).toBeCloseTo(90.91, 1); + }); + }); + + describe('setCurrency / getCurrency', () => { + it('getCurrency devuelve la moneda activa', () => { + const curr = createCurr({ EUR, USD }, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.getCurrency()).toBe('EUR'); + }); + + it('setCurrency cambia la moneda activa', () => { + const curr = createCurr({ EUR, USD }, ling, { selectedCurrency: 'EUR' }, 'es'); + curr.setCurrency('USD'); + expect(curr.getCurrency()).toBe('USD'); + }); + + it('setCurrency normaliza a mayúsculas', () => { + const curr = createCurr({ EUR, USD }, ling, {}, 'es'); + curr.setCurrency('eur'); + expect(curr.getCurrency()).toBe('EUR'); + }); + }); + + describe('get() - búsqueda de definiciones', () => { + it('devuelve la definición de una moneda', () => { + const curr = createCurr({ EUR, USD }, ling, {}, 'es'); + expect(curr.get('EUR')).toEqual(EUR); + }); + + it('es insensible a mayúsculas', () => { + const curr = createCurr({ EUR, USD }, ling, {}, 'es'); + expect(curr.get('eur')).toEqual(EUR); + }); + + it('devuelve undefined si no existe', () => { + const curr = createCurr({ EUR }, ling, {}, 'es'); + expect(curr.get('XXX')).toBeUndefined(); + }); + }); + + describe('getCurrencies()', () => { + it('devuelve todos los códigos ISO disponibles', () => { + const curr = createCurr({ EUR, USD, GBP }, ling, {}, 'es'); + const codes = curr.getCurrencies(); + expect(codes).toHaveLength(3); + expect(codes).toContain('EUR'); + expect(codes).toContain('USD'); + expect(codes).toContain('GBP'); + }); + }); +}); + +// ============================================================================ +// DATI +// ============================================================================ + +describe('createDati', () => { + const testDate = new Date('2024-03-15T14:30:00Z'); + + describe('f() - formateo', () => { + it('formatea con estilo long por defecto', () => { + const dati = createDati({}, 'es'); + const result = dati.f(testDate); + expect(result).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); + }); + + it('formatea con estilo short', () => { + const dati = createDati({}, 'es'); + const result = dati.f(testDate, 'short'); + expect(result).toBeTruthy(); + }); + + it('formatea con estilo medium', () => { + const dati = createDati({}, 'es'); + const result = dati.f(testDate, 'medium'); + expect(result).toBeTruthy(); + }); + + it('formatea con estilo full', () => { + const dati = createDati({}, 'es'); + const result = dati.f(testDate, 'full'); + expect(result).toBeTruthy(); + }); + + it('formatea fecha y hora por separado', () => { + const dati = createDati({ selectedTimeFormat: '24h' }, 'es'); + const opts: DatiOptions = { date: 'short', time: 'short' }; + const result = dati.f(testDate, opts); + expect(result).toBeTruthy(); + }); + + it('respeta el formato de hora 12h', () => { + const dati = createDati({ selectedTimeFormat: '12h' }, 'en-US'); + const opts: DatiOptions = { date: 'short', time: 'short' }; + const result = dati.f(testDate, opts); + expect(result).toBeTruthy(); + }); + + it('respeta el formato de hora 24h', () => { + const dati = createDati({ selectedTimeFormat: '24h' }, 'es'); + const opts: DatiOptions = { date: 'short', time: 'short' }; + const result = dati.f(testDate, opts); + expect(result).toBeTruthy(); + }); + + it('permite override el locale en cada llamada', () => { + const dati = createDati({}, 'es'); + const result = dati.f(testDate, 'long', 'en-US'); + expect(result).toBeTruthy(); + }); + + it('acepta locale como función (reactivo)', () => { + let current = 'es'; + const dati = createDati({}, () => current); + + const esResult = dati.f(testDate); + expect(esResult).toBeTruthy(); + + current = 'en-US'; + const enResult = dati.f(testDate); + expect(enResult).toBeTruthy(); + }); + }); + + describe('setTimeFormat / getTimeFormat', () => { + it('getTimeFormat devuelve el formato por defecto (24h)', () => { + const dati = createDati({}, 'es'); + expect(dati.getTimeFormat()).toBe('24h'); + }); + + it('setTimeFormat cambia el formato de hora', () => { + const dati = createDati({}, 'es'); + dati.setTimeFormat('12h'); + expect(dati.getTimeFormat()).toBe('12h'); + }); + }); + + describe('setDateOrder / getDateOrder', () => { + it('getDateOrder devuelve el orden por defecto (DMY)', () => { + const dati = createDati({}, 'es'); + expect(dati.getDateOrder()).toBe('DMY'); + }); + + it('setDateOrder cambia el orden de fecha', () => { + const dati = createDati({}, 'es'); + dati.setDateOrder('MDY'); + expect(dati.getDateOrder()).toBe('MDY'); + }); + }); +}); + +// ============================================================================ +// UNIT +// ============================================================================ + +describe('createUnit', () => { + let ling: ReturnType; + + beforeEach(() => { + ling = createLing({ + units: unitsLingNode, + }, 'es'); + }); + + describe('f() - formateo en sistema métrico', () => { + it('formatea peso en kg (sistema métrico)', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + expect(unit.weight.f(75)).toContain('75'); + }); + + it('formatea longitud en m (sistema métrico)', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + expect(unit.length.f(180)).toContain('180'); + }); + + it('formatea temperatura en °C (sistema métrico)', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + expect(unit.temp.f(20)).toContain('20'); + }); + }); + + describe('f() - formateo en sistema imperial', () => { + it('formatea peso en lb (sistema imperial)', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'imperial' }, 'en-US'); + expect(unit.weight.f(165)).toContain('165'); + }); + + it('formatea longitud en ft (sistema imperial)', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'imperial' }, 'en-US'); + expect(unit.length.f(6)).toContain('6'); + }); + + it('formatea temperatura en °F (sistema imperial)', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'imperial' }, 'en-US'); + expect(unit.temp.f(68)).toContain('68'); + }); + }); + + describe('fTo() - conversión y formateo', () => { + it('convierte peso: kg a lb', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + const result = unit.weight.fTo(75, 'kg', 'lb'); + // 75 kg ≈ 165.35 lb + expect(result).toBeTruthy(); + expect(result).toContain('165'); + }); + + it('convierte longitud: m a ft', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + const result = unit.length.fTo(1.8, 'm', 'ft'); + // 1.8 m ≈ 5.9 ft + expect(result).toBeTruthy(); + expect(result).toContain('5'); + }); + + it('convierte temperatura: °C a °F', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + const result = unit.temp.fTo(20, 'c', 'f'); + // 20°C = 68°F exactamente + expect(result).toContain('68'); + }); + + it('convierte desde la unidad base del sistema actual si se omite fromUnit', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + const result = unit.weight.fTo(75, undefined, 'lb'); + expect(result).toBeTruthy(); + expect(result).toContain('165'); + }); + + it('convierte a la unidad base del sistema actual si se omite targetUnit', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + const result = unit.weight.fTo(165, 'lb', undefined); + // 165 lb ≈ 74.84 kg + expect(result).toBeTruthy(); + expect(result).toContain('74'); + }); + }); + + describe('convert() - conversión sin formateo', () => { + it('convierte peso: kg a lb', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.weight.convert(75, 'kg', 'lb')).toBeCloseTo(165.35, 1); + }); + + it('convierte longitud: m a ft', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.length.convert(1, 'm', 'ft')).toBeCloseTo(3.28, 2); + }); + + it('convierte temperatura: °C a °F', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.temp.convert(20, 'c', 'f')).toBe(68); + }); + + it('devuelve el mismo valor si from === to', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.weight.convert(75, 'kg', 'kg')).toBe(75); + }); + }); + + describe('setSystem / getSystem', () => { + it('getSystem devuelve el sistema por defecto (metric)', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.getSystem()).toBe('metric'); + }); + + it('setSystem cambia el sistema activo', () => { + const unit = createUnit(ling, {}, {}, 'es'); + unit.setSystem('imperial'); + expect(unit.getSystem()).toBe('imperial'); + }); + + it('f() usa la unidad base del nuevo sistema tras setSystem', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + const metricResult = unit.weight.f(75); + expect(metricResult).toContain('kg'); + + unit.setSystem('imperial'); + const imperialResult = unit.weight.f(165); + expect(imperialResult).toContain('lb'); + }); + }); + + describe('display option', () => { + it('display: "abbr" muestra abreviatura', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + const result = unit.weight.f(75, 'abbr'); + expect(result).toContain('kg'); + }); + + it('display: "name" muestra nombre completo', () => { + const unit = createUnit(ling, {}, { selectedSystem: 'metric' }, 'es'); + const result = unit.weight.f(75, 'name'); + // Contiene "kilogramo" o "kg" según traducciones + expect(result).toBeTruthy(); + }); + }); + + describe('todas las categorías disponibles', () => { + it('weight está disponible', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.weight.f(70)).toBeTruthy(); + }); + + it('length está disponible', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.length.f(170)).toBeTruthy(); + }); + + it('temp está disponible', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.temp.f(25)).toBeTruthy(); + }); + + it('volume está disponible', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.volume.f(1000)).toBeTruthy(); + }); + + it('area está disponible', () => { + const unit = createUnit(ling, {}, {}, 'es'); + expect(unit.area.f(100)).toBeTruthy(); + }); + }); +}); diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 000000000..856f2b6c3 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/ling/lib/consts.ts b/src/lib/ling/lib/consts.ts new file mode 100644 index 000000000..21d9c98cf --- /dev/null +++ b/src/lib/ling/lib/consts.ts @@ -0,0 +1,9 @@ +export const ID_PREFIX = '#?'; + +export const DEFAULT_ISO_LOCALE = 'es' as const; + +export const MAX_RESOLVE_DEEP = 3; + +export const LOGGER_CATEGORY = 'ling'; + + diff --git a/src/lib/ling/lib/engine.ts b/src/lib/ling/lib/engine.ts new file mode 100644 index 000000000..474edf509 --- /dev/null +++ b/src/lib/ling/lib/engine.ts @@ -0,0 +1,320 @@ +import type { + SupportedLocale, + LeafPaths, + GetTypeAtPath, + ParamsFor, + HasParams, + PluralForms, + LingRecord, + LingString, LingInstance, LingLogger, LingNode, PluralConfig, +} from './types.ts'; +import {LING_ERRORS} from "./errors.ts"; +import {ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP} from "./consts.ts"; +import {isIDLing, isLingRecord} from "./guards.ts"; +import {pluralRule} from "./plural_rules.ts"; +import {resolvePath} from "./helpers.ts"; + + + + +// Memoizado — evaluado una sola vez en módulo load. +// Usa import.meta.env.DEV (Vite/SvelteKit) con fallback a process.env para Node. +const DEV: boolean = + typeof import.meta !== 'undefined' && import.meta.env != null + ? import.meta.env.DEV === true + : typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'; + +/** Deep-merge recursivo de objetos planos. Las funciones y primitivos de `source` sobreescriben `target`. */ +function deepMerge(target: any, source: any): any { + const result = { ...target }; + for (const key of Object.keys(source)) { + const srcVal = source[key]; + const tgtVal = result[key]; + if ( + typeof srcVal === 'object' && srcVal !== null && !Array.isArray(srcVal) && typeof srcVal !== 'function' && + typeof tgtVal === 'object' && tgtVal !== null && !Array.isArray(tgtVal) && typeof tgtVal !== 'function' + ) { + result[key] = deepMerge(tgtVal, srcVal); + } else { + result[key] = srcVal; + } + } + return result; +} + + + +// ============================== +// ENGINE +// ============================== + +export function createLing( + schema: S, + defaultLocale: SupportedLocale +): LingInstance { + + let currentSchema : LingNode = schema; + let currentLocale : SupportedLocale = defaultLocale; + let logger : LingLogger = consoleLogger; + let loggerSet : boolean = false; + + const listeners = new Set<(locale: SupportedLocale) => void>(); + const schemaListeners = new Set<() => void>(); + + // Expuesto para que instancias hijas (register) puedan ser destruidas. + let __dispose: (() => void) | undefined; + + // ------------------------------------------------------------------------- + // Logger + // ------------------------------------------------------------------------- + + function setLogger(external: LingLogger): void { + if (loggerSet) { + if (DEV) console.warn(LING_ERRORS.LOGGER_ALREADY_SET); + return; + } + logger = external; + loggerSet = true; + } + + // ------------------------------------------------------------------------- + // Locale + // ------------------------------------------------------------------------- + + function setLocale(locale: SupportedLocale): void { + currentLocale = locale; + listeners.forEach(fn => fn(locale)); + } + + function getLocale(): SupportedLocale { + return currentLocale; + } + + function onLocaleChange(fn: (locale: SupportedLocale) => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); + } + + // ------------------------------------------------------------------------- + // Resolución interna + // ------------------------------------------------------------------------- + + function tsRecord(record: LingRecord, path?: string, params?: any): string { + const translationInLocale = record[currentLocale]; + const isMissing = translationInLocale === undefined; + + let translation = translationInLocale ?? record[defaultLocale] ?? path ?? ''; + + if (params) { + // replaceAll con string literal es más rápido que new RegExp y evita + // inyección de caracteres especiales de regex en las claves de los params. + for (const [key, val] of Object.entries(params)) { + translation = translation.replaceAll(`{{${key}}}`, String(val)); + } + } + + if (DEV && isMissing) { + const msg = path + ? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale) + : LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale); + logger.warn(LOGGER_CATEGORY, msg); + } + + return translation; + } + + /** + * Resuelve un valor del schema siguiendo referencias (#?) y funciones + * de forma recursiva. Lanza si se supera el límite de profundidad. + */ + function resolveValue(value: any, args: any[], depth: number): any { + if (depth > MAX_RESOLVE_DEEP) { + logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(String(value))); + throw new Error("Circular reference in ling"); + } + + if (isIDLing(value)) { + const path = value.substring(ID_PREFIX.length); + const resolved = resolvePath(currentSchema, path); + return resolveValue(resolved, args, depth + 1); + } + + if (typeof value === 'function') { + return value(args[0]); + } + + return value; // LingRecord, string, etc. + } + + // ------------------------------------------------------------------------- + // API pública + // ------------------------------------------------------------------------- + + const t: LingInstance['t'] = (path: string, ...args: any[]): any => { + const rawValue = resolvePath(currentSchema, path); + + if (rawValue === undefined) { + if (DEV) logger.error(LOGGER_CATEGORY, LING_ERRORS.KEY_NOT_FOUND(path)); + return path; + } + + // Toda la resolución de referencias y funciones vive en resolveValue. + // t() solo orquesta: busca → resuelve → traduce. + const finalValue = resolveValue(rawValue, args, 0); + + if (isLingRecord(finalValue)) { + return tsRecord(finalValue, path, args[0]); + } + + return String(finalValue); + }; + + + function ts(value: LingString): string { + if (value == null) return ''; + const finalValue = resolveValue(value, [], 0); + if (isLingRecord(finalValue)) return tsRecord(finalValue); + return typeof finalValue === 'string' ? finalValue : String(finalValue); + } + + /** + * Similar a ts() pero permite especificar un locale específico. + * Útil para serializar mensajes al locale en que se registraron. + */ + function tsForLocale(value: LingString, locale: SupportedLocale): string { + if (value == null) return ''; + const prev = currentLocale; + try { + currentLocale = locale; + return ts(value); + } finally { + currentLocale = prev; + } + } + + + function tForLocale

, TType = GetTypeAtPath>( + path: P, + locale: SupportedLocale, + ...args: HasParams extends true ? [params: ParamsFor] : [] + ) : 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), + [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( + namespace: NS, + module: M + ): LingInstance { + const newSchema = { + ...(currentSchema as Record), + [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` 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(); + + const fn = (params: { count: number } & Record): 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), +}; \ No newline at end of file diff --git a/src/lib/ling/lib/errors.ts b/src/lib/ling/lib/errors.ts new file mode 100644 index 000000000..6119d7344 --- /dev/null +++ b/src/lib/ling/lib/errors.ts @@ -0,0 +1,62 @@ +import type { SupportedLocale } from './types.ts'; + +// ============================================================================ +// LING ERROR & WARNING MESSAGES +// ============================================================================ + +/** + * Mensajes de error y warning del sistema ling centralizados como constantes. + * + * No se usa el sistema de `Logr` para evitar dependencia cíclica — + * `Logr` depende de `ling`, por lo que `ling` no puede depender de `Logr`. + * + * Estos mensajes se emiten directamente por consola solo en desarrollo + * (`NODE_ENV === 'development'`). En producción la degradación es silenciosa. + */ +export const LING_ERRORS = { + + /** + * La clave de traducción no existe en el schema. + * Se emite como `console.error` — indica un error de programación, + * no una traducción faltante. + * + * @example + * LING_ERRORS.KEY_NOT_FOUND('checkout.total') + * // → '[ling] Translation key not found: "checkout.total"' + */ + KEY_NOT_FOUND: (path: string): string => + `[ling] Translation key not found: "${path}"`, + + CIRCULAR_REFERENCE: (path: string): string => + `[ling] Circular reference in "${path}".`, + + /** + * La clave existe pero no tiene traducción para el locale solicitado. + * Se emite como `console.warn` — degradación controlada con fallback. + * + * @example + * LING_ERRORS.MISSING_TRANSLATION('common.ok', 'de', 'es') + * // → '[ling] Missing translation for "common.ok" in "de". Falling back to "es".' + */ + MISSING_TRANSLATION: (path: string, locale: SupportedLocale, fallback: SupportedLocale): string => + `[ling] Missing translation for "${path}" in "${locale}". Falling back to "${fallback}".`, + + /** + * Variante de MISSING_TRANSLATION para cuando no hay path disponible + * — usado en `ts()` al resolver un `LocaleRecord` sin contexto de clave. + * + * @example + * LING_ERRORS.MISSING_TRANSLATION_RECORD('de', 'es') + * // → '[ling] Missing translation in "de". Falling back to "es".' + */ + MISSING_TRANSLATION_RECORD: (locale: SupportedLocale, fallback: SupportedLocale): string => + `[ling] Missing translation in "${locale}". Falling back to "${fallback}".`, + + + /** + * Se intentó llamar a setLogger() más de una vez. + * El logger solo puede inyectarse una vez — post-init es inmutable. + */ + LOGGER_ALREADY_SET: '[ling] Logger already set. setLogger() can only be called once.', + +} as const; \ No newline at end of file diff --git a/src/lib/ling/lib/guards.ts b/src/lib/ling/lib/guards.ts new file mode 100644 index 000000000..993e0c96a --- /dev/null +++ b/src/lib/ling/lib/guards.ts @@ -0,0 +1,45 @@ +import type {IDLing, LingRecord, LingString} from "./types.ts"; +import {DEFAULT_ISO_LOCALE, ID_PREFIX} from "./consts.ts"; + +/** + * Guard para identificar referencias. + * Usamos un chequeo de longitud para evitar que "#?" vacío sea válido. + */ +export function isIDLing(value: unknown): value is IDLing { + return ( + typeof value === 'string' && + value.startsWith(ID_PREFIX) && + value.length > 2 + ); +} + + + +/** + * Guard para registros de idioma. + * Valida que el objeto tenga la DefaultLocale y que su valor sea string, + * evitando falsos positivos con objetos de dominio que casualmente tengan esa clave. + */ +export function isLingRecord(value: unknown): value is LingRecord { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof value !== 'function' && + DEFAULT_ISO_LOCALE in value && + typeof (value as Record)[DEFAULT_ISO_LOCALE] === 'string' + ); +} + + + +/** + * Comprueba si un valor es un LingString. + * Un LingString puede ser: + * - string → texto plano + * - LingRecord → objeto multiidioma { es: '...', en: '...' } + * - IDLing → referencia con prefijo '#?...' + */ +export function isLingString(value: unknown): value is LingString { + return isIDLing(value) || isLingRecord(value) || typeof value === 'string'; +} \ No newline at end of file diff --git a/src/lib/ling/lib/helpers.ts b/src/lib/ling/lib/helpers.ts new file mode 100644 index 000000000..755e953de --- /dev/null +++ b/src/lib/ling/lib/helpers.ts @@ -0,0 +1,37 @@ +// ============================== +// HELPERS +// ============================== +import type {LingRecord, LingString} from "./types.ts"; +import {DEFAULT_ISO_LOCALE} from "./consts.ts"; +import { isLingString } from "./guards.ts"; + + + +export function resolvePath(obj: any, path: string): any { + return path.split('.').reduce((acc, key) => acc?.[key], obj); +} + + + +export function makeLingRecord (text?: string) : LingRecord { + return { + [DEFAULT_ISO_LOCALE]: text ?? '' + } as LingRecord; +} + + +// src/lib/ling/lib/helpers.ts — añadir esto + +/** + * Convierte un valor desconocido a LingString de forma segura. + * Útil en templates Svelte donde `as` no está disponible. + * + * - Si el valor ya es LingString válido → lo devuelve tal cual + * - Si es null/undefined → devuelve string vacío + * - Cualquier otra cosa → lo convierte a string con String() + */ +export function asLingString(value: unknown): LingString { + if (value == null) return ''; + if (isLingString(value)) return value; + return String(value); +} \ No newline at end of file diff --git a/src/lib/ling/lib/index.ts b/src/lib/ling/lib/index.ts new file mode 100644 index 000000000..b00548762 --- /dev/null +++ b/src/lib/ling/lib/index.ts @@ -0,0 +1,9 @@ +export * from './translations.ts'; +export * from './consts.ts'; +export * from './guards.ts'; +export * from './types.ts'; +export * from './engine.ts'; +export * from './helpers.ts'; +export * from './instance.ts'; +export * from './translations.ts'; + diff --git a/src/lib/ling/lib/instance.ts b/src/lib/ling/lib/instance.ts new file mode 100644 index 000000000..0d498611b --- /dev/null +++ b/src/lib/ling/lib/instance.ts @@ -0,0 +1,27 @@ +import type { TranslationSchema } from './translations.ts'; +import { createLing } from './engine.ts'; +import {translations} from './translations.ts'; + +// ─── CASO EAGER (esquema conocido en build time) ──────────────────────────────── +export const ling = createLing(translations, 'es'); + +// ─── CASO LAZY (módulos cargados en runtime) ───────────────────────────────── +// FullSchema declara en build time los tipos de los módulos lazy. +// import type no genera código — cero coste en el bundle. +// extend() en runtime mueve el schema para que coincida con lo que TypeScript ya sabe. +// +// import type { shopTranslations } from '@/shop/translations'; +// import type { adminTranslations } from '@/admin/translations'; +// +// export type FullSchema = typeof translations & { +// shop : typeof shopTranslations; +// admin : typeof adminTranslations; +// }; +// +// export const ling = createLing(translations, 'es'); +// +// // En el router, cuando el módulo se carga: +// const { shopTranslations } = await import('@/shop/translations'); +// ling.extend('shop', shopTranslations); + +export const { t, tForLocale, setLocale, getLocale, onLocaleChange } = ling; \ No newline at end of file diff --git a/src/lib/ling/lib/json.ts b/src/lib/ling/lib/json.ts new file mode 100644 index 000000000..4acb1e1ac --- /dev/null +++ b/src/lib/ling/lib/json.ts @@ -0,0 +1,76 @@ +import type { LingNode, LingRecord } from './types.ts'; +import { p } from './engine.ts'; +import { isLingRecord } from './guards.ts'; + + +/** + * Convierte un LingNode completo (árbol de traducciones) a JSON. + * - Las funciones de interpolación se convierten en registros con {{tokens}}. + * - Los plurales se marcan con __type: 'plural' para ser reconstruidos. + * - Las referencias IDLing (#?path) se serializan como strings y son reconocidas + * en runtime por isIDLing() al reconstruir con JSONToLingNode. + */ +export function lingNodeToJSON(node: LingNode | any): any { + // 1. Strings y Referencias IDLing (#?...) + if (typeof node === 'string') return node; + + // 2. Manejo de Funciones (Interpolación y Plurales) + if (typeof node === 'function') { + // Caso A: Es un plural generado por p() + if (node.__pluralConfig) { + return { + __type: 'plural', + config: node.__pluralConfig + }; + } + + // Caso B: Es una función de interpolación (LingFn) + // La ejecutamos con un Proxy para extraer las llaves como {{key}} + try { + const proxyParams = new Proxy({}, { + get: (_, prop) => `{{${String(prop)}}}` + }); + // Al ejecutarla, obtenemos un LingRecord plano + return lingNodeToJSON(node(proxyParams)); + } catch { + return {}; // Fallback si la función falla al ejecutarse + } + } + + // 3. Objetos (Namespaces o LingRecords) + if (typeof node === 'object' && node !== null) { + const result: Record = {}; + for (const key in node) { + result[key] = lingNodeToJSON(node[key]); + } + return result; + } + + return node; +} + +/** + * Reconstruye un LingNode a partir de un objeto JSON. + * - Reactiva los closures de pluralización usando p(). + * - Las referencias IDLing (#?path) se preservan como strings — isIDLing() las + * reconoce en runtime correctamente, por lo que el round-trip es transparente. + */ +export function JSONToLingNode(json: any): LingNode { + if (typeof json === 'string') return json as LingNode; + + if (typeof json === 'object' && json !== null) { + // Detectar y reconstruir plurales + if (json.__type === 'plural' && json.config) { + return p(json.config) as unknown as LingNode; + } + + // Procesar hijos recursivamente + const result: any = {}; + for (const key in json) { + result[key] = JSONToLingNode(json[key]); + } + return result; + } + + return json as LingNode; +} \ No newline at end of file diff --git a/src/lib/ling/lib/plural_rules.ts b/src/lib/ling/lib/plural_rules.ts new file mode 100644 index 000000000..28d7a7081 --- /dev/null +++ b/src/lib/ling/lib/plural_rules.ts @@ -0,0 +1,323 @@ +/** + * Reglas de pluralización CLDR para cardinales. + * Derivadas de la especificación Unicode CLDR (https://cldr.unicode.org/index/cldr-spec/plural-rules) + * y equivalentes a las generadas por make-plural (MIT License, https://github.com/eemeli/make-plural). + * + * Cero dependencias de entorno — funciona en Node, browser, edge, workers. + * + * Forma de uso: + * pluralRule('es', 1) // → 'one' + * pluralRule('es', 2) // → 'other' + * pluralRule('ar', 0) // → 'zero' + * pluralRule('ru', 3) // → 'few' + */ + +export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +type PluralFn = (n: number) => PluralCategory; + +// ─── HELPERS ───────────────────────────────────────────────────────────────── + +/** Parte entera de n */ +const i = (n: number) => Math.floor(Math.abs(n)); + +/** Número de dígitos decimales visibles (sin trailing zeros) */ +const v = (n: number) => { + const s = String(n); + const d = s.indexOf('.'); + return d < 0 ? 0 : s.length - d - 1; +}; + +/** Dígitos decimales visibles como número entero (sin trailing zeros) */ +const f = (n: number) => { + const s = String(n); + const d = s.indexOf('.'); + return d < 0 ? 0 : parseInt(s.slice(d + 1).replace(/0+$/, '') || '0', 10); +}; + +/** n mod m */ +const mod = (n: number, m: number) => n % m; + +// ─── REGLAS POR LOCALE ─────────────────────────────────────────────────────── + +const rules: Record = { + + // ── one/other (n = 1 → one) ─────────────────────────────────────────────── + // af, an, asa, az, bem, bez, bg, brx, ce, cgg, chr, ckb, dv, ee, el, + // eo, es, eu, fo, fur, gsw, ha, haw, hu, jgo, jmc, ka, kaj, kcg, kk, + // kkj, kl, ks, ksb, ku, ky, lb, lg, mas, mgo, ml, mn, mr, nah, nb, + // nd, ne, nn, nnh, no, nr, ny, nyn, om, or, os, pap, ps, rm, rof, + // rwk, saq, sd, seh, sn, so, sq, ss, ssy, st, syr, ta, te, teo, + // tig, tk, tn, tr, ts, uve, uz, ve, vo, vun, wae, xh, xog + af: n => n === 1 ? 'one' : 'other', + an: n => n === 1 ? 'one' : 'other', + az: n => n === 1 ? 'one' : 'other', + bg: n => n === 1 ? 'one' : 'other', + bn: n => i(n) === 0 || n === 1 ? 'one' : 'other', + ca: n => n === 1 && v(n) === 0 ? 'one' : 'other', + da: n => n === 1 || (n !== Math.floor(n) && [0, 1].includes(i(n))) ? 'one' : 'other', + de: n => n === 1 && v(n) === 0 ? 'one' : 'other', + el: n => n === 1 ? 'one' : 'other', + en: n => n === 1 && v(n) === 0 ? 'one' : 'other', + eo: n => n === 1 ? 'one' : 'other', + es: n => n === 1 ? 'one' : 'other', + et: n => n === 1 && v(n) === 0 ? 'one' : 'other', + eu: n => n === 1 ? 'one' : 'other', + fi: n => n === 1 && v(n) === 0 ? 'one' : 'other', + gl: n => n === 1 && v(n) === 0 ? 'one' : 'other', + gu: n => i(n) === 0 || n === 1 ? 'one' : 'other', + he: n => n === 1 && v(n) === 0 ? 'one' : n === 2 && v(n) === 0 ? 'two' : v(n) !== 0 ? 'many' : 'other', + hi: n => i(n) === 0 || n === 1 ? 'one' : 'other', + hu: n => n === 1 ? 'one' : 'other', + hy: n => i(n) === 0 || i(n) === 1 ? 'one' : 'other', + id: _ => 'other', + is: n => { + const mod10 = mod(i(n), 10); + const mod100 = mod(i(n), 100); + return (mod10 === 1 && mod100 !== 11) ? 'one' : 'other'; + }, + it: n => n === 1 && v(n) === 0 ? 'one' : 'other', + ja: _ => 'other', + ka: n => n === 1 ? 'one' : 'other', + km: _ => 'other', + kn: n => i(n) === 0 || n === 1 ? 'one' : 'other', + ko: _ => 'other', + lt: n => { + const n10 = mod(n, 10); + const n100 = mod(n, 100); + if (n10 === 1 && (n100 < 11 || n100 > 19)) return 'one'; + if (n10 >= 2 && n10 <= 9 && (n100 < 11 || n100 > 19)) return 'few'; + if (f(n) !== 0) return 'many'; + return 'other'; + }, + lv: n => { + const n10 = mod(n, 10); + const n100 = mod(n, 100); + if (n === 0) return 'zero'; + if (n10 === 1 && n100 !== 11) return 'one'; + return 'other'; + }, + mk: n => { + const i_ = i(n); + const v_ = v(n); + if (v_ === 0 && mod(i_, 10) === 1 && mod(i_, 100) !== 11) return 'one'; + if (v_ === 0 && mod(i_, 10) === 2 && mod(i_, 100) !== 12) return 'two'; + if ((v_ === 0 && (mod(i_, 10) === 7 || mod(i_, 10) === 8) && mod(i_, 100) !== 17 && mod(i_, 100) !== 18) || + (v_ !== 0 && (mod(f(n), 10) === 7 || mod(f(n), 10) === 8))) return 'many'; + return 'other'; + }, + ml: n => n === 1 ? 'one' : 'other', + mn: n => n === 1 ? 'one' : 'other', + mr: n => n === 1 ? 'one' : 'other', + ms: _ => 'other', + my: _ => 'other', + nb: n => n === 1 ? 'one' : 'other', + ne: n => n === 1 ? 'one' : 'other', + nl: n => n === 1 && v(n) === 0 ? 'one' : 'other', + or: n => n === 1 ? 'one' : 'other', + pa: n => n === 0 || n === 1 ? 'one' : 'other', + pl: n => { + const v_ = v(n); + const i_ = i(n); + const n10 = mod(i_, 10); + const n100 = mod(i_, 100); + if (i_ === 1 && v_ === 0) return 'one'; + if (v_ === 0 && n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few'; + if (v_ === 0 && i_ !== 1 && (n10 === 0 || n10 === 1) || + v_ === 0 && n10 >= 5 && n10 <= 9 || + v_ === 0 && n100 >= 12 && n100 <= 14) return 'many'; + return 'other'; + }, + pt: n => n >= 0 && n < 2 ? 'one' : 'other', + ro: n => { + const v_ = v(n); + const n100 = mod(n, 100); + if (i(n) === 1 && v_ === 0) return 'one'; + if (v_ !== 0 || n === 0 || (n100 >= 2 && n100 <= 19)) return 'few'; + return 'other'; + }, + ru: n => { + const v_ = v(n); + if (v_ !== 0) return 'other'; + const n10 = mod(i(n), 10); + const n100 = mod(i(n), 100); + if (n10 === 1 && n100 !== 11) return 'one'; + if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few'; + return 'other'; + }, + si: n => n === 0 || n === 1 || (i(n) === 0 && f(n) === 1) ? 'one' : 'other', + sk: n => { + const v_ = v(n); + const i_ = i(n); + if (i_ === 1 && v_ === 0) return 'one'; + if (i_ >= 2 && i_ <= 4 && v_ === 0) return 'few'; + if (v_ !== 0) return 'many'; + return 'other'; + }, + sl: n => { + const v_ = v(n); + const n100 = mod(i(n), 100); + if (n100 === 1 && v_ === 0) return 'one'; + if (n100 === 2 && v_ === 0) return 'two'; + if ((n100 >= 3 && n100 <= 4 || v_ !== 0)) return 'few'; + return 'other'; + }, + sq: n => n === 1 ? 'one' : 'other', + sr: n => { + const v_ = v(n); + const i_ = i(n); + const n10 = v_ === 0 ? mod(i_, 10) : mod(f(n), 10); + const n100 = v_ === 0 ? mod(i_, 100) : mod(f(n), 100); + if (n10 === 1 && n100 !== 11) return 'one'; + if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few'; + return 'other'; + }, + sv: n => n === 1 && v(n) === 0 ? 'one' : 'other', + sw: n => n === 1 && v(n) === 0 ? 'one' : 'other', + ta: n => n === 1 ? 'one' : 'other', + te: n => n === 1 ? 'one' : 'other', + th: _ => 'other', + tr: n => n === 1 ? 'one' : 'other', + uk: n => { + const v_ = v(n); + if (v_ !== 0) return 'other'; + const n10 = mod(i(n), 10); + const n100 = mod(i(n), 100); + if (n10 === 1 && n100 !== 11) return 'one'; + if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few'; + return 'other'; + }, + ur: n => n === 1 && v(n) === 0 ? 'one' : 'other', + uz: n => n === 1 ? 'one' : 'other', + vi: _ => 'other', + zh: _ => 'other', + zu: n => i(n) === 0 || n === 1 ? 'one' : 'other', + + // ── Árabe — 6 formas ────────────────────────────────────────────────────── + ar: n => { + if (n === 0) return 'zero'; + if (n === 1) return 'one'; + if (n === 2) return 'two'; + const n100 = mod(n, 100); + if (n100 >= 3 && n100 <= 10) return 'few'; + if (n100 >= 11 && n100 <= 99) return 'many'; + return 'other'; + }, + + // ── Galés — 6 formas ────────────────────────────────────────────────────── + cy: n => { + if (n === 0) return 'zero'; + if (n === 1) return 'one'; + if (n === 2) return 'two'; + if (n === 3) return 'few'; + if (n === 6) return 'many'; + return 'other'; + }, + + // ── Bretón — 5 formas ───────────────────────────────────────────────────── + br: n => { + const n10 = mod(n, 10); + const n100 = mod(n, 100); + const n1000000 = mod(n, 1000000); + if (n10 === 1 && n100 !== 11 && n100 !== 71 && n100 !== 91) return 'one'; + if (n10 === 2 && n100 !== 12 && n100 !== 72 && n100 !== 92) return 'two'; + if ((n10 === 3 || n10 === 4 || n10 === 9) && (n100 < 10 || n100 > 19) && (n100 < 70 || n100 > 79) && (n100 < 90 || n100 > 99)) return 'few'; + if (n !== 0 && n1000000 === 0) return 'many'; + return 'other'; + }, + + // ── Francés ─────────────────────────────────────────────────────────────── + fr: n => i(n) === 0 || i(n) === 1 ? 'one' : 'other', + + // ── Gallego ─────────────────────────────────────────────────────────────── + // (mismo que es, pt para cardinales) + + // ── Irlandés — 5 formas ─────────────────────────────────────────────────── + ga: n => { + if (n === 1) return 'one'; + if (n === 2) return 'two'; + if (n >= 3 && n <= 6) return 'few'; + if (n >= 7 && n <= 10) return 'many'; + return 'other'; + }, + + // ── Escocés gaélico — 4 formas ──────────────────────────────────────────── + gd: n => { + if (n === 1 || n === 11) return 'one'; + if (n === 2 || n === 12) return 'two'; + if ((n >= 3 && n <= 10) || (n >= 13 && n <= 19)) return 'few'; + return 'other'; + }, + + // ── Maltés — 4 formas ───────────────────────────────────────────────────── + mt: n => { + const n100 = mod(n, 100); + if (n === 1) return 'one'; + if (n === 0 || (n100 >= 2 && n100 <= 10)) return 'few'; + if (n100 >= 11 && n100 <= 19) return 'many'; + return 'other'; + }, + + // ── Bosnio/Croata/Serbio ────────────────────────────────────────────────── + bs: n => { + const v_ = v(n); + const i_ = i(n); + const n10 = v_ === 0 ? mod(i_, 10) : mod(f(n), 10); + const n100 = v_ === 0 ? mod(i_, 100) : mod(f(n), 100); + if (n10 === 1 && n100 !== 11) return 'one'; + if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few'; + return 'other'; + }, + hr: n => { + const v_ = v(n); + const i_ = i(n); + const n10 = v_ === 0 ? mod(i_, 10) : mod(f(n), 10); + const n100 = v_ === 0 ? mod(i_, 100) : mod(f(n), 100); + if (n10 === 1 && n100 !== 11) return 'one'; + if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few'; + return 'other'; + }, + + // ── Bielorruso ──────────────────────────────────────────────────────────── + be: n => { + const n10 = mod(n, 10); + const n100 = mod(n, 100); + if (n10 === 1 && n100 !== 11) return 'one'; + if (n10 >= 2 && n10 <= 4 && (n100 < 12 || n100 > 14)) return 'few'; + return 'other'; + }, + + // ── Checo/Eslovaco ──────────────────────────────────────────────────────── + cs: n => { + const v_ = v(n); + const i_ = i(n); + if (i_ === 1 && v_ === 0) return 'one'; + if (i_ >= 2 && i_ <= 4 && v_ === 0) return 'few'; + if (v_ !== 0) return 'many'; + return 'other'; + }, + + // ── Amhárico/Tigriña ────────────────────────────────────────────────────── + am: n => i(n) === 0 || n === 1 ? 'one' : 'other', + + // ── Persa ───────────────────────────────────────────────────────────────── + fa: n => i(n) === 0 || n === 1 ? 'one' : 'other', +}; + +/** + * Devuelve la categoría plural CLDR para un número y locale dados. + * Si el locale no está soportado, devuelve 'other' como fallback seguro. + * + * @example + * pluralRule('es', 1) // → 'one' + * pluralRule('es', 2) // → 'other' + * pluralRule('ar', 0) // → 'zero' + * pluralRule('ru', 3) // → 'few' + * pluralRule('xx', 5) // → 'other' (locale desconocido) + */ +export function pluralRule(locale: string, n: number): PluralCategory { + // Normalizar: 'es-ES' → 'es', 'zh-Hans' → 'zh' + const base = locale.split('-')[0].split('_')[0]; + const fn = rules[base]; + return fn ? fn(n) : 'other'; +} \ No newline at end of file diff --git a/src/lib/ling/lib/translations.ts b/src/lib/ling/lib/translations.ts new file mode 100644 index 000000000..70fcb425b --- /dev/null +++ b/src/lib/ling/lib/translations.ts @@ -0,0 +1,59 @@ +import type { LingNode } from './types.ts'; +import { p } from './engine.ts'; + + +/** + * Sin `: LingNode` en la declaración — TypeScript infiere el tipo exacto. + * `satisfies LingNode` valida la estructura sin borrar la información. + * + * Esto permite que LeafPaths derive rutas concretas + * como "checkout.total" y que t() quede completamente type-safe. + */ +export const translations = { + checkout: { + pay: { + es: "Pagar", + en: "Pay" + }, + + // LingPluralFn no aplica aquí, pero el tipo se infiere correctamente + // porque p() devuelve (params: { count: number } & ...) => LingRecord + total: (params: { amount: number; currency: string }) => ({ + es: `Total: ${params.amount}${params.currency}`, + en: `Total: ${params.currency}${params.amount}` + }) + }, + + common: { + ok: { + es: "Aceptar", + en: "OK" + }, + cancel: { + es: "Cancelar", + en: "Cancel" + } + }, + + errors: { + notFound: { + es: "Página no encontrada", + en: "Page not found" + }, + generic: (params: { code: number }) => ({ + es: `Ha ocurrido un error (${params.code})`, + en: `An error occurred (${params.code})` + }) + }, + + // Ejemplo de uso de p() con LingPluralFn — TypeScript infiere { count: number } + messages: { + unread: p({ + es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' }, + en: { one: '{{count}} unread message', other: '{{count}} unread messages' }, + }) + } + +} satisfies LingNode; + +export type TranslationSchema = typeof translations; \ No newline at end of file diff --git a/src/lib/ling/lib/types.ts b/src/lib/ling/lib/types.ts new file mode 100644 index 000000000..a72280288 --- /dev/null +++ b/src/lib/ling/lib/types.ts @@ -0,0 +1,197 @@ +// ============================== +// LOCALES +// ============================== + +import type {ID_PREFIX} from "./consts.ts"; +import type {PluralCategory} from "./plural_rules.ts"; + +/** + * Referencia interna: #?path.del.schema + */ +export type IDLing = `${typeof ID_PREFIX}${string}`; + +export type DefaultLocale = 'es'; + +export type SupportedLocale = + | DefaultLocale + | 'en' + | 'de' + | 'fr' + | 'it' + | 'pt' + | 'ca' + | 'eu' + | 'gl'; + + +// ============================== +// LOCALIZED TYPES +// ============================== + +/** + * Un registro de traducciones donde el DefaultLocale es obligatorio + * y el resto de locales son opcionales. + */ +export type LingRecord = { + [K in SupportedLocale]?: string; +} & { + [K in DefaultLocale]: string; +}; + +/** + * Función de traducción tipada con parámetros genéricos. + */ +export type LingFn

> = (params: P) => LingRecord; + +/** + * Función de pluralización: siempre requiere `count` más cualquier extra. + * Separado de LingFn para poder detectarlo explícitamente en HasParams. + */ +export type LingPluralFn

= Record> = + (params: { count: number } & P) => LingRecord; + +export type LingValue = + | LingRecord + | LingFn + | LingPluralFn + | IDLing; + +/** + * Nodo recursivo del schema de traducciones. + */ +export type LingNode = + | LingValue + | { [key: string]: LingNode }; + +export type LingString = + | string + | LingRecord + | IDLing; + + +// ============================== +// TYPE UTILITIES +// ============================== + +type Prev = [never, 0, 1, 2, 3, 4, 5, 6]; + +/** + * `LeafPaths` solo expone las rutas que terminan en una hoja + * (LingRecord o función), no las rutas intermedias (namespaces). + */ +export type LeafPaths = + [D] extends [never] + ? never + : T extends LingValue + ? '' + : T extends object + ? { + [K in keyof T & string]: + T[K] extends LingValue + ? K + : `${K}.${LeafPaths & string}` + }[keyof T & string] + : never; + + +export type GetTypeAtPath< + Root, + Current, + P extends string +> = + P extends `${infer K}.${infer Rest}` + ? K extends keyof Current + ? GetTypeAtPath + : never + : P extends keyof Current + ? Current[P] extends `#?${infer AliasPath}` + ? GetTypeAtPath + : Current[P] + : never; + + +/** + * Extrae los parámetros de una función de traducción o pluralización. + * Para LingPluralFn siempre incluirá `count: number`. + */ +export type ParamsFor = + T extends (params: infer P) => any + ? P + : never; + +/** + * Detecta si un tipo requiere parámetros: + * - LingFn

→ true (parámetros arbitrarios) + * - LingPluralFn

→ true (siempre incluye count) + * - LingRecord → false (sin parámetros) + * - IDLing → false (referencia, se resuelve) + */ +export type HasParams = + T extends (params: any) => any + ? true + : false; + + +/** Formas plurales según el estándar Unicode */ +export type PluralForms = Partial> & { other: string }; + +export type PluralConfig = { + [K in SupportedLocale]?: PluralForms; +} & { + [K in DefaultLocale]: PluralForms; +}; + + +// ============================== +// INSTANCE TYPE +// ============================== + +export type LingInstance = { + t: { +

, TType = GetTypeAtPath>( + path: P, + ...args: HasParams extends true ? [params: ParamsFor] : [] + ): string; + (path: string, params?: Record): string; + }; + + ts: (value: LingString) => string; + + tsForLocale: (value: LingString, locale: SupportedLocale) => string; + + tForLocale:

, TType = GetTypeAtPath>( + path: P, + locale: SupportedLocale, + ...args: HasParams extends true ? [params: ParamsFor] : [] + ) => string; + + setLocale : (locale: SupportedLocale) => void; + getLocale : () => SupportedLocale; + onLocaleChange : (fn: (locale: SupportedLocale) => void) => () => void; + /** Notifica cuando el schema cambia (extend() lazy). Devuelve un unsubscribe. */ + onSchemaChange : (fn: () => void) => () => void; + /** Muta la instancia actual añadiendo un módulo lazy. El type-safety viene de FullSchema. */ + extend : (namespace: string, module: LingNode) => void; + /** Devuelve una nueva instancia con el tipo actualizado. Útil para tests o contextos aislados. + * Llama a dispose() en la instancia devuelta para evitar memory leaks. */ + register : ( + namespace: NS, + module: M + ) => LingInstance; + setLogger : (logger: LingLogger) => void; + /** Elimina el listener de locale del padre. Llámalo cuando destruyas una instancia hija (register). */ + dispose : () => void; + /** @internal — usado por register() para inyectar el unsubscribe del padre. */ + __dispose : (() => void) | undefined; +}; + + + +// ============================== +// LING LOGGER INTERFACE +// ============================== + +export interface LingLogger { + warn : (category: string, message: string) => void; + error: (category: string, message: string) => void; +} \ No newline at end of file diff --git a/src/lib/ling/test/ling.test.ts b/src/lib/ling/test/ling.test.ts new file mode 100644 index 000000000..08b2bfcdd --- /dev/null +++ b/src/lib/ling/test/ling.test.ts @@ -0,0 +1,630 @@ +/** + * ling — test suite + * + * Ejecutar: npx vitest run + * Cobertura: npx vitest run --coverage + * + * Módulos cubiertos: + * - guards (isIDLing, isLingRecord, isLingString) + * - helpers (resolvePath, makeLingRecord) + * - plural-rules (pluralRule) + * - engine (createLing, p) + * - json (lingNodeToJSON, JSONToLingNode) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isIDLing, isLingRecord, isLingString } from '../lib/guards'; +import { resolvePath, makeLingRecord } from '../lib/helpers'; +import { pluralRule } from '../lib/plural_rules'; +import { createLing, p } from '../lib/engine'; +import { lingNodeToJSON, JSONToLingNode } from '../lib/json'; + + +// ============================================================================ +// GUARDS +// ============================================================================ + +describe('isIDLing', () => { + it('devuelve true para una referencia válida', () => { + expect(isIDLing('#?common.ok')).toBe(true); + }); + + it('devuelve false para el prefijo solo sin path', () => { + expect(isIDLing('#?')).toBe(false); + }); + + it('devuelve false para strings normales', () => { + expect(isIDLing('hola')).toBe(false); + expect(isIDLing('')).toBe(false); + }); + + it('devuelve false para no-strings', () => { + expect(isIDLing(null)).toBe(false); + expect(isIDLing(42)).toBe(false); + expect(isIDLing({})).toBe(false); + expect(isIDLing(undefined)).toBe(false); + }); + + it('devuelve true para referencias con paths anidados', () => { + expect(isIDLing('#?a.b.c')).toBe(true); + }); +}); + + +describe('isLingRecord', () => { + it('devuelve true para un registro con es obligatorio', () => { + expect(isLingRecord({ es: 'Aceptar', en: 'OK' })).toBe(true); + }); + + it('devuelve true con solo el locale por defecto', () => { + expect(isLingRecord({ es: 'Hola' })).toBe(true); + }); + + it('devuelve false si falta es', () => { + expect(isLingRecord({ en: 'Hello' })).toBe(false); + }); + + it('devuelve false si es no es string', () => { + expect(isLingRecord({ es: 42 })).toBe(false); + expect(isLingRecord({ es: null })).toBe(false); + }); + + it('devuelve false para arrays', () => { + expect(isLingRecord(['es', 'Hola'])).toBe(false); + }); + + it('devuelve false para funciones', () => { + expect(isLingRecord(() => ({ es: 'x' }))).toBe(false); + }); + + it('devuelve false para primitivos', () => { + expect(isLingRecord(null)).toBe(false); + expect(isLingRecord('hola')).toBe(false); + expect(isLingRecord(42)).toBe(false); + }); +}); + + +describe('isLingString', () => { + it('acepta strings planas', () => { + expect(isLingString('hola')).toBe(true); + expect(isLingString('')).toBe(true); + }); + + it('acepta LingRecord', () => { + expect(isLingString({ es: 'Hola' })).toBe(true); + }); + + it('acepta IDLing', () => { + expect(isLingString('#?common.ok')).toBe(true); + }); + + it('rechaza null y undefined', () => { + expect(isLingString(null)).toBe(false); + expect(isLingString(undefined)).toBe(false); + }); + + it('rechaza números', () => { + expect(isLingString(42)).toBe(false); + }); +}); + + +// ============================================================================ +// HELPERS +// ============================================================================ + +describe('resolvePath', () => { + const obj = { a: { b: { c: 'valor' } } }; + + it('resuelve paths anidados', () => { + expect(resolvePath(obj, 'a.b.c')).toBe('valor'); + }); + + it('resuelve un nivel', () => { + expect(resolvePath(obj, 'a')).toEqual({ b: { c: 'valor' } }); + }); + + it('devuelve undefined para paths inexistentes', () => { + expect(resolvePath(obj, 'a.x.y')).toBeUndefined(); + }); + + it('devuelve undefined para path vacío', () => { + expect(resolvePath(obj, '')).toBeUndefined(); + }); + + it('no lanza con objetos null intermedios', () => { + expect(resolvePath({ a: null }, 'a.b')).toBeUndefined(); + }); +}); + + +describe('makeLingRecord', () => { + it('crea un registro con el locale por defecto', () => { + const record = makeLingRecord('Hola'); + expect(record.es).toBe('Hola'); + }); + + it('usa string vacío si no se pasa texto', () => { + const record = makeLingRecord(); + expect(record.es).toBe(''); + }); + + it('usa string vacío si se pasa undefined explícitamente', () => { + const record = makeLingRecord(undefined); + expect(record.es).toBe(''); + }); +}); + + +// ============================================================================ +// PLURAL RULES +// ============================================================================ + +describe('pluralRule', () => { + describe('español (es) — one/other', () => { + it('1 → one', () => expect(pluralRule('es', 1)).toBe('one')); + it('0 → other', () => expect(pluralRule('es', 0)).toBe('other')); + it('2 → other', () => expect(pluralRule('es', 2)).toBe('other')); + it('21 → other', () => expect(pluralRule('es', 21)).toBe('other')); + }); + + describe('inglés (en) — one/other con v(n)=0', () => { + it('1 → one', () => expect(pluralRule('en', 1)).toBe('one')); + it('2 → other', () => expect(pluralRule('en', 2)).toBe('other')); + }); + + describe('árabe (ar) — 6 formas', () => { + it('0 → zero', () => expect(pluralRule('ar', 0)).toBe('zero')); + it('1 → one', () => expect(pluralRule('ar', 1)).toBe('one')); + it('2 → two', () => expect(pluralRule('ar', 2)).toBe('two')); + it('5 → few', () => expect(pluralRule('ar', 5)).toBe('few')); + it('15 → many', () => expect(pluralRule('ar', 15)).toBe('many')); + it('100 → other', () => expect(pluralRule('ar', 100)).toBe('other')); + }); + + describe('ruso (ru) — one/few/other', () => { + it('1 → one', () => expect(pluralRule('ru', 1)).toBe('one')); + it('21 → one', () => expect(pluralRule('ru', 21)).toBe('one')); + it('2 → few', () => expect(pluralRule('ru', 2)).toBe('few')); + it('5 → other', () => expect(pluralRule('ru', 5)).toBe('other')); + }); + + describe('locale desconocido — fallback a other', () => { + it('xx → other', () => expect(pluralRule('xx', 1)).toBe('other')); + }); + + describe('normalización de locale con región', () => { + it('es-ES se normaliza a es', () => expect(pluralRule('es-ES', 1)).toBe('one')); + it('en-US se normaliza a en', () => expect(pluralRule('en-US', 1)).toBe('one')); + }); +}); + + +// ============================================================================ +// ENGINE — createLing +// ============================================================================ + +const schema = { + common: { + ok: { es: 'Aceptar', en: 'OK' }, + cancel: { es: 'Cancelar', en: 'Cancel' }, + }, + greet: (params: { name: string }) => ({ + es: `Hola, {{name}}`, + en: `Hello, {{name}}`, + }), + messages: { + unread: p({ + es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' }, + en: { one: '{{count}} unread message', other: '{{count}} unread messages' }, + }), + }, + ref: '#?common.ok', + nested: { + deep: { + value: { es: 'Profundo', en: 'Deep' }, + }, + }, +} as const; + +describe('createLing — t()', () => { + let ling: ReturnType>; + + beforeEach(() => { + ling = createLing(schema, 'es'); + }); + + it('traduce una clave simple al locale por defecto', () => { + expect(ling.t('common.ok')).toBe('Aceptar'); + }); + + it('traduce al locale activo tras setLocale', () => { + ling.setLocale('en'); + expect(ling.t('common.ok')).toBe('OK'); + }); + + it('devuelve el path si la clave no existe', () => { + expect((ling.t as any)('no.existe')).toBe('no.existe'); + }); + + it('traduce con interpolación de params', () => { + expect((ling.t as any)('greet', { name: 'Ana' })).toBe('Hola, Ana'); + }); + + it('traduce con interpolación en locale alternativo', () => { + ling.setLocale('en'); + expect((ling.t as any)('greet', { name: 'Ana' })).toBe('Hello, Ana'); + }); + + it('resuelve referencias IDLing (#?)', () => { + expect((ling.t as any)('ref')).toBe('Aceptar'); + }); + + it('traduce paths anidados profundos', () => { + expect(ling.t('nested.deep.value')).toBe('Profundo'); + }); + + it('traduce plurales — one', () => { + expect((ling.t as any)('messages.unread', { count: 1 })).toBe('1 mensaje sin leer'); + }); + + it('traduce plurales — other', () => { + expect((ling.t as any)('messages.unread', { count: 5 })).toBe('5 mensajes sin leer'); + }); + + it('traduce plurales en locale alternativo', () => { + ling.setLocale('en'); + expect((ling.t as any)('messages.unread', { count: 1 })).toBe('1 unread message'); + expect((ling.t as any)('messages.unread', { count: 3 })).toBe('3 unread messages'); + }); + + it('hace fallback al locale por defecto si falta la traducción', () => { + const s = { only: { es: 'Solo español' } } as const; + const l = createLing(s, 'es'); + l.setLocale('de'); + expect(l.t('only')).toBe('Solo español'); + }); + + it('keys con caracteres especiales de regex en params no rompen la interpolación', () => { + const s = { msg: (_: any) => ({ es: 'Código: {{code}}' }) } as const; + const l = createLing(s, 'es'); + expect((l.t as any)('msg', { code: 'A+B' })).toBe('Código: A+B'); + }); +}); + + +describe('createLing — ts()', () => { + let ling: ReturnType>; + + beforeEach(() => { + ling = createLing(schema, 'es'); + }); + + it('traduce un LingRecord directamente', () => { + expect(ling.ts({ es: 'Directo', en: 'Direct' })).toBe('Directo'); + }); + + it('devuelve strings planas tal cual', () => { + expect(ling.ts('texto plano')).toBe('texto plano'); + }); + + it('devuelve string vacío para null', () => { + expect(ling.ts(null as any)).toBe(''); + }); + + it('NO devuelve string vacío para string vacío — ts(\'\') es válido', () => { + expect(ling.ts('')).toBe(''); + }); + + it('resuelve referencias IDLing', () => { + expect(ling.ts('#?common.ok')).toBe('Aceptar'); + }); +}); + + +describe('createLing — tForLocale()', () => { + let ling: ReturnType>; + + beforeEach(() => { + ling = createLing(schema, 'es'); + }); + + it('traduce en un locale específico sin cambiar el locale activo', () => { + ling.setLocale('es'); + const result = (ling.tForLocale as any)('common.ok', 'en'); + expect(result).toBe('OK'); + expect(ling.getLocale()).toBe('es'); + }); + + it('restaura el locale aunque el schema tenga una referencia circular', () => { + // Creamos un schema con referencia circular para forzar el throw + const circular = { a: '#?a' } as any; + const l = createLing(circular, 'es'); + expect(() => (l.tForLocale as any)('a', 'en')).toThrow(); + // El locale debe haberse restaurado a 'es' + expect(l.getLocale()).toBe('es'); + }); +}); + + +describe('createLing — setLocale / getLocale / onLocaleChange', () => { + it('getLocale devuelve el locale inicial', () => { + const ling = createLing(schema, 'es'); + expect(ling.getLocale()).toBe('es'); + }); + + it('setLocale notifica a los listeners', () => { + const ling = createLing(schema, 'es'); + const spy = vi.fn(); + ling.onLocaleChange(spy); + ling.setLocale('en'); + expect(spy).toHaveBeenCalledWith('en'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('el unsubscribe de onLocaleChange deja de notificar', () => { + const ling = createLing(schema, 'es'); + const spy = vi.fn(); + const unsub = ling.onLocaleChange(spy); + unsub(); + ling.setLocale('en'); + expect(spy).not.toHaveBeenCalled(); + }); +}); + + +describe('createLing — extend()', () => { + it('añade un namespace simple', () => { + const ling = createLing(schema, 'es'); + ling.extend('shop', { product: { es: 'Producto', en: 'Product' } }); + expect((ling.t as any)('shop.product')).toBe('Producto'); + }); + + it('añade con namespace con puntos', () => { + const ling = createLing(schema, 'es'); + ling.extend('app.settings', { title: { es: 'Ajustes', en: 'Settings' } }); + expect((ling.t as any)('app.settings.title')).toBe('Ajustes'); + }); + + it('hace deep-merge sin borrar claves existentes del namespace', () => { + const ling = createLing(schema, 'es'); + ling.extend('common', { extra: { es: 'Extra', en: 'Extra' } }); + // La clave original debe seguir funcionando + expect(ling.t('common.ok')).toBe('Aceptar'); + // Y la nueva también + expect((ling.t as any)('common.extra')).toBe('Extra'); + }); + + it('notifica a los listeners de onSchemaChange', () => { + const ling = createLing(schema, 'es'); + const spy = vi.fn(); + ling.onSchemaChange(spy); + ling.extend('shop', { product: { es: 'Producto' } }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('el unsubscribe de onSchemaChange deja de notificar', () => { + const ling = createLing(schema, 'es'); + const spy = vi.fn(); + const unsub = ling.onSchemaChange(spy); + unsub(); + ling.extend('shop', { product: { es: 'Producto' } }); + expect(spy).not.toHaveBeenCalled(); + }); +}); + + +describe('createLing — register()', () => { + it('devuelve una nueva instancia con el namespace registrado', () => { + const ling = createLing(schema, 'es'); + const extended = ling.register('shop', { product: { es: 'Producto', en: 'Product' } }); + expect((extended.t as any)('shop.product')).toBe('Producto'); + }); + + it('la instancia hija sincroniza el locale con el padre', () => { + const ling = createLing(schema, 'es'); + const child = ling.register('shop', { product: { es: 'Producto', en: 'Product' } }); + ling.setLocale('en'); + expect((child.t as any)('shop.product')).toBe('Product'); + }); + + it('dispose() elimina el listener del padre — no más sincronización', () => { + const ling = createLing(schema, 'es'); + const child = ling.register('shop', { product: { es: 'Producto', en: 'Product' } }); + child.dispose(); + ling.setLocale('en'); + // El hijo ya no escucha al padre — sigue en 'es' + expect(child.getLocale()).toBe('es'); + }); + + it('la instancia original no se ve afectada por register()', () => { + const ling = createLing(schema, 'es'); + ling.register('shop', { product: { es: 'Producto' } }); + expect((ling.t as any)('shop.product')).toBe('shop.product'); // no existe en el padre + }); +}); + + +describe('createLing — setLogger()', () => { + it('reemplaza el logger por defecto', () => { + const ling = createLing(schema, 'es'); + const warn = vi.fn(); + const error = vi.fn(); + ling.setLogger({ warn, error }); + // El logger se ha inyectado — verificamos que no lanza + expect(() => ling.setLogger({ warn, error })).not.toThrow(); + }); + + it('setLogger() solo puede llamarse una vez — la segunda no reemplaza', () => { + const ling = createLing(schema, 'es'); + const firstWarn = vi.fn(); + const secondWarn = vi.fn(); + ling.setLogger({ warn: firstWarn, error: vi.fn() }); + ling.setLogger({ warn: secondWarn, error: vi.fn() }); + // El segundo logger no debe haber sido instalado + // — verificamos que firstWarn sigue siendo el logger activo + // llamando a getLocale() para confirmar que no hay excepciones + expect(ling.getLocale()).toBe('es'); + }); +}); + + +// ============================================================================ +// ENGINE — p() (pluralización) +// ============================================================================ + +describe('p()', () => { + const plural = p({ + es: { one: '{{count}} elemento', other: '{{count}} elementos' }, + en: { one: '{{count}} item', other: '{{count}} items' }, + }); + + it('selecciona la forma singular para 1', () => { + const record = plural({ count: 1 }); + expect(record.es).toBe('{{count}} elemento'); + }); + + it('selecciona la forma plural para 2', () => { + const record = plural({ count: 2 }); + expect(record.es).toBe('{{count}} elementos'); + }); + + it('devuelve el mismo objeto desde la caché para el mismo count', () => { + const r1 = plural({ count: 5 }); + const r2 = plural({ count: 5 }); + expect(r1).toBe(r2); // referencia idéntica + }); + + it('cachea correctamente con floats (String como clave)', () => { + const r1 = plural({ count: 1.5 }); + const r2 = plural({ count: 1.5 }); + expect(r1).toBe(r2); + }); + + it('tiene __pluralConfig para serialización JSON', () => { + expect((plural as any).__pluralConfig).toBeDefined(); + }); + + it('usa other como fallback si la forma no existe', () => { + const fallback = p({ es: { other: 'varios' } }); + const record = fallback({ count: 1 }); // 'one' no está definido + expect(record.es).toBe('varios'); + }); +}); + + +// ============================================================================ +// JSON — lingNodeToJSON / JSONToLingNode +// ============================================================================ + +describe('lingNodeToJSON', () => { + it('serializa strings planas', () => { + expect(lingNodeToJSON('hola')).toBe('hola'); + }); + + it('serializa referencias IDLing como strings', () => { + expect(lingNodeToJSON('#?common.ok')).toBe('#?common.ok'); + }); + + it('serializa LingRecord como objeto plano', () => { + const result = lingNodeToJSON({ es: 'Hola', en: 'Hello' }); + expect(result).toEqual({ es: 'Hola', en: 'Hello' }); + }); + + it('serializa namespaces recursivamente', () => { + const node = { common: { ok: { es: 'Aceptar', en: 'OK' } } }; + const result = lingNodeToJSON(node); + expect(result.common.ok).toEqual({ es: 'Aceptar', en: 'OK' }); + }); + + it('serializa funciones de interpolación con {{tokens}}', () => { + const fn = (p: any) => ({ es: `Hola ${p.name}`, en: `Hello ${p.name}` }); + const result = lingNodeToJSON(fn); + expect(result.es).toBe('Hola {{name}}'); + expect(result.en).toBe('Hello {{name}}'); + }); + + it('serializa plurales con __type: plural', () => { + const plural = p({ + es: { one: '1 elemento', other: '{{count}} elementos' }, + }); + const result = lingNodeToJSON(plural); + expect(result.__type).toBe('plural'); + expect(result.config).toBeDefined(); + }); + + it('devuelve {} si la función de interpolación lanza', () => { + const broken = () => { throw new Error('broken'); }; + expect(lingNodeToJSON(broken)).toEqual({}); + }); +}); + + +describe('JSONToLingNode', () => { + it('reconstruye strings planas', () => { + expect(JSONToLingNode('hola')).toBe('hola'); + }); + + it('reconstruye LingRecord como objeto', () => { + const result = JSONToLingNode({ es: 'Hola', en: 'Hello' }); + expect(result).toEqual({ es: 'Hola', en: 'Hello' }); + }); + + it('reconstruye plurales como funciones', () => { + const config = { + es: { one: '{{count}} elemento', other: '{{count}} elementos' }, + }; + const result = JSONToLingNode({ __type: 'plural', config }) as any; + expect(typeof result).toBe('function'); + expect(result({ count: 1 }).es).toBe('{{count}} elemento'); + }); + + it('reconstruye namespaces recursivamente', () => { + const json = { common: { ok: { es: 'Aceptar' } } }; + const result = JSONToLingNode(json) as any; + expect(result.common.ok.es).toBe('Aceptar'); + }); +}); + + +describe('round-trip lingNodeToJSON → JSONToLingNode', () => { + it('LingRecord sobrevive el round-trip', () => { + const original = { es: 'Aceptar', en: 'OK' }; + const json = lingNodeToJSON(original); + const restored = JSONToLingNode(json) as any; + expect(restored.es).toBe('Aceptar'); + expect(restored.en).toBe('OK'); + }); + + it('plural sobrevive el round-trip y sigue funcionando', () => { + const original = p({ es: { one: '1 item', other: '{{count}} items' } }); + const json = lingNodeToJSON(original); + const restored = JSONToLingNode(json) as any; + expect(typeof restored).toBe('function'); + expect(restored({ count: 1 }).es).toBe('1 item'); + expect(restored({ count: 5 }).es).toBe('{{count}} items'); + }); + + it('referencia IDLing sobrevive el round-trip como string', () => { + const json = lingNodeToJSON('#?common.ok'); + const restored = JSONToLingNode(json); + expect(restored).toBe('#?common.ok'); + // Y sigue siendo reconocida por isIDLing + expect(isIDLing(restored)).toBe(true); + }); + + it('árbol completo sobrevive el round-trip', () => { + const node = { + common: { ok: { es: 'Aceptar', en: 'OK' } }, + ref: '#?common.ok', + items: p({ es: { one: '1 item', other: '{{count}} items' } }), + }; + const json = lingNodeToJSON(node); + const restored = JSONToLingNode(json) as any; + + expect(restored.common.ok.es).toBe('Aceptar'); + expect(restored.ref).toBe('#?common.ok'); + expect(typeof restored.items).toBe('function'); + }); +}); \ No newline at end of file diff --git a/src/lib/logr/lib/engine.ts b/src/lib/logr/lib/engine.ts new file mode 100644 index 000000000..51a323f0a --- /dev/null +++ b/src/lib/logr/lib/engine.ts @@ -0,0 +1,164 @@ +import type { + LingInstance, + LingString +} from '@/ling/lib'; + +import type { + LogrOptions, + LogEntry, + MessageCategory, + LogFilters, + Logr, + Transport +} from './types.ts'; +import { LogLevel } from './types.ts'; +import { consoleTransport } from './transports.ts'; + +// ============================================================================ +// ENGINE +// ============================================================================ + +/** + * Crea una instancia de `Logr` vinculada a una instancia de i18n. + * + * El logger no gestiona el locale internamente — lo delega al sistema i18n. + * Cuando cambia el locale en i18n, los mensajes siguientes se resuelven + * automáticamente al nuevo locale sin ninguna reconfiguración. + * + * Cada entrada se emite a todos los transports registrados de forma aislada: + * si un transport lanza, los demás siguen ejecutándose. + * + * @param ling - Instancia de i18n para resolución de mensajes localizados + * @param options - Configuración de nivel, historial y transports + * + * @example + * export const logr = createLogr(i18n, { + * level : LogLevel.WARN, + * transports: [ + * consoleTransport(), + * httpTransport({ url: 'https://logs.myapp.com', level: LogLevel.ERROR }), + * ] + * }); + */ +export function createLogr(ling: LingInstance, options: LogrOptions = {}): Logr { + + let level : LogLevel = options.level ?? LogLevel.WARN; + let maxLogs : number = options.maxLogs ?? 1000; + let transports : Transport[] = options.transports ?? [consoleTransport()]; + let entries : LogEntry[] = []; + + // ------------------------------------------------------------------------- + // Configuración + // ------------------------------------------------------------------------- + + function setLevel(l: LogLevel): void { + level = l; + } + + function setMaxLogs(max: number): void { + maxLogs = max; + // Recorta inmediatamente si el historial actual supera el nuevo límite + if (entries.length > maxLogs) entries = entries.slice(-maxLogs); + } + + // ------------------------------------------------------------------------- + // Core interno + // ------------------------------------------------------------------------- + + /** + * Centraliza el procesamiento de todos los niveles. + * Aplica el filtro de nivel, construye la entrada, gestiona el historial + * y emite a todos los transports de forma aislada. + */ + function log( + lvl : LogLevel, + category: MessageCategory, + message : LingString, + context?: Record + ): void { + if (lvl < level) return; + + const entry: LogEntry = { + timestamp: new Date(), + level : lvl, + category, + message, + // Captura el locale en el momento del registro para que serialize() + // pueda reproducir el mensaje tal como lo vio el usuario. + locale : ling.getLocale(), + context, + }; + + // Historial en memoria — slice(-maxLogs) en lugar de shift() para evitar + // O(n) por desplazamiento en cada inserción cuando se supera el límite. + entries.push(entry); + if (entries.length > maxLogs) entries = entries.slice(-maxLogs); + + // Resuelve el mensaje al locale activo y emite a cada transport de forma + // aislada: un transport que lanza no impide que los demás reciban la entrada. + const resolvedMessage = ling.ts(entry.message); + for (const transport of transports) { + try { + transport.write(entry, resolvedMessage); + } catch (err) { + console.error('[logr] Transport error:', err); + } + } + } + + // ------------------------------------------------------------------------- + // API pública + // ------------------------------------------------------------------------- + + function debug(category: MessageCategory, message: LingString, context?: Record): void { + log(LogLevel.DEBUG, category, message, context); + } + + function info(category: MessageCategory, message: LingString, context?: Record): void { + log(LogLevel.INFO, category, message, context); + } + + function warn(category: MessageCategory, message: LingString, context?: Record): void { + log(LogLevel.WARN, category, message, context); + } + + function error(category: MessageCategory, message: LingString, context?: Record): void { + log(LogLevel.ERROR, category, message, context); + } + + function getLogs(filters?: LogFilters): LogEntry[] { + // Copia defensiva — el llamador no puede mutar el historial interno + let result = entries.map(e => ({ ...e })); + + if (filters?.level !== undefined) result = result.filter(e => e.level === filters.level); + if (filters?.minLevel !== undefined) result = result.filter(e => e.level >= filters.minLevel!); + if (filters?.category !== undefined) result = result.filter(e => e.category === filters.category); + if (filters?.since !== undefined) result = result.filter(e => e.timestamp >= filters.since!); + + return result; + } + + function clear(): void { + entries = []; + } + + /** + * Exporta el historial como JSON con mensajes resueltos al locale original + * de cada entrada — el locale activo en el momento en que se registró. + * Esto garantiza que el JSON refleja lo que el usuario vio en pantalla, + * independientemente de cambios de locale posteriores. + */ + function serialize(): string { + return JSON.stringify( + entries.map(e => ({ + ...e, + message : ling.tsForLocale(e.message, e.locale as any), + timestamp: e.timestamp.toISOString(), + })), + null, + 2 + ); + } + + return { debug, info, warn, error, getLogs, clear, serialize, setLevel, setMaxLogs }; +} diff --git a/src/lib/logr/lib/index.ts b/src/lib/logr/lib/index.ts new file mode 100644 index 000000000..449010623 --- /dev/null +++ b/src/lib/logr/lib/index.ts @@ -0,0 +1,4 @@ +export * from './engine.ts'; +export * from './transports.ts'; +export * from './types.ts'; + diff --git a/src/lib/logr/lib/transports.ts b/src/lib/logr/lib/transports.ts new file mode 100644 index 000000000..4f23a9a5d --- /dev/null +++ b/src/lib/logr/lib/transports.ts @@ -0,0 +1,130 @@ +import type { + Transport, + ConsoleTransportOptions, + HttpTransportOptions, + LogEntry +} from './types.ts'; + +import { LogLevel } from './types.ts'; + +// ============================================================================ +// CONSOLE TRANSPORT +// ============================================================================ + +/** + * Transport que emite los logs a la consola del navegador/Node. + * Es el transport por defecto si no se especifica ninguno en `LogrOptions`. + * + * Usa el método de consola apropiado según el nivel: + * DEBUG → console.debug, INFO → console.info, WARN → console.warn, ERROR → console.error + * + * @example + * createLogr(i18n, { transports: [consoleTransport()] }) + * + * @example + * // Sin timestamp ni prefijo + * consoleTransport({ timestamp: false, prefix: false }) + */ +export function consoleTransport(options: ConsoleTransportOptions = {}): Transport { + const { timestamp: showTimestamp = true, prefix: showPrefix = true } = options; + + return { + write(entry: LogEntry, resolvedMessage: string): void { + const args: unknown[] = []; + + if (showTimestamp) args.push(entry.timestamp.toISOString()); + if (showPrefix) args.push(`[${entry.category}]`); + args.push(resolvedMessage); + + // Solo añade el contexto si existe — evita un '' vacío al final de cada línea + if (entry.context) args.push(entry.context); + + switch (entry.level) { + case LogLevel.DEBUG: console.debug(...args as [unknown, ...unknown[]]); break; + case LogLevel.INFO: console.info (...args as [unknown, ...unknown[]]); break; + case LogLevel.WARN: console.warn (...args as [unknown, ...unknown[]]); break; + case LogLevel.ERROR: console.error(...args as [unknown, ...unknown[]]); break; + } + } + }; +} + +// ============================================================================ +// HTTP TRANSPORT +// ============================================================================ + +/** + * Transport que envía las entradas de log a un endpoint HTTP remoto via POST. + * Útil para servicios de logging centralizados (Datadog, Logtail, custom APIs...). + * + * El envío es fire-and-forget — los errores de red se loguean en consola + * pero no interrumpen el flujo de la aplicación. + * + * Nota: no incluye retry ni batching. Para alto volumen considera un transport + * personalizado con cola y reintentos. + * + * @example + * httpTransport({ + * url : 'https://logs.myapp.com/ingest', + * headers: { 'Authorization': 'Bearer my-token' }, + * level : LogLevel.ERROR + * }) + */ +export function httpTransport(options: HttpTransportOptions): Transport { + // Usa la constante del enum en lugar del magic number 3 + const minLevel = options.level ?? LogLevel.ERROR; + + return { + write(entry: LogEntry, resolvedMessage: string): void { + if (entry.level < minLevel) return; + + const payload = { + timestamp: entry.timestamp.toISOString(), + level : entry.level, + category : entry.category, + message : resolvedMessage, + context : entry.context, + }; + + // Fire-and-forget — no bloqueamos el hilo principal + fetch(options.url, { + method : 'POST', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + body: JSON.stringify(payload), + }).catch(err => { + console.error('[logr:httpTransport] Failed to send log:', err); + }); + } + }; +} + +// ============================================================================ +// CALLBACK TRANSPORT +// ============================================================================ + +/** + * Transport que invoca una función callback por cada entrada de log. + * Es el transport más flexible — ideal para integraciones custom, + * tests, o cuando necesitas lógica de routing entre destinos. + * + * @example + * // Integración con Sentry + * callbackTransport((entry, message) => { + * if (entry.level >= LogLevel.ERROR) { + * Sentry.captureMessage(message, { extra: entry.context }); + * } + * }) + * + * @example + * // En tests — captura los logs sin output a consola + * const captured: string[] = []; + * callbackTransport((_, message) => captured.push(message)) + */ +export function callbackTransport( + fn: (entry: LogEntry, resolvedMessage: string) => void +): Transport { + return { write: fn }; +} diff --git a/src/lib/logr/lib/types.ts b/src/lib/logr/lib/types.ts new file mode 100644 index 000000000..66d86408f --- /dev/null +++ b/src/lib/logr/lib/types.ts @@ -0,0 +1,224 @@ +import type { LingString } from '@/ling/lib'; + + +// ============================================================================ +// LOG LEVEL +// ============================================================================ + +/** + * Niveles de severidad del log, ordenados de menor a mayor. + * Un logger configurado con un nivel X solo procesa entradas + * con nivel >= X. + * + * @example + * const logr = createLogr(i18n, { level: LogLevel.WARN }); + * logr.debug('auth', 'msg'); // ignorado — DEBUG < WARN + * logr.error('auth', 'msg'); // procesado — ERROR >= WARN + */ +export enum LogLevel { + /** Información detallada para diagnóstico en desarrollo */ + DEBUG = 0, + /** Eventos relevantes del flujo normal de la aplicación */ + INFO = 1, + /** Situaciones inesperadas que no interrumpen la ejecución */ + WARN = 2, + /** Errores que requieren atención inmediata */ + ERROR = 3, + /** Desactiva todos los logs — útil en tests. + * Valor alto deliberado para que cualquier nivel futuro lo preceda. */ + NONE = 999 +} + +// ============================================================================ +// CORE TYPES +// ============================================================================ + +/** + * Agrupa los logs por dominio funcional. + * Se usa como prefijo en consola `[auth]` y como filtro en `getLogs()`. + * + * @example + * 'auth' | 'checkout' | 'db' | 'render' + */ +export type MessageCategory = string; + +/** + * Entrada individual del historial de logs. + * El mensaje se almacena como `LingString` sin resolver — + * la resolución al locale original ocurre en serialize(). + */ +export interface LogEntry { + /** Momento exacto en que se registró el log */ + timestamp : Date; + /** Nivel de severidad */ + level : LogLevel; + /** Dominio funcional — ej: 'auth', 'checkout' */ + category : MessageCategory; + /** + * Mensaje del log. Puede ser un string simple o un LingRecord. + * Se resuelve al locale capturado en `locale` para preservar + * el contexto exacto en que se registró la entrada. + */ + message : LingString; + /** + * Locale activo en el momento en que se registró el log. + * Capturado por el engine para que serialize() pueda reproducir + * el mensaje tal como lo vio el usuario, independientemente de + * cambios de locale posteriores. + */ + locale : string; + /** Datos adicionales de contexto para facilitar el diagnóstico */ + context? : Record; +} + +/** + * Filtros para consultar el historial de logs con `getLogs()`. + * Todos los campos son opcionales y se combinan con AND. + * + * @example + * logr.getLogs({ category: 'auth', minLevel: LogLevel.WARN }) + * logr.getLogs({ since: new Date('2024-01-01') }) + */ +export interface LogFilters { + /** Filtra por nivel exacto de severidad */ + level? : LogLevel; + /** Filtra por nivel mínimo — incluye este nivel y todos los superiores */ + minLevel? : LogLevel; + /** Filtra por categoría exacta */ + category? : MessageCategory; + /** Filtra entradas con timestamp >= since */ + since? : Date; +} + +// ============================================================================ +// TRANSPORTS +// ============================================================================ + +/** + * Un transport es un destino de salida para las entradas de log. + * El engine emite cada entrada a todos los transports registrados. + * + * Recibe tanto la entrada original (`LogEntry`) como el mensaje + * ya resuelto al locale activo (`resolvedMessage`), para que el + * transport no necesite conocer el sistema i18n. + * + * @example + * const myTransport: Transport = { + * write: (entry, message) => { + * myExternalService.send({ level: entry.level, message }); + * } + * }; + */ +export interface Transport { + /** + * Recibe una entrada de log para procesarla. + * @param entry - Entrada original con mensaje sin resolver + * @param resolvedMessage - Mensaje ya traducido al locale activo + */ + write: (entry: LogEntry, resolvedMessage: string) => void; +} + +/** + * Opciones para el transport de consola. + */ +export interface ConsoleTransportOptions { + /** + * Incluye el timestamp en el output. + * @default true + */ + timestamp?: boolean; + /** + * Incluye el prefijo de categoría `[category]` en el output. + * @default true + */ + prefix?: boolean; +} + +/** + * Opciones para el transport HTTP. + */ +export interface HttpTransportOptions { + /** URL del endpoint que recibe los logs */ + url : string; + /** Headers adicionales — útil para autenticación */ + headers?: Record; + /** + * Nivel mínimo para enviar al servidor. + * Permite enviar solo errores al servidor aunque el logger esté en DEBUG. + * @default LogLevel.ERROR + */ + level? : LogLevel; +} + +// ============================================================================ +// OPTIONS +// ============================================================================ + +/** + * Opciones de configuración al crear una instancia de `Logr`. + * + * @example + * createLogr(i18n, { + * level : LogLevel.DEBUG, + * maxLogs : 5000, + * transports: [ consoleTransport(), httpTransport({ url: '...' }) ] + * }) + */ +export interface LogrOptions { + /** + * Nivel mínimo de severidad a procesar. + * @default LogLevel.WARN + */ + level? : LogLevel; + /** + * Número máximo de entradas a retener en el historial en memoria. + * Cuando se supera, se descartan las entradas más antiguas. + * @default 1000 + */ + maxLogs? : number; + /** + * Lista de transports a los que se emitirá cada entrada. + * Si no se especifica, usa `consoleTransport()` por defecto. + * @default [consoleTransport()] + */ + transports? : Transport[]; +} + +// ============================================================================ +// INSTANCE TYPE +// ============================================================================ + +/** + * Interfaz pública de una instancia de Logr. + * Creada mediante `createLogr(i18n, options)`. + * + * @example + * const logr = createLogr(i18n, { + * level : LogLevel.DEBUG, + * transports: [ consoleTransport(), httpTransport({ url: 'https://logs.myapp.com' }) ] + * }); + * + * logr.debug('auth', { es: 'Iniciando sesión', en: 'Logging in' }); + * logr.warn('db', { es: 'Conexión lenta', en: 'Slow connection' }, { ms: 2000 }); + * logr.error('auth', 'Unexpected error'); + */ +export type Logr = { + /** Registra un mensaje de nivel DEBUG */ + debug : (category: MessageCategory, message: LingString, context?: Record) => void; + /** Registra un mensaje de nivel INFO */ + info : (category: MessageCategory, message: LingString, context?: Record) => void; + /** Registra un mensaje de nivel WARN */ + warn : (category: MessageCategory, message: LingString, context?: Record) => void; + /** Registra un mensaje de nivel ERROR */ + error : (category: MessageCategory, message: LingString, context?: Record) => void; + /** Devuelve una copia del historial, opcionalmente filtrada */ + getLogs : (filters?: LogFilters) => LogEntry[]; + /** Vacía el historial en memoria */ + clear : () => void; + /** Exporta el historial como JSON con mensajes resueltos al locale original de cada entrada */ + serialize : () => string; + /** Cambia el nivel mínimo de severidad en tiempo de ejecución */ + setLevel : (level: LogLevel) => void; + /** Cambia el número máximo de entradas a retener en memoria y recorta si es necesario */ + setMaxLogs: (max: number) => void; +}; diff --git a/src/lib/logr/test/logr.test.ts b/src/lib/logr/test/logr.test.ts new file mode 100644 index 000000000..ca3b1b5e3 --- /dev/null +++ b/src/lib/logr/test/logr.test.ts @@ -0,0 +1,821 @@ +/** + * logr — test suite + * + * Ejecutar: npx vitest run + * Cobertura: npx vitest run --coverage + * + * Módulos cubiertos: + * - types (LogLevel, LogEntry, LogFilters, etc.) + * - engine (createLogr, log methods, serialize) + * - transports (console, http, callback) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createLing } from '../../ling/lib/engine'; +import { createLogr } from '../lib/engine'; +import { consoleTransport, httpTransport, callbackTransport } from '../lib/transports'; +import { LogLevel } from '../lib/types'; + +// ============================================================================ +// FIXTURES +// ============================================================================ + +const ling = createLing({ + common: { + ok: { es: 'Aceptar', en: 'OK' }, + error: { es: 'Error', en: 'Error' }, + }, +}, 'es'); + +// ============================================================================ +// LOG LEVEL +// ============================================================================ + +describe('LogLevel', () => { + it('los valores numéricos son correctos', () => { + expect(LogLevel.DEBUG).toBe(0); + expect(LogLevel.INFO).toBe(1); + expect(LogLevel.WARN).toBe(2); + expect(LogLevel.ERROR).toBe(3); + expect(LogLevel.NONE).toBe(999); + }); + + it('permite comparar niveles correctamente', () => { + expect(LogLevel.DEBUG < LogLevel.INFO).toBe(true); + expect(LogLevel.INFO < LogLevel.WARN).toBe(true); + expect(LogLevel.WARN < LogLevel.ERROR).toBe(true); + expect(LogLevel.ERROR < LogLevel.NONE).toBe(true); + }); +}); + +// ============================================================================ +// ENGINE — createLing +// ============================================================================ + +describe('createLogr — nivel por defecto', () => { + let captured: string[] = []; + + beforeEach(() => { + captured = []; + }); + + it('crea una instancia con nivel WARN por defecto', () => { + const logr = createLogr(ling, { + transports: [callbackTransport((_, msg) => captured.push(msg))], + }); + + logr.debug('test', 'debug msg'); + logr.info('test', 'info msg'); + logr.warn('test', 'warn msg'); + + expect(captured).toHaveLength(1); + expect(captured[0]).toBe('warn msg'); + }); +}); + +// ============================================================================ +// ENGINE — métodos de logging +// ============================================================================ + +describe('createLogr — debug(), info(), warn(), error()', () => { + let captured: Array<{ level: number; category: string; message: string; context?: Record }>; + + beforeEach(() => { + captured = []; + ling.setLocale('es'); // Reset locale antes de cada test + }); + + it('registra mensajes de DEBUG', () => { + const logr = createLogr(ling, { + level: LogLevel.DEBUG, + transports: [callbackTransport((entry, msg) => { + captured.push({ level: entry.level, category: entry.category, message: msg }); + })], + }); + + logr.debug('auth', 'Debug message'); + + expect(captured).toHaveLength(1); + expect(captured[0].level).toBe(LogLevel.DEBUG); + expect(captured[0].category).toBe('auth'); + expect(captured[0].message).toBe('Debug message'); + }); + + it('registra mensajes de INFO', () => { + const logr = createLogr(ling, { + level: LogLevel.INFO, + transports: [callbackTransport((entry, msg) => { + captured.push({ level: entry.level, category: entry.category, message: msg }); + })], + }); + + logr.info('db', 'Info message'); + + expect(captured).toHaveLength(1); + expect(captured[0].level).toBe(LogLevel.INFO); + expect(captured[0].category).toBe('db'); + }); + + it('registra mensajes de WARN', () => { + const logr = createLogr(ling, { + level: LogLevel.WARN, + transports: [callbackTransport((entry, msg) => { + captured.push({ level: entry.level, category: entry.category, message: msg }); + })], + }); + + logr.warn('api', 'Warn message'); + + expect(captured).toHaveLength(1); + expect(captured[0].level).toBe(LogLevel.WARN); + expect(captured[0].category).toBe('api'); + }); + + it('registra mensajes de ERROR', () => { + const logr = createLogr(ling, { + level: LogLevel.ERROR, + transports: [callbackTransport((entry, msg) => { + captured.push({ level: entry.level, category: entry.category, message: msg }); + })], + }); + + logr.error('payment', 'Error message'); + + expect(captured).toHaveLength(1); + expect(captured[0].level).toBe(LogLevel.ERROR); + expect(captured[0].category).toBe('payment'); + }); + + it('incluye contexto cuando se proporciona', () => { + const logr = createLogr(ling, { + level: LogLevel.INFO, + transports: [callbackTransport((entry, msg) => { + captured.push({ level: entry.level, category: entry.category, message: msg, context: entry.context }); + })], + }); + + const contextData = { userId: 123, action: 'login' }; + logr.info('auth', 'User logged in', contextData); + + expect(captured).toHaveLength(1); + expect(captured[0].context).toEqual(contextData); + }); + + it('resuelve LingRecord al locale activo', () => { + const msgCaptured: string[] = []; + const logr = createLogr(ling, { + level: LogLevel.INFO, + transports: [callbackTransport((_, msg) => msgCaptured.push(msg))], + }); + + logr.info('test', { es: 'Hola', en: 'Hello' }); + + expect(msgCaptured[0]).toBe('Hola'); + }); + + it('cambia el locale y resuelve correctamente', () => { + const msgCaptured: string[] = []; + const logr = createLogr(ling, { + level: LogLevel.INFO, + transports: [callbackTransport((_, msg) => msgCaptured.push(msg))], + }); + + logr.info('test', { es: 'Aceptar', en: 'OK' }); + expect(msgCaptured[0]).toBe('Aceptar'); + + ling.setLocale('en'); + logr.info('test', { es: 'Aceptar', en: 'OK' }); + expect(msgCaptured[1]).toBe('OK'); + }); + + it('resuelve referencias IDLing', () => { + const msgCaptured: string[] = []; + const logr = createLogr(ling, { + level: LogLevel.INFO, + transports: [callbackTransport((_, msg) => msgCaptured.push(msg))], + }); + + logr.info('test', '#?common.ok'); + + expect(msgCaptured[0]).toBe('Aceptar'); + }); +}); + +// ============================================================================ +// ENGINE — filtrado por nivel +// ============================================================================ + +describe('createLogr — filtrado por nivel', () => { + let captured: string[]; + + beforeEach(() => { + captured = []; + }); + + it('DEBUG permite todos los niveles', () => { + const logr = createLogr(ling, { + level: LogLevel.DEBUG, + transports: [callbackTransport((_, msg) => captured.push(msg))], + }); + + logr.debug('test', 'd'); + logr.info('test', 'i'); + logr.warn('test', 'w'); + logr.error('test', 'e'); + + expect(captured).toHaveLength(4); + }); + + it('INFO filtra DEBUG', () => { + const logr = createLogr(ling, { + level: LogLevel.INFO, + transports: [callbackTransport((_, msg) => captured.push(msg))], + }); + + logr.debug('test', 'd'); + logr.info('test', 'i'); + logr.warn('test', 'w'); + logr.error('test', 'e'); + + expect(captured).toHaveLength(3); + expect(captured).toEqual(['i', 'w', 'e']); + }); + + it('WARN filtra DEBUG e INFO', () => { + const logr = createLogr(ling, { + level: LogLevel.WARN, + transports: [callbackTransport((_, msg) => captured.push(msg))], + }); + + logr.debug('test', 'd'); + logr.info('test', 'i'); + logr.warn('test', 'w'); + logr.error('test', 'e'); + + expect(captured).toHaveLength(2); + expect(captured).toEqual(['w', 'e']); + }); + + it('ERROR filtra DEBUG, INFO y WARN', () => { + const logr = createLogr(ling, { + level: LogLevel.ERROR, + transports: [callbackTransport((_, msg) => captured.push(msg))], + }); + + logr.debug('test', 'd'); + logr.info('test', 'i'); + logr.warn('test', 'w'); + logr.error('test', 'e'); + + expect(captured).toHaveLength(1); + expect(captured).toEqual(['e']); + }); + + it('NONE filtra todo', () => { + const logr = createLogr(ling, { + level: LogLevel.NONE, + transports: [callbackTransport((_, msg) => captured.push(msg))], + }); + + logr.debug('test', 'd'); + logr.info('test', 'i'); + logr.warn('test', 'w'); + logr.error('test', 'e'); + + expect(captured).toHaveLength(0); + }); +}); + +// ============================================================================ +// ENGINE — historial +// ============================================================================ + +describe('createLogr — getLogs()', () => { + it('devuelve todas las entradas sin filtros', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.debug('cat1', 'msg1'); + logr.info('cat2', 'msg2'); + logr.warn('cat1', 'msg3'); + + const logs = logr.getLogs(); + expect(logs).toHaveLength(3); + }); + + it('filtra por nivel exacto', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.debug('cat1', 'msg1'); + logr.info('cat2', 'msg2'); + logr.debug('cat1', 'msg3'); + + const logs = logr.getLogs({ level: LogLevel.DEBUG }); + expect(logs).toHaveLength(2); + }); + + it('filtra por nivel mínimo', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.debug('cat1', 'msg1'); + logr.info('cat2', 'msg2'); + logr.warn('cat1', 'msg3'); + logr.error('cat2', 'msg4'); + + const logs = logr.getLogs({ minLevel: LogLevel.WARN }); + expect(logs).toHaveLength(2); + expect(logs.every(l => l.level >= LogLevel.WARN)).toBe(true); + }); + + it('filtra por categoría', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.debug('auth', 'msg1'); + logr.info('db', 'msg2'); + logr.warn('auth', 'msg3'); + + const logs = logr.getLogs({ category: 'auth' }); + expect(logs).toHaveLength(2); + expect(logs.every(l => l.category === 'auth')).toBe(true); + }); + + it('filtra por fecha (since)', async () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.debug('cat1', 'msg1'); + // Pequeño delay para asegurar diferencia en timestamps + await new Promise(resolve => setTimeout(resolve, 10)); + const now = new Date(); + logr.info('cat2', 'msg2'); + + const logs = logr.getLogs({ since: now }); + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('msg2'); + }); + + it('combina múltiples filtros con AND', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.debug('auth', 'msg1'); + logr.info('db', 'msg2'); + logr.warn('auth', 'msg3'); + logr.error('auth', 'msg4'); + + const logs = logr.getLogs({ category: 'auth', minLevel: LogLevel.WARN }); + expect(logs).toHaveLength(2); + expect(logs.every(l => l.category === 'auth' && l.level >= LogLevel.WARN)).toBe(true); + }); + + it('devuelve una copia defensiva — mutar no afecta el historial interno', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.info('test', 'original'); + + const logs = logr.getLogs(); + logs[0].message = 'mutated'; + + const logs2 = logr.getLogs(); + expect(logs2[0].message).toBe('original'); + }); +}); + +describe('createLogr — clear()', () => { + it('vacía el historial', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.debug('test', 'msg1'); + logr.info('test', 'msg2'); + expect(logr.getLogs()).toHaveLength(2); + + logr.clear(); + expect(logr.getLogs()).toHaveLength(0); + }); +}); + +// ============================================================================ +// ENGINE — serialize +// ============================================================================ + +describe('createLogr — serialize()', () => { + it('exporta el historial como JSON', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.info('auth', { es: 'Inicio de sesión', en: 'Login' }); + logr.error('db', 'Connection failed'); + + const json = logr.serialize(); + const parsed = JSON.parse(json); + + expect(parsed).toHaveLength(2); + expect(parsed[0].category).toBe('auth'); + expect(parsed[0].message).toBe('Inicio de sesión'); + expect(parsed[1].category).toBe('db'); + expect(parsed[1].message).toBe('Connection failed'); + }); + + it('serializa el timestamp como ISO string', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.info('test', 'msg'); + + const json = logr.serialize(); + const parsed = JSON.parse(json); + + expect(parsed[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('preserva el locale original del mensaje', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.info('test', { es: 'Mensaje en español', en: 'English message' }); + + ling.setLocale('en'); + const json = logr.serialize(); + const parsed = JSON.parse(json); + + // El mensaje serializado debe ser el del locale original (es) + expect(parsed[0].message).toBe('Mensaje en español'); + }); + + it('serializa el contexto si existe', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG }); + + logr.info('test', 'msg', { userId: 123, extra: 'data' }); + + const json = logr.serialize(); + const parsed = JSON.parse(json); + + expect(parsed[0].context).toEqual({ userId: 123, extra: 'data' }); + }); +}); + +// ============================================================================ +// ENGINE — configuración en runtime +// ============================================================================ + +describe('createLogr — setLevel()', () => { + let captured: string[]; + + beforeEach(() => { + captured = []; + }); + + it('cambia el nivel mínimo en runtime', () => { + const logr = createLogr(ling, { + level: LogLevel.ERROR, + transports: [callbackTransport((_, msg) => captured.push(msg))], + }); + + logr.debug('test', 'd'); + logr.info('test', 'i'); + expect(captured).toHaveLength(0); + + logr.setLevel(LogLevel.DEBUG); + logr.debug('test', 'd'); + logr.info('test', 'i'); + + expect(captured).toHaveLength(2); + }); +}); + +describe('createLogr — setMaxLogs()', () => { + it('limita el tamaño del historial', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG, maxLogs: 3 }); + + logr.debug('test', '1'); + logr.debug('test', '2'); + logr.debug('test', '3'); + logr.debug('test', '4'); + + const logs = logr.getLogs(); + expect(logs).toHaveLength(3); + expect(logs[0].message).toBe('2'); // el más antiguo fue descartado + expect(logs[2].message).toBe('4'); + }); + + it('recorta el historial si se reduce el maxLogs', () => { + const logr = createLogr(ling, { level: LogLevel.DEBUG, maxLogs: 100 }); + + logr.debug('test', '1'); + logr.debug('test', '2'); + logr.debug('test', '3'); + logr.debug('test', '4'); + + expect(logr.getLogs()).toHaveLength(4); + + logr.setMaxLogs(2); + + const logs = logr.getLogs(); + expect(logs).toHaveLength(2); + expect(logs[0].message).toBe('3'); + expect(logs[1].message).toBe('4'); + }); +}); + +// ============================================================================ +// TRANSPORTS +// ============================================================================ + +describe('consoleTransport', () => { + it('llama al método de consola correcto según el nivel', () => { + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const transport = consoleTransport(); + const logr = createLogr(ling, { + level: LogLevel.DEBUG, + transports: [transport], + }); + + logr.debug('test', 'debug msg'); + logr.info('test', 'info msg'); + logr.warn('test', 'warn msg'); + logr.error('test', 'error msg'); + + expect(debugSpy).toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + + debugSpy.mockRestore(); + infoSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('puede desactivar timestamp', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const transport = consoleTransport({ timestamp: false }); + transport.write({ + timestamp: new Date(), + level: LogLevel.WARN, + category: 'test', + message: 'msg', + locale: 'es', + }, 'resolved'); + + const firstArg = warnSpy.mock.calls[0][0]; + expect(firstArg).not.toMatch(/^\d{4}-\d{2}-\d{2}T/); + + warnSpy.mockRestore(); + }); + + it('puede desactivar el prefijo de categoría', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const transport = consoleTransport({ prefix: false }); + transport.write({ + timestamp: new Date(), + level: LogLevel.WARN, + category: 'test', + message: 'msg', + locale: 'es', + }, 'resolved'); + + const hasCategoryPrefix = warnSpy.mock.calls[0].some((arg: unknown) => + typeof arg === 'string' && arg === '[test]' + ); + expect(hasCategoryPrefix).toBe(false); + + warnSpy.mockRestore(); + }); + + it('incluye el contexto si existe', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const transport = consoleTransport(); + transport.write({ + timestamp: new Date(), + level: LogLevel.WARN, + category: 'test', + message: 'msg', + locale: 'es', + context: { key: 'value' }, + }, 'resolved'); + + const callArgs = warnSpy.mock.calls[0]; + expect(callArgs).toContainEqual({ key: 'value' }); + + warnSpy.mockRestore(); + }); +}); + +describe('httpTransport', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('envía POST al endpoint especificado', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response()); + + const transport = httpTransport({ url: 'https://logs.example.com/api' }); + transport.write({ + timestamp: new Date(), + level: LogLevel.ERROR, + category: 'test', + message: 'error msg', + locale: 'es', + }, 'resolved'); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://logs.example.com/api', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ); + }); + + it('incluye headers personalizados', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response()); + + const transport = httpTransport({ + url: 'https://logs.example.com/api', + headers: { 'Authorization': 'Bearer token123' }, + }); + transport.write({ + timestamp: new Date(), + level: LogLevel.ERROR, + category: 'test', + message: 'msg', + locale: 'es', + }, 'resolved'); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer token123', + }), + }) + ); + }); + + it('filtra por nivel mínimo (por defecto ERROR)', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response()); + + const transport = httpTransport({ url: 'https://logs.example.com/api' }); + + transport.write({ + timestamp: new Date(), + level: LogLevel.WARN, + category: 'test', + message: 'warn', + locale: 'es', + }, 'resolved'); + + transport.write({ + timestamp: new Date(), + level: LogLevel.ERROR, + category: 'test', + message: 'error', + locale: 'es', + }, 'resolved'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('puede configurar un nivel mínimo diferente', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response()); + + const transport = httpTransport({ + url: 'https://logs.example.com/api', + level: LogLevel.WARN, + }); + + transport.write({ + timestamp: new Date(), + level: LogLevel.WARN, + category: 'test', + message: 'warn', + locale: 'es', + }, 'resolved'); + + transport.write({ + timestamp: new Date(), + level: LogLevel.ERROR, + category: 'test', + message: 'error', + locale: 'es', + }, 'resolved'); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('loggea en consola si fetch falla (fire-and-forget)', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error')); + + const transport = httpTransport({ url: 'https://logs.example.com/api' }); + transport.write({ + timestamp: new Date(), + level: LogLevel.ERROR, + category: 'test', + message: 'error', + locale: 'es', + }, 'resolved'); + + // Esperar a que la promesa sea rechazada + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[logr:httpTransport] Failed to send log:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); +}); + +describe('callbackTransport', () => { + it('invoca el callback con cada entrada', () => { + const received: Array<{ entry: any; message: string }> = []; + + const transport = callbackTransport((entry, msg) => { + received.push({ entry, message: msg }); + }); + + const logr = createLogr(ling, { + level: LogLevel.DEBUG, + transports: [transport], + }); + + logr.info('test', 'callback test'); + + expect(received).toHaveLength(1); + expect(received[0].message).toBe('callback test'); + expect(received[0].entry.category).toBe('test'); + }); + + it('permite capturar logs para tests', () => { + const captured: string[] = []; + + const transport = callbackTransport((_, msg) => captured.push(msg)); + const logr = createLogr(ling, { + level: LogLevel.DEBUG, + transports: [transport], + }); + + logr.debug('test', 'one'); + logr.info('test', 'two'); + logr.warn('test', 'three'); + + expect(captured).toEqual(['one', 'two', 'three']); + }); +}); + +// ============================================================================ +// MÚLTIPLES TRANSPORTS +// ============================================================================ + +describe('múltiples transports', () => { + it('emite a todos los transports registrados', () => { + const consoleCaptured: string[] = []; + const customCaptured: string[] = []; + + const consoleTrans = callbackTransport((_, msg) => consoleCaptured.push(msg)); + const customTrans = callbackTransport((_, msg) => customCaptured.push(msg)); + + const logr = createLogr(ling, { + level: LogLevel.INFO, + transports: [consoleTrans, customTrans], + }); + + logr.info('test', 'multi transport'); + + expect(consoleCaptured).toHaveLength(1); + expect(customCaptured).toHaveLength(1); + expect(consoleCaptured[0]).toBe('multi transport'); + expect(customCaptured[0]).toBe('multi transport'); + }); + + it('un transport que falla no impide que los demás reciban la entrada', () => { + const goodCaptured: string[] = []; + + const failingTrans = callbackTransport(() => { + throw new Error('Transport error'); + }); + const goodTrans = callbackTransport((_, msg) => goodCaptured.push(msg)); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const logr = createLogr(ling, { + level: LogLevel.INFO, + transports: [failingTrans, goodTrans], + }); + + logr.info('test', 'msg'); + + expect(goodCaptured).toHaveLength(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[logr] Transport error:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/src/lib/uiux/lib/break_engine.ts b/src/lib/uiux/lib/break_engine.ts new file mode 100644 index 000000000..9cc1befc2 --- /dev/null +++ b/src/lib/uiux/lib/break_engine.ts @@ -0,0 +1,62 @@ +import type { BreakConfig, BreakInstance, BreakpointKey } from './types.ts'; + +// Breakpoints por defecto — coinciden con Tailwind v3 +const DEFAULT_POINTS: Record = { + sm : 640, + md : 768, + lg : 1024, + xl : 1280, + '2xl': 1536, +}; + +/** + * Resuelve el breakpoint activo dado un ancho y un mapa de puntos. + * Devuelve la clave del mayor breakpoint que el ancho cumple, o 'xs' si ninguno. + */ +function resolveBreakpoint(width: number, points: Record): BreakpointKey { + const sorted = Object.entries(points).sort(([, a], [, b]) => b - a); + for (const [key, value] of sorted) { + if (width >= value) return key; + } + return 'xs'; +} + +export function createBreak(config: BreakConfig = {}): BreakInstance { + const points = config.points ?? DEFAULT_POINTS; + + const getWidth = () => typeof window !== 'undefined' ? window.innerWidth : 0; + + let current : BreakpointKey = resolveBreakpoint(getWidth(), points); + const listeners = new Set<(breakpoint: BreakpointKey) => void>(); + + // ResizeObserver en browser, noop en SSR + if (typeof window !== 'undefined') { + const observer = new ResizeObserver(() => { + const next = resolveBreakpoint(window.innerWidth, points); + if (next === current) return; + current = next; + listeners.forEach(fn => fn(current)); + }); + observer.observe(document.documentElement); + } + + function is(breakpoint: BreakpointKey): boolean { + const min = points[breakpoint]; + if (min === undefined) return false; + return getWidth() >= min; + } + + function onChange(fn: (breakpoint: BreakpointKey) => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); + } + + return { + get current() { return current; }, + get isMobile() { return !is('md'); }, + get isTablet() { return is('md') && !is('lg'); }, + get isDesktop() { return is('lg'); }, + is, + onChange, + }; +} diff --git a/src/lib/uiux/lib/engine.ts b/src/lib/uiux/lib/engine.ts new file mode 100644 index 000000000..8cd6021b9 --- /dev/null +++ b/src/lib/uiux/lib/engine.ts @@ -0,0 +1,41 @@ +import type { Uiux, UiuxConfig } from './types.ts'; +import { createTheme } from './theme_engine.ts'; +import { createBreak } from './break_engine.ts'; +import { createToast } from './toast_engine.ts'; +import { createModal } from './modal_engine.ts'; +import { createMenu } from './menu_engine.ts'; + +/** + * Crea el contexto de UI de la aplicación. + * + * Todos los módulos están siempre disponibles — no requieren config explícita. + * La config solo ajusta los defaults (tema inicial, breakpoints, duración de toasts). + * + * Diseñado para funcionar como módulo independiente o como parte de `ACtx`: + * + * @example + * // Standalone + * const uiux = createUiux({ theme: { mode: 'auto' } }); + * + * @example + * // Integrado en ACtx (cuando se añada uiux a ACtxConfig) + * actx.uiux.theme.toggle() + * actx.uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' }) + * actx.uiux.modal.open('confirm', { title: '¿Eliminar?' }) + * actx.uiux.menu.toggle('sidebar') + * actx.uiux.break.isMobile + * + * @remarks + * **Persistencia**: el tema se guarda en memoria en esta versión. + * Cuando `stor` esté disponible, `ACtx` conectará `uiux.theme` con + * `stor.local.get/set('uiux:theme')` sin modificar esta librería. + */ +export function createUiux(config: UiuxConfig = {}): Uiux { + return { + theme : createTheme(config.theme), + break : createBreak(config.break), + toast : createToast(config.toast?.defaultDuration), + modal : createModal(), + menu : createMenu(), + }; +} diff --git a/src/lib/uiux/lib/index.ts b/src/lib/uiux/lib/index.ts new file mode 100644 index 000000000..1e30c8a0e --- /dev/null +++ b/src/lib/uiux/lib/index.ts @@ -0,0 +1,7 @@ +export * from './types.ts'; +export * from './engine.ts'; +export * from './theme_engine.ts'; +export * from './break_engine.ts'; +export * from './toast_engine.ts'; +export * from './modal_engine.ts'; +export * from './menu_engine.ts'; diff --git a/src/lib/uiux/lib/menu_engine.ts b/src/lib/uiux/lib/menu_engine.ts new file mode 100644 index 000000000..c2ff56d90 --- /dev/null +++ b/src/lib/uiux/lib/menu_engine.ts @@ -0,0 +1,45 @@ +import type { MenuInstance } from './types.ts'; + +export function createMenu(): MenuInstance { + let openIds = new Set(); + const listeners = new Set<(openIds: Set) => void>(); + + function notify(): void { + const snapshot = new Set(openIds); + listeners.forEach(fn => fn(snapshot)); + } + + function open(id: string): void { + if (openIds.has(id)) return; + openIds = new Set(openIds).add(id); + notify(); + } + + function close(id: string): void { + if (!openIds.has(id)) return; + openIds = new Set(openIds); + openIds.delete(id); + notify(); + } + + function toggle(id: string): void { + openIds.has(id) ? close(id) : open(id); + } + + function isOpen(id: string): boolean { + return openIds.has(id); + } + + function closeAll(): void { + if (openIds.size === 0) return; + openIds = new Set(); + notify(); + } + + function onChange(fn: (openIds: Set) => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); + } + + return { open, close, toggle, isOpen, closeAll, onChange }; +} diff --git a/src/lib/uiux/lib/modal_engine.ts b/src/lib/uiux/lib/modal_engine.ts new file mode 100644 index 000000000..c72d27ff0 --- /dev/null +++ b/src/lib/uiux/lib/modal_engine.ts @@ -0,0 +1,47 @@ +import type { ModalEntry, ModalInstance } from './types.ts'; + +export function createModal(): ModalInstance { + let stack: ModalEntry[] = []; + const listeners = new Set<(stack: ModalEntry[]) => void>(); + + function notify(): void { + const snapshot = [...stack]; + listeners.forEach(fn => fn(snapshot)); + } + + function open

(id: string, props?: P): void { + stack = [...stack, { id, props: props ?? {} }]; + notify(); + } + + function close(): void { + if (stack.length === 0) return; + stack = stack.slice(0, -1); + notify(); + } + + function closeAll(): void { + if (stack.length === 0) return; + stack = []; + notify(); + } + + function isOpen(id: string): boolean { + return stack.some(m => m.id === id); + } + + function onChange(fn: (stack: ModalEntry[]) => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); + } + + return { + get stack() { return stack; }, + get active() { return stack.length > 0 ? stack[stack.length - 1] : null; }, + open, + close, + closeAll, + isOpen, + onChange, + }; +} diff --git a/src/lib/uiux/lib/theme_engine.ts b/src/lib/uiux/lib/theme_engine.ts new file mode 100644 index 000000000..cfeea4b5a --- /dev/null +++ b/src/lib/uiux/lib/theme_engine.ts @@ -0,0 +1,100 @@ +import type { ThemeConfig, ThemeInstance, ThemeMode } from './types.ts'; + +const DEFAULT_DARK_CLASS = 'dark'; + +/** Resuelve 'auto' al modo del sistema operativo. */ +function resolveSystemMode(): 'light' | 'dark' { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function applyTheme( + resolved : 'light' | 'dark', + darkClass : string, + vars? : ThemeConfig['vars'] +): void { + if (typeof document === 'undefined') return; + + const html = document.documentElement; + + // Clase dark de Tailwind + resolved === 'dark' + ? html.classList.add(darkClass) + : html.classList.remove(darkClass); + + // Variables CSS custom + if (vars) { + const themeVars = vars[resolved] ?? {}; + for (const [key, value] of Object.entries(themeVars)) { + html.style.setProperty(key, value); + } + } +} + +export function createTheme(config: ThemeConfig = {}): ThemeInstance { + const darkClass = config.darkClass ?? DEFAULT_DARK_CLASS; + + let currentMode: ThemeMode = config.mode ?? 'auto'; + let resolved : 'light' | 'dark' = currentMode === 'auto' + ? resolveSystemMode() + : currentMode; + + const listeners = new Set<(mode: ThemeMode, resolved: 'light' | 'dark') => void>(); + + // Escucha cambios del sistema cuando el modo es 'auto' + let mediaUnsubscribe: (() => void) | undefined; + + function subscribeToSystem(): void { + if (typeof window === 'undefined') return; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => { + if (currentMode !== 'auto') return; + resolved = mq.matches ? 'dark' : 'light'; + applyTheme(resolved, darkClass, config.vars); + listeners.forEach(fn => fn(currentMode, resolved)); + }; + mq.addEventListener('change', handler); + mediaUnsubscribe = () => mq.removeEventListener('change', handler); + } + + function unsubscribeFromSystem(): void { + mediaUnsubscribe?.(); + mediaUnsubscribe = undefined; + } + + function set(mode: ThemeMode): void { + currentMode = mode; + + if (mode === 'auto') { + resolved = resolveSystemMode(); + subscribeToSystem(); + } else { + unsubscribeFromSystem(); + resolved = mode; + } + + applyTheme(resolved, darkClass, config.vars); + listeners.forEach(fn => fn(currentMode, resolved)); + } + + function toggle(): void { + set(resolved === 'dark' ? 'light' : 'dark'); + } + + function onChange(fn: (mode: ThemeMode, resolved: 'light' | 'dark') => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); + } + + // Aplicar tema inicial + applyTheme(resolved, darkClass, config.vars); + if (currentMode === 'auto') subscribeToSystem(); + + return { + get resolved() { return resolved; }, + get mode() { return currentMode; }, + set, + toggle, + onChange, + }; +} diff --git a/src/lib/uiux/lib/toast_engine.ts b/src/lib/uiux/lib/toast_engine.ts new file mode 100644 index 000000000..77a963cb9 --- /dev/null +++ b/src/lib/uiux/lib/toast_engine.ts @@ -0,0 +1,68 @@ +import type { Toast, ToastInstance, ToastOptions } from './types.ts'; +import type { LingString } from '@/ling/lib'; + +const DEFAULT_DURATION = 4000; + +let _id = 0; +function nextId(): string { return `toast-${++_id}`; } + +export function createToast(defaultDuration = DEFAULT_DURATION): ToastInstance { + let queue: Toast[] = []; + const listeners = new Set<(queue: Toast[]) => void>(); + const timers = new Map>(); + + function notify(): void { + const snapshot = [...queue]; + listeners.forEach(fn => fn(snapshot)); + } + + function dismiss(id: string): void { + const timer = timers.get(id); + if (timer) { clearTimeout(timer); timers.delete(id); } + queue = queue.filter(t => t.id !== id); + notify(); + } + + function show(message: LingString, opts: ToastOptions = {}): string { + const id = nextId(); + const duration = opts.duration ?? defaultDuration; + + const toast: Toast = { + id, + type : opts.type ?? 'info', + message, + duration, + context : opts.context, + }; + + queue = [...queue, toast]; + notify(); + + // Auto-dismiss si duration > 0 + if (duration > 0) { + timers.set(id, setTimeout(() => dismiss(id), duration)); + } + + return id; + } + + function clear(): void { + timers.forEach(t => clearTimeout(t)); + timers.clear(); + queue = []; + notify(); + } + + function onChange(fn: (queue: Toast[]) => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); + } + + return { + get queue() { return queue; }, + show, + dismiss, + clear, + onChange, + }; +} diff --git a/src/lib/uiux/lib/types.ts b/src/lib/uiux/lib/types.ts new file mode 100644 index 000000000..9ff520f91 --- /dev/null +++ b/src/lib/uiux/lib/types.ts @@ -0,0 +1,206 @@ +// ============================== +// UIUX TYPES +// ============================== + +import type { LingString } from '@/ling/lib'; + +// ============================================================================ +// THEME +// ============================================================================ + +export type ThemeMode = 'light' | 'dark' | 'auto'; + + + +export interface ThemeConfig { + /** Modo inicial. Default: `'auto'`. */ + mode? : ThemeMode; + /** + * Clase CSS aplicada a `` en modo dark. + * Debe coincidir con la configuración de Tailwind (`darkMode: 'class'`). + * Default: `'dark'`. + */ + darkClass? : string; + /** + * Variables CSS custom por tema. Se inyectan en `` como `style`. + * Útil para temas custom más allá de light/dark. + * @example + * vars: { light: { '--color-brand': '#3B82F6' }, dark: { '--color-brand': '#60A5FA' } } + */ + vars? : Partial>>; +} + +export interface ThemeInstance { + /** Modo activo resuelto — nunca `'auto'`, siempre `'light'` o `'dark'`. */ + readonly resolved : 'light' | 'dark'; + /** Modo configurado — puede ser `'auto'`. */ + readonly mode : ThemeMode; + set : (mode: ThemeMode) => void; + toggle : () => void; + onChange : (fn: (mode: ThemeMode, resolved: 'light' | 'dark') => void) => () => void; +} + +// ============================================================================ +// BREAKPOINTS +// ============================================================================ + +export type BreakpointKey = string; + +export interface BreakConfig { + /** + * Mapa de breakpoints en px. Default: breakpoints de Tailwind v3. + * @example { sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 } + */ + points?: Record; +} + +export interface BreakInstance { + /** Breakpoint activo según el ancho actual de la ventana. */ + readonly current : BreakpointKey; + readonly isMobile : boolean; // < md + readonly isTablet : boolean; // >= md && < lg + readonly isDesktop: boolean; // >= lg + /** Devuelve true si el viewport cumple el breakpoint mínimo indicado. */ + is : (breakpoint: BreakpointKey) => boolean; + onChange : (fn: (breakpoint: BreakpointKey) => void) => () => void; +} + +// ============================================================================ +// TOAST +// ============================================================================ + +export type ToastType = 'success' | 'error' | 'warn' | 'info'; + +export interface ToastOptions { + /** Tipo visual del toast. Default: `'info'`. */ + type? : ToastType; + /** + * Duración en ms antes de auto-dismiss. `0` = permanente. + * Default: `4000`. + */ + duration?: number; + /** Contexto adicional — por ejemplo un código de error. */ + context? : Record; +} + +export interface Toast { + id : string; + type : ToastType; + message : LingString; + duration: number; + context?: Record; +} + +export interface ToastInstance { + /** Cola de toasts activos. */ + readonly queue : Toast[]; + show : (message: LingString, opts?: ToastOptions) => string; + dismiss : (id: string) => void; + clear : () => void; + onChange : (fn: (queue: Toast[]) => void) => () => void; +} + +// ============================================================================ +// MODAL +// ============================================================================ + +export interface ModalEntry

{ + id : string; + props : P; +} + +export interface ModalInstance { + /** Stack de modales abiertos. El último es el activo. */ + readonly stack : ModalEntry[]; + /** Modal activo (último del stack) o `null` si no hay ninguno. */ + readonly active : ModalEntry | null; + open :

(id: string, props?: P) => void; + close : () => void; + closeAll : () => void; + isOpen : (id: string) => boolean; + onChange : (fn: (stack: ModalEntry[]) => void) => () => void; +} + +// ============================================================================ +// MENU +// ============================================================================ + +export interface MenuInstance { + open : (id: string) => void; + close : (id: string) => void; + toggle : (id: string) => void; + isOpen : (id: string) => boolean; + closeAll: () => void; + onChange: (fn: (openIds: Set) => void) => () => void; +} + +// ============================================================================ +// UIUX CONFIG & INSTANCE +// ============================================================================ + +export interface UiuxConfig { + theme? : ThemeConfig; + break? : BreakConfig; + toast? : { defaultDuration?: number }; +} + +/** + * Contexto de UI de la aplicación. + * Gestiona tema, breakpoints, toasts, modales y menús. + * + * Todos los módulos son siempre disponibles — no requieren config explícita. + * La config solo ajusta los defaults. + * + * @example + * uiux.theme.toggle() + * uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' }) + * uiux.modal.open('confirm', { title: '¿Eliminar?' }) + * uiux.menu.toggle('sidebar') + * uix.break.isMobile + */ +export interface Uiux { + theme : ThemeInstance; + break : BreakInstance; + toast : ToastInstance; + modal : ModalInstance; + menu : MenuInstance; +} + + + +// ============================================================================ +// REACTIVE UIX +// ============================================================================ + +/** + * Versión reactiva de `Uix` para el singleton de Svelte (`actx.svelte.ts`). + * + * Idéntica a `Uix` en métodos y estructura, pero las propiedades de solo + * lectura (`resolved`, `mode`, `queue`, `stack`, `active`, `current`, + * `isMobile`, `isTablet`, `isDesktop`) están respaldadas por `$state` — + * Svelte las observa y re-renderiza automáticamente cuando cambian. + * + * No añade ni elimina ninguna funcionalidad — solo garantiza reactividad + * en las propiedades que los componentes Svelte necesitan leer. + */ +export interface ReactiveUiux { + theme: Omit & { + readonly resolved: 'light' | 'dark'; // $state + readonly mode : ThemeMode; // $state + }; + toast: Omit & { + readonly queue: Toast[]; // $state + }; + modal: Omit & { + readonly stack : ModalEntry[]; // $state + readonly active: ModalEntry | null; // $state derivado + }; + menu : MenuInstance; // sin estado reactivo + break: Omit & { + readonly current : string; // $state + readonly isMobile : boolean; // derivado de $state + readonly isTablet : boolean; // derivado de $state + readonly isDesktop: boolean; // derivado de $state + }; +} + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 000000000..6503cb2f2 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,9 @@ + + + +{@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 000000000..cc88df0ea --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,2 @@ +

Welcome to SvelteKit

+

Visit svelte.dev/docs/kit to read the documentation

diff --git a/src/routes/layout.css b/src/routes/layout.css new file mode 100644 index 000000000..1c4d2a8bc --- /dev/null +++ b/src/routes/layout.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/typography'; diff --git a/src/routes/test/+page.svelte b/src/routes/test/+page.svelte new file mode 100644 index 000000000..89f9751b0 --- /dev/null +++ b/src/routes/test/+page.svelte @@ -0,0 +1,526 @@ + + +
+ + +
+
+ +
+ + {actx.locale.toUpperCase()} + + {actx.uiux.break.current} + +
+
+
+ +
+ + +
+ + +
+

ling

+
+ t() + {actx.t('common.ok')} / {actx.t('common.cancel')} +
+
+ ts() + {actx.ts({ es: 'Hola mundo', en: 'Hello world', de: 'Hallo Welt', fr: 'Bonjour monde', it: 'Ciao mondo', pt: 'Olá mundo' })} +
+
+ locale + {actx.locale} +
+
+ {#each locales as loc} + + {/each} +
+
+ + +
+

numr

+
+ f(1234567.891) + {actx.numr.f(1234567.891)} +
+
+ f(0.5, {'{'}min:2,max:4{'}'}) + {actx.numr.f(0.5, { minDecimals: 2, maxDecimals: 4 })} +
+
+ f(1000000) + {actx.numr.f(1000000)} +
+
+ f(3.14159) + {actx.numr.f(3.14159, { maxDecimals: 4 })} +
+
+ + +
+

dati

+
+ f(now, 'long') + {actx.dati.f(now, 'long')} +
+
+ f(now, 'short') + {actx.dati.f(now, 'short')} +
+
+ f(now, 'full') + {actx.dati.f(now, 'full')} +
+
+ date+time + {actx.dati.f(now, { date: 'short', time: 'short' })} +
+
+ + +
+

unit

+
+ 75kg → lb + {actx.unit.weight.fTo(75, 'kg', 'lb')} +
+
+ 100km → mi + {actx.unit.length.fTo(100, 'km', 'mi')} +
+
+ 100°C → °F + {actx.unit.temp.fTo(100, 'c', 'f')} +
+
+ 10L → gal + {actx.unit.volume.fTo(10, 'l', 'gal')} +
+
+ sistema + {actx.unit.getSystem()} +
+
+ + +
+
+ + +
+

curr

+
+ moneda activa + {actx.curr.getCurrency()} +
+
+ f(99.99, symbol) + {actx.curr.f(99.99, 'symbol')} +
+
+ f(99.99, code) + {actx.curr.f(99.99, 'code')} +
+
+ monedas + {actx.curr.getCurrencies().join(', ') || '(sin definir)'} +
+
+ + +
+

uiux.theme

+
+ mode + {actx.uiux.theme.mode} +
+
+ resolved + {actx.uiux.theme.resolved} +
+
+ + + +
+
+ {actx.uiux.theme.resolved} +
+
+ + +
+

uiux.break

+
+ current + {actx.uiux.break.current} +
+
+ isMobile + {actx.uiux.break.isMobile} +
+
+ isTablet + {actx.uiux.break.isTablet} +
+
+ isDesktop + {actx.uiux.break.isDesktop} +
+
+ {#each ['xs','sm','md','lg','xl','2xl'] as bp} + {bp} + {/each} +
+
+ + +
+

uiux.toast

+
+ queue + {actx.uiux.toast.queue.length} activos +
+
+ + + + +
+ +
+ {#each actx.uiux.toast.queue as toast (toast.id)} +
+ {actx.ts(toast.message)} + +
+ {/each} +
+
+ + +
+

uiux.modal

+
+ stack depth + {actx.uiux.modal.stack.length} +
+
+ active + {actx.uiux.modal.active?.id ?? 'ninguno'} +
+
+ + +
+
+ + +
+ {#if actx.uiux.modal.stack.length > 0} + + {/if} +
+ + +
+

uiux.menu

+
+ sidebar + {actx.uiux.menu.isOpen('sidebar') ? 'open' : 'closed'} +
+
+ dropdown + {actx.uiux.menu.isOpen('dropdown') ? 'open' : 'closed'} +
+
+ + +
+ +
+ + +
+

logr

+
+ + + + +
+ +
+ {#each logEntries.slice(-6).reverse() as entry} +
+ {entry.level} + {actx.ts(entry.message)} +
+ {/each} + {#if logEntries.length === 0} + sin logs + {/if} +
+
+ +
+
+
+ + \ No newline at end of file diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 000000000..b6dd6670c --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 000000000..d74ec4d63 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,35 @@ +import adapter from '@sveltejs/adapter-static'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter(), + alias: { + // @/ → src/ + // Sincronizado con vite.config.ts para que TypeScript, + // el compilador de Svelte y Vitest resuelvan el mismo alias. + '@': resolve(__dirname, 'src'), + // @/ling → src/lib/ling + '@/ling': resolve(__dirname, 'src/lib/ling'), + // @/logr → src/lib/logr + '@/logr': resolve(__dirname, 'src/lib/logr'), + // @/glob → src/lib/glob + '@/glob': resolve(__dirname, 'src/lib/glob'), + // @/actx → src/lib/actx + '@/actx': resolve(__dirname, 'src/lib/actx'), + // @/uiux → src/lib/uiux + '@/uiux': resolve(__dirname, 'src/lib/uiux'), + }, + }, + vitePlugin: { + dynamicCompileOptions: ({ filename }) => + filename.includes('node_modules') ? undefined : { runes: true } + } +}; + +export default config; + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..593d1635f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "types": [ + "@types/node" + ] + } +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 000000000..7b0ac636d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,69 @@ +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser-playwright'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { fileURLToPath } from 'url'; +import { resolve, dirname } from 'path'; + +// __dirname no existe en ESM — se reconstruye así +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + + resolve: { + alias: { + // @/ → src/ + '@': resolve(__dirname, 'src'), + // @/ling → src/lib/ling + '@/ling': resolve(__dirname, 'src/lib/ling'), + // @/logr → src/lib/logr + '@/logr': resolve(__dirname, 'src/lib/logr'), + // @/glob → src/lib/glob + '@/glob': resolve(__dirname, 'src/lib/glob'), + // @/actx → src/lib/actx + '@/actx': resolve(__dirname, 'src/lib/actx'), + // @/uiux → src/lib/uiux + '@/uiux': resolve(__dirname, 'src/lib/uiux'), + }, + }, + + test: { + expect: { requireAssertions: true }, + projects: [ + { + extends: './vite.config.ts', + test: { + name: 'client', + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium', headless: true }] + }, + include: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/lib/server/**'] + } + }, + + { + extends: './vite.config.ts', + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + '@/ling': resolve(__dirname, 'src/lib/ling'), + '@/logr': resolve(__dirname, 'src/lib/logr'), + '@/glob': resolve(__dirname, 'src/lib/glob'), + '@/actx': resolve(__dirname, 'src/lib/actx'), + '@/uiux': resolve(__dirname, 'src/lib/uiux'), + }, + }, + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + } + ] + } +});